diff --git a/succbone/succd/go.mod b/succbone/succd/go.mod
index 574985d..d35e107 100644
--- a/succbone/succd/go.mod
+++ b/succbone/succd/go.mod
@@ -4,5 +4,8 @@ go 1.22.3
 
 require (
 	github.com/coder/websocket v1.8.12
+	github.com/simonvetter/modbus v1.6.3
 	k8s.io/klog v1.0.0
 )
+
+require github.com/goburrow/serial v0.1.0 // indirect
diff --git a/succbone/succd/go.sum b/succbone/succd/go.sum
index c8b5195..ab9ca54 100644
--- a/succbone/succd/go.sum
+++ b/succbone/succd/go.sum
@@ -1,5 +1,9 @@
 github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
 github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
 github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas=
+github.com/goburrow/serial v0.1.0 h1:v2T1SQa/dlUqQiYIT8+Cu7YolfqAi3K96UmhwYyuSrA=
+github.com/goburrow/serial v0.1.0/go.mod h1:sAiqG0nRVswsm1C97xsttiYCzSLBmUZ/VSlVLZJ8haA=
+github.com/simonvetter/modbus v1.6.3 h1:kDzwVfIPczsM4Iz09il/Dij/bqlT4XiJVa0GYaOVA9w=
+github.com/simonvetter/modbus v1.6.3/go.mod h1:hh90ZaTaPLcK2REj6/fpTbiV0J6S7GWmd8q+GVRObPw=
 k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8=
 k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I=
diff --git a/succbone/succd/http.go b/succbone/succd/http.go
index f1503e7..01eab8e 100644
--- a/succbone/succd/http.go
+++ b/succbone/succd/http.go
@@ -62,6 +62,17 @@ type apiData struct {
 		// DPOn means the diffusion pump is turned on.
 		DPOn bool
 	}
+	// Temperature state.
+	Temperatures struct {
+		DPBottom float32
+		DPTop    float32
+		DPInlet  float32
+		SEM      float32
+	}
+	// Humidity state.
+	Humidity struct {
+		SEM float32
+	}
 	// Pressure feedback into evacuation board.
 	Feedback struct {
 		// RoughReached is true when the system has reached a rough vacuum
@@ -115,6 +126,11 @@ func (s *webServer) apiData(skipSystem bool) *apiData {
 	ad.Pirani.MbarFloat = mbar
 	ad.Pumps.RPOn = state.rpOn
 	ad.Pumps.DPOn = state.dpOn
+	ad.Temperatures.DPBottom = state.tempDPBottom
+	ad.Temperatures.DPTop = state.tempDPTop
+	ad.Temperatures.DPInlet = state.tempDPInlet
+	ad.Temperatures.SEM = state.tempSEM
+	ad.Humidity.SEM = state.humiditySEM
 	ad.Feedback.RoughReached = rough
 	ad.Feedback.HighReached = high
 	ad.LoopLoad = s.d.loopLoad()
@@ -217,6 +233,26 @@ func (s *webServer) viewMetrics(w http.ResponseWriter, r *http.Request) {
 	fmt.Fprintf(w, "# HELP sem_vacuum_high_reached Whether a high vacuum has been reached (boolean)\n")
 	fmt.Fprintf(w, "# TYPE sem_vacuum_high_reached gauge\n")
 	fmt.Fprintf(w, "sem_vacuum_high_reached %f\n", boolToFloat(high))
+
+	fmt.Fprintf(w, "# HELP sem_environment_temperature_celsius Environmental temperature of the SEM, in degrees celsius\n")
+	fmt.Fprintf(w, "# TYPE sem_environment_temperature_celsius gauge\n")
+	fmt.Fprintf(w, "sem_environment_temperature_celsius %f\n", state.tempSEM)
+
+	fmt.Fprintf(w, "# HELP sem_environment_humidity_percent Environmental relative humidity of the SEM, in percent\n")
+	fmt.Fprintf(w, "# TYPE sem_environment_humidity_percent gauge\n")
+	fmt.Fprintf(w, "sem_environment_humidity_percent %f\n", state.humiditySEM)
+
+	fmt.Fprintf(w, "# HELP sem_dp_bottom_temperature_celsius Temperature of the DP bottom, in degrees celsius\n")
+	fmt.Fprintf(w, "# TYPE sem_dp_bottom_temperature_celsius gauge\n")
+	fmt.Fprintf(w, "sem_dp_bottom_temperature_celsius %f\n", state.tempDPBottom)
+
+	fmt.Fprintf(w, "# HELP sem_dp_top_temperature_celsius Temperature of the DP top, in degrees celsius\n")
+	fmt.Fprintf(w, "# TYPE sem_dp_top_temperature_celsius gauge\n")
+	fmt.Fprintf(w, "sem_dp_top_temperature_celsius %f\n", state.tempDPTop)
+
+	fmt.Fprintf(w, "# HELP sem_dp_inlet_temperature_celsius Temperature of the DP inlet flange, in degrees celsius\n")
+	fmt.Fprintf(w, "# TYPE sem_dp_inlet_temperature_celsius gauge\n")
+	fmt.Fprintf(w, "sem_dp_inlet_temperature_celsius %f\n", state.tempDPInlet)
 }
 
 func (s *webServer) viewRoughingPumpEnable(w http.ResponseWriter, r *http.Request) {
diff --git a/succbone/succd/index.html b/succbone/succd/index.html
index abd5695..37f0849 100644
--- a/succbone/succd/index.html
+++ b/succbone/succd/index.html
@@ -9,19 +9,19 @@ body {
     padding: 2em;
 }
 table {
-    font-size: 40px;
+    font-size: 30px;
 }
 table.status td {
     width: 2em;
 }
 th, td {
     background-color: #e8e8e8;
-    padding: 0.4em;
+    padding: 0.3em;
 }
 th {
     font-weight: 100;
     text-align: right;
-    font-size: 30px;
+    font-size: 25px;
 }
 td {
     text-align: left;
@@ -34,7 +34,7 @@ h2 {
     font-weight: 100;
 }
 button {
-    height: 4.5em;
+    height: 3.3em;
     padding-left: 1.5em;
     padding-right: 1.5em;
 }
@@ -76,6 +76,7 @@ td > span {
 
 .has-hidden:hover .hidden-text {
     display: block;
+}
 
 @media only screen and (max-width: 700px) {
     body {
@@ -120,19 +121,6 @@ td > span {
         
     
 
-    
-        
-            | Pirani Pressure | 
-            
-                 {{ .Pirani.Mbar }} 
-                
-                    Voltage: {{ .Pirani.Volts }}
-                 
-             | 
-        
-    
-
-
     
         
             | Control | 
@@ -163,6 +151,41 @@ td > span {
         
     
 
+    
+        
+            | Pirani Pressure | 
+            
+                 {{ .Pirani.Mbar }} 
+                
+                    Voltage: {{ .Pirani.Volts }}
+                 
+             | 
+        
+        
+            | Temperatures | 
+            DP Bottom | 
+            {{ .Temperatures.DPBottom }} °C | 
+        
+        
+            | DP Top | 
+            {{ .Temperatures.DPTop }} °C | 
+        
+        
+            | DP Inlet | 
+            {{ .Temperatures.DPInlet }} °C | 
+        
+        
+            | SEM Environment | 
+            {{ .Temperatures.SEM }} °C | 
+        
+        
+            | Humidity | 
+            SEM Environment | 
+            {{ .Humidity.SEM }}% | 
+        
+    
+
+
     
         
     
@@ -316,6 +339,11 @@ window.addEventListener("load", (_) => {
     let trough = document.querySelector("#trough");
     let thigh = document.querySelector("#thigh");
     let load = document.querySelector("#load");
+    let tempSEM = document.querySelector("#temp-sem");
+    let tempDPBottom = document.querySelector("#temp-dp-bottom");
+    let tempDPTop = document.querySelector("#temp-dp-top");
+    let tempDPInlet = document.querySelector("#temp-dp-inlet");
+    let humiditySEM = document.querySelector('#humidity-sem');
 
     // Buttons
     let pd = document.querySelector("#pd");
@@ -388,6 +416,30 @@ window.addEventListener("load", (_) => {
                 dp.style = colors.default;
             }
 
+            tempSEM.innerHTML = data.Temperatures.SEM + " °C";
+            tempSEM.style = (data.Temperatures.SEM > 30) ?
+                colors.highlightCaution : colors.default;
+            humiditySEM.innerHTML = data.Humidity.SEM + "%";
+            humiditySEM.style = (data.Humidity.SEM > 59) ?
+                colors.highlightCaution : colors.default;
+
+            tempDPTop.innerHTML = data.Temperatures.DPTop + " °C";
+            tempDPTop.style = (data.Temperatures.DPTop > 30) ?
+                colors.highlightCaution : colors.default;
+
+            tempDPInlet.innerHTML = data.Temperatures.DPInlet + " °C";
+            tempDPInlet.style = (data.Temperatures.DPInlet > 30) ?
+                colors.highlightCaution : colors.default;
+
+            tempDPBottom.innerHTML = data.Temperatures.DPBottom + " °C";
+            if (data.Temperatures.DPBottom > 200) {
+                tempDPBottom.style = colors.highlightFault;
+            } else if (data.Temperatures.DPBottom > 50) {
+                tempDPBottom.style = colors.highlightNeutral;
+            } else {
+                tempDPBottom.style = colors.default;
+            }
+
             let t = [];
             if (data.Feedback.RoughReached) {
                 trough.innerHTML = "OK";
@@ -413,7 +465,7 @@ window.addEventListener("load", (_) => {
 
             // Indicate all process values as unknown
 
-            [failsafe, highpressure, rp, dp, trough, thigh, volts, mbar].forEach((el) => {
+            [failsafe, highpressure, rp, dp, trough, thigh, volts, mbar, tempDPBottom, tempDPTop, tempDPInlet].forEach((el) => {
                 if (!el.innerHTML.includes("??")) {
                     el.innerHTML += "??";
                 }
diff --git a/succbone/succd/main.go b/succbone/succd/main.go
index 64663a8..52a10db 100644
--- a/succbone/succd/main.go
+++ b/succbone/succd/main.go
@@ -35,6 +35,12 @@ func main() {
 	d.daemonState.piraniVolts3.limit = 3
 	d.daemonState.piraniVolts100.limit = 100
 
+	d.tempDPBottom = 420.6
+	d.tempDPTop = 69.0
+	d.tempDPInlet = 42.0
+	d.tempSEM = 42.5
+	d.humiditySEM = 66.6
+
 	d.aboveRough.threshold = float64(flagPressureThresholdRough)
 	d.aboveRough.hysteresis = float64(flagPressureThresholdRoughHysteresis)
 	d.aboveHigh.threshold = float64(flagPressureThresholdHigh)
@@ -42,12 +48,6 @@ func main() {
 	if flagFake {
 		klog.Infof("Starting with fake peripherals")
 		d.adcPirani = &fakeADC{}
-		d.gpioRoughingPump = &fakeGPIO{desc: "rp"}
-		d.gpioDiffusionPump = &fakeGPIO{desc: "~dp"}
-		d.gpioBtnPumpDown = &fakeGPIO{desc: "~pd"}
-		d.gpioBtnVent = &fakeGPIO{desc: "~vent"}
-		d.gpioBelowRough = &fakeGPIO{desc: "~rough"}
-		d.gpioBelowHigh = &fakeGPIO{desc: "~high"}
 	} else {
 		adc, err := newBBADC(0)
 		if err != nil {
@@ -55,21 +55,9 @@ func main() {
 		}
 		d.adcPirani = adc
 
-		for _, c := range []struct {
-			out *gpio
-			num int
-		}{
-			{&d.gpioRoughingPump, 115},
-			{&d.gpioDiffusionPump, 49},
-			{&d.gpioBtnPumpDown, 48},
-			{&d.gpioBtnVent, 60},
-			{&d.gpioBelowRough, 30},
-			{&d.gpioBelowHigh, 7},
-		} {
-			*c.out, err = newBBGPIO(c.num, true)
-			if err != nil {
-				klog.Exitf("Failed to setup GPIO: %v", err)
-			}
+		err = d.modbusConnect()
+		if err != nil {
+			klog.Exitf("Failed to connect to modbus %v", err)
 		}
 	}
 
@@ -86,5 +74,9 @@ func main() {
 	}()
 
 	go d.process(ctx)
+	if !flagFake {
+		go d.modbusProcess(ctx)
+	}
+
 	<-ctx.Done()
 }
diff --git a/succbone/succd/modbus.go b/succbone/succd/modbus.go
new file mode 100644
index 0000000..4aebd03
--- /dev/null
+++ b/succbone/succd/modbus.go
@@ -0,0 +1,186 @@
+package main
+
+import (
+	"context"
+	"time"
+
+	"github.com/simonvetter/modbus"
+	"k8s.io/klog"
+)
+
+func modbusValuesToFloat(v uint16) float32 {
+	return float32(v) / 10.0
+}
+
+func (d *daemon) modbusConnect() error {
+	var err error
+
+	d.mu.Lock()
+	defer d.mu.Unlock()
+
+	// Setup modbus client
+	d.modbusClient, err = modbus.NewClient(&modbus.ClientConfiguration{
+		URL:     "tcp://10.250.241.20:8887",
+		Timeout: 1 * time.Second,
+	})
+	if err != nil {
+		return err
+	}
+	// Connect to modbus client
+	err = d.modbusClient.Open()
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (d *daemon) modbusRestart() error {
+	d.modbusClient.Close()
+	return d.modbusClient.Open()
+}
+
+// There are currently two devices connected to the modbus.
+// The first one (slave 1) is a temperature/humidity sensor.
+// The second one (slave 2) is a PTA8D08 transmitter
+//
+// Returns whether modbus should restart (only in case of an underlying network error)
+func (d *daemon) modbusUpdate() bool {
+	var err error
+	var numDevicesNotResponding int
+
+	// Switch to slave 1 (BTA1)
+	d.modbusClient.SetUnitId(1)
+
+	// Read temperature and humidity
+	var registersBTA1 []uint16 // temperature, humidity
+	registersBTA1, err = d.modbusClient.ReadRegisters(1, 2, modbus.INPUT_REGISTER)
+	if err != nil {
+		numDevicesNotResponding += 1
+		klog.Warningf("error while reading registers from BTA1 %v", err)
+	} else if len(registersBTA1) != 2 {
+		klog.Warningf("expected two registers from modbus slave 1, but got %d", len(registersBTA1))
+	} else {
+		d.mu.Lock()
+		d.daemonState.tempSEM = modbusValuesToFloat(registersBTA1[0])
+		d.daemonState.humiditySEM = modbusValuesToFloat(registersBTA1[1])
+		d.mu.Unlock()
+	}
+
+	// Switch to slave 2 (KEC2)
+	d.modbusClient.SetUnitId(2)
+
+	// PT100 mapping
+	// Channel 0: Cable -WGA6, Sensor "dp bottom"
+	// Channel 1: Cable -WGA8, Sensor "dp inlet"
+	// Channel 2: Cable WGA7, Sensor "dp top"
+	var registersKEC2 []uint16 // temperatures dp
+	registersKEC2, err = d.modbusClient.ReadRegisters(0, 3, modbus.HOLDING_REGISTER)
+	if err != nil {
+		numDevicesNotResponding += 1
+		klog.Warningf("error while reading registers from KEC2 %v", err)
+	} else if len(registersKEC2) != 3 {
+		klog.Warningf("expected three registers from modbus slave 2, but got %d", len(registersKEC2))
+	} else {
+		d.mu.Lock()
+		d.daemonState.tempDPBottom = modbusValuesToFloat(registersKEC2[0])
+		d.daemonState.tempDPInlet = modbusValuesToFloat(registersKEC2[1])
+		d.daemonState.tempDPTop = modbusValuesToFloat(registersKEC2[2])
+		d.mu.Unlock()
+	}
+
+	// Switch to slave 3 (KEC1)
+	d.modbusClient.SetUnitId(3)
+
+	// Do a read first to avoid side-effects from the subsequent write to the relay states
+	var digitalInputs [8]bool
+	var digitalInputRegisters []uint16
+
+	digitalInputRegisters, err = d.modbusClient.ReadRegisters(0x81, 8, modbus.HOLDING_REGISTER)
+	if err != nil {
+		numDevicesNotResponding += 1
+		klog.Warningf("error while reading digital inputs from KEC1 %v", err)
+	} else {
+		// Convert MODBUS words into bools
+		for idx, value := range digitalInputRegisters {
+			if value != 0 {
+				digitalInputs[idx] = true
+			} else {
+				digitalInputs[idx] = false
+			}
+		}
+		// TODO: Input mapping goes here
+	}
+
+	// We must wait between reading and writing to the -KEC1 relay board
+	// because otherwise it chokes and times out the write registers
+	// command.
+	time.Sleep(time.Millisecond * 10)
+
+	// KFA1-KFA8
+	var relayState [8]bool
+	d.mu.Lock()
+	// -KFA1 Roughing Pump (normally closed contact)
+	relayState[0] = !d.daemonState.rpOn
+	// -KFA2 Diffusion Pump
+	relayState[1] = d.daemonState.dpOn
+	// -KFA4 Button Vent
+	relayState[3] = d.daemonState.vent.output
+	// -KFA5 Button Pump-Down
+	relayState[4] = d.daemonState.pumpdown.output
+	// -KFA6 Fake-Pirani Rough (normally closed contact)
+	relayState[5] = !d.aboveRough.output
+	// -KFA7 Fake-Pirani High (normally closed contact)
+	relayState[6] = !d.aboveHigh.output
+	d.mu.Unlock()
+
+	// The KEC1 module uses a non-standard MODBUS interface
+	// instead of coils
+	// 0x0100 is the open command
+	// 0x0200 is the close command
+	// We write 8 words (16-bit) to address 0x01 to update the relays
+	var registerValuesKEC1 [8]uint16
+	// Convert the boolean values to the commands
+	for idx, state := range relayState {
+		if state {
+			registerValuesKEC1[idx] = 0x0100
+		} else {
+			registerValuesKEC1[idx] = 0x0200
+		}
+	}
+
+	err = d.modbusClient.WriteRegisters(0x01, registerValuesKEC1[:])
+	if err != nil {
+		numDevicesNotResponding += 1
+		klog.Warningf("error while updating registers %v", err)
+	}
+
+	if numDevicesNotResponding >= 4 {
+		klog.Warningf("no device did respond to our request. Probably a network timeout.")
+		return true
+	}
+
+	return false
+}
+
+// Call modbusUpdate every 100 milliseconds
+func (d *daemon) modbusProcess(ctx context.Context) {
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		default:
+			shouldRestart := d.modbusUpdate()
+			// the modbus library does not reopen the tcp socket in case of
+			// a connection loss.
+			if shouldRestart {
+				klog.Infof("restarting modbus connection...")
+				err := d.modbusRestart()
+				if err != nil {
+					klog.Warningf("failed to restart modbus %v", err)
+				}
+			}
+			time.Sleep(time.Millisecond * 100)
+		}
+	}
+}
diff --git a/succbone/succd/process.go b/succbone/succd/process.go
index de99e84..7504b19 100644
--- a/succbone/succd/process.go
+++ b/succbone/succd/process.go
@@ -8,22 +8,17 @@ import (
 	"sync/atomic"
 	"time"
 
+	"github.com/simonvetter/modbus"
 	"k8s.io/klog"
 )
 
 // daemon is the main service of the succdaemon.
 type daemon struct {
+	modbusClient *modbus.ModbusClient
 	// adcPirani is the adc implementation returning the voltage of the Pfeiffer
 	// Pirani gauge.
 	adcPirani adc
 
-	gpioDiffusionPump gpio
-	gpioRoughingPump  gpio
-	gpioBtnPumpDown   gpio
-	gpioBtnVent       gpio
-	gpioBelowRough    gpio
-	gpioBelowHigh     gpio
-
 	load atomic.Int64
 
 	// mu guards the state below.
@@ -55,6 +50,13 @@ type daemonState struct {
 	pumpdown   momentaryOutput
 	aboveRough thresholdOutput
 	aboveHigh  thresholdOutput
+
+	tempDPBottom float32
+	tempDPTop    float32
+	tempDPInlet  float32
+
+	tempSEM     float32
+	humiditySEM float32
 }
 
 func (d *daemonState) vacuumStatus() (rough, high bool) {
@@ -162,33 +164,5 @@ func (d *daemon) processOnce(_ context.Context) error {
 		d.dpOn = false
 	}
 
-	// Update relay outputs.
-	for _, rel := range []struct {
-		name string
-		gpio gpio
-		// activeHigh means the relay is active high, ie. a true source will
-		// mean that NO/COM get connected, and a false source means that NC/COM
-		// get connected.
-		activeHigh bool
-		source     bool
-	}{
-		{"rp", d.gpioRoughingPump, false, d.rpOn},
-		{"dp", d.gpioDiffusionPump, true, d.dpOn},
-		{"pumpdown", d.gpioBtnPumpDown, true, d.pumpdown.output},
-		{"vent", d.gpioBtnVent, true, d.vent.output},
-		{"rough", d.gpioBelowRough, false, d.aboveRough.output},
-		{"high", d.gpioBelowHigh, false, d.aboveHigh.output},
-	} {
-		val := rel.source
-		if rel.activeHigh {
-			// Invert because the relays go through logical inversion (ie. a
-			// GPIO false is a relay trigger).
-			val = !val
-		}
-		if err := rel.gpio.set(val); err != nil {
-			return fmt.Errorf("when outputting %s: %w", rel.name, err)
-		}
-	}
-
 	return nil
 }