diff --git a/CMakeLists.txt b/CMakeLists.txt
index d7d847b6f54f901ee0069216fcbc55aa35d65e88..4febffa5f868145e0fd8e1e143ff822a6c57e2c0 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1,5 +1,5 @@
 project(daqd-trunk)
-cmake_minimum_required(VERSION 3.0)
+cmake_minimum_required(VERSION 3.12)
 
 enable_testing()
 
@@ -29,6 +29,7 @@ FIND_PACKAGE(Boost COMPONENTS filesystem system)
 FIND_PACKAGE(RapidJSON)
 FIND_PACKAGE(RPC)
 FIND_PACKAGE(libcds-pubsub)
+FIND_PACKAGE(Python3)
 
 CHECK_CXX_SOURCE_COMPILES("#include <iostream>
 #include <FlexLexer.h>
diff --git a/src/daqd/CMakeLists.txt b/src/daqd/CMakeLists.txt
index 3cc06ce4bc4e87b242aab543735b9d24a7fe3515..c7e2c5994786a5d3d8042ee64cdcf7252f2c2bf3 100644
--- a/src/daqd/CMakeLists.txt
+++ b/src/daqd/CMakeLists.txt
@@ -213,17 +213,14 @@ if (libNDS2Client_FOUND)
 	configure_file(tests/daqdrc_nds_test ${CMAKE_CURRENT_BINARY_DIR}/daqdrc_nds_test COPYONLY)
 	configure_file(tests/test_daqd_nds.sh.in ${CMAKE_CURRENT_BINARY_DIR}/test_daqd_nds.sh @ONLY)
 
-	configure_file(tests/test_daqd_stall_signal.sh.in ${CMAKE_CURRENT_BINARY_DIR}/test_daqd_stall_signal.sh @ONLY)
+	add_test(NAME test_daqd_stall_signal
+			COMMAND "${Python3_EXECUTABLE}" -B "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_daqd_stall_signal.py"
+			WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}")
 
 	add_test(NAME test_daqd_nds
 			COMMAND /bin/bash ./test_daqd_nds.sh
 			WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}")
 
-	add_test(NAME test_daqd_stall_signal
-			COMMAND /bin/bash ./test_daqd_stall_signal.sh
-			WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}")
-
-
 	add_executable(test_nds1_connections tests/test_nds1_connections.cc)
 	target_link_libraries(test_nds1_connections PUBLIC nds2client::cxx
 		${CMAKE_THREAD_LIBS_INIT})
diff --git a/src/daqd/tests/test_daqd_stall_signal.py b/src/daqd/tests/test_daqd_stall_signal.py
new file mode 100644
index 0000000000000000000000000000000000000000..982e4296386df4c20c1ba3e82f78f71efd598329
--- /dev/null
+++ b/src/daqd/tests/test_daqd_stall_signal.py
@@ -0,0 +1,121 @@
+#!/usr/bin/python3
+
+import os.path
+import tempfile
+import shutil
+import subprocess
+import time
+import epics
+
+TDIR = ""
+PID_MULTI_STREAM = None
+PID_DAQD = None
+
+
+def cleanup():
+    if TDIR != "":
+        shutil.rmtree(TDIR, ignore_errors=True)
+    if PID_MULTI_STREAM is not None:
+        PID_MULTI_STREAM.kill()
+    if PID_DAQD is not None:
+        PID_DAQD.kill()
+
+
+def check_access(path):
+    with open(path, "w"):
+        pass
+
+
+def stall_cb(pvname, value, status, **kwargs):
+    print("{0}={1} ({2})".format(pvname, value, status))
+
+
+try:
+    MULTI_STREAM = "../fe_stream_test/fe_multi_stream_test"
+    if not os.path.exists(MULTI_STREAM):
+        raise (RuntimeError("Unable to find the streamer at {0}".format(MULTI_STREAM)))
+    DAQD = "./daqd"
+    if not os.path.exists(DAQD):
+        raise (RuntimeError("Unable to file the daqd at {0}".format(DAQD)))
+
+    check_access("/dev/gpstime")
+    check_access("/dev/mbuf")
+
+    TDIR = tempfile.mkdtemp()
+    INI_DIR = os.path.join(TDIR, "ini_files")
+    MASTER_FILE = os.path.join(INI_DIR, 'master')
+    TESTPOINT_FILE = ""
+    os.mkdir(INI_DIR)
+    LOG_DIR = os.path.join(TDIR, "logs")
+    os.mkdir(LOG_DIR)
+    FRAME_DIR = os.path.join(TDIR, "frames")
+    os.mkdir(FRAME_DIR)
+    FULL_FRAME_DIR = os.path.join(FRAME_DIR, "full")
+    os.mkdir(FULL_FRAME_DIR)
+
+    print("Ini dir = {0}".format(INI_DIR))
+
+    with open(os.path.join(LOG_DIR, 'multi_stream.log'), 'wt') as multi_log:
+
+        PID_MULTI_STREAM = subprocess.Popen(args=[
+            MULTI_STREAM,
+            '-i', INI_DIR,
+            '-M', MASTER_FILE,
+            '-b', 'local_dc',
+            '-m', '100',
+            '-k', '700',
+            '-R', '100',
+        ],
+            stdin=None,
+            stdout=multi_log,
+            stderr=subprocess.STDOUT,
+        )
+        print("Streamer PID = {0}".format(PID_MULTI_STREAM.pid))
+
+        time.sleep(1)
+
+        with open('daqdrc_live_test', 'rt') as f:
+            data = f.read()
+            data = data.replace('MASTER', MASTER_FILE)
+            data = data.replace('TESTPOINT', TESTPOINT_FILE)
+            with open('daqdrc_stall_test_final', 'wt') as out_f:
+                out_f.write(data)
+
+        with open(os.path.join(LOG_DIR, 'daqd.log'), 'wt') as daqd_log:
+
+            PID_DAQD = subprocess.Popen(args=[DAQD,
+                                              '-c', 'daqdrc_stall_test_final'],
+                                        stdin=None,
+                                        stdout=daqd_log,
+                                        stderr=subprocess.STDOUT)
+
+            print("Daqd PID = {0}".format(PID_DAQD.pid))
+
+            tries = 0
+            NotStalled = epics.PV("X3:DAQ-SHM0_PRDCR_NOT_STALLED", callback=stall_cb)
+            if NotStalled.wait_for_connection(timeout=20):
+                print("Not connected after 10s")
+            while NotStalled.get() != 1:
+                print('X3:DAQ-SHM0_PRDCR_NOT_STALLED={0}'.format(NotStalled.get()))
+                tries += 1
+                if tries > 1000:
+                    raise RuntimeError("The daqd did not leave the stalled state")
+                time.sleep(2)
+
+            print("Killing the streamer")
+            PID_MULTI_STREAM.kill()
+            PID_MULTI_STREAM = None
+
+            time.sleep(2)
+
+            tries = 0
+            while NotStalled.get() == 1:
+                tries += 1
+                if tries > 10:
+                    raise RuntimeError("The daqd did not enter the stalled state")
+                time.sleep(1)
+            NotStalled.disconnect()
+            del NotStalled
+
+finally:
+    cleanup()
diff --git a/src/daqd/tests/test_daqd_stall_signal.sh.in b/src/daqd/tests/test_daqd_stall_signal.sh.in
deleted file mode 100644
index bfb35d01591b7b5d60ea0862d3dc57414d05c08e..0000000000000000000000000000000000000000
--- a/src/daqd/tests/test_daqd_stall_signal.sh.in
+++ /dev/null
@@ -1,124 +0,0 @@
-#!/bin/bash
-
-CWD="@CMAKE_CURRENT_BINARY_DIR@"
-
-TDIR=""
-PID_MULTI_STREAM=0
-PID_DAQD=0
-
-function kill_proc {
-    if [ $1 -gt 0 ]; then
-        echo "Closing process $1"
-        kill $1
-    fi
-}
-
-function cleanup {
-    rm -rf daqdrc_live_test_final
-    if [ "x$TDIR" != "x" ]; then
-        if [ -d $TDIR ]; then
-            rm -rf "$TDIR"
-        fi
-    fi
-    kill_proc $PID_MULTI_STREAM
-    kill_proc $PID_DAQD
-}
-
-MUTLI_STREAM="$CWD/../fe_stream_test/fe_multi_stream_test"
-if [ ! -x "$MUTLI_STREAM" ]; then
-    echo "cannot find $MULTI_STREAM"
-    exit 1
-fi
-
-DAQD="$CWD/../daqd/daqd"
-if [ ! -x "$DAQD" ]; then
-    echo "cannot find $DAQD"
-    exit 1
-fi
-
-if [ ! -r /dev/gpstime ]; then
-  echo "the gpstime module must be loaded, configured, and accessible by this user"
-  exit 1
-fi
-
-if [ ! -r /dev/mbuf ]; then
-  echo "the mbuf module must be loaded, configured, and accessible by this user"
-  exit 1
-fi
-
-PYTHON=""
-which python > /dev/null
-if [ $? -eq 0 ]; then
-    PYTHON=`which python`
-else
-    which python3 > /dev/null
-    if [ $? -eq 0 ]; then
-        PYTHON=`which python3`
-    else
-        echo "Cannot find python or python3"
-        exit 1
-    fi
-fi
-
-trap cleanup EXIT
-
-TDIR=`$PYTHON -c "from __future__ import print_function; import tempfile; print(tempfile.mkdtemp())"`
-mkdir "$TDIR/ini_files"
-mkdir "$TDIR/logs"
-mkdir "$TDIR/frames"
-mkdir "$TDIR/frames/full"
-
-echo "Ini dir = $TDIR/ini_files"
-
-"$MUTLI_STREAM" -i "$TDIR/ini_files" -M "$TDIR/ini_files/master" -b local_dc -m 100 -k 700 -R 100 > "$TDIR/logs/multi_stream" &
-PID_MULTI_STREAM=$!
-
-echo "Streamer PID = PID_MULTI_STREAM"
-
-sleep 1
-
-MASTER_FILE="$TDIR/ini_files/master"
-TESTPOINT_FILE=""
-
-cat daqdrc_live_test | sed s\|MASTER\|$MASTER_FILE\| | sed s\|TESTPOINT\|$TESTPOINT_FILE\| > daqdrc_stall_test_final
-"$DAQD" -c daqdrc_stall_test_final &> "$TDIR/logs/daqd" &
-PID_DAQD=$!
-
-echo "Daqd PID = $PID_DAQD"
-
-sleep 5
-tries=0
-SVAL=`caget -F z -f 0 X3:DAQ-SHM0_PRDCR_NOT_STALLED | cut -d z -f 2`
-while [ "$SVAL" -ne "1" ]; do
-    sleep 2
-    SVAL=`caget -F z -f 0 X3:DAQ-SHM0_PRDCR_NOT_STALLED | cut -d z -f 2`
-    let tries+=1
-    if [ "$tries" -gt "15" ]; then
-        echo "The daqd did not leave the stalled state"
-        exit 1
-    fi
-done
-
-echo "Killing the streamer"
-
-kill $PID_MULTI_STREAM
-PID_MULTI_STREAM=0
-
-sleep 2
-
-SVAL=`caget -F z -f 0 X3:DAQ-SHM0_PRDCR_NOT_STALLED | cut -d z -f 2`
-tries=0
-while [ "$SVAL" -ne "0" ]; do
-    echo "X3:DAQ-SHM0_PRDCR_NOT_STALLED = $SVAL"
-    let tries+=1
-    if [ "$tries" -gt "5" ]; then
-        echo "The daqd did not enter the stalled state"
-        exit 1
-    fi
-    sleep 1
-    SVAL=`caget -F z -f 0 X3:DAQ-SHM0_PRDCR_NOT_STALLED | cut -d z -f 2`
-done
-
-#echo "Press enter to continue..."
-#DUMMY=""
-#read DUMMY
\ No newline at end of file