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

Issue:
Add support for rpyc 5.0.0
Currently version ==4.1.5 is required. 5.0.0 is not backwards compatible.
Use systemd instead of screen to run the server
Fixes #293 


Discussion / hints:
unfortunately right now this isn't possible as rpyc 5 requires python 3.6 and the RedPitaya ecosystem is still at 3.5. I'll keep this open though, hoping that RP base image will get an update


Fix patch:
diff --git a/.flake8 b/.flake8
deleted file mode 100644
index 05b1b432..00000000
--- a/.flake8
+++ /dev/null
@@ -1,5 +0,0 @@
-[flake8]
-max-line-length = 88
-per-file-ignores =
-extend-ignore = E203, E402, E731, W503
-    tests/*: D103, D100
diff --git a/.gitattributes b/.gitattributes
index b405a0c9..f527a84c 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,2 +1,3 @@
 *.bin filter=lfs diff=lfs merge=lfs -text
-*.sh eol=lf
\ No newline at end of file
+*.sh eol=lf
+mdio-tool filter=lfs diff=lfs merge=lfs -text
\ No newline at end of file
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index b3f36830..6b625f40 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,12 +1,19 @@
 repos:
   - repo: https://github.com/psf/black
-    rev: 23.11.0
+    rev: 24.3.0
     hooks:
       - id: black
         exclude: ^(gateware/logic/|gateware/lowlevel/|gateware/linien_module.py|linien-server/linien_server/csrmap.py)
 
   - repo: https://github.com/pycqa/isort
-    rev: 5.12.0
+    rev: 5.13.2
     hooks:
       - id: isort
         name: isort (python)
+
+  - repo: https://github.com/pycqa/flake8
+    rev: 6.1.0
+    hooks:
+      - id: flake8
+        exclude: linien-server/linien_server/csrmap.py
+        additional_dependencies: [flake8-pyproject]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3c3f7807..c13151a2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,8 +3,29 @@
 All notable changes to this project will be documented in this file.
 
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
-and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) in spirit but
-uses [PEP440](https://peps.python.org/pep-0440/) for the version identification.
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [2.0.0] - 2024-04-05
+
+### Added
+* Use features of Python 3.10 available on RedPitaya OS 2.0 for `linien-server` by @bleykauf in https://github.com/linien-org/linien/pull/366
+* Add ability to start the server upon startup by @bleykauf in https://github.com/linien-org/linien/pull/387
+
+### Changed
+* Use systemd instead of screen for running the server by @bleykauf in https://github.com/linien-org/linien/pull/387
+* Use json to store devices and parameters by @bleykauf in https://github.com/linien-org/linien/pull/357
+* Better error handling by @bleykauf in https://github.com/linien-org/linien/pull/350
+* Improve startup and installation process  by @bleykauf in https://github.com/linien-org/linien/pull/372
+* Use official influxdb client by @bleykauf in https://github.com/linien-org/linien/pull/374
+* `mdio-tools` is now included in the `linien-server` package
+* Uses `rpyc==6.x` instead of `rpyc==4.x`
+
+### Deprecated
+* Removed support for RedPitaya OS 1.0: RedPitaya OS 2.0 is now necessary.
+
+### Fixed
+
+* Fix and enforce flake8 by @bleykauf in https://github.com/linien-org/linien/pull/368
 
 ## [1.0.2] - 2024-04-05
 
@@ -185,6 +206,7 @@ uses [PEP440](https://peps.python.org/pep-0440/) for the version identification.
 * **Bug fixes and performance improvements**
 
 
+[2.0.0]: https://github.com/linien-org/linien/compare/v1.0.2...v2.0.0
 [1.0.2]: https://github.com/linien-org/linien/compare/v1.0.1...v1.0.2
 [1.0.1]: https://github.com/linien-org/linien/compare/v1.0.0...v1.0.1
 [1.0.0]: https://github.com/linien-org/linien/compare/v0.8.0...v1.0.0
diff --git a/README.md b/README.md
index 2a6b8155..902a4bc0 100644
--- a/README.md
+++ b/README.md
@@ -37,24 +37,21 @@ Features
 
 ![image](https://raw.githubusercontent.com/linien-org/linien/master/docs/screencast.gif)
 
-Getting started: install Linien
----------------
+## Getting started: Install Linien
 
 Linien runs on Windows and Linux. For Windows users the [standalone
 binaries](#standalone-binary) containing the graphical user interface
 are recommended.
 These binaries run on your lab PC and contain everything to get Linien running on your RedPitaya.
 
-Both RedPitaya OS 1.x and 2.x are supported. However, support for OS 1.x will be dropped starting
-with Linien 2.x
-
-If you want to use the python interface you should [install it using pip](#installation-with-pip).
+Starting with Linien 2.0, only RedPitaya OS 2.x is supported. Linien 1.x works on RedPitaya OS
+but is no longer actively maintain.
 
 ### Standalone binary
 
-You can download standalone binaries for Windows on [the
-releases
-page](https://github.com/linien-org/linien/releases) (download the binary in the assets section of the latest version). For Linux users, we recommend installation via pip.
+You can download standalone binaries for Windows on
+[the releases page](https://github.com/linien-org/linien/releases) (download the binary in the assets
+section of the latest version). For Linux users, we recommend installation of `linien-gui` via pip.
 
 ### Installation with pip
 
@@ -72,12 +69,57 @@ linien
 
 in a terminal (on both Linux and Windows).
 
-In case you're only interested in the python client and don't want to install the graphical application, you may use the `linien-client` package:
+In case you're only interested in the Python client and don't want to install the graphical application, you may use the `linien-client` package:
 
 ```bash
 pip install linien-client
 ```
 
+### Installation of the server on the RedPitaya
+
+The easiest way to install the server component of Linien on the RedPitaya, is to use the graphical
+user interface. The first time you are connecting to the RedPitaya, the server is automatically
+installed.
+
+In case you are using the `linien-client`, the server can be installed with
+
+```python
+from linien_client.device import Device
+from linien_client.deploy import install_remote_server
+
+device = Device(
+    host="rp-xxxxxx.local",
+    user="root",
+    password="root"    
+)
+instalL_remote_server(device)
+```
+
+Finally, you can install the server manually, by connecting to the RedPitaya via SSH and
+then running
+
+```bash
+pip install linien-server
+```
+
+The server can then be started as a systemd service by running
+
+```bash
+linien-server start
+```
+
+on the RedPitaya. To check the status of the server, run
+
+
+```bash
+linien-server status
+```
+
+ For more options, run
+
+```bash
+linien-server --help
+```
 
 Physical setup
 --------------
@@ -192,14 +234,16 @@ Then, you should start the Linien server on your RedPitaya. This can be done by
 
 Once the server is up and running, you can connect using python:
 ```python
+from linien_client.device import Device
 from linien_client.connection import LinienClient
 from linien_common.common import  MHz, Vpp, ANALOG_OUT_V
 
-c = LinienClient(
+dev = Device(
     host="rp-xxxxxx.local",
     user="root",
-    password="root"
+    password="root"    
 )
+c = LinienClient(dev)
 c.connect(autostart_server=True, use_parameter_cache=True)
 
 # read out the modulation frequency
@@ -452,7 +496,7 @@ Linien ‒ User-friendly locking of lasers using RedPitaya (STEMlab 125-14) that
 
 Copyright © 2014-2015 Robert Jördens\
 Copyright © 2018-2022 Benjamin Wiegand\
-Copyright © 2021-2023 Bastian Leykauf\
+Copyright © 2021-2024 Bastian Leykauf\
 Copyright © 2022 Christian Freier
 
 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.
diff --git a/gateware/fpga_image_helper.py b/gateware/fpga_image_helper.py
index 6c34429b..e08aefc0 100644
--- a/gateware/fpga_image_helper.py
+++ b/gateware/fpga_image_helper.py
@@ -20,12 +20,12 @@
 # use `build_fpga_image.sh`
 from pathlib import Path
 
-REPO_ROOT_DIR = Path(__file__).resolve().parents[1]
-
 from .bit2bin import bit2bin
 from .hw_platform import Platform
 from .linien_module import RootModule
 
+REPO_ROOT_DIR = Path(__file__).resolve().parents[1]
+
 
 def py_csrconstants(map, fil):
     fil.write("csr_constants = {\n")
diff --git a/gateware/logic/autolock.py b/gateware/logic/autolock.py
index a6dc7643..cc537316 100644
--- a/gateware/logic/autolock.py
+++ b/gateware/logic/autolock.py
@@ -247,19 +247,19 @@ def init_csr(self, N_points):
 
         peak_height_bit = len(self.sum_diff_calculator.sum_value)
         self.peak_heights = [
-            CSRStorage(peak_height_bit, name="peak_height_%d" % idx)
+            CSRStorage(peak_height_bit, name=f"peak_height_{idx}")
             for idx in range(AUTOLOCK_MAX_N_INSTRUCTIONS)
         ]
         for idx, peak_height in enumerate(self.peak_heights):
-            setattr(self, "peak_height_%d" % idx, peak_height)
+            setattr(self, f"peak_height_{idx}", peak_height)
 
         x_data_length_bit = bits_for(N_points)
         self.wait_for = [
-            CSRStorage(x_data_length_bit, name="wait_for_%d" % idx)
+            CSRStorage(x_data_length_bit, name=f"wait_for_{idx}")
             for idx in range(AUTOLOCK_MAX_N_INSTRUCTIONS)
         ]
         for idx, wait_for in enumerate(self.wait_for):
-            setattr(self, "wait_for_%d" % idx, wait_for)
+            setattr(self, f"wait_for_{idx}", wait_for)
 
         return peak_height_bit, x_data_length_bit
 
diff --git a/gateware/logic/delta_sigma.py b/gateware/logic/delta_sigma.py
index 7fa70fc8..23aec60d 100644
--- a/gateware/logic/delta_sigma.py
+++ b/gateware/logic/delta_sigma.py
@@ -54,7 +54,7 @@ def __init__(self, out, **kwargs):
         for i, o in enumerate(out):
             ds = DeltaSigma(**kwargs)
             self.submodules += ds
-            cs = CSRStorage(len(ds.data), name="data%i" % i)
+            cs = CSRStorage(len(ds.data), name=f"data{i}")
             # atomic_write=True
-            setattr(self, "r_data%i" % i, cs)
+            setattr(self, f"r_data{i}", cs)
             self.sync += ds.data.eq(cs.storage), o.eq(ds.out)
diff --git a/gateware/logic/iir.py b/gateware/logic/iir.py
index 2380dd79..1aa5de7a 100644
--- a/gateware/logic/iir.py
+++ b/gateware/logic/iir.py
@@ -51,7 +51,7 @@ def __init__(
         self.c = c = {}
         for i in "ab":
             for j in range(order + 1):
-                name = "%s%i" % (i, j)
+                name = f"{i}{j}"
                 if name == "a0":
                     continue
                 ci = Signal((coeff_width, True), name=name)
@@ -83,8 +83,8 @@ def __init__(
         ]
 
         if mode == "pipelined":
-            r = [("b%i" % i, self.x) for i in reversed(range(order + 1))]
-            r += [("a%i" % i, y) for i in reversed(range(1, order + 1))]
+            r = [(f"b{i}", self.x) for i in reversed(range(order + 1))]
+            r += [(f"a{i}", y) for i in reversed(range(1, order + 1))]
             for coeff, signal in r:
                 zr = Signal.like(z)
                 self.sync += zr.eq(z)
@@ -105,10 +105,10 @@ def __init__(
             steps = []
             x = [self.x] + [Signal.like(self.x) for i in range(order + 1)]
             for i in reversed(range(order + 1)):
-                steps.append([x[i + 1].eq(x[i]), ma.eq(x[i]), mb.eq(c["b%i" % i])])
+                steps.append([x[i + 1].eq(x[i]), ma.eq(x[i]), mb.eq(c[f"b{i}"])])
             y = [None, y] + [Signal.like(y) for i in range(1, order + 1)]
             for i in reversed(range(1, order + 1)):
-                steps.append([y[i + 1].eq(y[i]), ma.eq(y[i]), mb.eq(c["a%i" % i])])
+                steps.append([y[i + 1].eq(y[i]), ma.eq(y[i]), mb.eq(c[f"a{i}"])])
             steps[1].append(mc.eq(z))
             latency = order + 4
             if order == 1:
diff --git a/linien-client/linien_client/communication.py b/linien-client/linien_client/communication.py
deleted file mode 100644
index c32592df..00000000
--- a/linien-client/linien_client/communication.py
+++ /dev/null
@@ -1,102 +0,0 @@
-# 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 3ad07334..3fa07b17 100644
--- a/linien-client/linien_client/connection.py
+++ b/linien-client/linien_client/connection.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 2021-2023 Bastian Leykauf <leykauf@physik.hu-berlin.de>
 #
 # This file is part of Linien and based on redpid.
 #
@@ -17,19 +17,17 @@
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
 import logging
-import random
-import string
 from socket import gaierror
 from time import sleep
 from traceback import print_exc
 from typing import Callable, Optional
 
 import rpyc
-from linien_common.config import DEFAULT_SERVER_PORT
+from linien_common.communication import LinienControlService
 
 from . import __version__
-from .communication import LinienControlService
 from .deploy import hash_username_and_password, start_remote_server
+from .device import Device, generate_random_key
 from .exceptions import (
     GeneralConnectionError,
     InvalidServerVersionException,
@@ -43,41 +41,27 @@
 
 
 class ServiceWithAuth(rpyc.Service):
-    def __init__(self, uuid: str, user: str, password: str) -> None:
+    def __init__(self, uuid: str, device: Device) -> None:
         super().__init__()
         self.exposed_uuid = uuid
-        self.auth_hash = hash_username_and_password(user, password).encode("utf-8")
+        self.auth_hash = hash_username_and_password(
+            device.username, device.password
+        ).encode("utf-8")
 
     def _connect(self, channel, config):
         channel.stream.sock.send(self.auth_hash)  # send hash before rpyc takes over
+        logger.debug("Sent authentication hash")
         return super()._connect(channel, config)
 
 
 class LinienClient:
-    def __init__(
-        self,
-        host: str,
-        user: str,
-        password: str,
-        port: int = DEFAULT_SERVER_PORT,
-        name: str = "",
-    ):
+    def __init__(self, device: Device) -> None:
         """Connect to a RedPitaya that runs linien server."""
-        self.host = host
-        self.user = user
-        self.password = password
-        self.port = port
-        self.name = name
-
-        if self.host in ("localhost", "127.0.0.1"):
-            # RP is configured such that "localhost" doesn't point to 127.0.0.1 in all
-            # cases
-            self.host = "127.0.0.1"
-
-        self.uuid = "".join(random.choice(string.ascii_lowercase) for _ in range(10))
+        self.device = device
+        self.uuid = generate_random_key()
 
         # for exposing client's uuid to server
-        self.client_service = ServiceWithAuth(self.uuid, self.user, self.password)
+        self.client_service = ServiceWithAuth(self.uuid, self.device)
 
     def connect(
         self,
@@ -91,11 +75,11 @@ def connect(
         while True:
             i += 1
             try:
-                logger.info(f"Try to connect to {self.host}:{self.port}")
+                logger.info(f"Try to connect to {self.device.host}:{self.device.port}")
 
                 self.connection = rpyc.connect(
-                    self.host,
-                    self.port,
+                    self.device.host,
+                    self.device.port,
                     service=self.client_service,
                     config={"allow_pickle": True},
                 )
@@ -112,7 +96,7 @@ def connect(
                 break
             except gaierror:
                 # host not found
-                logger.error(f"Error: host {self.host} not found")
+                logger.error(f"Error: host {self.device.host} not found")
                 break
             except EOFError:
                 logger.error("EOFError! Probably authentication failed")
@@ -123,7 +107,7 @@ def connect(
 
                 if i == 0:
                     logger.error("Server is not running. Launching it!")
-                    start_remote_server(self.host, self.user, self.password)
+                    start_remote_server(self.device)
                     sleep(3)
                 else:
                     if i < 20:
diff --git a/linien-client/linien_client/deploy.py b/linien-client/linien_client/deploy.py
index 5dce0852..d028b52b 100644
--- a/linien-client/linien_client/deploy.py
+++ b/linien-client/linien_client/deploy.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 2021-2023 Bastian Leykauf <leykauf@physik.hu-berlin.de>
 #
 # This file is part of Linien and based on redpid.
 #
@@ -28,12 +28,14 @@
 )
 from linien_common.communication import hash_username_and_password
 
+from .device import Device
+
 logger = logging.getLogger(__name__)
 logger.setLevel(logging.DEBUG)
 
 
 def read_remote_version(
-    host: str, user: str, password: str, port: int = 22, out_stream=sys.stdout
+    device: Device, ssh_port: int = 22, out_stream=sys.stdout
 ) -> str:
     """Read the remote version of linien."""
 
@@ -43,7 +45,10 @@ def read_remote_version(
         out_stream = open(os.devnull, "w")
 
     with Connection(
-        host, user=user, port=port, connect_kwargs={"password": password}
+        device.host,
+        user=device.username,
+        port=ssh_port,
+        connect_kwargs={"password": device.password},
     ) as conn:
         result = conn.run(
             'python3 -c "import linien_server; print(linien_server.__version__);"',
@@ -58,7 +63,7 @@ def read_remote_version(
 
 
 def start_remote_server(
-    host: str, user: str, password: str, port: int = 22, out_stream=sys.stdout
+    device: Device, ssh_port: int = 22, out_stream=sys.stdout
 ) -> None:
     """Start the remote linien server."""
 
@@ -68,10 +73,13 @@ def start_remote_server(
         out_stream = open(os.devnull, "w")
 
     with Connection(
-        host, user=user, port=port, connect_kwargs={"password": password}
+        device.host,
+        user=device.username,
+        port=ssh_port,
+        connect_kwargs={"password": device.password},
     ) as conn:
         local_version = linien_client.__version__.split("+")[0]
-        remote_version = read_remote_version(host, user, password, port).split("+")[0]
+        remote_version = read_remote_version(device, ssh_port).split("+")[0]
 
         if (local_version != remote_version) and not ("dev" in local_version):
             raise InvalidServerVersionException(local_version, remote_version)
@@ -79,7 +87,7 @@ def start_remote_server(
         logger.debug("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)}')\"",
+            f"write_hash_to_file('{hash_username_and_password(device.username, device.password)}')\"",  # noqa E501
             out_stream=out_stream,
             err_stream=out_stream,
             warn=True,
@@ -87,7 +95,7 @@ def start_remote_server(
 
         logger.debug("Starting server")
         conn.run(
-            "linien_start_server.sh",
+            "linien-server start",
             out_stream=out_stream,
             err_stream=out_stream,
             warn=True,
@@ -95,8 +103,8 @@ def start_remote_server(
 
 
 def install_remote_server(
-    host: str, user: str, password: str, port: int = 22, out_stream=sys.stdout
-):
+    device: Device, ssh_port: int = 22, out_stream=sys.stdout
+) -> None:
     """Install the remote linien server."""
 
     if not out_stream:
@@ -104,15 +112,17 @@ def install_remote_server(
         out_stream = open(os.devnull, "w")
 
     with Connection(
-        host, user=user, port=port, connect_kwargs={"password": password}
+        device.host,
+        user=device.username,
+        port=ssh_port,
+        connect_kwargs={"password": device.password},
     ) as conn:
         local_version = linien_client.__version__.split("+")[0]
         cmds = [
-            "linien_stop_server.sh",
+            "linien-server stop",
             "pip3 uninstall linien-server -y",
             "pip3 uninstall linien-common -y",
             f"pip3 install linien-server=={local_version} --no-cache-dir",
-            "linien_install_requirements.sh",
         ]
         for cmd in cmds:
             out_stream.write(f">> {cmd}\n")
diff --git a/linien-client/linien_client/device.py b/linien-client/linien_client/device.py
new file mode 100644
index 00000000..559933a1
--- /dev/null
+++ b/linien-client/linien_client/device.py
@@ -0,0 +1,141 @@
+# 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
+import logging
+import random
+import string
+from dataclasses import asdict, dataclass, field
+from typing import Dict, List
+
+from linien_common.communication import PathLike, RestorableParameterValues
+from linien_common.config import SERVER_PORT, USER_DATA_PATH
+
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.DEBUG)
+
+
+def generate_random_key():
+    """Generate a random key for the device."""
+    return "".join(random.choice(string.ascii_lowercase) for _ in range(10))
+
+
+@dataclass
+class Device:
+    """A device that can be connected to."""
+
+    key: str = field(default_factory=generate_random_key)
+    name: str = field(default_factory=str)
+    host: str = field(default_factory=str)
+    port: int = SERVER_PORT
+    username: str = field(default_factory=str)
+    password: str = field(default_factory=str)
+    parameters: Dict[str, RestorableParameterValues] = field(default_factory=dict)
+
+    def __post_init__(self):
+        if self.host == "":
+            self.host = "rp-xxxxxx.local"
+        if self.username == "":
+            self.username = "root"
+        if self.password == "":
+            self.password = "root"
+        # FIXME: is this even necessary?
+        if self.host in ("localhost", "127.0.0.1"):
+            # RP is configured such that "localhost" doesn't point to 127.0.0.1 in all
+            # cases
+            self.host = "127.0.0.1"
+
+    def __eq__(self, other):
+        if isinstance(other, Device):
+            return self.key == other.key
+        else:
+            return False
+
+
+def add_device(
+    device: Device, path: PathLike = USER_DATA_PATH / "devices.json"
+) -> None:
+    """Add a new device to the device list and save it to disk."""
+    devices = load_device_list(path)
+    if device in devices:
+        raise KeyError(f"Device with key {device.key} already exists in {path}.")
+    devices.append(device)
+    save_device_list(devices, path)
+    logger.debug(f"Added device with key {device.key} to {path}.")
+
+
+def load_device(key: str, path: PathLike) -> Device:
+    """Load a device from disk."""
+    devices = load_device_list(path)
+    for device in devices:
+        if device.key == key:
+            return device
+    raise KeyError(f"Device with key {key} doesn't exist in {path}.")
+
+
+def delete_device(
+    device: Device, path: PathLike = USER_DATA_PATH / "devices.json"
+) -> None:
+    """Remove a device from the device list and save it to disk."""
+    devices = load_device_list(path)
+    devices.remove(device)
+    save_device_list(devices, path)
+
+
+def update_device(
+    device: Device, path: PathLike = USER_DATA_PATH / "devices.json"
+) -> None:
+    """Update a device in the device list and save it to disk."""
+    devices = load_device_list(path)
+    if device not in devices:
+        raise KeyError(f"Device with key {device.key} doesn't exist in {path}.")
+    # this updates since equality is defined by the device key, see Device.__eq__
+    devices[devices.index(device)] = device
+    save_device_list(devices, path)
+    logger.debug(f"Updated device with key {device.key} in {path}.")
+
+
+def move_device(device: Device, direction: int) -> None:
+    """Move a device in the device list and save it to disk."""
+    devices = load_device_list()
+    current_index = devices.index(device)
+    new_index = current_index + direction
+    devices.insert(new_index, devices.pop(current_index))
+    save_device_list(devices)
+
+
+def save_device_list(
+    devices: List[Device], path: PathLike = USER_DATA_PATH / "devices.json"
+) -> None:
+    """Save a device list to disk."""
+    with open(path, "w") as f:
+        logger.debug(f"Saving devices to {path}.")
+        json.dump({i: asdict(device) for i, device in enumerate(devices)}, f, indent=2)
+
+
+def load_device_list(
+    path: PathLike = USER_DATA_PATH / "devices.json",
+) -> List[Device]:
+    """Load the device list from disk."""
+    try:
+        with open(path, "r") as f:
+            logger.debug(f"Loading devices from {path}.")
+            devices = [Device(**value) for _, value in json.load(f).items()]
+    except FileNotFoundError:
+        logger.debug("No devices.json found. Return empty list.")
+        devices = []
+    return devices
diff --git a/linien-client/linien_client/exceptions.py b/linien-client/linien_client/exceptions.py
index 9bd549d0..7ecb3c00 100644
--- a/linien-client/linien_client/exceptions.py
+++ b/linien-client/linien_client/exceptions.py
@@ -39,8 +39,7 @@ def __init__(self, client_version, remote_version):
         self.remote_version = remote_version
 
         super().__init__(
-            "Version mismatch: Client is %s and server is %s"
-            % (client_version, remote_version)
+            f"Version mismatch: Client is {client_version}, server is {remote_version}"
         )
 
 
diff --git a/linien-client/linien_client/remote_parameters.py b/linien-client/linien_client/remote_parameters.py
index 57686ade..e8756383 100644
--- a/linien-client/linien_client/remote_parameters.py
+++ b/linien-client/linien_client/remote_parameters.py
@@ -18,12 +18,10 @@
 
 from typing import Any, Callable, Dict, Iterator, List, Tuple, Union
 
-from linien_common.communication import pack, unpack
+from linien_common.communication import LinienControlService, 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."""
@@ -64,7 +62,7 @@ def log(self) -> bool:
     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):
+    def add_callback(self, callback: Callable, call_immediately: bool = False):
         """
         Register a callback function that is called whenever the parameter changes.
         """
@@ -77,7 +75,7 @@ def add_callback(self, callback: Callable, call_with_first_value: bool = True):
         self.parent._callbacks.setdefault(self.name, [])
         self.parent._callbacks[self.name].append(callback)
 
-        if call_with_first_value:
+        if call_immediately:
             callback(self.value)
 
     def reset(self) -> None:
@@ -112,7 +110,7 @@ def __init__(self, remote: LinienControlService, uuid: str, use_cache: bool):
         self._callbacks: Dict[str, List[Callable]] = {}
 
         # mimic functionality of `parameters.Parameters`:
-        all_parameters = unpack(self.remote.exposed_init_parameter_sync(self.uuid))
+        all_parameters = self.remote.exposed_init_parameter_sync(self.uuid)
         for name, value, can_be_cached, restorable, loggable, log in all_parameters:
             param = RemoteParameter(
                 parent=self,
@@ -188,9 +186,7 @@ def check_for_changed_parameters(self) -> None:
             and self._async_changed_parameters_queue.ready
         ):
             # We have a result.
-            queue: List[Tuple[str, Any]] = unpack(
-                self._async_changed_parameters_queue.value
-            )
+            queue: List[Tuple[str, Any]] = self._async_changed_parameters_queue.value
 
             # Now that we have our result, we can start the next call.
             self._async_changed_parameters_queue = async_(
diff --git a/linien-client/pyproject.toml b/linien-client/pyproject.toml
new file mode 100644
index 00000000..e8b1d9de
--- /dev/null
+++ b/linien-client/pyproject.toml
@@ -0,0 +1,42 @@
+[build-system]
+requires = ["setuptools>=61.2"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "linien-client"
+version = "2.0.0"
+authors = [
+    { name = "Benjamin Wiegand", email = "benjamin.wiegand@physik.hu-berlin.de" },
+    { name = "Bastian Leykauf", email = "leykauf@physik.hu-berlin.de" },
+    { name = "Robert Jördens", email = "rj@quartiq.de" },
+    { name = "Christian Freier", email = "christian.freier@gmail.com" },
+    { name = "Doron Behar", email = "doron.behar@gmail.com" },
+]
+maintainers = [
+    { name = "Bastian Leykauf", email = "leykauf@physik.hu-berlin.de" },
+]
+description = "Client components of the Linien spectroscopy lock application."
+classifiers = [
+    "Programming Language :: Python :: 3",
+    "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
+    "Operating System :: OS Independent",
+]
+requires-python = ">=3.8"
+dependencies = [
+    "fabric>=2.7.0,<3.0",
+    "typing_extensions>=4.5.0,<5.0",
+    "linien-common==2.0.0",
+]
+
+[project.readme]
+text = "Have a look at the [project repository](https://github.com/linien-org/linien) for installation instructions."
+content-type = "text/markdown"
+
+[project.urls]
+Homepage = "https://github.com/linien-org/linien/"
+
+[tool.setuptools]
+include-package-data = false
+
+[tool.setuptools.packages.find]
+namespaces = false
diff --git a/linien-client/setup.py b/linien-client/setup.py
deleted file mode 100644
index 91e096e0..00000000
--- a/linien-client/setup.py
+++ /dev/null
@@ -1,46 +0,0 @@
-# Copyright 2018-2022 Benjamin Wiegand <benjamin.wiegand@physik.hu-berlin.de>
-# Copyright 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/>.
-
-from setuptools import find_packages, setup
-
-version = "1.0.2"
-
-setup(
-    name="linien-client",
-    version=version,
-    author="Benjamin Wiegand",
-    author_email="highwaychile@posteo.de",
-    maintainer="Bastian Leykauf",
-    maintainer_email="leykauf@physik.hu-berlin.de",
-    description="Client components of the Linien spectroscopy lock application.",
-    long_description="Have a look at the [project repository](https://github.com/linien-org/linien) for installation instructions.",  # noqa: E501
-    long_description_content_type="text/markdown",
-    url="https://github.com/linien-org/linien/",
-    packages=find_packages(),
-    classifiers=[
-        "Programming Language :: Python :: 3",
-        "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
-        "Operating System :: OS Independent",
-    ],
-    python_requires=">=3.8",
-    install_requires=[
-        "fabric>=2.7.0",
-        "typing_extensions>=4.5.0",
-        "linien-common==1.0.2",
-    ],
-)
diff --git a/linien-common/linien_common/common.py b/linien-common/linien_common/common.py
index 51b70c4b..c9157f82 100644
--- a/linien-common/linien_common/common.py
+++ b/linien-common/linien_common/common.py
@@ -18,7 +18,7 @@
 
 """This file contains stuff that is required by the server as well as the client."""
 
-from enum import Enum, IntEnum
+from enum import IntEnum
 from time import time
 from typing import Dict, List, Tuple, Union
 
@@ -54,9 +54,9 @@ class AutolockMode(IntEnum):
     SIMPLE = 2
 
 
-class PSDAlgorithm(str, Enum):
-    WELCH = "welch"
-    LPSD = "lpsd"
+class PSDAlgorithm(IntEnum):
+    WELCH = 0
+    LPSD = 1
 
 
 class SpectrumUncorrelatedException(Exception):
@@ -293,7 +293,7 @@ def combine_error_signal(
     return np.array([v + combined_offset for v in signal])
 
 
-def check_plot_data(is_locked: bool, plot_data) -> bool:
+def check_plot_data(is_locked: bool, plot_data: Dict[str, np.ndarray]) -> bool:
     if is_locked:
         if "error_signal" not in plot_data or "control_signal" not in plot_data:
             return False
diff --git a/linien-common/linien_common/communication.py b/linien-common/linien_common/communication.py
index 7e7571a7..e35d4d6e 100644
--- a/linien-common/linien_common/communication.py
+++ b/linien-common/linien_common/communication.py
@@ -1,4 +1,4 @@
-# Copyright 2023 Bastian Leykauf <leykauf@physik.hu-berlin.de>
+# Copyright 2023-2024 Bastian Leykauf <leykauf@physik.hu-berlin.de>
 #
 # This file is part of Linien and based on redpid.
 #
@@ -16,32 +16,101 @@
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
 import hashlib
+import logging
+import os
 import pickle
 from socket import socket
-from typing import Any, Tuple
+from typing import Any, Callable, List, Tuple, Union
 
+from linien_common.influxdb import InfluxDBCredentials
 from rpyc.utils.authenticators import AuthenticationError
+from typing_extensions import Protocol
 
 from .config import USER_DATA_PATH
 
 HASH_FILE_NAME = "auth_hash.txt"
 
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.DEBUG)
 
-def pack(value: Any) -> bytes:
+ParameterValues = Union[int, float, str, bool, Callable, bytes]
+RestorableParameterValues = Union[int, float, bool]
+PathLike = Union[str, os.PathLike]
+
+
+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
+    ) -> List[Tuple[str, Any, bool, bool, bool, bool]]: ...
+
+    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
+    ) -> List[Tuple[str, Any]]: ...
+
+    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) -> InfluxDBCredentials: ...
+
+    def exposed_start_logging(self, interval: float) -> None: ...
+
+    def exposed_stop_logging(self) -> None: ...
+
+    def exposed_get_logging_status(self) -> bool: ...
+
+
+def pack(value: ParameterValues) -> Union[bytes, ParameterValues]:
     try:
         return pickle.dumps(value)
-    # FIXME: Replace with TypeError, AttributeError and maybe more
-    except Exception:
+    except (TypeError, AttributeError):
         # 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:
+def unpack(value: Union[bytes, ParameterValues]) -> ParameterValues:
     try:
-        return pickle.loads(value)
-    # FIXME: Replace with TypeError, AttributeError and maybe more
-    except Exception:
+        return pickle.loads(value)  # type: ignore[arg-type]
+    except TypeError:
         return value
 
 
diff --git a/linien-common/linien_common/config.py b/linien-common/linien_common/config.py
index cd8c94fb..7e3fbc9a 100644
--- a/linien-common/linien_common/config.py
+++ b/linien-common/linien_common/config.py
@@ -21,7 +21,7 @@
 from appdirs import AppDirs
 
 ACQUISITION_PORT = 19321
-DEFAULT_SERVER_PORT = 18862
+SERVER_PORT = 18862
 DEFAULT_SWEEP_SPEED = (125 * 2048) << 6
 
 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
index 1eb4c333..8e837dfb 100644
--- a/linien-common/linien_common/influxdb.py
+++ b/linien-common/linien_common/influxdb.py
@@ -17,6 +17,7 @@
 
 import json
 import logging
+from dataclasses import dataclass
 
 from .config import USER_DATA_PATH
 
@@ -26,29 +27,13 @@
 logger.setLevel(logging.DEBUG)
 
 
+@dataclass
 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,
-        )
+    url: str = "http://localhost:8086"
+    org: str = "my-org"
+    token: str = "my-token"
+    bucket: str = "my-bucket"
+    measurement: str = "my-measurement"
 
 
 def save_credentials(credentials: InfluxDBCredentials) -> None:
@@ -66,7 +51,7 @@ def save_credentials(credentials: InfluxDBCredentials) -> None:
             f,
             indent=2,
         )
-    logger.info("Saved InfluxDB credentials to %s" % filename)
+    logger.info(f"Saved InfluxDB credentials to {filename}")
 
 
 def restore_credentials() -> InfluxDBCredentials:
diff --git a/linien-common/pyproject.toml b/linien-common/pyproject.toml
new file mode 100644
index 00000000..cf6f5c75
--- /dev/null
+++ b/linien-common/pyproject.toml
@@ -0,0 +1,54 @@
+[build-system]
+requires = ["setuptools>=61.2"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "linien-common"
+version = "2.0.0"
+authors = [
+    { name = "Benjamin Wiegand", email = "benjamin.wiegand@physik.hu-berlin.de" },
+    { name = "Bastian Leykauf", email = "leykauf@physik.hu-berlin.de" },
+    { name = "Robert Jördens", email = "rj@quartiq.de" },
+    { name = "Christian Freier", email = "christian.freier@gmail.com" },
+    { name = "Doron Behar", email = "doron.behar@gmail.com" },
+]
+maintainers = [
+    { name = "Bastian Leykauf", email = "leykauf@physik.hu-berlin.de" },
+]
+description = "Shared components of the Linien spectroscopy lock application."
+classifiers = [
+    "Programming Language :: Python :: 3",
+    "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
+    "Operating System :: OS Independent",
+]
+requires-python = ">=3.8"
+dependencies = [
+    "appdirs>=1.4.4,<2.0",
+    "importlib_metadata>=2.1.3,<5.0",
+    "numpy>=1.21.5,<2.0",
+    "rpyc>=6.0,<7.0",
+    "scipy>=1.8.0,<2.0",
+]
+
+[project.optional-dependencies]
+dev = [
+    "black>=22.8.0",
+    "pre-commit>=2.20.0",
+    "flake8>=5.0.4",
+    "isort>=5.10.1",
+    "flake8-pyproject>=1.2.3",
+    "setuptools_scm>=6.2",
+]
+
+[project.readme]
+text = "Have a look at the [project repository](https://github.com/linien-org/linien) for installation instructions."
+content-type = "text/markdown"
+
+[project.urls]
+Homepage = "https://github.com/linien-org/linien"
+
+[tool.setuptools]
+include-package-data = false
+
+[tool.setuptools.packages.find]
+namespaces = false
diff --git a/linien-common/setup.py b/linien-common/setup.py
deleted file mode 100644
index 87a5c7b9..00000000
--- a/linien-common/setup.py
+++ /dev/null
@@ -1,45 +0,0 @@
-# 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 setuptools import find_packages, setup
-
-setup(
-    name="linien-common",
-    version="1.0.2",
-    author="Benjamin Wiegand",
-    author_email="highwaychile@posteo.de",
-    maintainer="Bastian Leykauf",
-    maintainer_email="leykauf@physik.hu-berlin.de",
-    description="Shared components of the Linien spectroscopy lock application.",
-    long_description="Have a look at the [project repository](https://github.com/linien-org/linien) for installation instructions.",  # noqa: E501
-    long_description_content_type="text/markdown",
-    url="https://github.com/linien-org/linien",
-    packages=find_packages(),
-    classifiers=[
-        "Programming Language :: Python :: 3",
-        "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
-        "Operating System :: OS Independent",
-    ],
-    python_requires=">=3.5",
-    install_requires=[
-        "appdirs>=1.4.4",
-        "importlib_metadata>=2.1.3",
-        "numpy>=1.11.0",
-        "rpyc>=4.0,<5.0",
-        "scipy>=0.17.0",
-    ],
-)
diff --git a/linien-gui/linien_gui/app.py b/linien-gui/linien_gui/app.py
index 822399f6..52caf1d8 100644
--- a/linien-gui/linien_gui/app.py
+++ b/linien-gui/linien_gui/app.py
@@ -22,12 +22,11 @@
 import click
 from linien_client.connection import LinienClient
 from linien_gui import __version__
-from linien_gui.config import load_settings
+from linien_gui.config import UI_PATH, 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.widgets import UI_PATH
 from PyQt5 import QtWidgets
 from PyQt5.QtCore import pyqtSignal
 from pyqtgraph.Qt import QtCore
@@ -55,7 +54,7 @@ def __init__(self, *args, **kwargs):
 
     def client_connected(self, client: LinienClient):
         self.device_manager.hide()
-        self.main_window.show(client.host, client.name)
+        self.main_window.show(client.device.host, client.device.name)
 
         self.client = client
         self.control = client.control
diff --git a/linien-gui/linien_gui/config.py b/linien-gui/linien_gui/config.py
index 493d223c..a620cd72 100644
--- a/linien-gui/linien_gui/config.py
+++ b/linien-gui/linien_gui/config.py
@@ -1,4 +1,6 @@
 # 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.
 #
@@ -17,16 +19,17 @@
 
 import json
 import logging
-import pickle
 from enum import Enum
-from typing import Callable, Iterator, List, Tuple
+from pathlib import Path
+from typing import Callable, Iterator, Tuple
 
-import rpyc
 from linien_common.config import USER_DATA_PATH
 
 logger = logging.getLogger(__name__)
 logger.setLevel(logging.DEBUG)
 
+UI_PATH = Path(__file__).parents[0].resolve() / "ui"
+
 # don't plot more often than once per `DEFAULT_PLOT_RATE_LIMIT` seconds
 DEFAULT_PLOT_RATE_LIMIT = 0.1
 
@@ -80,10 +83,10 @@ def value(self, value):
         for callback in self._callbacks.copy():
             callback(value)
 
-    def add_callback(self, function: Callable, call_with_first_value: bool = True):
+    def add_callback(self, function: Callable, call_immediatly: bool = True):
         self._callbacks.add(function)
 
-        if call_with_first_value:
+        if call_immediatly:
             if self._value is not None:
                 function(self._value)
 
@@ -105,9 +108,7 @@ def __init__(self):
 
         # save changed settings to disk
         for _, setting in self:
-            setting.add_callback(
-                lambda _: save_settings(self), call_with_first_value=False
-            )
+            setting.add_callback(lambda _: save_settings(self), call_immediatly=False)
 
     def __iter__(self) -> Iterator[Tuple[str, Setting]]:
         for name, setting in self.__dict__.items():
@@ -133,48 +134,3 @@ def load_settings() -> Settings:
         save_settings(settings)
 
     return settings
-
-
-def save_device_data(devices) -> None:
-    with open(USER_DATA_PATH / "devices", "wb") as f:
-        pickle.dump(devices, f)
-
-
-def load_device_data() -> List[dict]:
-    try:
-        with open(USER_DATA_PATH / "devices", "rb") as f:
-            devices = pickle.load(f)
-    except (FileNotFoundError, pickle.UnpicklingError, EOFError):
-        devices = []
-
-    return devices
-
-
-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
-        try:
-            device["params"][param_name] = rpyc.classic.obtain(value)
-        except Exception:
-            logger.exception("unable to obtain and save parameter %s" % param_name)
-    else:
-        try:
-            del device["params"][param_name]
-        except KeyError:
-            pass
-
-    save_device_data(devices)
-
-
-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", {})
-    return device["params"]
diff --git a/linien-gui/linien_gui/dialogs.py b/linien-gui/linien_gui/dialogs.py
index d28f4fa6..30ee8c2b 100644
--- a/linien-gui/linien_gui/dialogs.py
+++ b/linien-gui/linien_gui/dialogs.py
@@ -17,6 +17,7 @@
 
 from typing import Callable
 
+from linien_client.device import Device
 from PyQt5.QtCore import pyqtSignal
 from PyQt5.QtWidgets import (
     QDialog,
@@ -56,7 +57,7 @@ def on_thread_finished(self):
 
 
 def show_installation_progress_widget(
-    parent: QWidget, device: dict, callback: Callable
+    parent: QWidget, device: Device, callback: Callable
 ):
     window = QDialog(parent)
     window.setWindowTitle("Deploying Linien Server")
diff --git a/linien-gui/linien_gui/threads.py b/linien-gui/linien_gui/threads.py
index 05bd990f..bab3963d 100644
--- a/linien-gui/linien_gui/threads.py
+++ b/linien-gui/linien_gui/threads.py
@@ -18,20 +18,21 @@
 
 import logging
 import traceback
+from typing import Dict, Tuple
 
 from linien_client.connection import LinienClient
 from linien_client.deploy import install_remote_server
+from linien_client.device import Device, update_device
 from linien_client.exceptions import (
     GeneralConnectionError,
     InvalidServerVersionException,
     RPYCAuthenticationException,
     ServerNotInstalledException,
 )
-from linien_common.config import DEFAULT_SERVER_PORT
+from linien_client.remote_parameters import RemoteParameter
+from linien_common.communication import RestorableParameterValues
 from PyQt5.QtCore import QObject, QThread, pyqtSignal
 
-from .config import get_saved_parameters, save_parameter
-
 logger = logging.getLogger(__name__)
 logger.setLevel(logging.DEBUG)
 
@@ -50,24 +51,19 @@ def flush(self):
 
 
 class RemoteServerInstallationThread(QThread):
-    def __init__(self, device: dict):
+    def __init__(self, device: Device) -> None:
         """A thread that installs the linien server on a remote machine."""
         super(RemoteServerInstallationThread, self).__init__()
         self.device = device
 
     out_stream = RemoteOutStream()
 
-    def run(self):
-        install_remote_server(
-            host=self.device["host"],
-            user=self.device["username"],
-            password=self.device["password"],
-            out_stream=self.out_stream,
-        )
+    def run(self) -> None:
+        install_remote_server(self.device, out_stream=self.out_stream)
 
 
 class ConnectionThread(QThread):
-    def __init__(self, device: dict):
+    def __init__(self, device: Device) -> None:
         super(ConnectionThread, self).__init__()
         self.device = device
 
@@ -78,17 +74,11 @@ def __init__(self, device: dict):
     general_connection_exception_raised = pyqtSignal()
     other_exception_raised = pyqtSignal(str)
     connection_lost = pyqtSignal()
-    ask_for_parameter_restore = pyqtSignal()
+    parameter_difference = pyqtSignal(dict)
 
-    def run(self):
+    def run(self) -> None:
         try:
-            self.client = LinienClient(
-                host=self.device["host"],
-                user=self.device["username"],
-                password=self.device["password"],
-                port=self.device.get("port", DEFAULT_SERVER_PORT),
-                name=self.device.get("name", ""),
-            )
+            self.client = LinienClient(self.device)
             self.client.connect(
                 autostart_server=True,
                 use_parameter_cache=True,
@@ -97,16 +87,16 @@ def run(self):
             self.client_connected.emit(self.client)
 
             # Check for locally cached settings for this server
-            parameters_differ = self.restore_parameters(dry_run=True)
-            if parameters_differ:
-                self.ask_for_parameter_restore.emit()
+            param_diff = self.compare_local_and_remote_parameters()
+            if param_diff:
+                self.parameter_difference.emit(param_diff)
             else:
                 # if parameters don't differ, we can start monitoring remote parameter
                 # changes and write them to disk. We don't do this if parameters differ
                 # 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.write_restorable_parameters_to_disk_on_change()
+                self.add_callbacks_to_write_parameters_to_disk_on_change()
 
         except ServerNotInstalledException:
             self.server_not_installed_exception_raised.emit()
@@ -126,62 +116,60 @@ def run(self):
             traceback.print_exc()
             self.other_exception_raised.emit(traceback.format_exc())
 
-    def on_connection_lost(self):
+    def on_connection_lost(self) -> None:
         self.connection_lost.emit()
 
-    def answer_whether_to_restore_parameters(self, should_restore):
-        if should_restore:
-            self.restore_parameters(dry_run=False)
-
-        self.write_restorable_parameters_to_disk_on_change()
-
-    def restore_parameters(self, dry_run=False):
-        """
-        Reads settings for a server that were cached locally. Sends them to the server.
-        If `dry_run` is...
-
-            * `True`, this function returns a boolean indicating whether the
-              local parameters differ from the ones on the server
-            * `False`, the local parameters are uploaded to the server
-        """
-        params = get_saved_parameters(self.device["key"])
-        logger.info("Restoring parameters")
-
-        differences = False
-
-        for k, v in params.items():
-            if hasattr(self.client.parameters, k):
-                param = getattr(self.client.parameters, k)
-                if param.value != v:
-                    if dry_run:
-                        logger.info(f"parameter {k} differs")
-                        differences = True
-                        break
-                    else:
-                        param.value = v
-            else:
-                # This may happen if the settings were written with a different version
-                # of linien.
-                logger.warning(
-                    f"Unable to restore parameter {k}. Delete the cached value."
+    def compare_local_and_remote_parameters(
+        self,
+    ) -> Dict[str, Tuple[RestorableParameterValues, RestorableParameterValues]]:
+        """Get differences between local and remote parameters."""
+        differences = {}
+        for local_param_name, local_param_value in self.device.parameters.items():
+            if hasattr(self.client.parameters, local_param_name):
+                remote_param: RemoteParameter = getattr(
+                    self.client.parameters, local_param_name
                 )
-                save_parameter(self.device["key"], k, None, delete=True)
-
-        if not dry_run:
-            self.client.control.write_registers()
-
+                if remote_param.value != local_param_value:
+                    logger.info(
+                        f"Parameter {local_param_name} differs: "
+                        f"local={local_param_value}, remote={remote_param.value}"
+                    )
+                    differences[local_param_name] = (
+                        local_param_value,
+                        remote_param.value,
+                    )
         return differences
 
-    def write_restorable_parameters_to_disk_on_change(self):
+    def restore_parameters(
+        self,
+        differences: Dict[
+            str, Tuple[RestorableParameterValues, RestorableParameterValues]
+        ],
+    ) -> None:
+        """Restore the remote parameters with the local ones."""
+        logger.info("Restoring parameters...")
+        for param_name, (local_value, remote_value) in differences.items():
+            remote_param: RemoteParameter = getattr(self.client.parameters, param_name)
+            remote_param.value = local_value
+        self.client.control.exposed_write_registers()
+        logger.info("Parameters restored.")
+
+    def add_callbacks_to_write_parameters_to_disk_on_change(self) -> None:
         """
         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.
         """
-        for parameter_name, parameter in self.client.parameters:
-            if parameter.restorable:
-
-                def on_change(value, parameter_name: str = parameter_name) -> None:
-                    save_parameter(self.device["key"], parameter_name, value)
-
-                parameter.add_callback(on_change)
+        for param_name, param in self.client.parameters:
+            if param.restorable:
+
+                def on_change(value, parameter_name: str = param_name) -> None:
+                    logger.debug(f"Parameter {parameter_name} changed to {value}")
+                    if (
+                        parameter_name not in self.device.parameters
+                        or self.device.parameters[parameter_name] != value
+                    ):
+                        self.device.parameters[parameter_name] = value
+                        update_device(self.device)
+
+                param.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 407fcab0..6adc5124 100644
--- a/linien-gui/linien_gui/ui/device_manager.py
+++ b/linien-gui/linien_gui/ui/device_manager.py
@@ -16,8 +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 typing import Dict, Tuple
+
 import linien_gui
-from linien_gui.config import load_device_data, save_device_data
+from linien_client.device import Device, delete_device, load_device_list, move_device
+from linien_common.communication import RestorableParameterValues
+from linien_gui.config import UI_PATH
 from linien_gui.dialogs import (
     LoadingDialog,
     ask_for_parameter_restore_dialog,
@@ -28,14 +32,14 @@
 from linien_gui.threads import ConnectionThread
 from linien_gui.ui.new_device_dialog import NewDeviceDialog
 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
-from PyQt5.QtWidgets import QPushButton
+from PyQt5.QtWidgets import QListWidget, QPushButton
 
 
 class DeviceManager(QtWidgets.QMainWindow):
     addButton: QPushButton
     connectButton: QPushButton
+    deviceList: QListWidget
     editButton: QPushButton
     moveDownButton: QPushButton
     moveUpButton: QPushButton
@@ -47,7 +51,7 @@ def __init__(self, *args, **kwargs):
         self.setWindowTitle(f"Linien spectroscopy lock v{linien_gui.__version__}")
         set_window_icon(self)
         self.app = get_linien_app_instance()
-        QtCore.QTimer.singleShot(100, lambda: self.load_device_data(autoload=True))
+        QtCore.QTimer.singleShot(100, lambda: self.populate_device_list())
 
     def keyPressEvent(self, event):
         key = event.key()
@@ -55,27 +59,21 @@ def keyPressEvent(self, event):
         if key in (QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return):
             self.connect()
 
-    def load_device_data(self, autoload=False):
-        devices = load_device_data()
-        lst = self.deviceList
-        lst.clear()
-
-        for device in devices:
-            lst.addItem("{} ({})".format(device["name"], device["host"]))
-
-        if autoload and len(devices) == 1:
-            self.connect_to_device(devices[0])
+    def populate_device_list(self):
+        self.devices = load_device_list()
 
-    def connect(self):
-        devices = load_device_data()
+        self.deviceList.clear()
+        for device in self.devices:
+            self.deviceList.addItem(f"{device.name} ({device.host})")
 
-        if not devices:
+    def connect(self) -> None:
+        if not self.devices:
             return
         else:
-            self.connect_to_device(devices[self.get_list_index()])
+            self.connect_to_device(self.devices[self.get_list_index()])
 
-    def connect_to_device(self, device: dict):
-        loading_dialog = LoadingDialog(self, device["host"])
+    def connect_to_device(self, device: Device):
+        loading_dialog = LoadingDialog(self, device.host)
         loading_dialog.show()
 
         aborted = {}
@@ -154,7 +152,11 @@ def handle_other_exception(exception):
                 )
                 error_dialog(self, display_error)
 
-        def ask_for_parameter_restore():
+        def ask_for_parameter_restore(
+            parameter_difference: Dict[
+                str, Tuple[RestorableParameterValues, RestorableParameterValues]
+            ]
+        ) -> None:
             question = (
                 "Linien on RedPitaya is running with different parameters than the "
                 "ones saved locally on this machine. Do you want to upload the local "
@@ -166,7 +168,9 @@ def ask_for_parameter_restore():
             should_restore = ask_for_parameter_restore_dialog(
                 self, question, "Restore parameters?"
             )
-            self.connection_thread.answer_whether_to_restore_parameters(should_restore)
+            if should_restore:
+                self.connection_thread.restore_parameters(parameter_difference)
+            self.connection_thread.add_callbacks_to_write_parameters_to_disk_on_change()
 
         def handle_connection_lost():
             error_dialog(self, "Lost connection to the server!")
@@ -188,96 +192,72 @@ def handle_connection_lost():
             handle_general_connection_error
         )
         self.connection_thread.other_exception_raised.connect(handle_other_exception)
-        self.connection_thread.ask_for_parameter_restore.connect(
-            ask_for_parameter_restore
-        )
+        self.connection_thread.parameter_difference.connect(ask_for_parameter_restore)
         self.connection_thread.connection_lost.connect(handle_connection_lost)
 
         # Start the worker -------------------------------------------------------------
         self.connection_thread.start()
 
-    def new_device(self):
+    def get_list_index(self):
+        """Get the currently selected device index from the device list."""
+        return self.deviceList.currentIndex().row()
+
+    def reload_device_data(self) -> None:
+        # not very elegant...
+        QtCore.QTimer.singleShot(100, self.populate_device_list)
+
+    def new_device(self) -> None:
+        """Open the dialog to create a new device."""
         self.dialog = NewDeviceDialog()
         self.dialog.setModal(True)
         self.dialog.show()
+        self.dialog.accepted.connect(self.reload_device_data)
 
-        def reload_device_data():
-            # not very elegant...
-            QtCore.QTimer.singleShot(100, self.load_device_data)
-
-        self.dialog.accepted.connect(reload_device_data)
-
-    def edit_device(self):
-        devices = load_device_data()
-
-        if not devices:
-            return
-
-        device = devices[self.get_list_index()]
+    def edit_device(self) -> None:
+        """Open the dialog to edit the currently selected device."""
+        device = self.devices[self.get_list_index()]
 
         self.dialog = NewDeviceDialog(device)
         self.dialog.setModal(True)
         self.dialog.show()
-
-        def reload_device_data():
-            # not very elegant...
-            QtCore.QTimer.singleShot(100, self.load_device_data)
-
-        self.dialog.accepted.connect(reload_device_data)
-
-    def move_device_up(self):
-        self.move_device(-1)
-
-    def move_device_down(self):
-        self.move_device(1)
-
-    def move_device(self, direction):
-        devices = load_device_data()
-
-        if not devices:
-            return
-
-        current_index = self.get_list_index()
-        new_index = current_index + direction
-
-        if new_index < 0 or new_index > len(devices) - 1:
-            return
-
-        device = devices.pop(current_index)
-        devices = devices[:new_index] + [device] + devices[new_index:]
-        save_device_data(devices)
-        self.load_device_data()
-        self.deviceList.setCurrentRow(new_index)
-
-    def get_list_index(self):
-        return self.deviceList.currentIndex().row()
-
-    def remove_device(self):
-        devices = load_device_data()
-
-        if not devices:
-            return
-
-        devices.pop(self.get_list_index())
-        save_device_data(devices)
-        self.load_device_data()
-
-    def selected_device_changed(self):
-        idx = self.get_list_index()
-
+        self.dialog.accepted.connect(self.reload_device_data)
+
+    def move_device_up(self) -> None:
+        """Move the currently selected device up in the list."""
+        self.move_device_in_list(-1)
+
+    def move_device_down(self) -> None:
+        """Move the currently selected device down in the list."""
+        self.move_device_in_list(1)
+
+    def move_device_in_list(self, direction: int) -> None:
+        """Move the currently selected device in the list by the given direction."""
+        selected_index = self.get_list_index()
+        selected_device = self.devices[selected_index]
+        move_device(selected_device, direction)
+        self.populate_device_list()
+        self.deviceList.setCurrentRow(selected_index + direction)
+
+    def remove_device(self) -> None:
+        """
+        Remove the currently selected device from the list and save new list to disk.
+        """
+        selected_device = self.devices[self.get_list_index()]
+        delete_device(selected_device)
+        self.populate_device_list()
+
+    def selected_device_changed(self) -> None:
         disable_buttons = True
 
-        if idx >= 0:
-            devices = load_device_data()
-
-            if devices:
+        if self.get_list_index() >= 0:
+            if self.devices:
                 disable_buttons = False
 
-        for btn in [
+        for button in [
             self.connectButton,
             self.removeButton,
             self.editButton,
             self.moveUpButton,
             self.moveDownButton,
         ]:
-            btn.setEnabled(not disable_buttons)
+            button.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 688c23cf..53ff7ed1 100644
--- a/linien-gui/linien_gui/ui/general_panel.py
+++ b/linien-gui/linien_gui/ui/general_panel.py
@@ -22,9 +22,9 @@
     OutputChannel,
     convert_channel_mixing_value,
 )
+from linien_gui.config import UI_PATH
 from linien_gui.ui.spin_box import CustomDoubleSpinBoxNoSign
 from linien_gui.utils import get_linien_app_instance, param2ui
-from linien_gui.widgets import UI_PATH
 from PyQt5 import QtWidgets, uic
 
 
diff --git a/linien-gui/linien_gui/ui/locking_panel.py b/linien-gui/linien_gui/ui/locking_panel.py
index 6a3bcc43..86d8c17a 100644
--- a/linien-gui/linien_gui/ui/locking_panel.py
+++ b/linien-gui/linien_gui/ui/locking_panel.py
@@ -17,10 +17,10 @@
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
 from linien_common.common import AutolockMode
+from linien_gui.config import UI_PATH
 from linien_gui.ui.lock_status_panel import LockStatusPanel
 from linien_gui.ui.spin_box import CustomSpinBox
 from linien_gui.utils import get_linien_app_instance, param2ui
-from linien_gui.widgets import UI_PATH
 from PyQt5 import QtWidgets, uic
 
 
diff --git a/linien-gui/linien_gui/ui/logging_panel.py b/linien-gui/linien_gui/ui/logging_panel.py
index cdb0d822..a217565f 100644
--- a/linien-gui/linien_gui/ui/logging_panel.py
+++ b/linien-gui/linien_gui/ui/logging_panel.py
@@ -16,12 +16,11 @@
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
 import logging
-import pickle
 
 from linien_client.remote_parameters import RemoteParameters
 from linien_common.influxdb import InfluxDBCredentials
+from linien_gui.config import UI_PATH
 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
 
@@ -71,7 +70,7 @@ def on_connection_established(self) -> None:
         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())
+        credentials = self.control.exposed_get_influxdb_credentials()
         logger.debug("Received InfluxDB credentials from server.")
         self.lineEditURL.setText(credentials.url)
         self.lineEditOrg.setText(credentials.org)
diff --git a/linien-gui/linien_gui/ui/main_window.py b/linien-gui/linien_gui/ui/main_window.py
index 4b323a99..5ca799a2 100644
--- a/linien-gui/linien_gui/ui/main_window.py
+++ b/linien-gui/linien_gui/ui/main_window.py
@@ -16,22 +16,20 @@
 # 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
 import logging
 import pickle
 from math import log
-from time import time
 
 import linien_gui
 import numpy as np
+from linien_client.device import add_device, load_device, update_device
 from linien_common.common import check_plot_data
-from linien_gui.config import N_COLORS, Color
+from linien_gui.config import N_COLORS, UI_PATH, Color
 from linien_gui.ui.plot_widget import INVALID_POWER
 from linien_gui.ui.right_panel import RightPanel
 from linien_gui.ui.spin_box import CustomDoubleSpinBox
 from linien_gui.ui.sweep_control import SweepControlWidget, SweepSlider
 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
@@ -69,8 +67,8 @@ class MainWindow(QtWidgets.QMainWindow):
     legend_monitor_signal_history: QtWidgets.QLabel
     legend_slow_signal_history: QtWidgets.QLabel
     rightPanel: RightPanel
-    export_parameters_button: QtWidgets.QPushButton
-    import_parameters_button: QtWidgets.QPushButton
+    exportParametersButton: QtWidgets.QPushButton
+    importParametersButton: QtWidgets.QPushButton
     newVersionAvailableLabel: QtWidgets.QLabel
     pid_parameter_optimization_button: QtWidgets.QPushButton
     settings_toolbox: QtWidgets.QToolBox
@@ -96,10 +94,8 @@ def __init__(self, *args, **kwargs):
         # handle keyboard events
         self.setFocus()
 
-        self.export_parameters_button.clicked.connect(
-            self.export_parameters_select_file
-        )
-        self.import_parameters_button.clicked.connect(self.import_parameters)
+        self.exportParametersButton.clicked.connect(self.export_parameters)
+        self.importParametersButton.clicked.connect(self.import_parameters)
 
         def display_power(power, element):
             if power == INVALID_POWER:
@@ -209,9 +205,9 @@ def show_new_version_available(self):
         self.newVersionAvailableLabel.show()
 
     def handle_key_press(self, key):
-        logger.debug("key pressed %s" % key)
+        logger.debug(f"key pressed {key}")
 
-    def export_parameters_select_file(self):
+    def export_parameters(self):
         options = QtWidgets.QFileDialog.Options()
         # options |= QtWidgets.QFileDialog.DontUseNativeDialog
         default_ext = ".json"
@@ -219,26 +215,21 @@ def export_parameters_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
 
-            with open(fn, "w") as f:
-                json.dump(
-                    {
-                        "linien-version": linien_gui.__version__,
-                        "time": time(),
-                        "parameters": {
-                            name: param.value
-                            for name, param in self.parameters
-                            if param.restorable
-                        },
-                    },
-                    f,
+            try:
+                add_device(self.app.client.device, path=fn)
+            except KeyError:
+                logger.warning(
+                    f"Device with key {self.app.client.device.key} already exists in"
+                    f"{fn}. Updating the device instead."
                 )
+                update_device(self.app.client.device, path=fn)
 
     def import_parameters(self):
         options = QtWidgets.QFileDialog.Options()
@@ -250,18 +241,16 @@ def import_parameters(self):
             options=options,
         )
         if fn:
-            with open(fn, "r") as f:
-                data = json.load(f)
-
-            if "linien-version" not in data:
-                raise Exception("invalid parameter file")
-
-            for name, value in data["parameters"].items():
-                param = getattr(self.parameters, name)
-                if param.restorable:
+            try:
+                self.app.client.device = load_device(
+                    self.app.client.device.key, path=fn
+                )
+                for name, value in self.app.client.device.parameters.items():
+                    param = getattr(self.app.client.parameters, name)
                     param.value = value
-
-            self.control.write_registers()
+                self.control.exposed_write_registers()
+            except KeyError:
+                logger.error("Unable to load device from file. Key doesn't exist.")
 
     def update_std(self, to_plot, max_std_history_length=10):
         if self.parameters.lock.value and to_plot:
@@ -281,7 +270,7 @@ def update_std(self, to_plot, max_std_history_length=10):
                 ]
 
                 if error_signal is not None and control_signal is not None:
-                    self.error_std.setText("%.2f" % np.mean(self.error_std_history))
+                    self.error_std.setText(f"{np.mean(self.error_std_history):.2f}")
                     self.control_std.setText(f"{np.mean(self.control_std_history):.2f}")
 
     def reset_std_history(self):
diff --git a/linien-gui/linien_gui/ui/main_window.ui b/linien-gui/linien_gui/ui/main_window.ui
index 47d80853..24cb6eda 100644
--- a/linien-gui/linien_gui/ui/main_window.ui
+++ b/linien-gui/linien_gui/ui/main_window.ui
@@ -576,14 +576,14 @@ p, li { white-space: pre-wrap; }
        <item>
         <layout class="QHBoxLayout" name="horizontalLayout_2">
          <item>
-          <widget class="QPushButton" name="export_parameters_button">
+          <widget class="QPushButton" name="exportParametersButton">
            <property name="text">
             <string>Export settings</string>
            </property>
           </widget>
          </item>
          <item>
-          <widget class="QPushButton" name="import_parameters_button">
+          <widget class="QPushButton" name="importParametersButton">
            <property name="text">
             <string>Import settings</string>
            </property>
diff --git a/linien-gui/linien_gui/ui/modulation_sweep_panel.py b/linien-gui/linien_gui/ui/modulation_sweep_panel.py
index 3329c154..4a48b820 100644
--- a/linien-gui/linien_gui/ui/modulation_sweep_panel.py
+++ b/linien-gui/linien_gui/ui/modulation_sweep_panel.py
@@ -17,9 +17,9 @@
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
 from linien_common.common import MHz, Vpp
+from linien_gui.config import UI_PATH
 from linien_gui.ui.spin_box import CustomDoubleSpinBoxNoSign
 from linien_gui.utils import get_linien_app_instance, param2ui
-from linien_gui.widgets import UI_PATH
 from PyQt5 import QtWidgets, uic
 
 
diff --git a/linien-gui/linien_gui/ui/new_device_dialog.py b/linien-gui/linien_gui/ui/new_device_dialog.py
index 334226c7..6a8cc977 100644
--- a/linien-gui/linien_gui/ui/new_device_dialog.py
+++ b/linien-gui/linien_gui/ui/new_device_dialog.py
@@ -15,12 +15,11 @@
 # You should have received a copy of the GNU General Public License
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
-import random
-import string
 
-from linien_common.config import DEFAULT_SERVER_PORT
-from linien_gui.config import load_device_data, save_device_data
-from linien_gui.widgets import UI_PATH
+from typing import Optional
+
+from linien_client.device import Device, add_device, update_device
+from linien_gui.config import UI_PATH
 from PyQt5 import QtWidgets, uic
 
 
@@ -30,36 +29,34 @@ class NewDeviceDialog(QtWidgets.QDialog):
     username: QtWidgets.QLineEdit
     password: QtWidgets.QLineEdit
     port: QtWidgets.QSpinBox
-    explain_host: QtWidgets.QLabel
+    explainHostLabel: QtWidgets.QLabel
 
-    def __init__(self, initial_device=None):
+    def __init__(self, device: Optional[Device] = None) -> None:
         super(NewDeviceDialog, self).__init__()
         uic.loadUi(UI_PATH / "new_device_dialog.ui", self)
 
-        if initial_device is not None:
-            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"]
+        if device is None:
+            self.is_new_cevice = True
+            self.device = Device()  # create a new empty device
         else:
-            self.key = "".join(random.choice(string.ascii_lowercase) for i in range(10))
+            self.is_new_cevice = False
+            self.device = device
+            self.explainHostLabel.setVisible(False)
 
-    def add_new_device(self):
-        device = {
-            "key": self.key,
-            "name": self.deviceName.text(),
-            "host": self.host.text(),
-            "username": self.username.text(),
-            "password": self.password.text(),
-            "port": self.port.value(),
-            "params": {},
-        }
+        self.deviceName.setText(self.device.name)
+        self.host.setText(self.device.host)
+        self.username.setText(self.device.username)
+        self.password.setText(self.device.password)
+        self.port.setValue(self.device.port)
 
-        old_devices = [
-            device for device in load_device_data() if device["key"] != self.key
-        ]
-        devices = old_devices + [device]
-        save_device_data(devices)
+    def add_new_device(self):
+        self.device.name = self.deviceName.text()
+        self.device.host = self.host.text()
+        self.device.username = self.username.text()
+        self.device.password = self.password.text()
+        self.device.port = self.port.value()
+
+        if self.is_new_cevice:
+            add_device(self.device)
+        else:
+            update_device(self.device)
diff --git a/linien-gui/linien_gui/ui/new_device_dialog.ui b/linien-gui/linien_gui/ui/new_device_dialog.ui
index ff6eb1af..7d5790cf 100644
--- a/linien-gui/linien_gui/ui/new_device_dialog.ui
+++ b/linien-gui/linien_gui/ui/new_device_dialog.ui
@@ -57,7 +57,7 @@
             </widget>
            </item>
            <item>
-            <widget class="QLabel" name="explain_host">
+            <widget class="QLabel" name="explainHostLabel">
              <property name="text">
               <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;&lt;span style=&quot; font-weight:600;&quot;&gt;xxxxxx&lt;/span&gt; are the last 6 characters from MAC address of your RedPitaya. The MAC address is written on the Ethernet connector.&lt;/p&gt;&lt;p&gt;&lt;span style=&quot; font-weight:600;&quot;&gt;Note:&lt;/span&gt; If connections fails using host name, try using ip address of RedPitaya instead.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
              </property>
diff --git a/linien-gui/linien_gui/ui/optimization_panel.py b/linien-gui/linien_gui/ui/optimization_panel.py
index 44138441..740927ed 100644
--- a/linien-gui/linien_gui/ui/optimization_panel.py
+++ b/linien-gui/linien_gui/ui/optimization_panel.py
@@ -17,9 +17,9 @@
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
 from linien_common.common import MHz, Vpp
+from linien_gui.config import UI_PATH
 from linien_gui.ui.spin_box import CustomDoubleSpinBoxNoSign
 from linien_gui.utils import get_linien_app_instance, param2ui
-from linien_gui.widgets import UI_PATH
 from PyQt5 import QtWidgets, uic
 
 
@@ -122,27 +122,23 @@ def mod_param_changed(_):
             dual_channel = self.parameters.dual_channel.value
             channel = self.parameters.optimization_channel.value
             optimized = self.parameters.optimization_optimized_parameters.value
-
+            mod_phase = (
+                self.parameters.demodulation_phase_a,
+                self.parameters.demodulation_phase_b,
+            )[0 if not dual_channel else (0, 1)[channel]].value
             self.optimization_display_parameters.setText(
                 (
                     "<br />\n"
                     "<b>current parameters</b>: "
-                    " %.2f&nbsp;MHz, %.2f&nbsp;Vpp, %.2f&nbsp;deg<br />\n"
+                    f"{self.parameters.modulation_frequency.value / MHz:.2f}&nbsp;MHz, "
+                    f"{self.parameters.modulation_amplitude.value / Vpp:.2f}&nbsp;Vpp, "
+                    f"{mod_phase:.2f}&nbsp;deg<br />\n"
                     "<b>optimized parameters</b>: "
-                    "%.2f&nbsp;MHz, %.2f&nbsp;Vpp, %.2f&nbsp;deg\n"
+                    f"{optimized[0] / MHz:.2f}&nbsp;MHz, "
+                    f"{optimized[1] / Vpp:.2f}&nbsp;Vpp, "
+                    f"{optimized[2]:.2f}&nbsp;deg\n"
                     "<br />"
                 )
-                % (
-                    self.parameters.modulation_frequency.value / MHz,
-                    self.parameters.modulation_amplitude.value / Vpp,
-                    (
-                        self.parameters.demodulation_phase_a,
-                        self.parameters.demodulation_phase_b,
-                    )[0 if not dual_channel else (0, 1)[channel]].value,
-                    optimized[0] / MHz,
-                    optimized[1] / Vpp,
-                    optimized[2],
-                )
             )
 
         for param in (
diff --git a/linien-gui/linien_gui/ui/psd_plot_widget.py b/linien-gui/linien_gui/ui/psd_plot_widget.py
index 89964231..038aaa20 100644
--- a/linien-gui/linien_gui/ui/psd_plot_widget.py
+++ b/linien-gui/linien_gui/ui/psd_plot_widget.py
@@ -34,7 +34,7 @@ def logTickStrings(self, values, scale, spacing):
         # this method is mainly taken from pyqtgraph, just taking care that negative
         # exponents are also displayed in scientific notation
         estrings = [
-            "%0.1e" % x for x in 10 ** np.array(values).astype(float) * np.array(scale)
+            f"{x:0.1e}" for x in 10 ** np.array(values).astype(float) * np.array(scale)
         ]
 
         convdict = {
@@ -81,7 +81,7 @@ def __init__(self, *args, **kwargs):
                 "bottom": CustomLogAxis(orientation="bottom"),
                 "left": CustomLogAxis(orientation="left"),
             },
-            **kwargs
+            **kwargs,
         )
         self.app = get_linien_app_instance()
         self.app.connection_established.connect(self.on_connection_established)
@@ -193,7 +193,7 @@ def mouseMoved(self, evt):
 
             # if index > 0 and index < self.MFmax:
             self.cursor_label.setHtml(
-                "<span style='font-size: 12pt'>(%.1e,%.1e)</span>" % (10**x, 10**y)
+                f"<span style='font-size: 12pt'>({10**x:.1e},{10**y:.1e})</span>"
             )
             # this determines whether cursor label is on right or left side of
             # crosshair
diff --git a/linien-gui/linien_gui/ui/psd_table_widget.py b/linien-gui/linien_gui/ui/psd_table_widget.py
index f276bbdd..48d99eeb 100644
--- a/linien-gui/linien_gui/ui/psd_table_widget.py
+++ b/linien-gui/linien_gui/ui/psd_table_widget.py
@@ -76,7 +76,7 @@ def create_item(text):
         self.setItem(row_count, 3, create_item(data["p"]))
         self.setItem(row_count, 4, create_item(data["i"]))
         self.setItem(row_count, 5, create_item(data["d"]))
-        self.setItem(row_count, 6, create_item("%.4f" % data["fitness"]))
+        self.setItem(row_count, 6, create_item(f"{data['fitness']:.4f}"))
 
         self.resizeColumnsToContents()
 
diff --git a/linien-gui/linien_gui/ui/psd_window.py b/linien-gui/linien_gui/ui/psd_window.py
index 6989f7c6..dea1dc7e 100644
--- a/linien-gui/linien_gui/ui/psd_window.py
+++ b/linien-gui/linien_gui/ui/psd_window.py
@@ -21,6 +21,7 @@
 
 import linien_gui
 from linien_common.common import PSDAlgorithm
+from linien_gui.config import UI_PATH
 from linien_gui.dialogs import error_dialog
 from linien_gui.utils import (
     RandomColorChoser,
@@ -28,7 +29,6 @@
     param2ui,
     set_window_icon,
 )
-from linien_gui.widgets import UI_PATH
 from PyQt5 import QtWidgets, uic
 
 
@@ -78,11 +78,7 @@ def on_connection_established(self):
             self.maximum_measurement_time,
             lambda max_decimation: max_decimation - 12,
         )
-        param2ui(
-            self.parameters.psd_algorithm,
-            self.psd_algorithm,
-            lambda algo: {PSDAlgorithm.LPSD: 0, PSDAlgorithm.WELCH: 1}[algo],
-        )
+        param2ui(self.parameters.psd_algorithm, self.psd_algorithm)
 
         def update_status(_):
             psd_running = self.parameters.psd_acquisition_running.value
@@ -167,7 +163,7 @@ def export_psd(self):
             self,
             "QFileDialog.getSaveFileName()",
             "",
-            "PICKLE (*%s)" % default_ext,
+            f"PICKLE (*{default_ext})",
             options=options,
         )
         if fn:
@@ -192,7 +188,7 @@ def import_psd(self):
             self,
             "QFileDialog.getSaveFileName()",
             "",
-            "JSON (*%s)" % default_ext,
+            f"JSON (*{default_ext})",
             options=options,
         )
         if fn:
diff --git a/linien-gui/linien_gui/ui/spectroscopy_panel.py b/linien-gui/linien_gui/ui/spectroscopy_panel.py
index 0c7fa9e9..b4c76aa3 100644
--- a/linien-gui/linien_gui/ui/spectroscopy_panel.py
+++ b/linien-gui/linien_gui/ui/spectroscopy_panel.py
@@ -20,9 +20,9 @@
 
 from linien_client.remote_parameters import RemoteParameter
 from linien_common.common import FilterType
+from linien_gui.config import UI_PATH
 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
 
 
@@ -82,17 +82,18 @@ def change_filter_type(filter_i):
         )
 
         for filter_i in [1, 2]:
-            _get = lambda parent, attr, filter_i=filter_i: getattr(
-                parent, attr.format(filter_i)
-            )
-            _get(self, "filter_{}_enabled").stateChanged.connect(
-                _get(self, "change_filter_{}_enabled")
+
+            def get_(parent, attr, filter_i=filter_i):
+                return getattr(parent, attr.format(filter_i))
+
+            get_(self, "filter_{}_enabled").stateChanged.connect(
+                get_(self, "change_filter_{}_enabled")
             )
-            freq_input = _get(self, "filter_{}_frequency")
+            freq_input = get_(self, "filter_{}_frequency")
             freq_input.setKeyboardTracking(False)
-            freq_input.valueChanged.connect(_get(self, "change_filter_{}_frequency"))
-            _get(self, "filter_{}_type").currentIndexChanged.connect(
-                _get(self, "change_filter_{}_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):
@@ -152,9 +153,9 @@ def change_signal_offset(self):
         self.control.exposed_write_registers()
 
     def change_demod_phase(self):
-        self.get_param(
-            "demodulation_phase"
-        ).value = self.demodulationPhaseSpinBox.value()
+        self.get_param("demodulation_phase").value = (
+            self.demodulationPhaseSpinBox.value()
+        )
         self.control.exposed_write_registers()
 
     def change_demod_multiplier(self, idx):
diff --git a/linien-gui/linien_gui/ui/view_panel.py b/linien-gui/linien_gui/ui/view_panel.py
index 497e293b..d39d3857 100644
--- a/linien-gui/linien_gui/ui/view_panel.py
+++ b/linien-gui/linien_gui/ui/view_panel.py
@@ -21,10 +21,9 @@
 from os import path
 
 import numpy as np
-from linien_gui.config import N_COLORS
+from linien_gui.config import N_COLORS, UI_PATH
 from linien_gui.ui.spin_box import CustomDoubleSpinBoxNoSign, CustomSpinBox
 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
 
 
diff --git a/linien-gui/linien_gui/widgets.py b/linien-gui/linien_gui/widgets.py
deleted file mode 100644
index afccadfe..00000000
--- a/linien-gui/linien_gui/widgets.py
+++ /dev/null
@@ -1,21 +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/>.
-
-from pathlib import Path
-
-UI_PATH = Path(__file__).parents[0].resolve() / "ui"
diff --git a/linien-gui/pyproject.toml b/linien-gui/pyproject.toml
new file mode 100644
index 00000000..db44431f
--- /dev/null
+++ b/linien-gui/pyproject.toml
@@ -0,0 +1,51 @@
+[build-system]
+requires = ["setuptools>=61.2"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "linien-gui"
+version = "2.0.0"
+authors = [
+    { name = "Benjamin Wiegand", email = "benjamin.wiegand@physik.hu-berlin.de" },
+    { name = "Bastian Leykauf", email = "leykauf@physik.hu-berlin.de" },
+    { name = "Robert Jördens", email = "rj@quartiq.de" },
+    { name = "Christian Freier", email = "christian.freier@gmail.com" },
+    { name = "Doron Behar", email = "doron.behar@gmail.com" },
+]
+maintainers = [
+    { name = "Bastian Leykauf", email = "leykauf@physik.hu-berlin.de" },
+]
+description = "Graphical user interface of the Linien spectroscopy lock application."
+classifiers = [
+    "Programming Language :: Python :: 3",
+    "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
+    "Operating System :: OS Independent",
+]
+requires-python = ">=3.8"
+dependencies = [
+    "click>=8.1.7,<9.0",
+    "pyqtgraph>=0.10.0",
+    "PyQt5>=5.12.0,<6.0",
+    "requests>=2.31.0,<3.0",
+    "superqt>=0.2.3",
+    "linien_client==2.0.0",
+]
+
+[project.readme]
+text = "Have a look at the [project repository](https://github.com/linien-org/linien) for installation instructions."
+content-type = "text/markdown"
+
+[project.urls]
+Homepage = "https://github.com/linien-org/linien"
+
+[project.scripts]
+linien = "linien_gui.app:main"
+
+[tool.setuptools]
+include-package-data = false
+
+[tool.setuptools.packages.find]
+namespaces = false
+
+[tool.setuptools.package-data]
+linien_gui = ["*.ui"]
diff --git a/linien-gui/setup.py b/linien-gui/setup.py
deleted file mode 100644
index 0fc3ef06..00000000
--- a/linien-gui/setup.py
+++ /dev/null
@@ -1,51 +0,0 @@
-# Copyright 2018-2022 Benjamin Wiegand <benjamin.wiegand@physik.hu-berlin.de>
-# Copyright 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/>.
-
-from setuptools import find_packages, setup
-
-setup(
-    name="linien-gui",
-    version="1.0.2",
-    author="Benjamin Wiegand",
-    author_email="highwaychile@posteo.de",
-    maintainer="Bastian Leykauf",
-    maintainer_email="leykauf@physik.hu-berlin.de",
-    description="Graphical user interface of the Linien spectroscopy lock application.",
-    long_description="Have a look at the [project repository](https://github.com/linien-org/linien) for installation instructions.",  # noqa: E501
-    long_description_content_type="text/markdown",
-    url="https://github.com/linien-org/linien",
-    packages=find_packages(),
-    classifiers=[
-        "Programming Language :: Python :: 3",
-        "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
-        "Operating System :: OS Independent",
-    ],
-    entry_points={"console_scripts": ["linien=linien_gui.app:run_application"]},
-    python_requires=">=3.8",
-    install_requires=[
-        "click>=7.1.2",
-        "pyqtgraph>=0.10.0",
-        "PyQt5>=5.12.0",
-        "superqt>=0.2.3",
-        "linien_client==1.0.2",
-    ],
-    package_data={
-        # IMPORTANT: any changes have to be made in pyinstaller.spec, too
-        "": ["*.ui", "*.ico"]
-    },
-)
diff --git a/linien-server/linien_server/acquisition.py b/linien-server/linien_server/acquisition.py
index 7d5a4aa9..39a6921a 100644
--- a/linien-server/linien_server/acquisition.py
+++ b/linien-server/linien_server/acquisition.py
@@ -18,13 +18,12 @@
 
 import logging
 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
+from typing import Any, Optional
 
 import numpy as np
 from linien_common.common import DECIMATION, MAX_N_POINTS, N_POINTS
@@ -40,20 +39,20 @@
 
 
 class AcquisitionService(Service):
-    def __init__(self):
+    def __init__(self) -> None:
         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 = []
+        self.csr_queue: list[tuple[str, int]] = []
+        self.csr_iir_queue: list[tuple[str, list[float], list[float]]] = []
 
-        self.data = pickle.dumps(None)
+        self.data: bytes | None = pickle.dumps(None)
         self.data_was_raw = False
-        self.data_hash = None
-        self.data_uuid = None
+        self.data_hash: float | None = None
+        self.data_uuid: float | None = None
 
         self.locked = False
         self.exposed_set_sweep_speed(9)
@@ -94,12 +93,12 @@ def _acquisition_loop(
                 self.csr.set(key, value)
 
             while self.csr_iir_queue:
-                args = self.csr_iir_queue.pop(0)
-                self.csr.set_iir(*args)
+                name, b, a = self.csr_iir_queue.pop(0)
+                self.csr.set_iir(name, b, a)
 
             if self.locked and not self.confirmed_that_in_lock:
-                self.confirmed_that_in_lock = self.csr.get(
-                    "logic_autolock_lock_running"
+                self.confirmed_that_in_lock = bool(
+                    self.csr.get("logic_autolock_lock_running")
                 )
                 if not self.confirmed_that_in_lock:
                     sleep(0.05)
@@ -142,7 +141,7 @@ def _acquisition_loop(
 
             self.program_acquisition_and_rearm()
 
-    def read_data(self) -> Dict[str, np.ndarray]:
+    def read_data(self) -> dict[str, np.ndarray]:
         signals = []
 
         channel_offsets = [0x10000]
@@ -189,7 +188,7 @@ def read_data(self) -> Dict[str, np.ndarray]:
 
     def read_data_raw(
         self, offset: int, addr: int, data_length: int
-    ) -> Tuple[Any, ...]:
+    ) -> tuple[Any, ...]:
         max_data_length = 16383
         if data_length + addr > max_data_length:
             to_read_later = data_length + addr - max_data_length
@@ -240,14 +239,12 @@ def program_acquisition_and_rearm(self, trigger_delay=16384):
 
         self.red_pitaya.scope.rearm(trigger_source=TriggerSource.ext_posedge)
 
-    def exposed_return_data(
-        self, last_hash: Optional[float]
-    ) -> Tuple[
+    def exposed_return_data(self, last_hash: Optional[float]) -> tuple[
         bool,
-        Union[float, None],
-        Union[bool, None],
-        Union[bytes, None],
-        Union[float, None],
+        float | None,
+        bool | None,
+        bytes | None,
+        float | None,
     ]:
         no_data_available = self.data_hash is None
         data_not_changed = self.data_hash == last_hash
@@ -279,8 +276,8 @@ def exposed_set_dual_channel(self, 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_set_iir_csr(self, name: str, b: list[float], a: list[float]) -> None:
+        self.csr_iir_queue.append((name, b, a))
 
     def exposed_stop_acquisition(self) -> None:
         self.stop_event.set()
@@ -311,18 +308,8 @@ def exposed_continue_acquisition(self, uuid: Optional[float]) -> None:
 
 def flash_fpga():
     filepath = Path(__file__).resolve().parent / "gateware.bin"
-
-    # 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")
-
-    if fpga_dev_file.exists():
-        logger.info("Copying gateware to %s" % fpga_dev_file)
-        shutil.copy(str(filepath), str(fpga_dev_file))
-    else:
-        logger.info("Using fpgautil to deploy gateware.")
-        subprocess.Popen(["/opt/redpitaya/bin/fpgautil", "-b", str(filepath)]).wait()
+    logger.info("Using fpgautil to deploy gateware.")
+    subprocess.Popen(["/opt/redpitaya/bin/fpgautil", "-b", str(filepath)]).wait()
 
 
 def start_nginx():
@@ -336,5 +323,5 @@ def stop_nginx():
 
 if __name__ == "__main__":
     threaded_server = ThreadedServer(AcquisitionService(), port=ACQUISITION_PORT)
-    logger.info("Starting AcquisitionService on port %s" % ACQUISITION_PORT)
+    logger.info(f"Starting AcquisitionService on port {ACQUISITION_PORT}")
     threaded_server.start()
diff --git a/linien-server/linien_server/autolock/algorithm_selection.py b/linien-server/linien_server/autolock/algorithm_selection.py
index 7ac9ef8b..54884f89 100644
--- a/linien-server/linien_server/autolock/algorithm_selection.py
+++ b/linien-server/linien_server/autolock/algorithm_selection.py
@@ -66,7 +66,7 @@ def check(self):
             ]
             max_shift = max(abs_shifts)
             logger.debug(
-                "jitter / line width ratio: %s" % (max_shift / (self.line_width / 2))
+                f"jitter / line width ratio: {max_shift / (self.line_width / 2)}"
             )
 
             if max_shift <= self.line_width / 2:
diff --git a/linien-server/linien_server/autolock/autolock.py b/linien-server/linien_server/autolock/autolock.py
index a22599ca..feecf4d7 100644
--- a/linien-server/linien_server/autolock/autolock.py
+++ b/linien-server/linien_server/autolock/autolock.py
@@ -19,17 +19,23 @@
 import logging
 import pickle
 
-from linien_common.common import check_plot_data, combine_error_signal, get_lock_point
+from linien_common.common import (
+    SpectrumUncorrelatedException,
+    check_plot_data,
+    combine_error_signal,
+    get_lock_point,
+)
 from linien_server.autolock.algorithm_selection import AutolockAlgorithmSelector
 from linien_server.autolock.robust import RobustAutolock
 from linien_server.autolock.simple import SimpleAutolock
+from linien_server.parameters import Parameters
 
 logger = logging.getLogger(__name__)
 logger.setLevel(logging.DEBUG)
 
 
 class Autolock:
-    def __init__(self, control, parameters):
+    def __init__(self, control, parameters: Parameters) -> None:
         self.control = control
         self.parameters = parameters
 
@@ -60,10 +66,10 @@ def run(
         x0,
         x1,
         spectrum,
-        should_watch_lock=False,
-        auto_offset=True,
+        should_watch_lock: bool = False,
+        auto_offset: bool = True,
         additional_spectra=None,
-    ):
+    ) -> None:
         """
         Start the autolock.
 
@@ -103,18 +109,18 @@ def run(
             if self.autolock_mode_detector.done:
                 self.start_autolock(self.autolock_mode_detector.mode)
 
-        except Exception:
+        except SpectrumUncorrelatedException:
             # This may happen if `additional_spectra` contain uncorrelated data. Then
             # either autolock algorithm selector or `start_autolock` may raise a
             # spectrum uncorrelated exception
             logger.exception("Error while starting autolock")
             self.parameters.autolock_failed.value = True
-            return self.exposed_stop()
+            self.exposed_stop()
 
         self.add_data_listener()
 
     def start_autolock(self, mode):
-        logger.debug("start autolock with mode %s" % mode)
+        logger.debug(f"Start autolock with mode {mode}")
         self.parameters.autolock_mode.value = mode
 
         self.algorithm = [None, RobustAutolock, SimpleAutolock][mode](
@@ -130,15 +136,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.add_callback(
-                self.react_to_new_spectrum, call_with_first_value=False
-            )
+            self.parameters.to_plot.add_callback(self.react_to_new_spectrum)
 
-    def remove_data_listener(self):
+    def remove_data_listener(self) -> None:
         self._data_listener_added = False
         self.parameters.to_plot.remove_callback(self.react_to_new_spectrum)
 
-    def react_to_new_spectrum(self, plot_data):
+    def react_to_new_spectrum(self, plot_data: bytes) -> None:
         """
         React to new spectrum data.
 
@@ -160,21 +164,24 @@ def react_to_new_spectrum(self, plot_data):
         if plot_data is None or not self.parameters.autolock_running.value:
             return
 
-        plot_data = pickle.loads(plot_data)
-        if plot_data is None:
+        plot_data_unpickled = pickle.loads(plot_data)
+        if plot_data_unpickled is None:
             return
 
         is_locked = self.parameters.lock.value
 
         # check that `plot_data` contains the information we need otherwise skip this
         # round
-        if not check_plot_data(is_locked, plot_data):
+        if not check_plot_data(is_locked, plot_data_unpickled):
             return
 
         try:
             if not is_locked:
                 combined_error_signal = combine_error_signal(
-                    (plot_data["error_signal_1"], plot_data.get("error_signal_2")),
+                    (
+                        plot_data_unpickled["error_signal_1"],
+                        plot_data_unpickled.get("error_signal_2"),
+                    ),
                     self.parameters.dual_channel.value,
                     self.parameters.channel_mixing.value,
                     self.parameters.combined_offset.value,
@@ -194,14 +201,17 @@ def react_to_new_spectrum(self, plot_data):
                     else:
                         return
 
-                return self.algorithm.handle_new_spectrum(combined_error_signal)
+                if self.algorithm is not None:
+                    return self.algorithm.handle_new_spectrum(combined_error_signal)
 
             else:
-                error_signal = plot_data["error_signal"]
-                control_signal = plot_data["control_signal"]
+                error_signal = plot_data_unpickled["error_signal"]
+                control_signal = plot_data_unpickled["control_signal"]
 
                 return self.after_lock(
-                    error_signal, control_signal, plot_data.get("slow_control_signal")
+                    error_signal,
+                    control_signal,
+                    plot_data_unpickled.get("slow_control_signal"),
                 )
 
         except Exception:
@@ -265,7 +275,7 @@ def relock(self):
         # relock.
         self.add_data_listener()
 
-    def exposed_stop(self):
+    def exposed_stop(self) -> None:
         """Abort any operation."""
         self.parameters.autolock_preparing.value = False
         self.parameters.autolock_percentage.value = 0
diff --git a/linien-server/linien_server/autolock/robust.py b/linien-server/linien_server/autolock/robust.py
index b6bacf65..faaf8f9a 100644
--- a/linien-server/linien_server/autolock/robust.py
+++ b/linien-server/linien_server/autolock/robust.py
@@ -109,7 +109,7 @@ def handle_new_spectrum(self, spectrum):
             )
             t2 = time()
             dt = t2 - t1
-            logger.debug("calculation of autolock description took %s" % dt)
+            logger.debug(f"Calculation of autolock description took {dt}")
 
             # sets up a timeout: if the lock doesn't finish within a certain time span,
             # throw an error
@@ -135,8 +135,8 @@ def handle_new_spectrum(self, spectrum):
 
         else:
             logger.error(
-                "not enough spectra collected: %d of %d"
-                % (len(self.spectra), self.N_spectra_required)
+                "Not enough spectra collected:"
+                f"{len(self.spectra)} of {self.N_spectra_required}"
             )
 
     def setup_timeout(self, N_acquisitions_to_wait=5):
@@ -154,7 +154,7 @@ def setup_timeout(self, N_acquisitions_to_wait=5):
             * sweep_speed_to_time(self.parameters.sweep_speed.value)
         )
 
-        self.parameters.ping.add_callback(self.check_for_timeout)
+        self.parameters.ping.add_callback(self.check_for_timeout, call_immediately=True)
 
     def check_for_timeout(self, ping):
         min_time_to_wait = 5
@@ -182,7 +182,7 @@ def calculate_autolock_instructions(spectra_with_jitter, target_idxs):
         round(np.mean([get_time_scale(spectrum, target_idxs) for spectrum in spectra]))
     )
 
-    logger.debug("x scale is %d" % time_scale)
+    logger.debug(f"x scale is {time_scale}")
 
     prepared_spectrum = get_diff_at_time_scale(sum_up_spectrum(spectra[0]), time_scale)
     peaks = get_all_peaks(prepared_spectrum, target_idxs)
@@ -191,7 +191,7 @@ def calculate_autolock_instructions(spectra_with_jitter, target_idxs):
     lock_regions = [get_lock_region(spectrum, target_idxs) for spectrum in spectra]
 
     for tolerance_factor in [0.95, 0.9, 0.85, 0.8, 0.75, 0.7, 0.65, 0.6, 0.55, 0.5]:
-        logger.debug("try out tolerance %s" % tolerance_factor)
+        logger.debug(f"Try out tolerance {tolerance_factor}")
         peaks_filtered = [
             (peak_position, peak_height * tolerance_factor)
             for peak_position, peak_height in peaks
@@ -216,7 +216,7 @@ def calculate_autolock_instructions(spectra_with_jitter, target_idxs):
             ):
                 break
         final_wait_time = target_peak_idx - current_idx
-        logger.debug("final wait time is %d samples" % final_wait_time)
+        logger.debug(f"final wait time is {final_wait_time} samples")
 
         description = []
 
@@ -247,12 +247,10 @@ def calculate_autolock_instructions(spectra_with_jitter, target_idxs):
         raise UnableToFindDescription()
 
     if len(description) > AUTOLOCK_MAX_N_INSTRUCTIONS:
-        logger.warning(
-            "warning: autolock description too long. Cropping! %s" % len(description)
-        )
+        logger.warning(f"Autolock description too long. Cropping! {description}")
         description = description[-AUTOLOCK_MAX_N_INSTRUCTIONS:]
 
-    logger.debug("description is %s" % description)
+    logger.debug(f"Description is {description}")
     return description, final_wait_time, time_scale
 
 
diff --git a/linien-server/linien_server/autolock/simple.py b/linien-server/linien_server/autolock/simple.py
index 19889642..11d8eb96 100644
--- a/linien-server/linien_server/autolock/simple.py
+++ b/linien-server/linien_server/autolock/simple.py
@@ -69,7 +69,7 @@ def handle_new_spectrum(self, spectrum) -> None:
             round((shift * (-1)) * self.parameters.sweep_amplitude.value * 8191)
         )
 
-        logger.debug("lock point is %s, shift is %s" % (lock_point, shift))
+        logger.debug(f"lock point is {lock_point}, shift is {shift}")
 
         self.parameters.autolock_target_position.value = int(lock_point)
         self.parameters.autolock_preparing.value = False
diff --git a/linien-server/linien_server/cli.py b/linien-server/linien_server/cli.py
new file mode 100644
index 00000000..cbc2cd36
--- /dev/null
+++ b/linien-server/linien_server/cli.py
@@ -0,0 +1,116 @@
+# Copyright 2018-2022 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.
+#
+# 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 logging
+import shutil
+import subprocess
+from pathlib import Path
+from typing import Optional
+
+import fire
+from linien_common.communication import (
+    no_authenticator,
+    username_and_password_authenticator,
+)
+from linien_server import __version__, mdio_tool
+from linien_server.server import (
+    FakeRedPitayaControlService,
+    RedPitayaControlService,
+    run_threaded_server,
+)
+
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.DEBUG)
+
+
+def copy_systemd_service_file() -> None:
+    """Copy systemd service to /etc/systemd/system."""
+    src = Path(__file__).parent / "linien-server.service"
+    dst = Path("/etc/systemd/system/linien-server.service")
+    shutil.copyfile(src, dst)
+    logger.debug("Copied linien-server.service to /etc/systemd/system")
+
+
+class LinienServerCLI:
+    def version(self) -> str:
+        """Return the version of the Linien server."""
+        return __version__
+
+    def start(self) -> None:
+        """Start the Linien server as a systemd service."""
+        copy_systemd_service_file()
+        logger.info("Starting Linien server")
+        subprocess.run(["systemctl", "start", "linien-server.service"])
+        logger.info("Started Linien server")
+
+    def stop(self) -> None:
+        """Stop the Linien server running as a systemd service."""
+        logger.info("Stopping Linien server")
+        subprocess.run(["systemctl", "stop", "linien-server.service"])
+        logger.info("Stopped Linien server")
+
+    def status(self) -> None:
+        """Check the status of the Linien server."""
+        subprocess.run(["journalctl", "-u", "linien-server.service"])
+
+    def run(self, fake: bool = False, host: Optional[str] = None) -> None:
+        """
+        Run the Linien server.
+
+        Args:
+            fake: Whether to run a fake server.
+            host: The hostname of the Red Pitaya.
+        """
+        if fake:
+            control = FakeRedPitayaControlService()
+        else:
+            control = RedPitayaControlService(host=host)
+
+        if fake or host:
+            authenticator = no_authenticator
+        else:
+            authenticator = username_and_password_authenticator
+
+        try:
+            if not (fake or host):  # only available on RP
+                mdio_tool.disable_ethernet_blinking()
+            run_threaded_server(control, authenticator=authenticator)
+        finally:
+            if not (fake or host):  # only available on RP
+                mdio_tool.enable_ethernet_blinking()
+
+    def enable(self) -> None:
+        """Enable the Linien server to start on boot."""
+        copy_systemd_service_file()
+        logger.info("Enabling Linien server")
+        subprocess.run(["systemctl", "enable", "linien-server.service"])
+        logger.info("Enabled Linien server")
+
+    def disable(self) -> None:
+        """Disable the Linien server from starting on boot."""
+        logger.info("Disabling Linien server")
+        subprocess.run(["systemctl", "disable", "linien-server.service"])
+        logger.info("Disabled Linien server")
+
+
+def main() -> None:
+    fire.Fire(LinienServerCLI)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/linien-server/linien_server/csr.py b/linien-server/linien_server/csr.py
index ec4126eb..8fbab436 100644
--- a/linien-server/linien_server/csr.py
+++ b/linien-server/linien_server/csr.py
@@ -27,16 +27,16 @@ class PythonCSR:
     constants = csrmap.csr_constants
     offset = 0x40300000
 
-    def __init__(self, rp):
+    def __init__(self, rp) -> None:
         self.rp = rp
 
-    def set_one(self, addr: str, value: int) -> None:
+    def set_one(self, addr: int, value: int) -> None:
         self.rp.write(addr, value)
 
-    def get_one(self, addr):
+    def get_one(self, addr: int):
         return int(self.rp.read(addr))
 
-    def set(self, name, value):
+    def set(self, name: str, value: int) -> None:
         map, addr, width, wr = self.map[name]
         assert wr, name
 
@@ -44,7 +44,7 @@ def set(self, name, value):
         bit_mask = ma - 1
         val = value & bit_mask
         assert value == val or ma + value == val, (
-            "value for %s out of range" % name,
+            f"Value for {name} out of range",
             (value, val, ma),
         )
 
@@ -66,20 +66,19 @@ def get(self, name: str) -> int:
             )
         return v
 
-    def set_iir(self, prefix, b, a, z=0):
+    def set_iir(self, prefix: str, b: list[float], a: list[float]) -> None:
         shift = self.get(prefix + "_shift") or 16
         width = self.get(prefix + "_width") or 18
-        interval = self.get(prefix + "_interval") or 1
-        b, a, params = get_params(b, a, shift, width, interval)
+        bb, _, params = get_params(b, a, shift, width)
 
         for k in sorted(params):
             self.set(prefix + "_" + k, params[k])
-        self.set(prefix + "_z0", z)
-        for i in range(len(b), 3):
-            n = prefix + "_b%i" % i
+        self.set(prefix + "_z0", 0)
+        for i in range(len(bb), 3):
+            n = prefix + f"_b{i}"
             if n in self.map:
                 self.set(n, 0)
-                self.set(prefix + "_a%i" % i, 0)
+                self.set(prefix + f"_a{i}", 0)
 
     def states(self, *names):
         return sum(1 << csrmap.states.index(name) for name in names)
diff --git a/linien-server/linien_server/iir_coeffs.py b/linien-server/linien_server/iir_coeffs.py
index 4997582d..8d48cdc8 100644
--- a/linien-server/linien_server/iir_coeffs.py
+++ b/linien-server/linien_server/iir_coeffs.py
@@ -19,11 +19,14 @@
 
 import warnings
 from math import ceil, log2, pi
+from typing import Optional
 
 from scipy import signal
 
 
-def make_filter(name, k=1.0, f=0.0, g=1e20, q=0.5):
+def make_filter(
+    name: str, k: float = 1.0, f: float = 0.0, g: float = 1e20, q: float = 0.5
+) -> tuple[list[float], list[float]]:
     f *= pi
 
     if name == "LP":  # k/(s + 1)
@@ -109,7 +112,9 @@ def make_filter(name, k=1.0, f=0.0, g=1e20, q=0.5):
     return b, a
 
 
-def quantize_filter(b, a, shift=None, width=25):
+def quantize_filter(
+    b: list[float], a: list[float], shift: Optional[int] = None, width: int = 25
+) -> tuple[list[int], list[int], int]:
     b, a = [i / a[0] for i in b], [i / a[0] for i in a]
 
     if shift is None:
@@ -121,31 +126,33 @@ def quantize_filter(b, a, shift=None, width=25):
             shift = min(shift, int(width - 1 - m))
     s = 1 << shift
 
-    b = [int(round(i * s)) for i in b]
-    a = [int(round(i * s)) for i in a]
+    bb = [int(round(i * s)) for i in b]
+    aa = [int(round(i * s)) for i in a]
 
     m = 1 << (width - 1)
-    for i in b + a:
-        assert -m <= i < m, (hex(i), hex(m))
+    for i in bb + aa:
+        assert -m <= i < m, (hex(int(i)), hex(int(m)))
 
     with warnings.catch_warnings():
         warnings.simplefilter("ignore", category=signal.BadCoefficients)
         warnings.simplefilter("ignore", category=RuntimeWarning)
-        z, p, k = signal.tf2zpk(b, a)
+        z, p, k = signal.tf2zpk(bb, aa)
     if any(abs(_) > 1 for _ in p):
         warnings.warn(
             "unstable filter: z={}, p={}, k={}".format(z, p, k), RuntimeWarning
         )
 
-    return b, a, shift
+    return bb, aa, shift
 
 
-def get_params(b, a, shift=None, width=25, interval=1):
-    b, a, shift = quantize_filter(b, a, shift, width)
+def get_params(
+    b: list[float], a: list[float], shift: Optional[int] = None, width: int = 25
+) -> tuple[list[int], list[int], dict[str, int]]:
+    bb, aa, shift = quantize_filter(b, a, shift, width)
     params = {}
-    for i, (ai, bi) in enumerate(zip(a, b)):
-        params["a%i" % i] = int(-ai)
-        params["b%i" % i] = int(bi)
+    for i, (ai, bi) in enumerate(zip(aa, bb)):
+        params[f"a{i}"] = int(-ai)
+        params[f"b{i}"] = int(bi)
     del params["a0"]
     # params["shift"] = shift
-    return b, a, params
+    return bb, aa, params
diff --git a/linien-server/linien_server/influxdb.py b/linien-server/linien_server/influxdb.py
index 2c9eec49..f23867c6 100644
--- a/linien-server/linien_server/influxdb.py
+++ b/linien-server/linien_server/influxdb.py
@@ -17,9 +17,10 @@
 
 from threading import Event, Thread
 from time import sleep
-from typing import Tuple
 
-import requests
+from influxdb_client import InfluxDBClient
+from influxdb_client.client.write_api import SYNCHRONOUS
+from linien_common.communication import ParameterValues
 from linien_common.influxdb import InfluxDBCredentials, save_credentials
 from linien_server.parameters import Parameters
 
@@ -28,10 +29,11 @@ class InfluxDBLogger:
     def __init__(
         self, credentials: InfluxDBCredentials, parameters: Parameters
     ) -> None:
-        self.credentials = credentials
-        self.parameters = parameters
+        self.credentials: InfluxDBClient = credentials
+        self.parameters: Parameters = parameters
         self.stop_event = Event()
         self.stop_event.set()
+        self.update_connection()
 
     @property
     def credentials(self) -> InfluxDBCredentials:
@@ -42,6 +44,14 @@ def credentials(self, value: InfluxDBCredentials) -> None:
         self._credentials = value
         save_credentials(value)
 
+    def update_connection(self) -> InfluxDBClient:
+        client = InfluxDBClient(
+            url=self.credentials.url,
+            token=self.credentials.token,
+            org=self.credentials.org,
+        )
+        self.write_api = client.write_api(write_options=SYNCHRONOUS)
+
     def start_logging(self, interval: float) -> None:
         conn_success, status_code, message = self.test_connection(self.credentials)
         self.thread = Thread(
@@ -54,8 +64,8 @@ def start_logging(self, interval: float) -> None:
             self.thread.start()
         else:
             raise ConnectionError(
-                "Failed to connect to InfluxDB database: %s (Status code: %s)"
-                % (message, status_code)
+                "Failed to connect to InfluxDB database: "
+                f" {message} (Status code: {status_code})"
             )
 
     def stop_logging(self) -> None:
@@ -77,48 +87,29 @@ def _logging_loop(self, interval: float) -> None:
 
     def test_connection(
         self, credentials: InfluxDBCredentials
-    ) -> Tuple[bool, int, str]:
+    ) -> 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
+        client = InfluxDBClient(
+            url=credentials.url,
+            token=credentials.token,
+            org=credentials.org,
+        )
+
+        # FIXME: This does not test the credentials, yet.
+        status_code = 0
+        message = ""
+        success = client.ping()
+        return success, status_code, message
 
     def write_data(
-        self, credentials: InfluxDBCredentials, data: dict
-    ) -> requests.Response:
+        self, credentials: InfluxDBCredentials, fields: dict[str, ParameterValues]
+    ) -> None:
         """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
+        self.write_api.write(
+            bucket=credentials.bucket,
+            org=credentials.org,
+            record={
+                "measurement": credentials.measurement,
+                "fields": fields,
+            },
+        )
diff --git a/linien-server/linien_server/linien-server.service b/linien-server/linien_server/linien-server.service
new file mode 100644
index 00000000..0fa93ee4
--- /dev/null
+++ b/linien-server/linien_server/linien-server.service
@@ -0,0 +1,10 @@
+[Unit]
+Description=Spectroscopy lock server for RedPitaya
+Wants=network-online.target
+After=network-online.target
+
+[Service]
+ExecStart=/usr/bin/env linien-server run
+
+[Install]
+WantedBy=multi-user.target
\ No newline at end of file
diff --git a/linien-server/linien_server/linien_install_requirements.sh b/linien-server/linien_server/linien_install_requirements.sh
deleted file mode 100644
index 3d260677..00000000
--- a/linien-server/linien_server/linien_install_requirements.sh
+++ /dev/null
@@ -1,20 +0,0 @@
-#!/bin/bash
-
-if [[ $EUID -ne 0 ]]; then
-   echo "This script must be run as root"
-   exit 1
-fi
-
-# the server is started in a screen session
-echo 'installing screen...'
-apt-get -y install screen
-
-# https://github.com/RedPitaya/RedPitaya/issues/205
-cd /tmp
-echo 'building ethernet blinking fix'
-git clone https://github.com/linien-org/mdio-tool.git
-cd mdio-tool
-git checkout v1.0.0
-cmake .
-make
-mv -f mdio-tool /usr/bin
\ No newline at end of file
diff --git a/linien-server/linien_server/linien_start_server.sh b/linien-server/linien_server/linien_start_server.sh
deleted file mode 100644
index 305d79f8..00000000
--- a/linien-server/linien_server/linien_start_server.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/bin/bash
-
-# quit any remaining screen session
-linien_stop_server.sh
-
-# stop ethernet blinking and start the server inside the screen session. Start ethernet blinking again after server stopped.
-# Regarding ethernet blinking, see https://github.com/RedPitaya/RedPitaya/issues/205
-screen -dmS linien-server bash -c "mdio-tool w eth0 0x1b 0x0000;linien-server;mdio-tool w eth0 0x1b 0x0f00"
\ No newline at end of file
diff --git a/linien-server/linien_server/linien_stop_server.sh b/linien-server/linien_server/linien_stop_server.sh
deleted file mode 100644
index 7c7385b1..00000000
--- a/linien-server/linien_server/linien_stop_server.sh
+++ /dev/null
@@ -1,5 +0,0 @@
-#!/bin/bash
-
-# Close screen session and start ethernet blinking again, see https://github.com/RedPitaya/RedPitaya/issues/205
-screen -XS linien-server quit
-mdio-tool w eth0 0x1b 0x0f00
\ No newline at end of file
diff --git a/linien-server/linien_server/mdio-tool b/linien-server/linien_server/mdio-tool
new file mode 100755
index 00000000..88662256
--- /dev/null
+++ b/linien-server/linien_server/mdio-tool
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f1bd0d93a35a7ee6f9df7c638934f16597eadfb9b43a012ed825d30761485580
+size 8368
diff --git a/linien-server/linien_server/mdio_tool.py b/linien-server/linien_server/mdio_tool.py
new file mode 100644
index 00000000..2b96e938
--- /dev/null
+++ b/linien-server/linien_server/mdio_tool.py
@@ -0,0 +1,45 @@
+# 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 logging
+import subprocess
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.DEBUG)
+
+
+def enable_ethernet_blinking() -> None:
+    """
+    Enable the blinking of the ethernet LEDs.
+
+    See https://github.com/RedPitaya/RedPitaya/issues/205 for details.
+    """
+    binary_path = Path(__file__).parent / "mdio-tool"
+    logger.info(f"Enabling ethernet blinking with mdio-tool at {binary_path}")
+    subprocess.run([f"{binary_path}", "w", "eth0", "0x1b", "0x0f00"])
+
+
+def disable_ethernet_blinking() -> None:
+    """
+    Disable the blinking of the ethernet LEDs.
+
+    See https://github.com/RedPitaya/RedPitaya/issues/205 for details.
+    """
+    binary_path = Path(__file__).parent / "mdio-tool"
+    logger.info(f"Disabling ethernet blinking with mdio-tool at {binary_path}")
+    subprocess.run([f"{binary_path}", "w", "eth0", "0x1b", "0x0000"])
diff --git a/linien-server/linien_server/noise_analysis.py b/linien-server/linien_server/noise_analysis.py
index 5070ed5f..1dff054a 100644
--- a/linien-server/linien_server/noise_analysis.py
+++ b/linien-server/linien_server/noise_analysis.py
@@ -21,7 +21,6 @@
 import random
 import string
 from time import sleep, time
-from typing import Tuple
 
 import numpy as np
 from linien_common.common import PSDAlgorithm
@@ -29,6 +28,8 @@
 from pylpsd import lpsd
 from scipy import signal
 
+from .parameters import Parameters
+
 ALL_DECIMATIONS = list(range(32))
 
 logger = logging.getLogger(__name__)
@@ -37,7 +38,7 @@
 
 def calculate_psd(
     sig: np.ndarray, fs: float, algorithm: PSDAlgorithm
-) -> Tuple[np.ndarray, np.ndarray]:
+) -> tuple[np.ndarray, np.ndarray]:
     """
     Calculate the power spectral density.
 
@@ -102,6 +103,8 @@ def generate_curve_uuid():
 
 
 class PSDAcquisition:
+    parameters: Parameters
+
     def __init__(self, control, parameters, is_child=False):
         self.decimation_index = 0
 
@@ -126,7 +129,7 @@ def run(self):
 
     def add_callbacks(self):
         self.parameters.acquisition_raw_data.add_callback(
-            self.react_to_new_signal, call_with_first_value=False
+            self.react_to_new_signal, call_immediately=False
         )
 
     def cleanup(self):
@@ -151,8 +154,8 @@ def react_to_new_signal(self, data_pickled):
             data = pickle.loads(data_pickled)
 
             current_decimation = self.parameters.acquisition_raw_decimation.value
-            logger.debug("recorded signal for decimation %s" % current_decimation)
-            logger.debug("recording took %s s" % (time() - self.time_decimation_set))
+            logger.debug(f"Recorded signal for decimation {current_decimation}")
+            logger.debug(f"Recording took {time()-self.time_decimation_set} s")
             self.recorded_signals_by_decimation[current_decimation] = data
             self.recorded_psds_by_decimation[current_decimation] = residual_freq_noise(
                 1 / (125e6) * (2 ** (current_decimation)),
@@ -173,7 +176,7 @@ def react_to_new_signal(self, data_pickled):
 
             if not complete:
                 new_decimation = self.decimation_index
-                logger.debug("set new decimation %s" % new_decimation)
+                logger.debug(f"Set new decimation {new_decimation}")
                 self.set_decimation(new_decimation)
             else:
                 self.cleanup()
@@ -226,6 +229,8 @@ def exposed_stop(self):
 
 
 class PIDOptimization:
+    parameters: Parameters
+
     def __init__(self, control, parameters):
         self.control = control
         self.parameters = parameters
@@ -237,7 +242,7 @@ def __init__(self, control, parameters):
     def run(self):
         try:
             self.parameters.psd_data_complete.add_callback(
-                self.psd_data_received, call_with_first_value=False
+                self.psd_data_received, call_immediately=False
             )
             self.parameters.psd_optimization_running.value = True
             self.start_single_psd_measurement()
@@ -266,7 +271,7 @@ def psd_data_received(self, psd_data_pickled):
             psd_data = pickle.loads(psd_data_pickled)
 
             params = (psd_data["p"], psd_data["i"])
-            logger.debug("received fitness %s, %s" % (psd_data["fitness"], params))
+            logger.debug(f"Received fitness {psd_data['fitness']}, {params}")
 
             self.engine.tell(psd_data["fitness"], params)
 
diff --git a/linien-server/linien_server/optimization/approach_line.py b/linien-server/linien_server/optimization/approach_line.py
index f3332d7b..e6d25735 100644
--- a/linien-server/linien_server/optimization/approach_line.py
+++ b/linien-server/linien_server/optimization/approach_line.py
@@ -88,7 +88,7 @@ def approach_line(self, error_signal):
         )
         shift *= initial_sweep_amplitude
         self.history.append((zoomed_ref, zoomed_err))
-        self.history.append("shift %f" % (-1 * shift))
+        self.history.append(f"shift {-1 * shift}")
 
         if self.N_at_this_zoom == 0:
             # if we are at the final zoom, we should be very quick.
diff --git a/linien-server/linien_server/optimization/engine.py b/linien-server/linien_server/optimization/engine.py
index c7855233..22d587e8 100644
--- a/linien-server/linien_server/optimization/engine.py
+++ b/linien-server/linien_server/optimization/engine.py
@@ -247,7 +247,7 @@ def tell(self, i, q):
 
             params.optimization_optimized_parameters.value = complete_parameter_set
 
-        logger.debug("improvement %d" % (improvement * 100))
+        logger.debug(f"improvement {improvement * 100}")
 
         fitness = math.log(1 / optimized_slope)
 
diff --git a/linien-server/linien_server/optimization/general.py b/linien-server/linien_server/optimization/general.py
index 56f8afb7..1a49266c 100644
--- a/linien-server/linien_server/optimization/general.py
+++ b/linien-server/linien_server/optimization/general.py
@@ -18,9 +18,9 @@
 
 """Module providing the Optimizer interface class."""
 
-from typing import List, NewType
+from typing import NewType
 
-Params = NewType("Params", List[float])
+Params = NewType("Params", list[float])
 """A type alias for actual optmizer params. """
 
 
diff --git a/linien-server/linien_server/optimization/optimization.py b/linien-server/linien_server/optimization/optimization.py
index fed4456c..392de73f 100644
--- a/linien-server/linien_server/optimization/optimization.py
+++ b/linien-server/linien_server/optimization/optimization.py
@@ -62,7 +62,7 @@ def run(self, x0, x1, spectrum):
 
         params = self.parameters
         self.engine = OptimizerEngine(self.control, params)
-        params.to_plot.add_callback(self.react_to_new_spectrum)
+        params.to_plot.add_callback(self.react_to_new_spectrum, call_immediately=True)
         params.optimization_running.value = True
         params.optimization_improvement.value = 0
 
@@ -77,7 +77,7 @@ def record_first_error_signal(self, error_signal):
         ) = get_lock_point(
             error_signal,
             *list(sorted([self.x0, self.x1])),
-            final_zoom_factor=FINAL_ZOOM_FACTOR
+            final_zoom_factor=FINAL_ZOOM_FACTOR,
         )
 
         self.target_zoom = target_zoom
@@ -103,8 +103,8 @@ def react_to_new_spectrum(self, spectrum):
             channel = params.optimization_channel.value
             spectrum_idx = 1 if not dual_channel else (1, 2)[channel]
             unpickled = pickle.loads(spectrum)
-            spectrum = unpickled["error_signal_%d" % spectrum_idx]
-            quadrature = unpickled["error_signal_%d_quadrature" % spectrum_idx]
+            spectrum = unpickled[f"error_signal_{spectrum_idx}"]
+            quadrature = unpickled[f"error_signal_{spectrum_idx}_quadrature"]
 
             if self.parameters.optimization_approaching.value:
                 approaching_finished = self.approacher.approach_line(spectrum)
diff --git a/linien-server/linien_server/optimization/utils.py b/linien-server/linien_server/optimization/utils.py
index 7b9f9df3..5f0d7c98 100644
--- a/linien-server/linien_server/optimization/utils.py
+++ b/linien-server/linien_server/optimization/utils.py
@@ -19,8 +19,7 @@
 import numpy as np
 from scipy import optimize, stats
 
-# after the line was centered, its width will be 1/FINAL_ZOOM_FACTOR of the
-# view.
+# after the line was centered, its width will be 1/FINAL_ZOOM_FACTOR of the view.
 FINAL_ZOOM_FACTOR = 20
 
 
diff --git a/linien-server/linien_server/parameters.py b/linien-server/linien_server/parameters.py
index 5284131c..063d8fad 100644
--- a/linien-server/linien_server/parameters.py
+++ b/linien-server/linien_server/parameters.py
@@ -20,13 +20,13 @@
 import json
 import logging
 from time import time
-from typing import Any, Callable, Dict, Iterator, List, Tuple
+from typing import Any, Callable, Iterator
 
 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"
+PARAMETER_STORE_FILENAME = "parameters.json"
 
 logger = logging.getLogger(__name__)
 logger.setLevel(logging.DEBUG)
@@ -82,13 +82,11 @@ def reset(self):
         self.value = self._start
 
     def add_callback(
-        self,
-        function: Callable[[Any], None],
-        call_with_first_value: bool = True,
+        self, function: Callable[[Any], None], call_immediately: bool = False
     ) -> None:
         self._callbacks.add(function)
 
-        if call_with_first_value:
+        if call_immediately:
             if self._value is not None:
                 function(self._value)
 
@@ -121,15 +119,15 @@ class Parameters:
     """
 
     def __init__(self):
-        # Dict[str, List[Tuple[str, Any]]]
+        # dict[str, list[tuple[str, Any]]]
         self._changed_parameters_queue = {}
-        # Dict[Tuple[Parameter, Callable[[Any], None]]]
+        # dict[tuple[Parameter, Callable[[Any], None]]]
         self._remote_listener_callbacks = {}
 
         self.to_plot = Parameter(sync=False)
         """
-        The `to_plot` parameter is a pickled dictionary that contains signalsvthat may
-        be plotted. Depending on the locking state, it may contain thesevsignals:
+        The `to_plot` parameter is a pickled dictionary that contains signals that may
+        be plotted. Depending on the locking state, it may contain these signals:
         Unlocked state:
           - `error_signal_1` and `error_signal_1_quadrature`:
               IQ-demodulated and low-pass-filtered error signals from ANALOG IN 0
@@ -594,14 +592,14 @@ def __init__(self):
             start=18, min_=1, max_=32, restorable=True
         )
 
-    def __iter__(self) -> Iterator[Tuple[str, Parameter]]:
+    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]]:
+    ) -> 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
@@ -628,8 +626,8 @@ def append_changed_values_to_queue(value: Any) -> None:
             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)
+        param: Parameter = getattr(self, param_name)
+        param.add_callback(append_changed_values_to_queue, call_immediately=True)
 
         self._remote_listener_callbacks[uuid].append(
             (param, append_changed_values_to_queue)
@@ -642,9 +640,7 @@ def unregister_remote_listeners(self, uuid: str):
         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]]]:
+    def get_changed_parameters_queue(self, uuid: 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] = []
@@ -666,8 +662,10 @@ def restore_parameters(parameters: Parameters) -> Parameters:
     filename = str(USER_DATA_PATH / PARAMETER_STORE_FILENAME)
     try:
         with open(filename, "r") as f:
+            logger.info(f"Restoring parameters from {filename}")
             data = json.load(f)
     except FileNotFoundError:
+        logger.info(f"Couldn't find {filename}. Using default parameters.")
         return parameters
 
     for name, attributes in data["parameters"].items():
@@ -676,7 +674,7 @@ def restore_parameters(parameters: Parameters) -> Parameters:
             getattr(parameters, name).log = attributes["log"]
         except AttributeError:  # ignore parameters that don't exist (anymore)
             continue
-    logger.info("Restored parameters from %s" % filename)
+    logger.info(f"Restored parameters from {filename}")
     return parameters
 
 
@@ -699,4 +697,4 @@ def save_parameters(parameters: Parameters) -> None:
             f,
             indent=2,
         )
-    logger.info("Saved parameters to %s" % filename)
+    logger.info(f"Saved parameters to {filename}")
diff --git a/linien-server/linien_server/registers.py b/linien-server/linien_server/registers.py
index 7cddbaca..c9e32601 100644
--- a/linien-server/linien_server/registers.py
+++ b/linien-server/linien_server/registers.py
@@ -58,21 +58,24 @@ def __init__(
 
         self._last_sweep_speed = None
         self._last_raw_acquisition_settings = None
-        self._iir_cache = {}  # type: ignore[var-annotated]
+        self._iir_cache: dict[str, tuple[list[float], list[float]]] = {}
 
         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.acquisition.exposed_set_fetch_additional_signals, call_immediately=True
         )
         self.parameters.dual_channel.add_callback(
-            self.acquisition.exposed_set_dual_channel
+            self.acquisition.exposed_set_dual_channel, call_immediately=True
         )
 
     def write_registers(self):
         """Writes data from `parameters` to the FPGA."""
 
-        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))
+        def max_(val):
+            return val if np.abs(val) <= 8191 else (8191 * val / np.abs(val))
+
+        def phase_to_delay(phase):
+            return int(phase / 360 * (1 << 14))
 
         if not self.parameters.dual_channel.value:
             factor_a = 256
@@ -99,13 +102,17 @@ def write_registers(self):
             # NOTE: Sweep center is set by `logic_out_offset`.
             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.pid_only_mode.value
-            else 0,
-            logic_mod_amp=self.parameters.modulation_amplitude.value
-            if (self.parameters.modulation_frequency.value > 0)
-            and (not self.parameters.pid_only_mode.value)
-            else 0,
+            logic_mod_freq=(
+                self.parameters.modulation_frequency.value
+                if not self.parameters.pid_only_mode.value
+                else 0
+            ),
+            logic_mod_amp=(
+                self.parameters.modulation_amplitude.value
+                if (self.parameters.modulation_frequency.value > 0)
+                and (not self.parameters.pid_only_mode.value)
+                else 0
+            ),
             logic_dual_channel=int(self.parameters.dual_channel.value),
             logic_pid_only_mode=int(self.parameters.pid_only_mode.value),
             logic_chain_a_factor=factor_a,
@@ -136,24 +143,24 @@ def write_registers(self):
             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(
-                self.parameters.demodulation_phase_a.value
-            )
-            if (self.parameters.modulation_frequency.value > 0)
-            and (not self.parameters.pid_only_mode.value)
-            else 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.pid_only_mode.value)
+                else 0
+            ),
             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=csrmap.signals.index("zero"),
             fast_a_invert=int(self.parameters.invert_a.value),
             # channel B
-            fast_b_demod_delay=phase_to_delay(
-                self.parameters.demodulation_phase_b.value
-            )
-            if (self.parameters.modulation_frequency.value > 0)
-            and (not self.parameters.pid_only_mode.value)
-            else 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.pid_only_mode.value)
+                else 0
+            ),
             fast_b_demod_multiplier=self.parameters.demodulation_multiplier_b.value,
             fast_b_dx_sel=csrmap.signals.index("zero"),
             fast_b_y_tap=1,
@@ -173,8 +180,8 @@ def write_registers(self):
         for instruction_idx, [wait_for, peak_height] in enumerate(
             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
+            new[f"logic_autolock_robust_peak_height_{instruction_idx}"] = peak_height
+            new["logic_autolock_robust_wait_for_{instruction_idx}"] = wait_for
 
         if self.parameters.lock.value:
             # display combined error signal and control signal
@@ -289,15 +296,13 @@ def channel_polarity(channel):
         )
 
         for chain in ("a", "b"):
-            automatic = getattr(self.parameters, "filter_automatic_%s" % chain).value
+            automatic = getattr(self.parameters, f"filter_automatic_{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
                 for iir_sub_idx in range(2):
-                    iir_name = "fast_%s_iir_%s_%d" % (
-                        chain,
-                        ("c", "d")[iir_idx],
-                        iir_sub_idx + 1,
+                    iir_name = (
+                        f"fast_{chain}_iir_{('c', 'd')[iir_idx]}_{iir_sub_idx + 1}"
                     )
 
                     if automatic:
@@ -316,15 +321,13 @@ def channel_polarity(channel):
                             filter_enabled = False
                     else:
                         filter_enabled = getattr(
-                            self.parameters,
-                            "filter_%d_enabled_%s" % (iir_idx + 1, chain),
+                            self.parameters, f"filter_{iir_idx + 1}_enabled_{chain}"
                         ).value
                         filter_type = getattr(
-                            self.parameters, "filter_%d_type_%s" % (iir_idx + 1, chain)
+                            self.parameters, f"filter_{iir_idx + 1}_type_{chain}"
                         ).value
                         filter_frequency = getattr(
-                            self.parameters,
-                            "filter_%d_frequency_%s" % (iir_idx + 1, chain),
+                            self.parameters, f"filter_{iir_idx + 1}_frequency_{chain}"
                         ).value
 
                     if not filter_enabled:
@@ -346,7 +349,7 @@ def channel_polarity(channel):
                             )
                         else:
                             raise Exception(
-                                "unknown filter %s for %s" % (filter_type, iir_name)
+                                f"Unknown filter {filter_type} for {iir_name}"
                             )
 
         if lock_changed:
@@ -385,15 +388,15 @@ def set_slow_pid(self, strength, slope, reset=None):
     def set(self, key, value):
         self.acquisition.exposed_set_csr(key, value)
 
-    def set_iir(self, iir_name, *args):
-        if self._iir_cache.get(iir_name) != args:
+    def set_iir(self, iir_name: str, b: list[float], a: list[float]) -> None:
+        if self._iir_cache.get(iir_name) != (b, a):
             # 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
+            self.acquisition.exposed_set_iir_csr(iir_name, b, a)
+            self._iir_cache[iir_name] = (b, a)
 
 
-def twos_complement(num, N_bits):
+def twos_complement(num: int, N_bits: int) -> int:
     max_ = 1 << (N_bits - 1)
     full = 2 * max_
     if num < 0:
diff --git a/linien-server/linien_server/server.py b/linien-server/linien_server/server.py
index 53953854..56393c05 100644
--- a/linien-server/linien_server/server.py
+++ b/linien-server/linien_server/server.py
@@ -22,21 +22,21 @@
 import pickle
 from copy import copy
 from random import randint, random
+from socket import socket
 from threading import Event, Thread
 from time import sleep
-from typing import List, Optional, Tuple
+from typing import Any, Callable
 
-import click
 import numpy as np
 import rpyc
 from linien_common.common import N_POINTS, check_plot_data, update_signal_history
 from linien_common.communication import (
-    no_authenticator,
+    LinienControlService,
+    ParameterValues,
     pack,
     unpack,
-    username_and_password_authenticator,
 )
-from linien_common.config import DEFAULT_SERVER_PORT
+from linien_common.config import SERVER_PORT
 from linien_common.influxdb import InfluxDBCredentials, restore_credentials
 from linien_server import __version__
 from linien_server.autolock.autolock import Autolock
@@ -45,6 +45,7 @@
 from linien_server.optimization.optimization import OptimizeSpectroscopy
 from linien_server.parameters import Parameters, restore_parameters, save_parameters
 from linien_server.registers import Registers
+from rpyc.core.protocol import Connection
 from rpyc.utils.server import ThreadedServer
 
 logger = logging.getLogger(__name__)
@@ -61,7 +62,7 @@ def __init__(self) -> None:
         self.parameters = Parameters()
         self.parameters = restore_parameters(self.parameters)
         atexit.register(save_parameters, self.parameters)
-        self._uuid_mapping = {}  # type: ignore[var-annotated]
+        self._uuid_mapping: dict[Connection, str] = {}
 
         influxdb_credentials = restore_credentials()
         self.influxdb_logger = InfluxDBLogger(influxdb_credentials, self.parameters)
@@ -69,43 +70,47 @@ def __init__(self) -> None:
         self.stop_event = Event()
         self.stop_log_event = Event()
 
-    def on_connect(self, client) -> None:
-        self._uuid_mapping[client] = client.root.uuid
+    def on_connect(self, conn: Connection) -> None:
+        self._uuid_mapping[conn] = conn.root.uuid
 
-    def on_disconnect(self, client) -> None:
-        uuid = self._uuid_mapping[client]
+    def on_disconnect(self, conn: Connection) -> None:
+        uuid = self._uuid_mapping[conn]
         self.parameters.unregister_remote_listeners(uuid)
 
     def exposed_get_server_version(self) -> str:
         return __version__
 
-    def exposed_get_param(self, param_name: str) -> bytes:
+    def exposed_get_param(self, param_name: str) -> bytes | ParameterValues:
         return pack(getattr(self.parameters, param_name).value)
 
-    def exposed_set_param(self, param_name: str, value: bytes) -> None:
+    def exposed_set_param(
+        self, param_name: str, value: bytes | ParameterValues
+    ) -> 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_init_parameter_sync(
+        self, uuid: str
+    ) -> list[tuple[str, Any, bool, bool, bool, bool]]:
+        return list(self.parameters.init_parameter_sync(uuid))
 
     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: str, param_names: List[str]
+        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_changed_parameters_queue(self, uuid: str) -> bytes:
-        return pack(self.parameters.get_changed_parameters_queue(uuid))
+    def exposed_get_changed_parameters_queue(self, uuid: str) -> list[tuple[str, Any]]:
+        return 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:
-            logger.debug("Setting log for %s to %s" % (param_name, value))
+            logger.debug(f"Setting log for {param_name} to {value}")
             getattr(self.parameters, param_name).log = value
 
     def exposed_get_parameter_log(self, param_name: str) -> bool:
@@ -113,7 +118,7 @@ def exposed_get_parameter_log(self, param_name: str) -> bool:
 
     def exposed_update_influxdb_credentials(
         self, credentials: InfluxDBCredentials
-    ) -> Tuple[bool, int, str]:
+    ) -> tuple[bool, int, str]:
         credentials = copy(credentials)
         (
             connection_succesful,
@@ -125,13 +130,13 @@ def exposed_update_influxdb_credentials(
             logger.info("InfluxDB credentials updated successfully")
         else:
             logger.info(
-                "InfluxDB credentials update failed. Error message: %s (Status Code %s)"
-                % (message, status_code)
+                "InfluxDB credentials update failed. Error message: "
+                f" {message} (Status Code {status_code})"
             )
         return connection_succesful, status_code, message
 
-    def exposed_get_influxdb_credentials(self) -> bytes:
-        return pack(self.influxdb_logger.credentials)
+    def exposed_get_influxdb_credentials(self) -> InfluxDBCredentials:
+        return self.influxdb_logger.credentials
 
     def exposed_start_logging(self, interval: float) -> None:
         logger.info("Starting logging")
@@ -145,7 +150,7 @@ def exposed_get_logging_status(self) -> bool:
         return not self.influxdb_logger.stop_event.is_set()
 
 
-class RedPitayaControlService(BaseService):
+class RedPitayaControlService(BaseService, LinienControlService):
     """Control server that runs on the RP that provides high-level methods."""
 
     def __init__(self, host=None):
@@ -183,9 +188,9 @@ def _send_ping_loop(self, stop_event: Event):
         while not stop_event.is_set():
             self.parameters.ping.value += 1
             if self.parameters.ping.value <= MAX_PING:
-                logger.debug("ping  %s" % self.parameters.ping.value)
+                logger.debug(f"Ping  {self.parameters.ping.value}")
                 if self.parameters.ping.value == MAX_PING:
-                    logger.debug("further pings will be suppressed")
+                    logger.debug("Further pings will be suppressed")
             sleep(1)
 
     def _push_acquired_data_to_parameters(self, stop_event: Event):
@@ -223,10 +228,10 @@ def _push_acquired_data_to_parameters(self, stop_event: Event):
                     # 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)
+                        stats[f"{signal_name}_mean"] = np.mean(signal)
+                        stats[f"{signal_name}_std"] = np.std(signal)
+                        stats[f"{signal_name}_max"] = np.max(signal)
+                        stats[f"{signal_name}_min"] = np.min(signal)
                     self.parameters.signal_stats.value = stats
                     # update signal history (if in locked state)
                     (
@@ -270,9 +275,11 @@ def exposed_start_autolock(self, x0, x1, spectrum, additional_spectra=None):
                 spectrum,
                 should_watch_lock=start_watching,
                 auto_offset=auto_offset,
-                additional_spectra=pickle.loads(additional_spectra)
-                if additional_spectra is not None
-                else None,
+                additional_spectra=(
+                    pickle.loads(additional_spectra)
+                    if additional_spectra is not None
+                    else None
+                ),
             )
 
     def exposed_start_optimization(self, x0, x1, spectrum):
@@ -341,7 +348,7 @@ def exposed_set_csr_direct(self, key: str, value: int) -> None:
         self.registers.set(key, value)
 
 
-class FakeRedPitayaControlService(BaseService):
+class FakeRedPitayaControlService(BaseService, LinienControlService):
     def __init__(self):
         super().__init__()
         self.exposed_is_locked = None
@@ -356,7 +363,10 @@ def __init__(self):
     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)])
+
+            def gen():
+                return np.array([randint(-max_, max_) for _ in range(N_POINTS)])
+
             self.parameters.to_plot.value = pickle.dumps(
                 {
                     "error_signal_1": gen(),
@@ -371,10 +381,10 @@ def exposed_write_registers(self):
         pass
 
     def exposed_start_autolock(self, x0, x1, spectrum):
-        logger.debug("start autolock %s %s" % (x0, x1))
+        logger.info(f"Start autolock {x0} {x1}")
 
     def exposed_start_optimization(self, x0, x1, spectrum):
-        logger.debug("start optimization")
+        logger.info("Start optimization")
         self.parameters.optimization_running.value = True
 
     def exposed_shutdown(self):
@@ -387,49 +397,20 @@ def exposed_continue_acquisition(self):
         pass
 
 
-# ignore type, otherwise "Argument 1 has incompatible type "Callable[[int, bool, str |
-# None, bool], Any]"; expected <nothing>" is raised for click 8.1.4.
-@click.command("linien-server")  # type: ignore[arg-type]
-@click.version_option(__version__)
-@click.argument("port", default=DEFAULT_SERVER_PORT, type=int, required=False)
-@click.option(
-    "--fake", is_flag=True, help="Runs a fake server that just returns random data"
-)
-@click.option(
-    "--host",
-    help=(
-        "Allows to run the server locally for development and connects to a RedPitaya. "
-        "Specify the RP's host as follows: --host=rp-f0xxxx.local"
-    ),
-)
-@click.option("--no-auth", is_flag=True, help="Disable authentication")
-def run_server(
-    port: int = DEFAULT_SERVER_PORT,
-    fake: bool = False,
-    host: Optional[str] = None,
-    no_auth: bool = False,
-):
-    logger.info("Start server on port %s" % port)
-
-    if fake:
-        logger.info("starting fake server")
-        control = FakeRedPitayaControlService()
+def run_threaded_server(
+    control: BaseService,
+    authenticator: Callable[[socket], tuple[socket, None]],
+) -> None:
+    """Run a (Fake)RedPitayaControlService in a threaded server."""
+    if isinstance(control, FakeRedPitayaControlService):
+        logger.info("Starting fake server")
     else:
-        control = RedPitayaControlService(host=host)
-
-    if no_auth or fake:
-        authenticator = no_authenticator
-    else:
-        authenticator = username_and_password_authenticator
+        logger.info("Starting server.")
 
     thread = ThreadedServer(
         control,
-        port=port,
+        port=SERVER_PORT,
         authenticator=authenticator,
-        protocol_config={"allow_pickle": True},
+        protocol_config={"allow_pickle": True, "allow_public_attrs": True},
     )
     thread.start()
-
-
-if __name__ == "__main__":
-    run_server()
diff --git a/linien-server/pyproject.toml b/linien-server/pyproject.toml
new file mode 100644
index 00000000..416c0e99
--- /dev/null
+++ b/linien-server/pyproject.toml
@@ -0,0 +1,48 @@
+[build-system]
+requires = ["setuptools>=61.2"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "linien-server"
+version = "2.0.0"
+authors = [
+    { name = "Benjamin Wiegand", email = "benjamin.wiegand@physik.hu-berlin.de" },
+    { name = "Bastian Leykauf", email = "leykauf@physik.hu-berlin.de" },
+    { name = "Robert Jördens", email = "rj@quartiq.de" },
+    { name = "Christian Freier", email = "christian.freier@gmail.com" },
+    { name = "Doron Behar", email = "doron.behar@gmail.com" },
+]
+maintainers = [
+    { name = "Bastian Leykauf", email = "leykauf@physik.hu-berlin.de" },
+]
+description = "Server components of the Linien spectroscopy lock application."
+classifiers = [
+    "Programming Language :: Python :: 3",
+    "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
+    "Operating System :: OS Independent",
+]
+requires-python = ">=3.10"
+dependencies = [
+    "cma>=3.0.3,<4.0",
+    "fire>=0.6.0",
+    "influxdb-client[ciso]>=1.9,<2.0",
+    "pylpsd>=0.1.4",
+    "pyrp3>=2.0.1,<3.0;platform_machine=='armv7l'",
+    "linien-common==2.0.0",
+]
+
+[project.readme]
+text = "Have a look at the [project repository](https://github.com/linien-org/linien) for installation instructions."
+content-type = "text/markdown"
+
+[project.urls]
+Homepage = "https://github.com/linien-org/linien"
+
+[project.scripts]
+linien-server = "linien_server.cli:main"
+
+[tool.setuptools.packages.find]
+namespaces = false
+
+[tool.setuptools.package-data]
+linien_server = ["gateware.bin", "mdio-tool", "linien-server.service"]
diff --git a/linien-server/setup.py b/linien-server/setup.py
deleted file mode 100644
index f3375def..00000000
--- a/linien-server/setup.py
+++ /dev/null
@@ -1,63 +0,0 @@
-# Copyright 2018-2022 Benjamin Wiegand <benjamin.wiegand@physik.hu-berlin.de>
-# Copyright 2022-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 setuptools import find_packages, setup
-
-setup(
-    name="linien-server",
-    version="1.0.2",
-    author="Benjamin Wiegand",
-    author_email="highwaychile@posteo.de",
-    maintainer="Bastian Leykauf",
-    maintainer_email="leykauf@physik.hu-berlin.de",
-    description="Server components of the Linien spectroscopy lock application.",
-    long_description="Have a look at the [project repository](https://github.com/linien-org/linien) for installation instructions.",  # noqa: E501
-    long_description_content_type="text/markdown",
-    url="https://github.com/linien-org/linien",
-    packages=find_packages(),
-    classifiers=[
-        "Programming Language :: Python :: 3",
-        "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
-        "Operating System :: OS Independent",
-    ],
-    entry_points={"console_scripts": ["linien-server=linien_server.server:run_server"]},
-    python_requires=">=3.5",
-    install_requires=[
-        "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,<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==1.0.2",
-    ],
-    scripts=[
-        "linien_server/linien_start_server.sh",
-        "linien_server/linien_stop_server.sh",
-        "linien_server/linien_install_requirements.sh",
-    ],
-    package_data={
-        "": [
-            "gateware.bin",
-            "linien_start_server.sh",
-            "linien_stop_server.sh",
-            "linien_install_requirements.sh",
-        ]
-    },
-)
diff --git a/mypy.ini b/mypy.ini
deleted file mode 100644
index c14cfe7a..00000000
--- a/mypy.ini
+++ /dev/null
@@ -1,33 +0,0 @@
-[mypy]
-plugins = numpy.typing.mypy_plugin
-
-[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
-
diff --git a/pyproject.toml b/pyproject.toml
index bdadc30a..3199c476 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,5 +1,10 @@
+[tool.flake8]
+max-line-length = 88
+extend-ignore = "E203"
+docstring-convention = "numpy"
+
 [tool.black]
-exclude = '''
+force-exclude = """
 (
   /(
     gateware/logic
@@ -8,7 +13,7 @@ exclude = '''
    | linien-server/linien_server/csrmap.py
    )/
 )
-'''
+"""
 
 [tool.isort]
 profile = "black"
@@ -20,3 +25,24 @@ plt_dirname = "tests/plots"
 filterwarnings = "ignore::pytest.PytestConfigWarning"
 
 markers = """slow : marks tests as slow (deselect with '-m "not slow"')"""
+
+[tool.mypy]
+plugins = "numpy.typing.mypy_plugin"
+exclude = ["[^/]+/build/"]
+
+[[tool.mypy.overrides]]
+module = [
+  "linien_common.*",
+  "linien_client.*",
+  "rpyc.*",
+  "fabric",
+  "setuptools.*",
+  "scipy.*",
+  "cma.*",
+  "pyrp3.*",
+  "pylpsd.*",
+  "pyqtgraph.*",
+  "fire",
+  "influxdb_client.*",
+]
+ignore_missing_imports = true


Test patch:
diff --git a/tests/test_pid_transfer.py b/tests/test_pid_transfer.py
index 48f18711..b5f8facd 100644
--- a/tests/test_pid_transfer.py
+++ b/tests/test_pid_transfer.py
@@ -62,8 +62,7 @@ def plot_transfer(x, y, label=None):
         def plot_theory(f, p, i, d, plot_color):
             plt.plot(
                 f,
-                20
-                * np.log10(np.abs(p / 4096 + 10 * i / f + d * (f / 125e6) / (2**6))),
+                20 * np.log10(np.abs(p / 4096 + 10 * i / f + d * (f / 125e6) / (2**6))),
                 color=plot_color,
                 linestyle="dashed",
             )