diff --git a/python/ur_simple_control/robots/dual_arm_interface.py b/python/ur_simple_control/robots/dual_arm_interface.py
new file mode 100644
index 0000000000000000000000000000000000000000..023cb82542af81f3c06630b1c521ae0f1a28c5d2
--- /dev/null
+++ b/python/ur_simple_control/robots/dual_arm_interface.py
@@ -0,0 +1,71 @@
+import numpy as np
+import pinocchio as pin
+from ur_simple_control.robots.robotmanager_abstract import RobotManagerAbstract
+
+
+class DualArmInterface(RobotManagerAbstract):
+    def __init__(self):
+        self._abs_frame: pin.SE3
+        # NOTE: you need to fill in the specific names from the urdf here for your robot
+        # self._l_ee_frame_id = self.model.getFrameId("left_ee_name")
+        # self._r_ee_frame_id = self.model.getFrameId("right_ee_name")
+
+        # l stands for left
+        # r stands for right
+        self._l_ee_frame_id: int
+        self._r_ee_frame_id: int
+
+        self._abs_T_w_e: pin.SE3
+        self._l_T_w_e: pin.SE3
+        self._r_T_w_e: pin.SE3
+
+        # TODO: you have to have relative frame transformations here
+        # (from absolute frame to left frame and to the right frame)
+        raise NotImplementedError
+
+    def getAbsT_w_e(self, q=None):
+        """
+        getAbsT_w_e
+        -----------
+        get absolute frame, as seen in base frame, based on the current, or provident joint configuration
+        TODO: figure out what's the best way to get this
+        """
+        raise NotImplementedError
+
+    def getLeftRightT_w_e(self, q=None):
+        """
+        getLeftRightT_w_e
+        -----------
+        returns a tuple (l_T_w_e, r_T_w_e), i.e. left and right end-effector frames in the base frame,
+        based on the current, or provident joint configuration
+        """
+        if q is None:
+            return (self._l_T_w_e.copy(), self._r_T_w_e.copy())
+        assert type(q) is np.ndarray
+        # NOTE:
+        # calling forward kinematics and updateFramePlacements here is ok
+        # because we rely on robotmanager attributes instead of model.something in the rest of the code,
+        # i.e. this won't update this class' atribute
+        pin.forwardKinematics(
+            self.model,
+            self.data,
+            q,
+            #            np.zeros(self.model.nv),
+            #            np.zeros(self.model.nv),
+        )
+        # NOTE: this also returns the frame, so less copying possible
+        # pin.updateFramePlacements(self.model, self.data)
+        pin.updateFramePlacement(self.model, self.data, self._l_ee_frame_id)
+        pin.updateFramePlacement(self.model, self.data, self._r_ee_frame_id)
+        return (
+            self.data.oMf[self._l_ee_frame_id].copy(),
+            self.data.oMf[self._r_ee_frame_id].copy(),
+        )
+
+    def forwardKinematics(self):
+        pin.updateFramePlacement(self.model, self.data, self._l_ee_frame_id)
+        pin.updateFramePlacement(self.model, self.data, self._r_ee_frame_id)
+        self.l_T_w_e = self.data.oMf[self._l_ee_frame_id].copy()
+        self.r_T_w_e = self.data.oMf[self._r_ee_frame_id].copy()
+        # TODO: contruct the absolute frame from the relative frames here!!!
+        raise NotImplementedError
diff --git a/python/ur_simple_control/robots/force_torque_sensor_interface.py b/python/ur_simple_control/robots/force_torque_sensor_interface.py
new file mode 100644
index 0000000000000000000000000000000000000000..e1ce9feec98bcc644b94397ceb64293f7488aef8
--- /dev/null
+++ b/python/ur_simple_control/robots/force_torque_sensor_interface.py
@@ -0,0 +1,92 @@
+import abc
+import numpy as np
+import time
+
+
+class ForceTorqueSensorInterface(abc.ABC):
+    def __init__(self):
+        # in base frame
+        self._wrench_base = np.zeros(6)
+        # whatever you set to be the frame
+        self._ft_sensor_frame_id: int
+        self._wrench = np.zeros(6)
+        # NOTE: wrench bias will be defined in the frame your sensor's gives readings
+        self._wrench_bias = np.zeros(6)
+
+    def getWrench(self):
+        """
+        getWrench
+        ---------
+        returns UNFILTERED! force-torque sensor reading, i.e. wrench,
+        in the your prefered frame
+        NOTE: it is given in the end-effector frame by default
+        """
+        return self._wrench.copy()
+
+    def getWrenchBase(self):
+        """
+        getWrench
+        ---------
+        returns UNFILTERED! force-torque sensor reading, i.e. wrench,
+        in the base frame of the robot
+        """
+        return self._wrench_base.copy()
+
+    @abc.abstractmethod
+    def _getWrench(self):
+        """
+        get wrench reading from whatever interface you have.
+        NOTE:
+            1) it is YOUR job to know in which frame the reading is, and to map this
+               BOTH to the base frame of the robot AND your prefered frame.
+               this is because it is impossible to know what's the default on your sensor or where it is.
+               this way you are forced to verify and know in which frame you get your readings from.
+            2) do NOT do any filtering in this method - that happens elsewhere - here we just want the reading
+               in correct frames
+            3) you can count on your RobotManager to have already called forward kinematics before this function, i.e.
+               you can use frames stored in your RobotManager's attributes
+            4) if self.args.real == False, provide noise for better simulation testing
+        """
+        pass
+
+    def _getWrenchInEE(self, step_called=False):
+        if self.robot_name != "yumi":
+        else:
+            self.wrench = np.zeros(6)
+
+    def calibrateFT(self, robot):
+        """
+        calibrateFT
+        -----------
+        TODO: make generic
+        Read from the f/t sensor a bit, average the results
+        and return the result.
+        This can be used to offset the bias of the f/t sensor.
+        NOTE: this is not an ideal solution.
+        ALSO TODO: test whether the offset changes when
+        the manipulator is in different poses.
+        """
+        ft_readings = []
+        print("Will read from f/t sensors for a some number of seconds")
+        print("and give you the average.")
+        print("Use this as offset.")
+        # NOTE: zeroFtSensor() needs to be called frequently because it drifts
+        # by quite a bit in a matter of minutes.
+        # if you are running something on the robot for a long period of time, you need
+        # to reapply zeroFtSensor() to get reasonable results.
+        # because the robot needs to stop for the zeroing to make sense,
+        # this is the responsibility of the user!!!
+        robot.rtde_control.zeroFtSensor()
+        for i in range(2000):
+            start = time.time()
+            ft = robot.rtde_receive.getActualTCPForce()
+            ft_readings.append(ft)
+            end = time.time()
+            diff = end - start
+            if diff < self.dt:
+                time.sleep(self.dt - diff)
+
+        ft_readings = np.array(ft_readings)
+        self.wrench_offset = np.average(ft_readings, axis=0)
+        print(self.wrench_offset)
+        return self.wrench_offset.copy()
diff --git a/python/ur_simple_control/robots/heron.py b/python/ur_simple_control/robots/heron.py
deleted file mode 100644
index beb6909144d235ce416c1647bcd7f856daa075e3..0000000000000000000000000000000000000000
--- a/python/ur_simple_control/robots/heron.py
+++ /dev/null
@@ -1,7 +0,0 @@
-class RobotManagerHeron(RobotManagerAbstract):
-    def __init__(self, args):
-        self.args = args
-        self.model, self.collision_model, self.visual_model, self.data = (
-            heron_approximation()
-        )
-        self.ee_frame_id = self.model.getFrameId("tool0")
diff --git a/python/ur_simple_control/robots/implementations/heron.py b/python/ur_simple_control/robots/implementations/heron.py
new file mode 100644
index 0000000000000000000000000000000000000000..67b3223d7a4bd2ebe21cd62ba6d3e33153b5c427
--- /dev/null
+++ b/python/ur_simple_control/robots/implementations/heron.py
@@ -0,0 +1,62 @@
+import time
+from ur_simple_control.robots.robotmanager_abstract import RobotManagerAbstract
+
+
+class RobotManagerHeron(RobotManagerAbstract):
+    def __init__(self, args):
+        self.args = args
+        self.model, self.collision_model, self.visual_model, self.data = (
+            heron_approximation()
+        )
+        self.ee_frame_id = self.model.getFrameId("tool0")
+
+
+class RobotManagerHeronReal(RobotManagerHeron):
+    def __init__(self, args):
+        super().__init__(args)
+
+    # TODO: here assert you need to have ros2 installed to run on real heron
+    # and then set all this bullshit here instead of elsewhere
+    def set_publisher_vel_base(self, publisher_vel_base):
+        self.publisher_vel_base = publisher_vel_base
+        print("set vel_base_publisher into robotmanager")
+
+    def set_publisher_vel_left(self, publisher_vel_left):
+        self.publisher_vel_left = publisher_vel_left
+        print("set vel_left_publisher into robotmanager")
+
+    def set_publisher_vel_right(self, publisher_vel_right):
+        self.publisher_vel_right = publisher_vel_right
+        print("set vel_right_publisher into robotmanager")
+
+    def sendVelocityCommand(self, v):
+        v = super().sendVelocityCommand(v)
+        # speedj(qd, scalar_lead_axis_acc, hangup_time_on_command)
+        self.rtde_control.speedJ(v[3:], self._acceleration, self._dt)
+        cmd_msg = msg.Twist()
+        cmd_msg.linear.x = v[0]
+        # already clipped away, but no cost in being more explicit
+        # about underactuation
+        cmd_msg.linear.y = 0.0
+        cmd_msg.angular.z = v[2]
+        self.publisher_vel_base.publish(cmd_msg)
+        # good to keep because updating is slow otherwise
+        # it's not correct, but it's more correct than not updating
+        # self.q = pin.integrate(self.model, self.q, qd * self.dt)
+
+    def stopRobot(self):
+        print("stopping via freedrive lel")
+        self._rtde_control.freedriveMode()
+        time.sleep(0.5)
+        self._rtde_control.endFreedriveMode()
+        raise NotImplementedError(
+            "idk how to stop the base lmao. the arm should be the same as for ur5e"
+        )
+
+    def setFreedrive(self):
+        # NOTE: only works for the arm
+        self._rtde_control.FreedriveMode()
+
+    def unSetFreedrive(self):
+        # NOTE: only works for the arm
+        self._rtde_control.endFreedriveMode()
diff --git a/python/ur_simple_control/robots/implementations/mir.py b/python/ur_simple_control/robots/implementations/mir.py
new file mode 100644
index 0000000000000000000000000000000000000000..ae5a9c394a2ef89b3537175c5c1deacd33950493
--- /dev/null
+++ b/python/ur_simple_control/robots/implementations/mir.py
@@ -0,0 +1,35 @@
+    sendVelocityCommand(self, v):
+        """
+        sendVelocityCommand
+        -----
+        different things need to be send depending on whether you're running a simulation,
+        you're on a real robot, you're running some new simulator bla bla. this is handled
+        here because this things depend on the arguments which are manager here (hence the
+        class name RobotManager)
+        """
+        # y-direction is not possible on heron
+        qd[1] = 0
+        cmd_msg = msg.Twist()
+        cmd_msg.linear.x = qd[0]
+        cmd_msg.angular.z = qd[2]
+        # print("about to publish", cmd_msg)
+        self.publisher_vel_base.publish(cmd_msg)
+        # good to keep because updating is slow otherwise
+        # it's not correct, but it's more correct than not updating
+        # self.q = pin.integrate(self.model, self.q, qd * self.dt)
+
+
+
+    def stopRobot(self):
+        print("stopping via freedrive lel")
+        self._rtde_control.freedriveMode()
+        time.sleep(0.5)
+        self._rtde_control.endFreedriveMode()
+
+    def setFreedrive(self):
+        self._rtde_control.freedriveMode()
+        raise NotImplementedError("freedrive function only written for ur5e")
+
+    def unSetFreedrive(self):
+        self._rtde_control.endFreedriveMode()
+        raise NotImplementedError("freedrive function only written for ur5e")
diff --git a/python/ur_simple_control/robots/implementations/mobile_yumi.py b/python/ur_simple_control/robots/implementations/mobile_yumi.py
new file mode 100644
index 0000000000000000000000000000000000000000..7cf3176e3349d9cda9d65cc86c5beeaa49305085
--- /dev/null
+++ b/python/ur_simple_control/robots/implementations/mobile_yumi.py
@@ -0,0 +1,41 @@
+from ur_simple_control.robots.robotmanager_abstract import RobotManagerAbstract
+
+
+class RobotManagerMobileYumi(RobotManagerAbstract, DualArmInterface):
+    def __init__(self, args):
+        self.args = args
+        self.model, self.collision_model, self.visual_model, self.data = (
+            get_yumi_model()
+        )
+        self.r_ee_frame_id = self.model.getFrameId("robr_joint_7")
+        self.l_ee_frame_id = self.model.getFrameId("robl_joint_7")
+
+
+class RobotManagerMobileYumiReal(RobotManagerMobileYumi):
+    def __init__(self, args):
+        super().__init__(args)
+
+    # TODO: here assert you need to have ros2 installed to run on real heron
+    # and then set all this bullshit here instead of elsewhere
+    def set_publisher_joints_cmd(self, publisher_joints_cmd):
+        self.publisher_joints_cmd = publisher_joints_cmd
+        print("set publisher_joints_cmd into RobotManager")
+
+    def sendVelocityCommand(self, v):
+        #        qd_base = qd[:3]
+        #        qd_left = qd[3:10]
+        #        qd_right = qd[10:]
+        #        self.publisher_vel_base(qd_base)
+        #        self.publisher_vel_left(qd_left)
+        #        self.publisher_vel_right(qd_right)
+        empty_msg = JointState()
+        for i in range(29):
+            empty_msg.velocity.append(0.0)
+        msg = empty_msg
+        msg.header.stamp = Time().to_msg()
+        for i in range(3):
+            msg.velocity[i] = qd_cmd[i]
+        for i in range(15, 29):
+            msg.velocity[i] = qd_cmd[i - 12]
+
+        self.publisher_joints_cmd.publish(msg)
diff --git a/python/ur_simple_control/robots/implementations/simulated_robot.py b/python/ur_simple_control/robots/implementations/simulated_robot.py
new file mode 100644
index 0000000000000000000000000000000000000000..43bcb015976f59af1c78f9b95d9551f97e636b19
--- /dev/null
+++ b/python/ur_simple_control/robots/implementations/simulated_robot.py
@@ -0,0 +1,18 @@
+from ur_simple_control.robots.robotmanager_abstract import RobotManagerAbstract
+from ur_simple_control.util.get_model import *
+
+
+class RobotManagerSimulated(RobotManagerAbstract):
+    # TODO: find a way for this to inherit from the particular robot manager (the non-real part)
+    def __init__(self, args):
+        pass
+
+    def sendVelocityCommand(self, v):
+        """
+        sendVelocityCommand
+        -----
+        in simulation we just integrate the velocity for a dt and that's it
+        """
+        v = super().sendVelocityCommand(v)
+        self._v = v
+        self._q = pin.integrate(self.model, self._q, v * self._dt)
diff --git a/python/ur_simple_control/robots/implementations/ur5e.py b/python/ur_simple_control/robots/implementations/ur5e.py
new file mode 100644
index 0000000000000000000000000000000000000000..ae1c74822927ed148f74685f20a67ee30d844c38
--- /dev/null
+++ b/python/ur_simple_control/robots/implementations/ur5e.py
@@ -0,0 +1,134 @@
+from ur_simple_control.robots.single_arm_interface import SingleArmInterface
+from ur_simple_control.robots.force_torque_sensor_interface import (
+    ForceTorqueSensorInterface,
+)
+from ur_simple_control.util.get_model import get_model
+import numpy as np
+import pinocchio as pin
+
+# TODO: make rtde optional - ofc assert it has to exist in UR5e real constructor
+from rtde_control import RTDEControlInterface
+from rtde_receive import RTDEReceiveInterface
+from rtde_io import RTDEIOInterface
+from ur_simple_control.util.grippers.robotiq.robotiq_gripper import RobotiqGripper
+from ur_simple_control.util.grippers.on_robot.twofg import TWOFG
+import time
+
+
+# NOTE: this one assumes a jaw gripper
+class RobotManagerUR5e(SingleArmInterface, ForceTorqueSensorInterface):
+    def __init__(self, args):
+        self.args = args
+        self.model, self.collision_model, self.visual_model, self.data = get_model()
+        self._MAX_ACCELERATION = 1.7  # const
+        self._MAX_QD = 3.14  # const
+
+
+class RobotManagerU5eReal(RobotManagerUR5e):
+    def __init__(self, args):
+        super().__init__(args)
+        # NOTE: UR's sleep slider is evil and nothing works unless if it's set to 1.0!!!
+        # you have to control the acceleration via the acceleration argument.
+        # we need to set it to 1.0 with ur_rtde so that's why it's here and explicitely named
+        self._speed_slider = 1.0  # const
+        self._rtde_control: RTDEControlInterface
+        self._rtde_receive: RTDEReceiveInterface
+        self._rtde_io: RTDEIOInterface
+        self.connectToRobot()
+        self.connectToGripper()
+        self.setInitialPose()
+
+    def connectToGripper(self):
+        if (self.args.gripper != "none") and self.args.real:
+            if self.args.gripper == "robotiq":
+                self.gripper = RobotiqGripper()
+                self.gripper.connect(self.args.robot_ip, 63352)
+                self.gripper.activate()
+            if self.args.gripper == "onrobot":
+                self.gripper = TWOFG()
+        if self.args.gripper == "none":
+            self.gripper = None
+
+    def setInitialPose(self):
+        if not self.args.real and self.args.start_from_current_pose:
+            self._rtde_receive = RTDEReceiveInterface(self.args.robot_ip)
+            self.q = np.array(self._rtde_receive.getActualQ())
+            if self.args.visualize_manipulator:
+                self.visualizer_manager.sendCommand({"q": self.q})
+        if not self.args.real and not self.args.start_from_current_pose:
+            self.q = pin.randomConfiguration(
+                self.model, -1 * np.ones(self.model.nq), np.ones(self.model.nq)
+            )
+        if self.args.real:
+            self.q = np.array(self._rtde_receive.getActualQ())
+
+    def connectToRobot(self):
+        if self.args.real:
+            # NOTE: you can't connect twice, so you can't have more than one RobotManager per robot.
+            # if this produces errors like "already in use", and it's not already in use,
+            # try just running your new program again. it could be that the socket wasn't given
+            # back to the os even though you've shut off the previous program.
+            print("CONNECTING TO UR5e!")
+            self._rtde_control = RTDEControlInterface(self.args.robot_ip)
+            self._rtde_receive = RTDEReceiveInterface(self.args.robot_ip)
+            self._rtde_io = RTDEIOInterface(self.args.robot_ip)
+            self._rtde_io.setSpeedSlider(self.args.speed_slider)
+            # NOTE: the force/torque sensor just has large offsets for no reason,
+            # and you need to minus them to have usable readings.
+            # we provide this with calibrateFT
+            self.wrench_offset = self.calibrateFT()
+        else:
+            self.wrench_offset = np.zeros(6)
+
+    def setSpeedSlider(self, value):
+        """
+        setSpeedSlider
+        ---------------
+        update in all places
+        """
+        assert value <= 1.0 and value > 0.0
+        if not self.args.pinocchio_only:
+            self._rtde_io.setSpeedSlider(value)
+        self.speed_slider = value
+
+    def _getQ(self):
+        q = self._rtde_receive.getActualQ()
+        self._q = np.array(q)
+
+    def _getV(self):
+        v = self._rtde_receive.getActualQd()
+        self._v = np.array(v)
+
+    def _getWrench(self):
+        # NOTE: UR5e's ft-sensors gives readings in robot's base frame
+        if not self.args.real:
+            self._wrench_base = (
+                np.array(self._rtde_receive.getActualTCPForce()) - self._wrench_bias
+            )
+        else:
+            # TODO: do something better here (at least a better distribution)
+            self._wrench_base = np.random.random(6)
+        # NOTE: we define the default wrench to be given in the end-effector frame
+        mapping = np.zeros((6, 6))
+        mapping[0:3, 0:3] = self._T_w_e.rotation
+        mapping[3:6, 3:6] = self._T_w_e.rotation
+        self._wrench = mapping.T @ self._wrench_base
+
+    def sendVelocityCommand(self, v):
+        v = super().sendVelocityCommand(v)
+        # speedj(qd, scalar_lead_axis_acc, hangup_time_on_command)
+        self._rtde_control.speedJ(v, self._acceleration, self._dt)
+
+    def stopRobot(self):
+        print("stopping via freedrive lel")
+        self._rtde_control.freedriveMode()
+        time.sleep(0.5)
+        self._rtde_control.endFreedriveMode()
+
+    def setFreedrive(self):
+        self._rtde_control.freedriveMode()
+        raise NotImplementedError("freedrive function only written for ur5e")
+
+    def unSetFreedrive(self):
+        self._rtde_control.endFreedriveMode()
+        raise NotImplementedError("freedrive function only written for ur5e")
diff --git a/python/ur_simple_control/robots/mobile_base_interface.py b/python/ur_simple_control/robots/mobile_base_interface.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/python/ur_simple_control/robots/mobile_yumi.py b/python/ur_simple_control/robots/mobile_yumi.py
deleted file mode 100644
index 8abda3f0a984564f3ad277a1c077cea9ec2356c6..0000000000000000000000000000000000000000
--- a/python/ur_simple_control/robots/mobile_yumi.py
+++ /dev/null
@@ -1,8 +0,0 @@
-
-class RobotManagerMobileYumi(RobotManagerAbstract):
-    def __init__(self, args):
-        self.args = args
-        self.model, self.collision_model, self.visual_model, self.data = (
-            get_yumi_model()
-        self.r_ee_frame_id = self.model.getFrameId("robr_joint_7")
-        self.l_ee_frame_id = self.model.getFrameId("robl_joint_7")
diff --git a/python/ur_simple_control/robots/robotmanager_abstract.py b/python/ur_simple_control/robots/robotmanager_abstract.py
index 06de3ef2359fe8c108143e7e1b585efa313b0de8..d097fadd55c627e9207493a603c8b445c2a655f7 100644
--- a/python/ur_simple_control/robots/robotmanager_abstract.py
+++ b/python/ur_simple_control/robots/robotmanager_abstract.py
@@ -2,19 +2,11 @@ import abc
 import argparse
 import pinocchio as pin
 import numpy as np
-from ur_simple_control.util.grippers.robotiq.robotiq_gripper import RobotiqGripper
-from ur_simple_control.util.grippers.on_robot.twofg import TWOFG
 
 from ur_simple_control.util.logging_utils import LogManager
 from functools import partial
 from ur_simple_control.visualize.visualize import manipulatorVisualizer
 
-###################################
-# TODO: make everything different on each robot an abstract method.
-# TODO: try to retain non-robot defined functions like _step, getters, setters etc
-# TODO: rename pinocchio_only to real (also toggle the boolean!)
-############################################
-
 
 class RobotManagerAbstract(abc.ABC):
     """
@@ -22,14 +14,10 @@ class RobotManagerAbstract(abc.ABC):
     ---------------
     - design goal: rely on pinocchio as much as possible while
                    concealing obvious bookkeeping
-    - right now it is assumed you're running this on UR5e so some
-      magic numbers are just put to it.
-      this will be extended once there's a need for it.
-    - it's just a boilerplate reduction class
-    - if this was a real programming language, a lot of variables would really be private variables.
-      as it currently stands, "private" functions have the '_' prefix
-      while the public getters don't have a prefix.
-    - TODO: write out default arguments needed here as well
+    - this class serves to:
+        - store the non-interfacing information
+          about the robot like maximum allowed velocity, pin.model etc
+        - provide functions every robot has to have like getQ
     """
 
     # just pass all of the arguments here and store them as is
@@ -52,6 +40,12 @@ class RobotManagerAbstract(abc.ABC):
         self._dt = 1 / self._update_rate  # [s]
         self._t: float = 0.0
         # defined per robot - hardware limit
+        # TODO TODO TODO:
+        # these max things need to be arrays
+        # and if nothing is set via arguments, set it to model.velocityLimit by default!!!!!!!
+        #    qd_cmd = np.clip(
+        #        qd, -1 * self.model.velocityLimit, self.model.velocityLimit
+        #    )
         self._MAX_ACCELERATION: float | np.ndarray  # in joint space
         assert (
             self.args.acceleration <= self._MAX_ACCELERATION
@@ -59,30 +53,21 @@ class RobotManagerAbstract(abc.ABC):
         )
         self._acceleration = self.args.acceleration
         # defined per robot - hardware limit
-        self._MAX_QD: float | np.ndarray
-        assert self.args.max_qd <= self._MAX_QD and self.args.max_qd > 0.0
+        self._MAX_V: float | np.ndarray
+        assert self.args.max_v <= self._MAX_V and self.args.max_v > 0.0
         # internally clipped to this
-        self._max_qd = args.max_qd
+        self._max_v = args.max_v
 
         # NOTE: make sure you've read pinocchio docs and understand why
         #       nq and nv are not necessarily the same number
         self.q = np.zeros(self.model.nq)
-        self.v_q = np.zeros(self.model.nv)
-        self.a_q = np.zeros(self.model.nv)
+        self.v = np.zeros(self.model.nv)
+        self.a = np.zeros(self.model.nv)
 
-        self.gripper = None
-        if (self.args.gripper != "none") and self.args.real:
-            if self.args.gripper == "robotiq":
-                self.gripper = RobotiqGripper()
-                self.gripper.connect(args.robot_ip, 63352)
-                self.gripper.activate()
-            if self.args.gripper == "onrobot":
-                self.gripper = TWOFG()
-
-        # start visualize manipulator process if selected.
         # since there is only one robot and one visualizer, this is robotmanager's job
         self.visualizer_manager: None | ProcessManager
-        if args.visualizer:
+        self.visualizer_manager = None
+        if self.args.visualizer:
             side_function = partial(
                 manipulatorVisualizer,
                 self.model,
@@ -92,36 +77,22 @@ class RobotManagerAbstract(abc.ABC):
             self.visualizer_manager = ProcessManager(
                 args, side_function, {"q": self.q.copy()}, 0
             )
+            # TODO: move this bs to visualizer, there are 0 reasons to keep it here
             if args.visualize_collision_approximation:
                 # TODO: import the ellipses here, and write an update ellipse function
                 # then also call that in controlloopmanager
                 raise NotImplementedError(
                     "sorry, almost done - look at utils to get 90% of the solution!"
                 )
-        else:
-            self.visualizer_manager = None
-
-        self.connectToRobot()
 
-        # wrench being the force-torque sensor reading, if any
-        self.wrench_offset = np.zeros(6)
-
-        self.setInitialPose()
+        self.gripper = None
         self._step()
 
-    #######################################################################
-    #               getters which assume you called step()                #
-    #######################################################################
-
-    # NOTE: just do nothing if you're only integrating with pinocchio,
-    #       or start a physics simulator if you're using it
-    @abc.abstractmethod
-    def connectToRobot(self):
-        pass
-
     @abc.abstractmethod
     def setInitialPose(self):
         """
+        setInitialPose
+        --------------
         for example, if just integrating, do:
         self.q = pin.randomConfiguration(
             self.model, -1 * np.ones(self.model.nq), np.ones(self.model.nq)
@@ -134,418 +105,54 @@ class RobotManagerAbstract(abc.ABC):
     def getQ(self):
         return self.q.copy()
 
-    def getQd(self):
-        return self.v_q.copy()
-
-    def getT_w_e(self, q_given=None):
-        if self.robot_name != "yumi":
-            if q_given is None:
-                return self.T_w_e.copy()
-            else:
-                assert type(q_given) is np.ndarray
-                # calling these here is ok because we rely
-                # on robotmanager attributes instead of model.something
-                # (which is copying data, but fully separates state and computation,
-                # which is important in situations like this)
-                pin.forwardKinematics(
-                    self.model,
-                    self.data,
-                    q_given,
-                    np.zeros(self.model.nv),
-                    np.zeros(self.model.nv),
-                )
-                # NOTE: this also returns the frame, so less copying possible
-                # pin.updateFramePlacements(self.model, self.data)
-                pin.updateFramePlacement(self.model, self.data, self.ee_frame_id)
-                return self.data.oMf[self.ee_frame_id].copy()
-        else:
-            if q_given is None:
-                return self.T_w_e_left.copy(), self.T_w_e_right.copy().copy()
-            else:
-                assert type(q_given) is np.ndarray
-                # calling these here is ok because we rely
-                # on robotmanager attributes instead of model.something
-                # (which is copying data, but fully separates state and computation,
-                # which is important in situations like this)
-                pin.forwardKinematics(
-                    self.model,
-                    self.data,
-                    q_given,
-                    np.zeros(self.model.nv),
-                    np.zeros(self.model.nv),
-                )
-                # NOTE: this also returns the frame, so less copying possible
-                # pin.updateFramePlacements(self.model, self.data)
-                pin.updateFramePlacement(self.model, self.data, self.r_ee_frame_id)
-                pin.updateFramePlacement(self.model, self.data, self.l_ee_frame_id)
-                return (
-                    self.data.oMf[self.l_ee_frame_id].copy(),
-                    self.data.oMf[self.r_ee_frame_id].copy(),
-                )
-
-    # this is in EE frame by default (handled in step which
-    # is assumed to be called before this)
-    def getWrench(self):
-        return self.wrench.copy()
+    def getV(self):
+        return self.v.copy()
 
-    def calibrateFT(self):
+    @abc.abstractmethod
+    def forwardKinematics(self):
         """
-        calibrateFT
-        -----------
-        Read from the f/t sensor a bit, average the results
-        and return the result.
-        This can be used to offset the bias of the f/t sensor.
-        NOTE: this is not an ideal solution.
-        ALSO TODO: test whether the offset changes when
-        the manipulator is in different poses.
+        forwardKinematics
+        -----------------
+        compute all the frames you care about based on current configuration
+        and put them into class attributes.
+        for a single manipulator with just the end-effector frame,
+        this is a pointless function, but there is more to do for dual
+        arm robots for example.
         """
-        ft_readings = []
-        print("Will read from f/t sensors for a some number of seconds")
-        print("and give you the average.")
-        print("Use this as offset.")
-        # NOTE: zeroFtSensor() needs to be called frequently because it drifts
-        # by quite a bit in a matter of minutes.
-        # if you are running something on the robot for a long period of time, you need
-        # to reapply zeroFtSensor() to get reasonable results.
-        # because the robot needs to stop for the zeroing to make sense,
-        # this is the responsibility of the user!!!
-        self.rtde_control.zeroFtSensor()
-        for i in range(2000):
-            start = time.time()
-            ft = self.rtde_receive.getActualTCPForce()
-            ft_readings.append(ft)
-            end = time.time()
-            diff = end - start
-            if diff < self.dt:
-                time.sleep(self.dt - diff)
-
-        ft_readings = np.array(ft_readings)
-        self.wrench_offset = np.average(ft_readings, axis=0)
-        print(self.wrench_offset)
-        return self.wrench_offset.copy()
+        pass
 
+    @abc.abstractmethod
     def _step(self):
         """
         _step
         ----
-        - the idea is to update everything that should be updated
-          on a step-by-step basis
-        - the actual problem this is solving is that you're not calling
+        Purpose:
+        - update everything that should be updated on a step-by-step basis
+        Reason for existance:
+        - the problem this is solving is that you're not calling
           forwardKinematics, an expensive call, more than once per step.
-        - within the TODO is to make all (necessary) variable private
-          so that you can rest assured that everything is handled the way
-          it's supposed to be handled. then have getters for these
-          private variables which return deepcopies of whatever you need.
-          that way the computations done in the control loop
-          can't mess up other things. this is important if you want
-          to switch between controllers during operation and have a completely
-          painless transition between them.
-          TODO: make the getQ, getQd and the rest here do the actual communication,
-          and make these functions private.
-          then have the deepcopy getters public.
-          also TODO: make ifs for the simulation etc.
-          this is less ifs overall right.
+        - you also don't need to remember to do call forwardKinematics
+        Usage:
+        - don't call this method yourself! this is CommandLoopManager's job
+        - use getters to get copies of the variables you want
         """
-        self._getQ()
-        self._getQd()
-        # self._getWrench()
-        # computeAllTerms is certainly not necessary btw
-        # but if it runs on time, does it matter? it makes everything available...
-        # (includes forward kinematics, all jacobians, all dynamics terms, energies)
-        # pin.computeAllTerms(self.model, self.data, self.q, self.v_q)
-        pin.forwardKinematics(self.model, self.data, self.q, self.v_q)
-        if self.robot_name != "yumi":
-            pin.updateFramePlacement(self.model, self.data, self.ee_frame_id)
-            self.T_w_e = self.data.oMf[self.ee_frame_id].copy()
-        else:
-            pin.updateFramePlacement(self.model, self.data, self.l_ee_frame_id)
-            pin.updateFramePlacement(self.model, self.data, self.r_ee_frame_id)
-            self.T_w_e_left = self.data.oMf[self.l_ee_frame_id].copy()
-            self.T_w_e_right = self.data.oMf[self.r_ee_frame_id].copy()
-        # wrench in EE should obviously be the default
-        self._getWrenchInEE(step_called=True)
-        # this isn't real because we're on a velocity-controlled robot,
-        # so this is actually None (no tau, no a_q, as expected)
-        self.a_q = self.data.ddq
-        # TODO NOTE: you'll want to do the additional math for
-        # torque controlled robots here, but it's ok as is rn
 
-    def setSpeedSlider(self, value):
-        """
-        setSpeedSlider
-        ---------------
-        update in all places
-        """
-        assert value <= 1.0 and value > 0.0
-        if not self.args.pinocchio_only:
-            self.rtde_io.setSpeedSlider(value)
-        self.speed_slider = value
-
-    def _getQ(self):
-        """
-        _getQ
-        -----
-        NOTE: private function for use in _step(), use the getter getQ()
-        urdf treats gripper as two prismatic joints,
-        but they do not affect the overall movement
-        of the robot, so we add or remove 2 items to the joint list.
-        also, the gripper is controlled separately so we'd need to do this somehow anyway
-        NOTE: this gripper_past_pos thing is not working atm, but i'll keep it here as a TODO
-        TODO: make work for new gripper
-        """
-        if not self.pinocchio_only:
-            q = self.rtde_receive.getActualQ()
-            if self.args.gripper == "robotiq":
-                # TODO: make it work or remove it
-                # self.gripper_past_pos = self.gripper_pos
-                # this is pointless by itself
-                self.gripper_pos = self.gripper.get_current_position()
-                # the /255 is to get it dimensionless.
-                # the gap is 5cm,
-                # thus half the gap is 0.025m (and we only do si units here).
-                q.append((self.gripper_pos / 255) * 0.025)
-                q.append((self.gripper_pos / 255) * 0.025)
-            else:
-                # just fill it with zeros otherwise
-                if self.robot_name == "ur5e":
-                    q.append(0.0)
-                    q.append(0.0)
-            # let's just have both options for getting q, it's just a 8d float list
-            # readability is a somewhat subjective quality after all
-            q = np.array(q)
-            self.q = q
-
-    # TODO remove evil hack
-    def _getT_w_e(self, q_given=None):
-        """
-        _getT_w_e
-        -----
-        NOTE: private function, use the getT_w_e() getter
-        urdf treats gripper as two prismatic joints,
-        but they do not affect the overall movement
-        of the robot, so we add or remove 2 items to the joint list.
-        also, the gripper is controlled separately so we'd need to do this somehow anyway
-        NOTE: this gripper_past_pos thing is not working atm, but i'll keep it here as a TODO.
-        NOTE: don't use this if use called _step() because it repeats forwardKinematics
-        """
-        test = True
-        try:
-            test = q_given.all() == None
-            print(test)
-            print(q_given)
-        except AttributeError:
-            test = True
-
-        if test:
-            if not self.pinocchio_only:
-                q = self.rtde_receive.getActualQ()
-                if self.args.gripper == "robotiq":
-                    # TODO: make it work or remove it
-                    # self.gripper_past_pos = self.gripper_pos
-                    # this is pointless by itself
-                    self.gripper_pos = self.gripper.get_current_position()
-                    # the /255 is to get it dimensionless.
-                    # the gap is 5cm,
-                    # thus half the gap is 0.025m (and we only do si units here).
-                    q.append((self.gripper_pos / 255) * 0.025)
-                    q.append((self.gripper_pos / 255) * 0.025)
-                else:
-                    # just fill it with zeros otherwise
-                    q.append(0.0)
-                    q.append(0.0)
-            else:
-                q = self.q
-        else:
-            q = copy.deepcopy(q_given)
-        q = np.array(q)
-        self.q = q
-        pin.forwardKinematics(self.model, self.data, q)
-        if self.robot_name != "yumi":
-            pin.updateFramePlacement(self.model, self.data, self.ee_frame_id)
-            self.T_w_e = self.data.oMf[self.ee_frame_id].copy()
-        else:
-            pin.updateFramePlacement(self.model, self.data, self.l_ee_frame_id)
-            pin.updateFramePlacement(self.model, self.data, self.r_ee_frame_id)
-            self.T_w_e_left = self.data.oMf[self.l_ee_frame_id].copy()
-            self.T_w_e_right = self.data.oMf[self.r_ee_frame_id].copy()
-            # NOTE: VERY EVIL, to bugfix things that depend on this like wrench (which we don't have on yumi)
-            # self.T_w_e = self.data.oMf[self.l_ee_frame_id].copy()
-
-    def _getQd(self):
-        """
-        _getQd
-        -----
-        NOTE: private function, use the _getQd() getter
-        same note as _getQ.
-        TODO NOTE: atm there's no way to get current gripper velocity.
-        this means you'll probably want to read current positions and then finite-difference
-        to get the velocity.
-        as it stands right now, we'll just pass zeros in because I don't need this ATM
-        """
-        if not self.pinocchio_only:
-            qd = self.rtde_receive.getActualQd()
-            if self.args.gripper:
-                # TODO: this doesn't work because we're not ensuring stuff is called
-                # at every timestep
-                # self.gripper_vel = (gripper.get_current_position() - self.gripper_pos) / self.dt
-                # so it's just left unused for now - better give nothing than wrong info
-                self.gripper_vel = 0.0
-                # the /255 is to get it dimensionless
-                # the gap is 5cm
-                # thus half the gap is 0.025m and we only do si units here
-                # no need to deepcopy because only literals are passed
-                qd.append(self.gripper_vel)
-                qd.append(self.gripper_vel)
-            else:
-                # just fill it with zeros otherwise
-                qd.append(0.0)
-                qd.append(0.0)
-            # let's just have both options for getting q, it's just a 8d float list
-            # readability is a somewhat subjective quality after all
-            qd = np.array(qd)
-            self.v_q = qd
-
-    def _getWrenchRaw(self):
-        """
-        _getWrench
-        -----
-        different things need to be send depending on whether you're running a simulation,
-        you're on a real robot, you're running some new simulator bla bla. this is handled
-        here because this things depend on the arguments which are manager here (hence the
-        class name RobotManager)
-        """
-        if not self.pinocchio_only:
-            wrench = np.array(self.rtde_receive.getActualTCPForce())
-        else:
-            raise NotImplementedError("Don't have time to implement this right now.")
-
-    def _getWrench(self):
-        if not self.pinocchio_only:
-            self.wrench = (
-                np.array(self.rtde_receive.getActualTCPForce()) - self.wrench_offset
-            )
-        else:
-            # TODO: do something better here (at least a better distribution)
-            self.wrench = np.random.random(self.n_arm_joints)
-
-    def _getWrenchInEE(self, step_called=False):
-        if self.robot_name != "yumi":
-            if not self.pinocchio_only:
-                self.wrench = (
-                    np.array(self.rtde_receive.getActualTCPForce()) - self.wrench_offset
-                )
-            else:
-                # TODO: do something better here (at least a better distribution)
-                self.wrench = np.random.random(self.n_arm_joints)
-            if not step_called:
-                self._getT_w_e()
-            # NOTE: this mapping is equivalent to having a purely rotational action
-            # this is more transparent tho
-            mapping = np.zeros((6, 6))
-            mapping[0:3, 0:3] = self.T_w_e.rotation
-            mapping[3:6, 3:6] = self.T_w_e.rotation
-            self.wrench = mapping.T @ self.wrench
-        else:
-            self.wrench = np.zeros(6)
-
-    def sendQd(self, qd):
+    @abc.abstractmethod
+    def sendVelocityCommand(self, v):
         """
-        sendQd
+        sendVelocityCommand
         -----
         different things need to be send depending on whether you're running a simulation,
         you're on a real robot, you're running some new simulator bla bla. this is handled
         here because this things depend on the arguments which are manager here (hence the
         class name RobotManager)
         """
-        # we're hiding the extra 2 prismatic joint shenanigans from the control writer
-        # because there you shouldn't need to know this anyway
-        if self.robot_name == "ur5e":
-            qd_cmd = qd[:6]
-            # np.clip is ok with bounds being scalar, it does what it should
-            # (but you can also give it an array)
-            qd_cmd = np.clip(qd_cmd, -1 * self.max_qd, self.max_qd)
-            if not self.pinocchio_only:
-                # speedj(qd, scalar_lead_axis_acc, hangup_time_on_command)
-                self.rtde_control.speedJ(qd_cmd, self.acceleration, self.dt)
-            else:
-                # this one takes all 8 elements of qd since we're still in pinocchio
-                # this is ugly, todo: fix
-                qd = qd[:6]
-                qd = qd_cmd.reshape((6,))
-                qd = list(qd)
-                qd.append(0.0)
-                qd.append(0.0)
-                qd = np.array(qd)
-                self.v_q = qd
-                self.q = pin.integrate(self.model, self.q, qd * self.dt)
-
-        if self.robot_name == "heron":
-            # y-direction is not possible on heron
-            qd_cmd = np.clip(
-                qd, -1 * self.model.velocityLimit, self.model.velocityLimit
-            )
-            # qd[1] = 0
-            self.v_q = qd_cmd
-            self.q = pin.integrate(self.model, self.q, qd_cmd * self.dt)
-
-        if self.robot_name == "heronros":
-            # y-direction is not possible on heron
-            qd[1] = 0
-            cmd_msg = msg.Twist()
-            cmd_msg.linear.x = qd[0]
-            cmd_msg.angular.z = qd[2]
-            # print("about to publish", cmd_msg)
-            self.publisher_vel_base.publish(cmd_msg)
-            # good to keep because updating is slow otherwise
-            # it's not correct, but it's more correct than not updating
-            # self.q = pin.integrate(self.model, self.q, qd * self.dt)
-
-        if self.robot_name == "mirros":
-            # y-direction is not possible on heron
-            qd[1] = 0
-            cmd_msg = msg.Twist()
-            cmd_msg.linear.x = qd[0]
-            cmd_msg.angular.z = qd[2]
-            # print("about to publish", cmd_msg)
-            self.publisher_vel_base.publish(cmd_msg)
-            # good to keep because updating is slow otherwise
-            # it's not correct, but it's more correct than not updating
-            # self.q = pin.integrate(self.model, self.q, qd * self.dt)
-
-        if self.robot_name == "gripperlessur5e":
-            qd_cmd = np.clip(qd, -1 * self.max_qd, self.max_qd)
-            if not self.pinocchio_only:
-                self.rtde_control.speedJ(qd_cmd, self.acceleration, self.dt)
-            else:
-                self.v_q = qd_cmd
-                self.q = pin.integrate(self.model, self.q, qd_cmd * self.dt)
-
-        if self.robot_name == "yumi":
-            qd_cmd = np.clip(
-                qd, -0.01 * self.model.velocityLimit, 0.01 * self.model.velocityLimit
-            )
-            self.v_q = qd_cmd
-            #    if self.args.pinocchio_only:
-            #        self.q = pin.integrate(self.model, self.q, qd_cmd * self.dt)
-            #    else:
-            #        qd_base = qd[:3]
-            #        qd_left = qd[3:10]
-            #        qd_right = qd[10:]
-            #        self.publisher_vel_base(qd_base)
-            #        self.publisher_vel_left(qd_left)
-            #        self.publisher_vel_right(qd_right)
-            empty_msg = JointState()
-            for i in range(29):
-                empty_msg.velocity.append(0.0)
-            msg = empty_msg
-            msg.header.stamp = Time().to_msg()
-            for i in range(3):
-                msg.velocity[i] = qd_cmd[i]
-            for i in range(15, 29):
-                msg.velocity[i] = qd_cmd[i - 12]
-
-            self.publisher_joints_cmd.publish(msg)
+        assert type(v) == np.ndarray
+        assert len(v) == self.model.nv
+        v = np.clip(v, -1 * self._max_v, self._max_v)
+        return v
 
+    # TODO: make faux gripper class to avoid this bullshit here
     def openGripper(self):
         if self.gripper is None:
             if self.args.debug_prints:
@@ -558,6 +165,7 @@ class RobotManagerAbstract(abc.ABC):
         else:
             print("not implemented yet, so nothing is going to happen!")
 
+    # TODO: make faux gripper class to avoid this bullshit here
     def closeGripper(self):
         if self.gripper is None:
             if self.args.debug_prints:
@@ -570,78 +178,6 @@ class RobotManagerAbstract(abc.ABC):
         else:
             print("not implemented yet, so nothing is going to happen!")
 
-    #######################################################################
-    #                          utility functions                          #
-    #######################################################################
-
-    def defineGoalPointCLI(self):
-        """
-        defineGoalPointCLI
-        ------------------
-        NOTE: this assume _step has not been called because it's run before the controlLoop
-        --> best way to handle the goal is to tell the user where the gripper is
-            in both UR tcp frame and with pinocchio and have them
-            manually input it when running.
-            this way you force the thinking before the moving,
-            but you also get to view and analyze the information first
-        TODO get the visual thing you did in ivc project with sliders also.
-        it's just text input for now because it's totally usable, just not superb.
-        but also you do want to have both options. obviously you go for the sliders
-        in the case you're visualizing, makes no sense otherwise.
-        """
-        self._getQ()
-        q = self.getQ()
-        # define goal
-        pin.forwardKinematics(self.model, self.data, np.array(q))
-        pin.updateFramePlacement(self.model, self.data, self.ee_frame_id)
-        T_w_e = self.data.oMf[self.ee_frame_id]
-        print("You can only specify the translation right now.")
-        if not self.pinocchio_only:
-            print(
-                "In the following, first 3 numbers are x,y,z position, and second 3 are r,p,y angles"
-            )
-            print(
-                "Here's where the robot is currently. Ensure you know what the base frame is first."
-            )
-            print(
-                "base frame end-effector pose from pinocchio:\n",
-                *self.data.oMi[6].translation.round(4),
-                *pin.rpy.matrixToRpy(self.data.oMi[6].rotation).round(4)
-            )
-            print("UR5e TCP:", *np.array(self.rtde_receive.getActualTCPPose()).round(4))
-        # remain with the current orientation
-        # TODO: add something, probably rpy for orientation because it's the least number
-        # of numbers you need to type in
-        Mgoal = T_w_e.copy()
-        # this is a reasonable way to do it too, maybe implement it later
-        # Mgoal.translation = Mgoal.translation + np.array([0.0, 0.0, -0.1])
-        # do a while loop until this is parsed correctly
-        while True:
-            goal = input(
-                "Please enter the target end-effector position in the x.x,y.y,z.z format: "
-            )
-            try:
-                e = "ok"
-                goal_list = goal.split(",")
-                for i in range(len(goal_list)):
-                    goal_list[i] = float(goal_list[i])
-            except:
-                e = exc_info()
-                print("The input is not in the expected format. Try again.")
-                print(e)
-            if e == "ok":
-                Mgoal.translation = np.array(goal_list)
-                break
-        print("this is goal pose you defined:\n", Mgoal)
-
-        # NOTE i'm not deepcopying this on purpose
-        # but that might be the preferred thing, we'll see
-        self.Mgoal = Mgoal
-        if self.args.visualize_manipulator:
-            # TODO document this somewhere
-            self.visualizer_manager.sendCommand({"Mgoal": Mgoal})
-        return Mgoal
-
     def killManipulatorVisualizer(self):
         """
         killManipulatorVisualizer
@@ -656,25 +192,6 @@ class RobotManagerAbstract(abc.ABC):
         """
         self.visualizer_manager.terminateProcess()
 
-    def stopRobot(self):
-        if not self.args.pinocchio_only:
-            print("stopping via freedrive lel")
-            self.rtde_control.freedriveMode()
-            time.sleep(0.5)
-            self.rtde_control.endFreedriveMode()
-
-    def setFreedrive(self):
-        if self.robot_name in ["ur5e", "gripperlessur5e"]:
-            self.rtde_control.freedriveMode()
-        else:
-            raise NotImplementedError("freedrive function only written for ur5e")
-
-    def unSetFreedrive(self):
-        if self.robot_name in ["ur5e", "gripperlessur5e"]:
-            self.rtde_control.endFreedriveMode()
-        else:
-            raise NotImplementedError("freedrive function only written for ur5e")
-
     def updateViz(self, viz_dict: dict):
         """
         updateViz
@@ -689,18 +206,114 @@ class RobotManagerAbstract(abc.ABC):
             if self.args.debug_prints:
                 print("you didn't select viz")
 
-    def set_publisher_vel_base(self, publisher_vel_base):
-        self.publisher_vel_base = publisher_vel_base
-        print("set vel_base_publisher into robotmanager")
 
-    def set_publisher_vel_left(self, publisher_vel_left):
-        self.publisher_vel_left = publisher_vel_left
-        print("set vel_left_publisher into robotmanager")
+class RobotManagerRealAbstract(RobotManagerAbstract, abc.ABC):
+    """
+    RobotManagerRealAbstract
+    ------------------------
+    enumerate the templates that the interface for your real robot
+    has to have, connectToRobot
+    """
 
-    def set_publisher_vel_right(self, publisher_vel_right):
-        self.publisher_vel_right = publisher_vel_right
-        print("set vel_right_publisher into robotmanager")
+    def __init__(self, args):
+        super().__init__(args)
+        self.connectToRobot()
+        self.connectToGripper()
+        self.setInitialPose()
 
-    def set_publisher_joints_cmd(self, publisher_joints_cmd):
-        self.publisher_joints_cmd = publisher_joints_cmd
-        print("set publisher_joints_cmd into RobotManager")
+    @abc.abstractmethod
+    def connectToRobot(self):
+        """
+        connectToRobot
+        --------------
+        create whatever is necessary to:
+        1) send commands to the robot
+        2) receive state information
+        3) set options if applicable
+        Setting up a gripper, force-torque sensor and similar is handled separately
+        """
+        pass
+
+    @abc.abstractmethod
+    def connectToGripper(self):
+        """
+        connectToGripper
+        --------------
+        create a gripper class based on selected argument.
+        this needs to be made for each robot individually because even if the gripper
+        is the same, the robot is likely to have its own way of interfacing it
+        """
+        pass
+
+    @abc.abstractmethod
+    def _getQ(self):
+        """
+        update internal _q joint angles variable with current angles obtained
+        from your robot's communication interface
+        """
+        pass
+
+    @abc.abstractmethod
+    def _getV(self):
+        """
+        update internal _v joint velocities variable with current velocities obtained
+        from your robot's communication interface
+        """
+        pass
+
+    def _step(self):
+        self._getQ()
+        self._getV()
+        # self._getWrench()
+
+        # NOTE: when implementing an interface for torque-controlled robots,
+        # use computeAllTerms to make life easier
+
+        self.forwardKinematics()
+        # wrench in EE should obviously be the default
+        self._getWrenchInEE(step_called=True)
+        # this isn't real because we're on a velocity-controlled robot,
+        # so this is actually None (no tau, no a_q, as expected)
+        self.a_q = self.data.ddq
+        # TODO NOTE: you'll want to do the additional math for
+        # torque controlled robots here, but it's ok as is rn
+
+    @abc.abstractmethod
+    def stopRobot(self):
+        """
+        stopRobot
+        ---------
+        implement whatever stops the robot as cleanly as possible on your robot.
+        does not need to be emergency option, could be sending zero velocity
+        commands, could be switching to freedrive/leadthrough/whatever.
+
+        depending on the communication interface on your robot,
+        it could happen that it stays in "externally guided motion" mode,
+        i.e. it keeps listening to new velocity commands, while executing the last velocity command.
+        such a scenario is problematic because they last control command might not be
+        all zeros, meaning the robot will keep slowly moving and hit something after 5min
+        (obviously this exact thing happened to the author...)
+        """
+        pass
+
+    @abc.abstractmethod
+    def setFreedrive(self):
+        """
+        setFreedrive
+        ------------
+        set the robot into the "teaching mode", "freedrive mode", "leadthrough mode"
+        or whatever the term is for when you can manually (with your hands) move the robot
+        around
+        """
+        pass
+
+    def unSetFreedrive(self):
+        """
+        setFreedrive
+        ------------
+        unset the robot into the "teaching mode", "freedrive mode", "leadthrough mode"
+        or whatever the term is for when you can manually (with your hands) move the robot
+        around, back to "wait" or "accept commands" or whatever mode the robot has to
+        receive external commands.
+        """
+        pass
diff --git a/python/ur_simple_control/robots/simulated_robot.py b/python/ur_simple_control/robots/simulated_robot.py
deleted file mode 100644
index 066af4db934da8db98b7ae5bf846c0ad980d803d..0000000000000000000000000000000000000000
--- a/python/ur_simple_control/robots/simulated_robot.py
+++ /dev/null
@@ -1,31 +0,0 @@
-from ur_simple_control.robots.robotmanager_abstract import RobotManagerAbstract
-from ur_simple_control.util.get_model import *
-
-
-class RobotManagerSimulated(RobotManagerAbstract):
-    def __init__(self, args):
-        # TODO: idk where i pass args
-        super(RobotManagerAbstract, self).__init__(args)
-        if self.robot_name == "ur5e":
-            self.model, self.collision_model, self.visual_model, self.data = get_model()
-        if self.robot_name == "heron":
-            self.model, self.collision_model, self.visual_model, self.data = (
-                heron_approximation()
-            )
-        if self.robot_name == "heronros":
-            self.model, self.collision_model, self.visual_model, self.data = (
-                heron_approximation()
-            )
-        if self.robot_name == "mirros":
-            self.model, self.collision_model, self.visual_model, self.data = (
-                mir_approximation()
-            )
-            # self.publisher_vel_base = create_publisher(msg.Twist, '/cmd_vel', 5)
-            # self.publisher_vel_base = publisher_vel_base
-        if self.robot_name == "gripperlessur5e":
-            self.model, self.collision_model, self.visual_model, self.data = (
-                getGripperlessUR5e()
-            )
-        if self.robot_name == "yumi":
-            self.model, self.collision_model, self.visual_model, self.data = (
-                get_yumi_model()
diff --git a/python/ur_simple_control/robots/single_arm_interface.py b/python/ur_simple_control/robots/single_arm_interface.py
index c12ad68a1c44e1760120bbae6f506f5c536be76e..b3c09792c01ffd70487abe1bb111ed31cd5738bd 100644
--- a/python/ur_simple_control/robots/single_arm_interface.py
+++ b/python/ur_simple_control/robots/single_arm_interface.py
@@ -1,9 +1,34 @@
 import abs
+import numpy as np
+import pinocchio as pin
 from ur_simple_control.robots.robotmanager_abstract import RobotManagerAbstract
 
 
 class SingleArmInterface(RobotManagerAbstract):
     def __init__(self):
-        # idk if this is correct
-        super().__init__
-        self.ee_frame_id = self.model.getFrameId("tool0")
+        self._ee_frame_id = self.model.getFrameId("tool0")
+        self._T_w_e: pin.SE3
+
+    def getT_w_e(self, q=None):
+        if q is None:
+            return self._T_w_e.copy()
+        assert type(q) is np.ndarray
+        # calling these here is ok because we rely
+        # on robotmanager attributes instead of model.something
+        # (which is copying data, but fully separates state and computation,
+        # which is important in situations like this)
+        pin.forwardKinematics(
+            self.model,
+            self.data,
+            q,
+            #            np.zeros(self.model.nv),
+            #            np.zeros(self.model.nv),
+        )
+        # NOTE: this also returns the frame, so less copying possible
+        # pin.updateFramePlacements(self.model, self.data)
+        pin.updateFramePlacement(self.model, self.data, self._ee_frame_id)
+        return self.data.oMf[self._ee_frame_id].copy()
+
+    def forwardKinematics(self):
+        pin.updateFramePlacement(self.model, self.data, self._ee_frame_id)
+        self._T_w_e = self.data.oMf[self._ee_frame_id].copy()
diff --git a/python/ur_simple_control/robots/ur5e.py b/python/ur_simple_control/robots/ur5e.py
deleted file mode 100644
index 32e2b3ffa7b2973c031f2869d7ee42403034c22d..0000000000000000000000000000000000000000
--- a/python/ur_simple_control/robots/ur5e.py
+++ /dev/null
@@ -1,63 +0,0 @@
-from ur_simple_control.robots.robotmanager_abstract import RobotManagerAbstract
-from ur_simple_control.robots.single_arm_interface import SingleArmInterface
-from ur_simple_control.util.get_model import get_model
-import numpy as np
-import pinocchio as pin
-from rtde_control import RTDEControlInterface
-from rtde_receive import RTDEReceiveInterface
-from rtde_io import RTDEIOInterface
-
-
-# NOTE: this one assumes a jaw gripper
-class RobotManagerUR5e(SingleArmInterface):
-    def __init__(self, args):
-        self.args = args
-        self.model, self.collision_model, self.visual_model, self.data = get_model()
-        self._MAX_ACCELERATION = 1.7  # const
-        self._MAX_QD = 3.14  # const
-        # NOTE: this is evil and everything only works if it's set to 1
-        # you really should control the acceleration via the acceleration argument.
-        # we need to set it to 1.0 with ur_rtde so that's why it's here and explicitely named
-        self._speed_slider = 1.0  # const
-
-        self.connectToRobot()
-
-        self.setInitialPose()
-        if not self.args.real and self.args.start_from_current_pose:
-            self.rtde_receive = RTDEReceiveInterface(args.robot_ip)
-            q = self.rtde_receive.getActualQ()
-            q = np.array(q)
-            self.q = q
-            if args.visualize_manipulator:
-                self.visualizer_manager.sendCommand({"q": q})
-
-    def setInitialPose(self):
-        if not self.args.real and self.args.start_from_current_pose:
-            self.rtde_receive = RTDEReceiveInterface(args.robot_ip)
-            self.q = np.array(self.rtde_receive.getActualQ())
-            if self.args.visualize_manipulator:
-                self.visualizer_manager.sendCommand({"q": self.q})
-        if not self.args.real and not self.args.start_from_current_pose:
-            self.q = pin.randomConfiguration(
-                self.model, -1 * np.ones(self.model.nq), np.ones(self.model.nq)
-            )
-        if self.args.real:
-            self.q = np.array(self.rtde_receive.getActualQ())
-
-    def connectToRobot(self):
-        if self.args.real:
-            # NOTE: you can't connect twice, so you can't have more than one RobotManager per robot.
-            # if this produces errors like "already in use", and it's not already in use,
-            # try just running your new program again. it could be that the socket wasn't given
-            # back to the os even though you've shut off the previous program.
-            print("CONNECTING TO UR5e!")
-            self.rtde_control = RTDEControlInterface(self.args.robot_ip)
-            self.rtde_receive = RTDEReceiveInterface(self.args.robot_ip)
-            self.rtde_io = RTDEIOInterface(self.args.robot_ip)
-            self.rtde_io.setSpeedSlider(self.args.speed_slider)
-            # NOTE: the force/torque sensor just has large offsets for no reason,
-            # and you need to minus them to have usable readings.
-            # we provide this with calibrateFT
-            self.wrench_offset = self.calibrateFT()
-        else:
-            self.wrench_offset = np.zeros(6)
diff --git a/python/ur_simple_control/robots/utils.py b/python/ur_simple_control/robots/utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..de3be244e94ea905a12d44fba23684c52cef1943
--- /dev/null
+++ b/python/ur_simple_control/robots/utils.py
@@ -0,0 +1,72 @@
+import numpy as np
+import pinocchio as pin
+
+# TODO: make robot-independent
+def defineGoalPointCLI(robot. robot):
+    """
+    defineGoalPointCLI
+    ------------------
+    get a nice TUI-type prompt to put in a frame goal for p2p motion.
+    --> best way to handle the goal is to tell the user where the gripper is
+        in both UR tcp frame and with pinocchio and have them
+        manually input it when running.
+        this way you force the thinking before the moving,
+        but you also get to view and analyze the information first
+    TODO get the visual thing you did in ivc project with sliders also.
+    it's just text input for now because it's totally usable, just not superb.
+    but also you do want to have both options. obviously you go for the sliders
+    in the case you're visualizing, makes no sense otherwise.
+    """
+    robot._step()
+    robot._getQ()
+    q = robot.getQ()
+    # define goal
+    pin.forwardKinematics(robot.model, robot.data, np.array(q))
+    pin.updateFramePlacement(robot.model, robot.data, robot.ee_frame_id)
+    T_w_e = robot.data.oMf[robot.ee_frame_id]
+    print("You can only specify the translation right now.")
+    if not robot.pinocchio_only:
+        print(
+            "In the following, first 3 numbers are x,y,z position, and second 3 are r,p,y angles"
+        )
+        print(
+            "Here's where the robot is currently. Ensure you know what the base frame is first."
+        )
+        print(
+            "base frame end-effector pose from pinocchio:\n",
+            *robot.data.oMi[6].translation.round(4),
+            *pin.rpy.matrixToRpy(robot.data.oMi[6].rotation).round(4)
+        )
+        print("UR5e TCP:", *np.array(robot.rtde_receive.getActualTCPPose()).round(4))
+    # remain with the current orientation
+    # TODO: add something, probably rpy for orientation because it's the least number
+    # of numbers you need to type in
+    Mgoal = T_w_e.copy()
+    # this is a reasonable way to do it too, maybe implement it later
+    # Mgoal.translation = Mgoal.translation + np.array([0.0, 0.0, -0.1])
+    # do a while loop until this is parsed correctly
+    while True:
+        goal = input(
+            "Please enter the target end-effector position in the x.x,y.y,z.z format: "
+        )
+        try:
+            e = "ok"
+            goal_list = goal.split(",")
+            for i in range(len(goal_list)):
+                goal_list[i] = float(goal_list[i])
+        except:
+            e = exc_info()
+            print("The input is not in the expected format. Try again.")
+            print(e)
+        if e == "ok":
+            Mgoal.translation = np.array(goal_list)
+            break
+    print("this is goal pose you defined:\n", Mgoal)
+
+    # NOTE i'm not deepcopying this on purpose
+    # but that might be the preferred thing, we'll see
+    robot.Mgoal = Mgoal
+    if robot.args.visualize_manipulator:
+        # TODO document this somewhere
+        robot.visualizer_manager.sendCommand({"Mgoal": Mgoal})
+    return Mgoal