From c5273f5dce2f401f6fadd7bab8af083b09b1f18d Mon Sep 17 00:00:00 2001 From: Andy Krassowski Date: Thu, 23 Apr 2026 10:20:53 -0400 Subject: [PATCH 1/4] icons --- modules/01-operating-room/Arm.py | 621 ++++++++++++++++++++ modules/01-operating-room/ArmController.cxx | 479 +++++++++++++++ modules/01-operating-room/Orchestrator.cxx | 530 +++++++++++++++++ modules/01-operating-room/PatientMonitor.py | 583 ++++++++++++++++++ modules/01-operating-room/PatientSensor.cxx | 194 ++++++ resource/images/Arm-Controller.png | Bin 0 -> 2763 bytes resource/images/Arm-Monitor.png | Bin 0 -> 2393 bytes resource/images/Orchestrator.png | Bin 0 -> 3032 bytes resource/images/Patient-Monitor.png | Bin 0 -> 2875 bytes 9 files changed, 2407 insertions(+) create mode 100644 modules/01-operating-room/Arm.py create mode 100644 modules/01-operating-room/ArmController.cxx create mode 100644 modules/01-operating-room/Orchestrator.cxx create mode 100644 modules/01-operating-room/PatientMonitor.py create mode 100644 modules/01-operating-room/PatientSensor.cxx create mode 100644 resource/images/Arm-Controller.png create mode 100644 resource/images/Arm-Monitor.png create mode 100644 resource/images/Orchestrator.png create mode 100644 resource/images/Patient-Monitor.png diff --git a/modules/01-operating-room/Arm.py b/modules/01-operating-room/Arm.py new file mode 100644 index 0000000..4f26570 --- /dev/null +++ b/modules/01-operating-room/Arm.py @@ -0,0 +1,621 @@ +# +# (c) 2024 Copyright, Real-Time Innovations, Inc. (RTI) All rights reserved. +# +# RTI grants Licensee a license to use, modify, compile, and create derivative +# works of the software solely for use with RTI Connext DDS. Licensee may +# redistribute copies of the software provided that all such copies are +# subject to this license. The software is provided "as is", with no warranty +# of any type, including any warranty for fitness for any purpose. RTI is +# under no obligation to maintain or support the software. RTI shall not be +# liable for any incidental or consequential damages arising out of the use or +# inability to use the software. + +import sys +import math +import time +import threading +import signal + +from PySide6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QLabel, QFrame, + QHBoxLayout, QVBoxLayout, QSizePolicy, QScrollArea +) +from PySide6.QtCore import Qt, QTimer, QRectF, QPointF, Signal, QObject +from PySide6.QtGui import ( + QPainter, QColor, QPen, QBrush, QFont, QConicalGradient, QPainterPath, + QPixmap, QIcon +) + +import rti.connextdds as dds +from Types import Common, SurgicalRobot, Orchestrator, DdsEntities +from DdsUtils import register_type + +# ─── RTI Brand Colors ──────────────────────────────────────────────────── +RTI_BLUE = "#004C97" +RTI_ORANGE = "#ED8B00" +BG_MAIN = "#0A0E17" +BG_PANEL = "#0F1822" +BG_ROW_ALT = "#0D1520" +BG_HEADER = "#071020" +BORDER_DIM = "#1A2A3A" + +# Per-joint color palette +JOINT_COLORS = { + SurgicalRobot.Motors.BASE: RTI_BLUE, + SurgicalRobot.Motors.SHOULDER: RTI_ORANGE, + SurgicalRobot.Motors.ELBOW: "#00BFFF", # Electric blue + SurgicalRobot.Motors.WRIST: "#7CFC00", # Lime green + SurgicalRobot.Motors.HAND: "#DA70D6", # Orchid +} + +JOINT_NAMES = { + SurgicalRobot.Motors.BASE: "BASE", + SurgicalRobot.Motors.SHOULDER: "SHOULDER", + SurgicalRobot.Motors.ELBOW: "ELBOW", + SurgicalRobot.Motors.WRIST: "WRIST", + SurgicalRobot.Motors.HAND: "HAND", +} + +UPDATE_MS = 100 # refresh rate ms (~10 fps) + +# Starting joint angles shown before the first DDS sample arrives +INITIAL_ANGLES = { + SurgicalRobot.Motors.BASE: 204.0, + SurgicalRobot.Motors.SHOULDER: 176.0, + SurgicalRobot.Motors.ELBOW: 156.0, + SurgicalRobot.Motors.WRIST: 165.0, + SurgicalRobot.Motors.HAND: 151.0, +} + + +# ─── Circular Arc Gauge ────────────────────────────────────────────────── +class ArcGauge(QWidget): + """Draws a 270° arc gauge showing angle 0-360°.""" + + def __init__(self, color: str, parent=None): + super().__init__(parent) + self.color = QColor(color) + self._value = 180.0 + self.setFixedSize(110, 110) + self.setStyleSheet("background: transparent;") + + def set_value(self, v: float): + self._value = v % 360.0 + self.update() + + def paintEvent(self, event): + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + + side = min(self.width(), self.height()) - 10 + rect = QRectF((self.width() - side) / 2, + (self.height() - side) / 2, + side, side) + + # ── Background track ────────────────────────────────────── + pen_bg = QPen(QColor("#1A2A40"), 8, Qt.PenStyle.SolidLine, Qt.PenCapStyle.FlatCap) + p.setPen(pen_bg) + p.drawArc(rect, 225 * 16, -270 * 16) # 270° arc, starts at 225° + + # ── Foreground arc (value / 360 × 270°) ─────────────────── + span = int((self._value / 360.0) * 270 * 16) + grad_color = self.color + pen_fg = QPen(grad_color, 8, Qt.PenStyle.SolidLine, Qt.PenCapStyle.FlatCap) + p.setPen(pen_fg) + p.drawArc(rect, 225 * 16, -span) + + # ── Needle ──────────────────────────────────────────────── + import math as _math + cx, cy = self.width() / 2, self.height() / 2 + needle_r = (side / 2) * 0.72 + hub_r = (side / 2) * 0.12 + # Qt arc: 0°=east, positive=CCW; our arc starts at 225° spanning -270° + angle_deg = 225.0 - (self._value / 360.0) * 270.0 + angle_rad = _math.radians(angle_deg) + tip_x = cx + needle_r * _math.cos(angle_rad) + tip_y = cy - needle_r * _math.sin(angle_rad) + # Back stub in opposite direction + back_x = cx - hub_r * _math.cos(angle_rad) + back_y = cy + hub_r * _math.sin(angle_rad) + # Draw shadow + pen_shadow = QPen(QColor("#000000"), 4, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap) + p.setPen(pen_shadow) + p.drawLine(QPointF(back_x + 1, back_y + 1), QPointF(tip_x + 1, tip_y + 1)) + # Draw needle + pen_needle = QPen(grad_color, 2.5, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap) + p.setPen(pen_needle) + p.drawLine(QPointF(back_x, back_y), QPointF(tip_x, tip_y)) + + # ── Centre dot ──────────────────────────────────────────── + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(QBrush(grad_color)) + p.drawEllipse(QPointF(cx, cy), 4, 4) + + p.end() + + +# ─── Direction indicator ───────────────────────────────────────────────── +class DirectionBadge(QLabel): + def __init__(self, parent=None): + super().__init__(" — ", parent) + self._dir = "STATIONARY" + self._update_style() + + def set_direction(self, direction: str): + self._dir = direction + symbols = {"INCREMENT": " ▲ INC", "DECREMENT": " ▼ DEC", "STATIONARY": " — "} + self.setText(symbols.get(direction, " — ")) + self._update_style() + + def _update_style(self): + colors = { + "INCREMENT": (RTI_ORANGE, "#1A0F00"), + "DECREMENT": ("#FF4444", "#1A0505"), + "STATIONARY": ("#445566", "#0F151C"), + } + fg, bg = colors.get(self._dir, ("#445566", "#0F151C")) + self.setStyleSheet( + f"color: {fg}; background-color: {bg}; font-size: 26px; font-weight: bold; " + f"padding: 4px 10px; border-radius: 4px; border: 1px solid {fg}44;" + ) + + +# ─── Single joint row widget ────────────────────────────────────────────── +class JointRow(QFrame): + def __init__(self, motor: SurgicalRobot.Motors, parent=None): + super().__init__(parent) + self.motor = motor + self.color = JOINT_COLORS[motor] + self.name = JOINT_NAMES[motor] + self._angle = 180.0 + + self.setFrameShape(QFrame.Shape.Box) + self.setStyleSheet(f""" + JointRow {{ + background-color: {BG_PANEL}; + border: 1px solid {self.color}33; + border-radius: 6px; + }} + """) + self._build_ui() + + def _build_ui(self): + row = QHBoxLayout(self) + row.setContentsMargins(8, 4, 8, 4) + row.setSpacing(8) + + # Joint name + name_lbl = QLabel(self.name) + name_lbl.setFixedWidth(190) + name_lbl.setStyleSheet( + f"color: {self.color}; font-size: 26px; font-weight: bold; " + f"background: transparent; letter-spacing: 1px;" + ) + row.addWidget(name_lbl) + + # Arc gauge + value label below + gauge_col = QVBoxLayout() + gauge_col.setContentsMargins(0, 0, 0, 0) + gauge_col.setSpacing(2) + self.gauge = ArcGauge(self.color) + self.gauge.set_value(180.0) + gauge_col.addWidget(self.gauge, alignment=Qt.AlignmentFlag.AlignHCenter) + self.angle_lbl = QLabel("180.0°") + self.angle_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.angle_lbl.setStyleSheet( + f"color: {self.color}; font-size: 20px; font-weight: bold; " + f"background: transparent;" + ) + gauge_col.addWidget(self.angle_lbl) + row.addLayout(gauge_col) + + # Direction badge + self.dir_badge = DirectionBadge() + self.dir_badge.setVisible(False) + + def update_data(self, angle: float, direction: str): + self._angle = angle + self.gauge.set_value(angle) + self.angle_lbl.setText(f"{angle:.1f}°") + self.dir_badge.set_direction(direction) + + +# ─── 2-D Kinematic Arm Visualisation ────────────────────────────────────── +# Forward-kinematics: each joint angle is *relative* to the previous segment. +# 180° = no bend (straight continuation); values > 180 bend counter-clockwise, +# values < 180 bend clockwise, mirroring the arc-gauge convention in JointRow. +# +# Joint order: BASE → SHOULDER → ELBOW → WRIST → HAND (5 links, same as joints) +# A 6th "link" past HAND represents the end-effector stub. + +_MOTORS_ORDERED = [ + SurgicalRobot.Motors.BASE, + SurgicalRobot.Motors.SHOULDER, + SurgicalRobot.Motors.ELBOW, + SurgicalRobot.Motors.WRIST, + SurgicalRobot.Motors.HAND, +] + + +class ArmVizWidget(QWidget): + """Draws a simplified 2-D stick-figure robotic arm using QPainter. + + The arm is rooted at the bottom-centre of the widget and extends + upward. Each joint bends by (angle - 180°) relative to the incoming + segment direction, so at 180° all segments are collinear (straight up). + """ + + SEGMENT_LEN = 70 # px — length of each arm link (scaled dynamically) + JOINT_R = 12 # px — radius of joint circles + EE_SIZE = 16 # px — half-size of end-effector marker + GROUND_W = 80 # px — half-width of ground hatch + GROUND_LINES = 6 # number of hatch lines under base + + def __init__(self, parent=None): + super().__init__(parent) + self._angles: dict = {m: 180.0 for m in _MOTORS_ORDERED} + self.setMinimumWidth(260) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.setStyleSheet(f"background-color: {BG_PANEL};") + + def update_angles(self, angles: dict): + """Receive updated angle dict and schedule a repaint.""" + self._angles = dict(angles) + self.update() # schedules paintEvent on next event-loop iteration + + # ── painting ────────────────────────────────────────────────────────── + def paintEvent(self, event): + w, h = self.width(), self.height() + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + + # Background + p.fillRect(self.rect(), QColor(BG_PANEL)) + + # Title + p.setPen(QPen(QColor("#445566"))) + p.setFont(QFont("Courier New", 11, QFont.Weight.Bold)) + p.drawText(12, 22, "ARM VISUALIZATION") + + # Scale segment length so the full arm (5 links) fills ~90 % of the + # widget height, leaving room for the title at top and ground at bottom. + usable_h = h - 70 # subtract top title space + bottom ground space + seg = int(usable_h * 0.90 / len(_MOTORS_ORDERED)) + seg = max(seg, 40) + + # Base point — bottom centre (leave room for ground symbol) + bx = w / 2.0 + by = h - 36.0 + + # ── Ground symbol ────────────────────────────────────────────── + gw = self.GROUND_W + ground_pen = QPen(QColor("#334455"), 2) + p.setPen(ground_pen) + p.drawLine(QPointF(bx - gw, by), QPointF(bx + gw, by)) + hatch_dx = gw / (self.GROUND_LINES + 1) + for i in range(self.GROUND_LINES): + hx = bx - gw + hatch_dx * (i + 1) + p.drawLine(QPointF(hx, by), QPointF(hx - 10, by + 12)) + + # ── Forward kinematics ───────────────────────────────────────── + # Start pointing straight up (π/2 in unit-circle convention; + # screen Y is inverted, so subtract when computing screen coords). + cumul_dir = math.pi / 2.0 + x, y = bx, by + + points = [(x, y)] # joint positions + + for motor in _MOTORS_ORDERED: + angle = self._angles.get(motor, 180.0) + delta_rad = (angle - 180.0) * math.pi / 180.0 + cumul_dir += delta_rad + nx = x + seg * math.cos(cumul_dir) + ny = y - seg * math.sin(cumul_dir) # screen Y inverted + points.append((nx, ny)) + x, y = nx, ny + + # ── Draw links ──────────────────────────────────────────────── + link_pen_width = 7 + for i, motor in enumerate(_MOTORS_ORDERED): + color = QColor(JOINT_COLORS[motor]) + x0, y0 = points[i] + x1, y1 = points[i + 1] + pen = QPen(color, link_pen_width, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap) + p.setPen(pen) + p.drawLine(QPointF(x0, y0), QPointF(x1, y1)) + + # ── Draw end-effector stub (past HAND) ──────────────────────── + ee_color = QColor(JOINT_COLORS[SurgicalRobot.Motors.HAND]) + ee_pen = QPen(ee_color, 2) + p.setPen(ee_pen) + ex, ey = points[-1] + # Small diamond + es = self.EE_SIZE + diamond = QPainterPath() + diamond.moveTo(ex, ey - es) + diamond.lineTo(ex + es, ey) + diamond.lineTo(ex, ey + es) + diamond.lineTo(ex - es, ey) + diamond.closeSubpath() + p.setBrush(QBrush(ee_color.darker(180))) + p.drawPath(diamond) + + # ── Draw joint circles + labels ─────────────────────────────── + jr = self.JOINT_R + for i, motor in enumerate(_MOTORS_ORDERED): + color = QColor(JOINT_COLORS[motor]) + cx, cy = points[i] + # filled circle + p.setPen(QPen(color, 2)) + p.setBrush(QBrush(color.darker(200))) + p.drawEllipse(QPointF(cx, cy), jr, jr) + # joint name label (abbreviated, 3 chars) + name = JOINT_NAMES[motor][:3] + p.setPen(QPen(color)) + p.setFont(QFont("Courier New", 16, QFont.Weight.Bold)) + label_x = cx + jr + 8 + label_y = cy + 6 + p.drawText(QPointF(label_x, label_y), name) + + # ── Angle readouts next to each link midpoint ───────────────── + p.setFont(QFont("Courier New", 14)) + for i, motor in enumerate(_MOTORS_ORDERED): + color = QColor(JOINT_COLORS[motor]) + p.setPen(QPen(color.lighter(130))) + mx = (points[i][0] + points[i + 1][0]) / 2 + 10 + my = (points[i][1] + points[i + 1][1]) / 2 + p.drawText(QPointF(mx, my), f"{self._angles.get(motor, 180.0):.0f}°") + + p.end() + + +# ─── Main Window ───────────────────────────────────────────────────────── +class ArmWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("RTI Connext — Surgical Arm Monitor") + self.setMinimumSize(640, 650) + self.setStyleSheet(f"background-color: {BG_MAIN};") + _icon_px = QPixmap("../../resource/images/Arm-Monitor.png") + if not _icon_px.isNull(): + self.setWindowIcon(QIcon(_icon_px)) + + self._build_ui() + + def _build_ui(self): + central = QWidget() + central.setStyleSheet(f"background-color: {BG_MAIN};") + self.setCentralWidget(central) + root = QVBoxLayout(central) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + # ── Header ─────────────────────────────────────────────── + header = QWidget() + header.setFixedHeight(80) + header.setStyleSheet( + f"background-color: {BG_HEADER}; border-bottom: 2px solid {RTI_BLUE};" + ) + h_layout = QHBoxLayout(header) + h_layout.setContentsMargins(20, 0, 20, 0) + + _logo_px = QPixmap("../../resource/images/Arm-Monitor.png") + if not _logo_px.isNull(): + logo_lbl = QLabel() + logo_lbl.setStyleSheet("background: transparent;") + logo_lbl.setPixmap(_logo_px.scaled(56, 56, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)) + h_layout.addWidget(logo_lbl) + + rti_lbl = QLabel("RTI Connext") + rti_lbl.setStyleSheet( + f"color: {RTI_BLUE}; font-size: 28px; font-weight: bold; background: transparent;" + ) + h_layout.addWidget(rti_lbl) + + bar = QLabel("|") + bar.setStyleSheet("color: #334455; font-size: 30px; background: transparent;") + h_layout.addWidget(bar) + + title_lbl = QLabel("Surgical Arm Monitor") + title_lbl.setStyleSheet( + "color: #E0E8F0; font-size: 38px; font-weight: bold; background: transparent;" + ) + h_layout.addWidget(title_lbl) + h_layout.addStretch() + + self.state_lbl = QLabel("ON") + self.state_lbl.setStyleSheet( + f"color: #000; background-color: #00E676; font-size: 22px; " + f"font-weight: bold; padding: 3px 12px; border-radius: 4px;" + ) + h_layout.addWidget(self.state_lbl) + + qos_lbl = QLabel("Command QoS") + qos_lbl.setStyleSheet( + f"color: {RTI_ORANGE}88; font-size: 22px; background: transparent; margin-left: 12px;" + ) + h_layout.addWidget(qos_lbl) + + root.addWidget(header) + + # ── Body: arm visualisation only ───────────────────────── + self._arm_angles = dict(INITIAL_ANGLES) + self.arm_viz = ArmVizWidget() + self.arm_viz.update_angles(self._arm_angles) + root.addWidget(self.arm_viz, 1) + + # ── Footer ─────────────────────────────────────────────── + footer = QWidget() + footer.setFixedHeight(44) + footer.setStyleSheet( + f"background-color: {BG_HEADER}; border-top: 1px solid {BORDER_DIM};" + ) + f_layout = QHBoxLayout(footer) + f_layout.setContentsMargins(20, 0, 20, 0) + f_lbl = QLabel( + "Real-Time Innovations · RTI Connext · MedTech Reference Architecture" + ) + f_lbl.setStyleSheet("color: #445566; font-size: 20px; background: transparent;") + f_layout.addWidget(f_lbl) + f_layout.addStretch() + root.addWidget(footer) + + def update_joint(self, motor: SurgicalRobot.Motors, angle: float, direction: str): + self._arm_angles[motor] = angle + self.arm_viz.update_angles(self._arm_angles) + + def set_state(self, state: str): + colors = {"ON": "#00E676", "PAUSED": RTI_ORANGE, "OFF": "#FF4444"} + c = colors.get(state, "#888") + self.state_lbl.setText(state) + self.state_lbl.setStyleSheet( + f"color: #000; background-color: {c}; font-size: 22px; " + f"font-weight: bold; padding: 3px 12px; border-radius: 4px;" + ) + + +# ─── Application class ──────────────────────────────────────────────────── +class ArmApp: + def __init__(self): + self.angles = { + SurgicalRobot.Motors.BASE: 204.0, + SurgicalRobot.Motors.SHOULDER: 176.0, + SurgicalRobot.Motors.ELBOW: 156.0, + SurgicalRobot.Motors.WRIST: 165.0, + SurgicalRobot.Motors.HAND: 151.0, + } + self.directions = { + SurgicalRobot.Motors.BASE: "STATIONARY", + SurgicalRobot.Motors.SHOULDER: "STATIONARY", + SurgicalRobot.Motors.ELBOW: "STATIONARY", + SurgicalRobot.Motors.WRIST: "STATIONARY", + SurgicalRobot.Motors.HAND: "STATIONARY", + } + + self.arm_status = None + self.status_writer = None + self.hb_writer = None + self.motor_control_reader = None + self.cmd_reader = None + self.window = None + self.cmd_waitset = None + + # ── DDS heartbeat thread ───────────────────────────────────────── + def write_hb(self, hb_writer): + while self.arm_status.status != Common.DeviceStatuses.OFF: + hb = Common.DeviceHeartbeat() + hb.device = Common.DeviceType.ARM + hb_writer.write(hb) + time.sleep(0.05) + + # ── DDS poll timer callback (Qt main thread) ───────────────────── + def _poll_dds(self): + # Motor control samples + samples = self.motor_control_reader.take_data() + for sample in samples: + if self.arm_status.status == Common.DeviceStatuses.ON: + if sample.direction == SurgicalRobot.MotorDirections.INCREMENT: + self.angles[sample.id] = (self.angles[sample.id] + 0.3) % 360.0 + self.directions[sample.id] = "INCREMENT" + elif sample.direction == SurgicalRobot.MotorDirections.DECREMENT: + self.angles[sample.id] = (self.angles[sample.id] - 0.3) % 360.0 + self.directions[sample.id] = "DECREMENT" + else: + self.directions[sample.id] = "STATIONARY" + self.window.update_joint( + sample.id, self.angles[sample.id], self.directions[sample.id] + ) + + # Command samples + cmd_samples = self.cmd_reader.take_data() + for sample in cmd_samples: + if sample.command == Orchestrator.DeviceCommands.START: + print("Arm received Start Command") + self.arm_status.status = Common.DeviceStatuses.ON + self.window.set_state("ON") + elif sample.command == Orchestrator.DeviceCommands.PAUSE: + print("Arm received Pause Command") + self.arm_status.status = Common.DeviceStatuses.PAUSED + self.window.set_state("PAUSED") + else: + print("Arm received Shutdown Command") + self.arm_status.status = Common.DeviceStatuses.OFF + self.window.set_state("OFF") + QApplication.quit() + self.status_writer.write(self.arm_status) + + def _cleanup(self): + print("Shutting down Arm") + + # ── Connext setup ───────────────────────────────────────────────── + def connext_setup(self): + entities = DdsEntities.Constants + register_type(Common.DeviceStatus) + register_type(Common.DeviceHeartbeat) + register_type(Orchestrator.DeviceCommand) + register_type(SurgicalRobot.MotorControl) + + qos_provider = dds.QosProvider.default + participant = qos_provider.create_participant_from_config(entities.ARM_DP) + + self.status_writer = dds.DataWriter( + participant.find_datawriter(entities.STATUS_DW) + ) + self.hb_writer = dds.DataWriter( + participant.find_datawriter(entities.HB_DW) + ) + self.arm_status = Common.DeviceStatus( + device=Common.DeviceType.ARM, status=Common.DeviceStatuses.ON + ) + self.status_writer.write(self.arm_status) + + self.motor_control_reader = dds.DataReader( + participant.find_datareader(entities.MOTOR_CONTROL_DR) + ) + self.cmd_reader = dds.DataReader( + participant.find_datareader(entities.DEVICE_COMMAND_DR) + ) + + # ── Entry point ─────────────────────────────────────────────────── + def run(self): + app = QApplication(sys.argv) + app.setStyle("Fusion") + _icon = QIcon(QPixmap("../../resource/images/Arm-Monitor.png")) + if not _icon.isNull(): + app.setWindowIcon(_icon) + + self.window = ArmWindow() + self.connext_setup() + + # DDS poll timer + dds_timer = QTimer() + dds_timer.timeout.connect(self._poll_dds) + dds_timer.start(UPDATE_MS) + + # Heartbeat in background thread + hb_thread = threading.Thread( + target=self.write_hb, args=[self.hb_writer], daemon=True + ) + hb_thread.start() + + # Allow Ctrl+C to cleanly quit the Qt event loop. + # The QTimer is needed so the event loop periodically yields control + # back to Python, enabling signal delivery. + signal.signal(signal.SIGINT, lambda *_: app.quit()) + _sig_timer = QTimer() + _sig_timer.timeout.connect(lambda: None) + _sig_timer.start(300) + app.aboutToQuit.connect(self._cleanup) + + self.window.show() + print("Started Arm") + + app.exec() + + self.arm_status.status = Common.DeviceStatuses.OFF + + +if __name__ == "__main__": + arm = ArmApp() + arm.run() + diff --git a/modules/01-operating-room/ArmController.cxx b/modules/01-operating-room/ArmController.cxx new file mode 100644 index 0000000..95c943d --- /dev/null +++ b/modules/01-operating-room/ArmController.cxx @@ -0,0 +1,479 @@ +// +// (c) 2024 Copyright, Real-Time Innovations, Inc. (RTI) All rights reserved. +// +// RTI grants Licensee a license to use, modify, compile, and create derivative +// works of the software solely for use with RTI Connext DDS. Licensee may +// redistribute copies of the software provided that all such copies are +// subject to this license. The software is provided "as is", with no warranty +// of any type, including any warranty for fitness for any purpose. RTI is +// under no obligation to maintain or support the software. RTI shall not be +// liable for any incidental or consequential damages arising out of the use or +// inability to use the software. + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "Types.hpp" + +#ifdef __APPLE__ + #include "MacOsDockIcon.h" +#endif + +#ifndef _WIN32 + #include +#endif + +using namespace DdsEntities::Constants; + +class SurgicalArmController { +public: + SurgicalArmController() + : current_status( + Common::DeviceType::ARM_CONTROLLER, + Common::DeviceStatuses::ON) + { + // Initialize Connext entities + initialize_connext(); + } + + void run(int argc, char const *argv[]) + { + // Start threads + std::thread hb_thread(&SurgicalArmController::write_hb, this); + std::thread play_thread(&SurgicalArmController::playing, this); + waitset_command.start(); + write_status(); + + // Run GTK UI + app = Gtk::Application::create("armcontroller.armcontroller"); + app->signal_activate().connect( + sigc::mem_fun(*this, &SurgicalArmController::ui_setup)); + +#ifndef _WIN32 + // Route SIGINT/SIGTERM through the GLib main loop so GTK functions + // can be called safely from the callback. + g_unix_signal_add( + SIGINT, + [](gpointer data) -> gboolean { + static_cast(data) + ->window_close_from_signal(); + return G_SOURCE_REMOVE; + }, + this); + g_unix_signal_add( + SIGTERM, + [](gpointer data) -> gboolean { + static_cast(data) + ->window_close_from_signal(); + return G_SOURCE_REMOVE; + }, + this); +#endif + + app->run(argc, const_cast(argv)); + + // Join threads before exiting + hb_thread.join(); + play_thread.join(); + } + +private: + // Connext entities + dds::pub::DataWriter status_writer = dds::core::null; + dds::pub::DataWriter hb_writer = dds::core::null; + dds::pub::DataWriter arm_writer = + dds::core::null; + dds::sub::DataReader cmd_reader = + dds::core::null; + rti::core::cond::AsyncWaitSet waitset_command; + + Common::DeviceStatus current_status; + + // GTK UI entities + Gtk::Window *window = nullptr; + Glib::RefPtr app; + std::map motor_play_btns; + std::map motor_dir_labels; + std::map inc_dec_timers; + Gtk::TextView *console = nullptr; + + // Initialize Connext participants, readers, and writers + void initialize_connext() + { + // We need to register the types before we start creating DDS entities + rti::domain::register_type(); + rti::domain::register_type(); + rti::domain::register_type(); + rti::domain::register_type(); + + // Connext will load XML files through the default provider from the + // NDDS_QOS_PROFILES environment variable + auto default_provider = dds::core::QosProvider::Default(); + + dds::domain::DomainParticipant participant = + default_provider.extensions().create_participant_from_config( + ARM_CONTROLLER_DP); + + // Initialize DataWriters + status_writer = rti::pub::find_datawriter_by_name< + dds::pub::DataWriter>( + participant, + STATUS_DW); + hb_writer = rti::pub::find_datawriter_by_name< + dds::pub::DataWriter>( + participant, + HB_DW); + arm_writer = rti::pub::find_datawriter_by_name< + dds::pub::DataWriter>( + participant, + MOTOR_CONTROL_DW); + + // Initialize DataReader + cmd_reader = rti::sub::find_datareader_by_name< + dds::sub::DataReader>( + participant, + DEVICE_COMMAND_DR); + + // Setup command handling with a WaitSet + dds::sub::cond::ReadCondition command_read_condition( + cmd_reader, + dds::sub::status::DataState::any(), + [this]() { process_command(); }); + + waitset_command += command_read_condition; + } + + // Publish heartbeat every 50ms + void write_hb() + { + while (current_status.status() != Common::DeviceStatuses::OFF) { + Common::DeviceHeartbeat hb(Common::DeviceType::ARM_CONTROLLER); + hb_writer.write(hb); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + } + + // Publish status + void write_status() + { + status_writer.write(current_status); + } + + // Write motor command + void write_command( + SurgicalRobot::Motors motor, + SurgicalRobot::MotorDirections dir) + { + if (current_status.status() == Common::DeviceStatuses::ON) { + SurgicalRobot::MotorControl sample(motor, dir); + arm_writer.write(sample); + } + } + + // Publish random motor controls for the motors that have been marked as + // playing + void playing() + { + while (current_status.status() != Common::DeviceStatuses::OFF) { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + for (const auto &btn : motor_play_btns) { + if (btn.second->get_active()) { + write_command( + btn.first, + static_cast( + rand() % 3)); + } + } + } + } + + // Process received device commands from Orchestrator + void process_command() + { + dds::sub::LoanedSamples samples = + cmd_reader.take(); + + for (const auto &sample : samples) { + if (sample.info().valid()) { + if (sample.data().command() + == Orchestrator::DeviceCommands::PAUSE) { + log_alert("Received PAUSE Command from Orchestrator"); + current_status.status(Common::DeviceStatuses::PAUSED); + } else if ( + sample.data().command() + == Orchestrator::DeviceCommands::START) { + log_alert("Received START Command from Orchestrator"); + current_status.status(Common::DeviceStatuses::ON); + } else { // shutdown + log_alert("Received SHUTDOWN Command from Orchestrator"); + std::cout << "Arm Controller shutting down" << std::endl; + current_status.status(Common::DeviceStatuses::OFF); + app->quit(); + } + } + } + + write_status(); + } + + // Logic for play all and stop all + void set_all(bool play_all) + { + if (play_all) + log_alert("Playing All"); + else + log_alert("Stopping All"); + + for (auto &btn : motor_play_btns) { + btn.second->set_active(play_all); + } + } + + // Setup UI + void ui_setup() + { + // Load CSS stylesheet + auto css_provider = Gtk::CssProvider::create(); + try { + css_provider->load_from_path("ui/armcontroller.css"); + } catch (const Glib::Error &e) { + std::cerr << "Warning: could not load armcontroller.css: " + << e.what() << std::endl; + } + Gtk::StyleContext::add_provider_for_screen( + Gdk::Screen::get_default(), + css_provider, + GTK_STYLE_PROVIDER_PRIORITY_USER); + + auto builder = Gtk::Builder::create_from_file("ui/armcontroller.glade"); + builder->get_widget("window", window); + + // Load RTI logo into header and set as dock/taskbar icon + { + Gtk::Box *hdr = nullptr; + builder->get_widget("header_bar", hdr); + try { + auto pb = Gdk::Pixbuf::create_from_file( + "../../resource/images/Arm-Controller.png"); + window->set_icon(pb); +#ifdef __APPLE__ + set_macos_dock_icon(pb); +#endif + if (hdr) { + auto scaled = + pb->scale_simple(56, 56, Gdk::INTERP_BILINEAR); + auto *logo = Gtk::manage(new Gtk::Image(scaled)); + logo->set_visible(true); + logo->set_margin_end(8); + hdr->pack_start(*logo, false, false, 0); + hdr->reorder_child(*logo, 0); + } + } catch (...) { + std::cerr << "Warning: artwork failure " << std::endl; + } + } + + window->signal_delete_event().connect( + sigc::mem_fun(*this, &SurgicalArmController::on_window_close)); + + builder->get_widget( + "base_play", + motor_play_btns[SurgicalRobot::Motors::BASE]); + builder->get_widget( + "shoulder_play", + motor_play_btns[SurgicalRobot::Motors::SHOULDER]); + builder->get_widget( + "elbow_play", + motor_play_btns[SurgicalRobot::Motors::ELBOW]); + builder->get_widget( + "wrist_play", + motor_play_btns[SurgicalRobot::Motors::WRIST]); + builder->get_widget( + "hand_play", + motor_play_btns[SurgicalRobot::Motors::HAND]); + + builder->get_widget("console", console); + + // Force dark background on the text view (CSS alone is unreliable + // for GtkTextView internals in GTK3) + { + Gdk::RGBA bg, fg; + bg.set("#060F0A"); + fg.set("#00CC66"); + console->override_background_color(bg); + console->override_color(fg); + console->override_font( + Pango::FontDescription("Courier New Bold 20")); + } + + connect_buttons(builder); + + app->add_window(*window); + window->set_visible(true); + + log_alert("Started Arm Controller"); + } + + // Handle window close event + bool on_window_close(GdkEventAny *event) + { + std::cout << "Arm Controller UI closed, shutting down" << std::endl; + current_status.status(Common::DeviceStatuses::OFF); + return false; + } + + // Close the window from within the GLib main loop (e.g. on SIGINT). + void window_close_from_signal() + { + if (window) + window->close(); + else if (app) + app->quit(); + } + + // Connect buttons to their respective signal handlers. + // INC/DEC buttons: + // - On press: deactivate AUTO for that joint, send one command + // immediately, + // then repeat at 20 Hz (every 50 ms) while held. + // - On release: stop repeating. + // - AUTO / PLAY ALL can re-enable automatic mode. + void connect_buttons(const Glib::RefPtr &builder) + { + auto connect_inc_dec = [this, &builder]( + const std::string &btn_name, + SurgicalRobot::Motors motor, + SurgicalRobot::MotorDirections + direction) { + Gtk::Button *button = nullptr; + builder->get_widget(btn_name, button); + if (!button) + return; + + button->signal_button_press_event().connect( + [this, motor, direction, btn_name]( + GdkEventButton *ev) -> bool { + if (ev->button == 1) { + // Deactivate AUTO for this joint + motor_play_btns[motor]->set_active(false); + // Send one command right away + write_command(motor, direction); + // Start 20 Hz repeat timer + inc_dec_timers[btn_name] = + Glib::signal_timeout().connect( + [this, motor, direction]() -> bool { + write_command(motor, direction); + return true; + }, + 50); + } + return false; + }, + false); + + button->signal_button_release_event().connect( + [this, btn_name](GdkEventButton *ev) -> bool { + if (ev->button == 1) { + auto it = inc_dec_timers.find(btn_name); + if (it != inc_dec_timers.end()) { + it->second.disconnect(); + inc_dec_timers.erase(it); + } + } + return false; + }, + false); + }; + + connect_inc_dec( + "base_inc", + SurgicalRobot::Motors::BASE, + SurgicalRobot::MotorDirections::INCREMENT); + connect_inc_dec( + "base_dec", + SurgicalRobot::Motors::BASE, + SurgicalRobot::MotorDirections::DECREMENT); + connect_inc_dec( + "shoulder_inc", + SurgicalRobot::Motors::SHOULDER, + SurgicalRobot::MotorDirections::INCREMENT); + connect_inc_dec( + "shoulder_dec", + SurgicalRobot::Motors::SHOULDER, + SurgicalRobot::MotorDirections::DECREMENT); + connect_inc_dec( + "elbow_inc", + SurgicalRobot::Motors::ELBOW, + SurgicalRobot::MotorDirections::INCREMENT); + connect_inc_dec( + "elbow_dec", + SurgicalRobot::Motors::ELBOW, + SurgicalRobot::MotorDirections::DECREMENT); + connect_inc_dec( + "wrist_inc", + SurgicalRobot::Motors::WRIST, + SurgicalRobot::MotorDirections::INCREMENT); + connect_inc_dec( + "wrist_dec", + SurgicalRobot::Motors::WRIST, + SurgicalRobot::MotorDirections::DECREMENT); + connect_inc_dec( + "hand_inc", + SurgicalRobot::Motors::HAND, + SurgicalRobot::MotorDirections::INCREMENT); + connect_inc_dec( + "hand_dec", + SurgicalRobot::Motors::HAND, + SurgicalRobot::MotorDirections::DECREMENT); + + Gtk::Button *playall = nullptr; + builder->get_widget("playall", playall); + if (playall) { + playall->signal_clicked().connect([this]() { set_all(true); }); + } + + Gtk::Button *stopall = nullptr; + builder->get_widget("stopall", stopall); + if (stopall) { + stopall->signal_clicked().connect([this]() { set_all(false); }); + } + } + + // Print message to the alerts console in the UI + void log_alert(const std::string &msg) + { + Glib::RefPtr buffer = console->get_buffer(); + Gtk::TextBuffer::iterator iter = buffer->end(); + + std::time_t now = std::time(nullptr); + std::tm *local_time = std::localtime(&now); + char time_str[100]; + std::strftime( + time_str, + sizeof(time_str), + "%Y-%m-%d %H:%M:%S", + local_time); + + std::stringstream ss; + ss << "\n" << time_str << " - " << msg; + buffer->insert(iter, ss.str()); + } +}; + +int main(int argc, char const *argv[]) +{ + SurgicalArmController arm_controller; + arm_controller.run(argc, argv); + return 0; +} diff --git a/modules/01-operating-room/Orchestrator.cxx b/modules/01-operating-room/Orchestrator.cxx new file mode 100644 index 0000000..b2c5ed4 --- /dev/null +++ b/modules/01-operating-room/Orchestrator.cxx @@ -0,0 +1,530 @@ +// +// (c) 2024 Copyright, Real-Time Innovations, Inc. (RTI) All rights reserved. +// +// RTI grants Licensee a license to use, modify, compile, and create derivative +// works of the software solely for use with RTI Connext DDS. Licensee may +// redistribute copies of the software provided that all such copies are +// subject to this license. The software is provided "as is", with no warranty +// of any type, including any warranty for fitness for any purpose. RTI is +// under no obligation to maintain or support the software. RTI shall not be +// liable for any incidental or consequential damages arising out of the use or +// inability to use the software. + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "Types.hpp" + +#ifdef __APPLE__ + #include "MacOsDockIcon.h" +#endif + +#ifndef _WIN32 + #include +#endif + +using namespace DdsEntities::Constants; +#ifdef RTI_SECURITY_AVAILABLE + #include "SecureLogUtils.hpp" +#endif + +// Heartbeat listener to automatically monitor other applications +class HeartbeatListener + : public dds::sub::NoOpDataReaderListener { +public: + HeartbeatListener( + std::map &statMap, + std::function logAlert) + : stat_map(statMap), log_alert(logAlert) + { + } + + void on_requested_deadline_missed( + dds::sub::DataReader &reader, + const dds::core::status::RequestedDeadlineMissedStatus &status) + override + { + Common::DeviceHeartbeat sample; + reader.key_value(sample, status.last_instance_handle()); + + Gtk::Label *lbl = stat_map.at(sample.device()); + if (lbl->get_text() != "DeviceStatuses::OFF") { + std::stringstream ss; + ss << sample.device() + << " is no longer sending heartbeats. Updating Status to OFF."; + log_alert(ss.str()); + Glib::signal_idle().connect([lbl]() -> bool { + lbl->set_text("DeviceStatuses::OFF"); + auto ctx = lbl->get_style_context(); + ctx->remove_class("status-on"); + ctx->remove_class("status-paused"); + ctx->add_class("status-off"); + return false; + }); + } + } + +private: + std::map &stat_map; + std::function log_alert; +}; + +// Main application class +class OrchestratorApp { +public: + OrchestratorApp(int argc, char const *argv[]) : running(true) + { + // We need to register the types before we start creating DDS entities + rti::domain::register_type(); + rti::domain::register_type(); + rti::domain::register_type(); + + // Connext will load XML files through the default provider from the + // NDDS_QOS_PROFILES environment variable + auto default_provider = dds::core::QosProvider::Default(); + + participant = + default_provider.extensions().create_participant_from_config( + ORCHESTRATOR_DP); + + // Initialize DataWriter + command_writer = rti::pub::find_datawriter_by_name< + dds::pub::DataWriter>( + participant, + DEVICE_COMMAND_DW); + + // Initialize DataReaders + status_reader = rti::sub::find_datareader_by_name< + dds::sub::DataReader>( + participant, + STATUS_DR); + hb_reader = rti::sub::find_datareader_by_name< + dds::sub::DataReader>( + participant, + HB_DR); + + hb_listener = std::make_shared( + stat_map, + [this](std::string msg) { log_alert(msg); }); + hb_reader.set_listener(hb_listener); + + status_read_condition = dds::sub::cond::ReadCondition( + status_reader, + dds::sub::status::DataState::any(), + [this]() { process_status(); }); + + waitset_status += status_read_condition; + +#ifdef RTI_SECURITY_AVAILABLE + if (SecureLogUtils::is_secure(participant)) { + securelog_reader = SecureLogUtils::setup_secure_log_reader( + std::bind( + &OrchestratorApp::process_secure_log, + this, + std::placeholders::_1), + default_provider); + } +#endif + + app = Gtk::Application::create("orchestrator.orchestrator"); + app->signal_activate().connect([this]() { ui_setup(); }); + } + + void run() + { +#ifndef _WIN32 + // Route SIGINT/SIGTERM through the GLib main loop so GTK functions + // can be called safely from the callback. + g_unix_signal_add( + SIGINT, + [](gpointer data) -> gboolean { + static_cast(data) + ->window_close_from_signal(); + return G_SOURCE_REMOVE; + }, + this); + g_unix_signal_add( + SIGTERM, + [](gpointer data) -> gboolean { + static_cast(data) + ->window_close_from_signal(); + return G_SOURCE_REMOVE; + }, + this); +#endif + app->run(); + } + + // Close the window from within the GLib main loop (e.g. on SIGINT). + void window_close_from_signal() + { + if (window) + window->close(); + else if (app) + app->quit(); + } + +private: + // Member variables + + bool running; + // UI + Gtk::Window *window; + Glib::RefPtr app; + std::map stat_map; + std::map device_map; + Gtk::ScrolledWindow *scroll; + Glib::RefPtr buffer; + Gtk::Label *security_indicator = nullptr; + sigc::connection security_flash_connection; + bool security_flash_on = false; + int security_flash_ticks_remaining = 0; + + // Connext entities + dds::domain::DomainParticipant participant = dds::core::null; + dds::pub::DataWriter command_writer = + dds::core::null; + dds::sub::DataReader status_reader = dds::core::null; + dds::sub::DataReader hb_reader = dds::core::null; + dds::sub::cond::ReadCondition status_read_condition = dds::core::null; + rti::core::cond::AsyncWaitSet waitset_status; + std::shared_ptr hb_listener; + +#ifdef RTI_SECURITY_AVAILABLE + // Connext secure logging entities + SecureLogUtils::SecureLogReader securelog_reader = { dds::core::null, + dds::core::null }; +#endif + + void log_alert(std::string msg) + { + Glib::signal_idle().connect([this, msg]() -> bool { + Gtk::TextBuffer::iterator iter = buffer->end(); + + // timestamp + std::time_t now = std::time(nullptr); + std::tm *local_time = std::localtime(&now); + char time_str[100]; + std::strftime( + time_str, + sizeof(time_str), + "%Y-%m-%d %H:%M:%S", + local_time); + + // write to buffer + std::stringstream ss; + ss << "\n" << time_str << " - " << msg; + buffer->insert(iter, ss.str()); + + // scroll window down + auto v_adjustment = scroll->get_vadjustment(); + v_adjustment->set_value( + v_adjustment->get_upper() - v_adjustment->get_page_size()); + + return false; + }); + } + + void btn_handler(Orchestrator::DeviceCommands cmd) + { + Common::DeviceType device; + + for (auto const &btn : device_map) { + if (btn.second->get_active()) { + device = btn.first; + break; + } + } + + std::stringstream ss; + ss << "Writing " << cmd << " to " << device; + log_alert(ss.str()); + + Orchestrator::DeviceCommand command(device, cmd); + command_writer.write(command); + } + + void set_security_indicator_ok() + { + if (security_indicator == nullptr) { + return; + } + + auto ctx = security_indicator->get_style_context(); + ctx->remove_class("security-threat-on"); + ctx->remove_class("security-threat-off"); + ctx->add_class("security-ok"); + security_indicator->set_text("SECURITY: OK"); + } + + void trigger_security_flash() + { + Glib::signal_idle().connect([this]() -> bool { + if (security_indicator == nullptr) { + return false; + } + + // Keep flashing for ~8s after the latest threat event. + security_flash_ticks_remaining = 16; + + if (security_flash_connection.connected()) { + return false; + } + + security_flash_on = false; + security_flash_connection = Glib::signal_timeout().connect( + [this]() -> bool { + if (security_indicator == nullptr) { + return false; + } + + auto ctx = security_indicator->get_style_context(); + ctx->remove_class("security-ok"); + ctx->remove_class("security-threat-on"); + ctx->remove_class("security-threat-off"); + + security_flash_on = !security_flash_on; + if (security_flash_on) { + security_indicator->set_text("SECURITY THREAT"); + ctx->add_class("security-threat-on"); + } else { + security_indicator->set_text("SECURITY THREAT"); + ctx->add_class("security-threat-off"); + } + + --security_flash_ticks_remaining; + if (security_flash_ticks_remaining <= 0) { + set_security_indicator_ok(); + security_flash_connection.disconnect(); + return false; + } + + return true; + }, + 500); + + return false; + }); + } + +#ifdef RTI_SECURITY_AVAILABLE + bool is_security_threat(const DDSSecurity::BuiltinLoggingTypeV2 &sample) + { + return static_cast(sample.severity()) + // return sample.value("severity") + <= static_cast( + DDSSecurity::LoggingLevel::WARNING_LEVEL); + } + + void process_secure_log(const SecureLogUtils::SecureLogType &log) + { + if (is_security_threat(log)) { + std::stringstream ss; + ss << "SECURITY THREAT [" << log.appname() << "] " << log.message(); + log_alert(ss.str()); + trigger_security_flash(); + } + } +#endif + + void process_status() + { + dds::sub::LoanedSamples samples = + status_reader.take(); + + for (const auto &sample : samples) { + if (sample.info().valid()) { + // update the label + std::stringstream ss_label; + ss_label << sample.data().status(); + Gtk::Label *lbl = stat_map.at(sample.data().device()); + lbl->set_text(ss_label.str()); + apply_status_class(lbl, ss_label.str()); + + // print alert + std::stringstream ss_log; + ss_log << "Received " << sample.data().status() + << " status message from " << sample.data().device(); + log_alert(ss_log.str()); + } + } + } + + // Switches status-on/status-paused/status-off CSS class on a label + void apply_status_class(Gtk::Label *lbl, const std::string &status_str) + { + auto ctx = lbl->get_style_context(); + ctx->remove_class("status-on"); + ctx->remove_class("status-paused"); + ctx->remove_class("status-off"); + if (status_str.find("ON") != std::string::npos) { + ctx->add_class("status-on"); + } else if (status_str.find("PAUSED") != std::string::npos) { + ctx->add_class("status-paused"); + } else { + ctx->add_class("status-off"); + } + } + + void ui_setup() + { + // Load CSS stylesheet + auto css_provider = Gtk::CssProvider::create(); + try { + css_provider->load_from_path("ui/orchestrator.css"); + } catch (const Glib::Error &e) { + std::cerr << "Warning: could not load orchestrator.css: " + << e.what() << std::endl; + } + Gtk::StyleContext::add_provider_for_screen( + Gdk::Screen::get_default(), + css_provider, + GTK_STYLE_PROVIDER_PRIORITY_USER); + + auto builder = Gtk::Builder::create_from_file("ui/orchestrator.glade"); + builder->get_widget("window", window); + + // Load RTI logo into header and set as dock/taskbar icon + { + Gtk::Box *hdr = nullptr; + builder->get_widget("header_bar", hdr); + try { + auto pb = Gdk::Pixbuf::create_from_file( + "../../resource/images/Orchestrator.png"); + window->set_icon(pb); +#ifdef __APPLE__ + set_macos_dock_icon(pb); +#endif + if (hdr) { + auto scaled = + pb->scale_simple(56, 56, Gdk::INTERP_BILINEAR); + auto *logo = Gtk::manage(new Gtk::Image(scaled)); + logo->set_visible(true); + logo->set_margin_end(8); + hdr->pack_start(*logo, false, false, 0); + hdr->reorder_child(*logo, 0); + } + } catch (...) { + } + + if (hdr) { + security_indicator = Gtk::manage(new Gtk::Label("")); + { + auto ctx = security_indicator->get_style_context(); + ctx->add_class("security-indicator"); +#ifdef RTI_SECURITY_AVAILABLE + if (SecureLogUtils::is_secure(participant)) { + ctx->add_class("security-ok"); + security_indicator->set_text("SECURITY: OK"); + } else { + ctx->add_class("security-unsecure"); + security_indicator->set_text("UNSECURE MODE"); + } +#else + ctx->add_class("security-unsecure"); + security_indicator->set_text("UNSECURE MODE"); +#endif + } + security_indicator->set_margin_start(10); + security_indicator->set_margin_end(4); + security_indicator->set_visible(true); + hdr->pack_end(*security_indicator, false, false, 0); + } + } + + window->signal_delete_event().connect([this](GdkEventAny *event) { + std::cout << "Orchestrator UI closed" << std::endl; + running = false; + return false; + }); + + builder->get_widget( + "arm_label", + stat_map[Common::DeviceType::ARM]); + builder->get_widget( + "armctrl_label", + stat_map[Common::DeviceType::ARM_CONTROLLER]); + builder->get_widget( + "p_sensor_label", + stat_map[Common::DeviceType::PATIENT_SENSOR]); + builder->get_widget( + "p_monitor_label", + stat_map[Common::DeviceType::PATIENT_MONITOR]); + + builder->get_widget( + "arm", + device_map[Common::DeviceType::ARM]); + builder->get_widget( + "armctrl", + device_map[Common::DeviceType::ARM_CONTROLLER]); + builder->get_widget( + "p_sensor", + device_map[Common::DeviceType::PATIENT_SENSOR]); + builder->get_widget( + "p_monitor", + device_map[Common::DeviceType::PATIENT_MONITOR]); + + Gtk::TextView *console; + builder->get_widget("console", console); + buffer = console->get_buffer(); + + // Force dark background on the text view (CSS alone is unreliable + // for GtkTextView internals in GTK3) + { + Gdk::RGBA bg, fg; + bg.set("#060F0A"); + fg.set("#00CC66"); + console->override_background_color(bg); + console->override_color(fg); + console->override_font( + Pango::FontDescription("Courier New Bold 20")); + } + + builder->get_widget("scroll", scroll); + + Gtk::Button *start; + Orchestrator::DeviceCommands startmsg = + Orchestrator::DeviceCommands::START; + builder->get_widget("start", start); + start->signal_clicked().connect( + [this, startmsg]() { btn_handler(startmsg); }); + + Gtk::Button *pause; + Orchestrator::DeviceCommands pausemsg = + Orchestrator::DeviceCommands::PAUSE; + builder->get_widget("pause", pause); + pause->signal_clicked().connect( + [this, pausemsg]() { btn_handler(pausemsg); }); + + Gtk::Button *off; + Orchestrator::DeviceCommands offmsg = + Orchestrator::DeviceCommands::SHUTDOWN; + builder->get_widget("off", off); + off->signal_clicked().connect( + [this, offmsg]() { btn_handler(offmsg); }); + + app->add_window(*window); + window->set_visible(true); + log_alert("Started Orchestrator"); + + waitset_status.start(); + } +}; + +int main(int argc, char const *argv[]) +{ + OrchestratorApp app(argc, argv); + app.run(); + return 0; +} diff --git a/modules/01-operating-room/PatientMonitor.py b/modules/01-operating-room/PatientMonitor.py new file mode 100644 index 0000000..eaf9c7b --- /dev/null +++ b/modules/01-operating-room/PatientMonitor.py @@ -0,0 +1,583 @@ +# +# (c) 2024 Copyright, Real-Time Innovations, Inc. (RTI) All rights reserved. +# +# RTI grants Licensee a license to use, modify, compile, and create derivative +# works of the software solely for use with RTI Connext DDS. Licensee may +# redistribute copies of the software provided that all such copies are +# subject to this license. The software is provided "as is", with no warranty +# of any type, including any warranty for fitness for any purpose. RTI is +# under no obligation to maintain or support the software. RTI shall not be +# liable for any incidental or consequential damages arising out of the use or +# inability to use the software. + +import sys +import math +import time +import threading +import signal +import numpy as np + +from PySide6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QLabel, QFrame, + QGridLayout, QHBoxLayout, QVBoxLayout, QSizePolicy +) +from PySide6.QtCore import Qt, QTimer, Signal, QObject +from PySide6.QtGui import QFont, QColor, QPalette, QFontDatabase, QPixmap, QIcon + +import pyqtgraph as pg + +import rti.connextdds as dds +from Types import Common, PatientMonitor, Orchestrator, DdsEntities +from DdsUtils import register_type + +# ─── RTI Brand Colors ─────────────────────────────────────────────────────── +RTI_BLUE = "#004C97" +RTI_ORANGE = "#ED8B00" +BG_MAIN = "#0A0E17" # Very dark navy +BG_PANEL = "#0F1822" # Panel background +BG_HEADER = "#071020" # Header strip +BORDER_DIM = "#1A2A3A" # Subtle panel borders + +# Per-vital colour scheme (matches real ICU monitors) +COLOR_HR = "#00E676" # Bright green – ECG +COLOR_SPO2 = "#00B0FF" # Cyan-blue – SpO2 / plethysmograph +COLOR_ETCO2 = "#FFD600" # Amber-yellow – Capnography +COLOR_NIBP = "#FF7043" # Warm orange – NiBP + +# ─── Waveform generators ──────────────────────────────────────────────────── +SAMPLE_RATE = 200 # samples / second for waveform buffer +DISPLAY_SECS = 6 # seconds visible in the strip +BUFFER_LEN = SAMPLE_RATE * DISPLAY_SECS + + +def _ecg_template(n_pts: int = SAMPLE_RATE) -> np.ndarray: + """One beat of a synthetic PQRST waveform (normalised 0-1).""" + t = np.linspace(0, 1, n_pts) + # P wave + p = 0.15 * np.exp(-((t - 0.12) ** 2) / (2 * 0.008 ** 2)) + # Q dip + q = -0.08 * np.exp(-((t - 0.22) ** 2) / (2 * 0.004 ** 2)) + # R spike + r = 1.00 * np.exp(-((t - 0.26) ** 2) / (2 * 0.003 ** 2)) + # S dip + s = -0.15 * np.exp(-((t - 0.30) ** 2) / (2 * 0.004 ** 2)) + # T wave + tw = 0.30 * np.exp(-((t - 0.42) ** 2) / (2 * 0.015 ** 2)) + return p + q + r + s + tw + + +def _pleth_template(n_pts: int = SAMPLE_RATE) -> np.ndarray: + """One beat of a plethysmograph waveform (smooth hill).""" + t = np.linspace(0, 1, n_pts) + return np.clip( + np.exp(-((t - 0.35) ** 2) / (2 * 0.05 ** 2)) + + 0.25 * np.exp(-((t - 0.55) ** 2) / (2 * 0.04 ** 2)), + 0, 1 + ) + + +def _capno_template(n_pts: int = SAMPLE_RATE) -> np.ndarray: + """One respiratory cycle capnography waveform.""" + t = np.linspace(0, 1, n_pts) + # rise phase (0.3-0.6), plateau (0.6-0.85), fall (0.85-0.95) + w = np.zeros(n_pts) + rise = (t >= 0.30) & (t < 0.60) + plat = (t >= 0.60) & (t < 0.85) + fall = (t >= 0.85) & (t < 0.95) + w[rise] = (t[rise] - 0.30) / 0.30 + w[plat] = 1.0 + w[fall] = 1.0 - (t[fall] - 0.85) / 0.10 + return np.clip(w, 0, 1) + + +# ─── DDS → Qt bridge ──────────────────────────────────────────────────────── +class DdsBridge(QObject): + vitals_received = Signal(float, float, float, float, float) # hr, spo2, etco2, nibp_s, nibp_d + shutdown_received = Signal() + status_changed = Signal(str) # "ON" / "PAUSED" + + +# ─── Waveform strip panel ───────────────────────────────────────────────── +class VitalPanel(QFrame): + """A self-contained vital-signs panel: large numeric + scrolling waveform.""" + + def __init__(self, vital_name: str, unit: str, color: str, + y_min: float, y_max: float, parent=None): + super().__init__(parent) + self.vital_name = vital_name + self.unit = unit + self.color = color + self.y_min = y_min + self.y_max = y_max + + # Waveform state + self.buf = np.zeros(BUFFER_LEN) + self.buf_ptr = 0 # write pointer (circular) + self.phase = 0.0 # current beat phase (0–1) + self.beat_rate = 1.0 # beats per second (driven by live value) + self.live_value = 0.0 + self.amplitude = 1.0 + + self._build_ui() + + # ── UI construction ────────────────────────────────────────────────── + def _build_ui(self): + self.setFrameShape(QFrame.Shape.Box) + self.setStyleSheet(f""" + VitalPanel {{ + background-color: {BG_PANEL}; + border: 1px solid {self.color}55; + border-radius: 8px; + }} + """) + + root = QVBoxLayout(self) + root.setContentsMargins(10, 8, 10, 8) + root.setSpacing(4) + + # ── Top row: name + value ───────────────────────────────────── + top = QHBoxLayout() + top.setSpacing(0) + + name_lbl = QLabel(self.vital_name) + name_lbl.setStyleSheet(f"color: {self.color}; font-size: 20px; font-weight: bold; background: transparent;") + top.addWidget(name_lbl) + + top.addStretch() + + self.unit_lbl = QLabel(self.unit) + self.unit_lbl.setStyleSheet(f"color: {self.color}88; font-size: 16px; background: transparent;") + self.unit_lbl.setAlignment(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignRight) + top.addWidget(self.unit_lbl) + root.addLayout(top) + + # ── Numeric value ───────────────────────────────────────────── + self.value_lbl = QLabel("---") + self.value_lbl.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + self.value_lbl.setStyleSheet( + f"color: {self.color}; font-size: 54px; font-weight: bold; " + f"font-family: 'Courier New', monospace; background: transparent; " + f"letter-spacing: -2px;" + ) + root.addWidget(self.value_lbl) + + # ── Waveform plot ───────────────────────────────────────────── + self.plot_widget = pg.PlotWidget(background=BG_PANEL) + self.plot_widget.setMinimumHeight(150) + self.plot_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.plot_widget.hideAxis("left") + self.plot_widget.hideAxis("bottom") + self.plot_widget.setMouseEnabled(x=False, y=False) + self.plot_widget.setMenuEnabled(False) + pen = pg.mkPen(color=self.color, width=2) + self.curve = self.plot_widget.plot(pen=pen) + self.plot_widget.setYRange(self.y_min, self.y_max, padding=0.05) + root.addWidget(self.plot_widget) + + # ── Waveform advance ────────────────────────────────────────────── + def advance_waveform(self, template: np.ndarray, n_new: int): + """Advance n_new samples through the cyclic waveform template.""" + t_len = len(template) + for _ in range(n_new): + idx = int(self.phase * t_len) % t_len + self.buf[self.buf_ptr % BUFFER_LEN] = template[idx] * self.amplitude + self.buf_ptr += 1 + self.phase += self.beat_rate / SAMPLE_RATE + if self.phase >= 1.0: + self.phase -= 1.0 + + def get_display_buffer(self) -> np.ndarray: + """Return the circular buffer in chronological order.""" + ptr = self.buf_ptr % BUFFER_LEN + return np.roll(self.buf, -ptr) + + def set_value(self, val, fmt=".0f"): + self.value_lbl.setText(f"{val:{fmt}}") + self.live_value = float(val) + + def update_curve(self): + data = self.get_display_buffer() + self.curve.setData(data) + + +# ─── NiBP Panel (no waveform — spot measurement readout) ───────────────── +class NiBPPanel(QFrame): + def __init__(self, parent=None): + super().__init__(parent) + self.setFrameShape(QFrame.Shape.Box) + self.setStyleSheet(f""" + NiBPPanel {{ + background-color: {BG_PANEL}; + border: 1px solid {COLOR_NIBP}55; + border-radius: 8px; + }} + """) + root = QVBoxLayout(self) + root.setContentsMargins(10, 8, 10, 8) + root.setSpacing(6) + + top = QHBoxLayout() + name_lbl = QLabel("NiBP") + name_lbl.setStyleSheet(f"color: {COLOR_NIBP}; font-size: 20px; font-weight: bold; background: transparent;") + top.addWidget(name_lbl) + top.addStretch() + unit_lbl = QLabel("mmHg") + unit_lbl.setStyleSheet(f"color: {COLOR_NIBP}88; font-size: 16px; background: transparent;") + top.addWidget(unit_lbl) + root.addLayout(top) + + root.addStretch() + + # sys / dia + bp_row = QHBoxLayout() + bp_row.setAlignment(Qt.AlignmentFlag.AlignCenter) + + self.sys_lbl = QLabel("---") + self.sys_lbl.setStyleSheet( + f"color: {COLOR_NIBP}; font-size: 54px; font-weight: bold; " + f"font-family: 'Courier New', monospace; background: transparent;" + ) + bp_row.addWidget(self.sys_lbl) + + sep = QLabel("/") + sep.setStyleSheet(f"color: {COLOR_NIBP}88; font-size: 42px; background: transparent;") + bp_row.addWidget(sep) + + self.dia_lbl = QLabel("---") + self.dia_lbl.setStyleSheet( + f"color: {COLOR_NIBP}; font-size: 54px; font-weight: bold; " + f"font-family: 'Courier New', monospace; background: transparent;" + ) + bp_row.addWidget(self.dia_lbl) + root.addLayout(bp_row) + + sub_row = QHBoxLayout() + sub_row.setAlignment(Qt.AlignmentFlag.AlignCenter) + sub_lbl = QLabel("Systolic / Diastolic") + sub_lbl.setStyleSheet(f"color: {COLOR_NIBP}66; font-size: 20px; background: transparent;") + sub_row.addWidget(sub_lbl) + root.addLayout(sub_row) + + root.addStretch() + + # MAP estimate + self.map_lbl = QLabel("MAP: ---") + self.map_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.map_lbl.setStyleSheet( + f"color: {COLOR_NIBP}AA; font-size: 20px; background: transparent;" + ) + root.addWidget(self.map_lbl) + + def set_values(self, s, d): + self.sys_lbl.setText(f"{s:.0f}") + self.dia_lbl.setText(f"{d:.0f}") + _map = (s + 2 * d) / 3 + self.map_lbl.setText(f"MAP: {_map:.0f}") + + +# ─── Main Window ────────────────────────────────────────────────────────── +class PatientMonitorWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("RTI Connext — Patient Monitor") + self.setMinimumSize(900, 600) + self.resize(1100, 720) + self._apply_global_style() + + # ── Build vitals panels ────────────────────────────────────── + self.hr_panel = VitalPanel("ECG / Heart Rate", "bpm", COLOR_HR, -0.2, 1.1) + self.spo2_panel = VitalPanel("SpO₂", "%", COLOR_SPO2, -0.1, 1.1) + self.etco2_panel = VitalPanel("EtCO₂", "mmHg", COLOR_ETCO2, -0.1, 1.1) + self.nibp_panel = NiBPPanel() + + # Waveform templates (precomputed once) + self._ecg_tpl = _ecg_template(SAMPLE_RATE) + self._pleth_tpl = _pleth_template(SAMPLE_RATE) + self._capno_tpl = _capno_template(SAMPLE_RATE) + + # DDS-driven values + self._hr = 60.0 + self._spo2 = 98.0 + self._etco2 = 38.0 + self._nibp_s = 120.0 + self._nibp_d = 80.0 + self.paused = False + + # Frames per tick derived from timer interval + self._timer_ms = 40 + self._new_per_tick = int(SAMPLE_RATE * self._timer_ms / 1000) + + self._build_ui() + self._set_icon() + + # ── Animation timer ────────────────────────────────────────── + self.anim_timer = QTimer(self) + self.anim_timer.timeout.connect(self._tick) + self.anim_timer.start(self._timer_ms) # 25 fps + + # ── Window icon ───────────────────────────────────────────────── + def _set_icon(self): + _px = QPixmap("../../resource/images/Patient-Monitor.png") + if not _px.isNull(): + self.setWindowIcon(QIcon(_px)) + + # ── Style ──────────────────────────────────────────────────────── + def _apply_global_style(self): + self.setStyleSheet(f""" + QMainWindow, QWidget#central {{ + background-color: {BG_MAIN}; + }} + """) + + # ── Layout ─────────────────────────────────────────────────────── + def _build_ui(self): + central = QWidget() + central.setObjectName("central") + central.setStyleSheet(f"background-color: {BG_MAIN};") + self.setCentralWidget(central) + + root = QVBoxLayout(central) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + # ── Header bar ─────────────────────────────────────────────── + header = QWidget() + header.setFixedHeight(80) + header.setStyleSheet(f"background-color: {BG_HEADER}; border-bottom: 2px solid {RTI_BLUE};") + h_layout = QHBoxLayout(header) + h_layout.setContentsMargins(20, 0, 20, 0) + + _logo_px = QPixmap("../../resource/images/Patient-Monitor.png") + if not _logo_px.isNull(): + logo_lbl = QLabel() + logo_lbl.setStyleSheet("background: transparent;") + logo_lbl.setPixmap(_logo_px.scaled(56, 56, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)) + h_layout.addWidget(logo_lbl) + + rti_lbl = QLabel("RTI Connext") + rti_lbl.setStyleSheet(f"color: {RTI_BLUE}; font-size: 34px; font-weight: bold; background: transparent;") + h_layout.addWidget(rti_lbl) + + bar = QLabel("|") + bar.setStyleSheet(f"color: #334455; font-size: 34px; background: transparent;") + h_layout.addWidget(bar) + + title_lbl = QLabel("Patient Monitor") + title_lbl.setStyleSheet("color: #E0E8F0; font-size: 34px; font-weight: bold; background: transparent;") + h_layout.addWidget(title_lbl) + + h_layout.addStretch() + + self.status_lbl = QLabel("● CONNECTED — Streaming QoS") + self.status_lbl.setStyleSheet(f"color: {COLOR_HR}; font-size: 20px; background: transparent;") + h_layout.addWidget(self.status_lbl) + + self.state_lbl = QLabel("ON") + self.state_lbl.setStyleSheet( + f"color: #000; background-color: {COLOR_HR}; font-size: 20px; " + f"font-weight: bold; padding: 3px 10px; border-radius: 4px; margin-left: 10px;" + ) + h_layout.addWidget(self.state_lbl) + + root.addWidget(header) + + # ── Vital grid ──────────────────────────────────────────────── + grid_container = QWidget() + grid_container.setStyleSheet(f"background-color: {BG_MAIN};") + grid = QGridLayout(grid_container) + grid.setContentsMargins(12, 12, 12, 12) + grid.setSpacing(10) + + grid.addWidget(self.hr_panel, 0, 0) + grid.addWidget(self.spo2_panel, 0, 1) + grid.addWidget(self.etco2_panel, 1, 0) + grid.addWidget(self.nibp_panel, 1, 1) + + grid.setRowStretch(0, 1) + grid.setRowStretch(1, 1) + grid.setColumnStretch(0, 1) + grid.setColumnStretch(1, 1) + + root.addWidget(grid_container, 1) + + # ── Footer bar ──────────────────────────────────────────────── + footer = QWidget() + footer.setFixedHeight(44) + footer.setStyleSheet(f"background-color: {BG_HEADER}; border-top: 1px solid {BORDER_DIM};") + f_layout = QHBoxLayout(footer) + f_layout.setContentsMargins(20, 0, 20, 0) + f_lbl = QLabel("Real-Time Innovations · RTI Connext · MedTech Reference Architecture") + f_lbl.setStyleSheet("color: #445566; font-size: 20px; background: transparent;") + f_layout.addWidget(f_lbl) + f_layout.addStretch() + root.addWidget(footer) + + # ── Animation tick ─────────────────────────────────────────────── + def _tick(self): + if self.paused: + return + # ECG — rate driven by HR (beats per minute → bps) + self.hr_panel.beat_rate = self._hr / 60.0 + # SpO2 — same rate as HR (plethysmograph synced to cardiac cycle) + self.spo2_panel.beat_rate = self._hr / 60.0 + # EtCO2 — respiratory rate ≈ HR/4, capped 8-30 breaths/min + rr = max(8.0, min(30.0, self._hr / 4.0)) + self.etco2_panel.beat_rate = rr / 60.0 + + self.hr_panel.advance_waveform(self._ecg_tpl, self._new_per_tick) + self.spo2_panel.advance_waveform(self._pleth_tpl, self._new_per_tick) + self.etco2_panel.advance_waveform(self._capno_tpl, self._new_per_tick) + # EtCO2 - capnogram height, capped to 60 mmHg for stability in the UI + self.etco2_panel.amplitude = max(0.0, min(60.0, self._etco2)) / 60.0 + + self.hr_panel.update_curve() + self.spo2_panel.update_curve() + self.etco2_panel.update_curve() + + # ── DDS value update (called from polling timer in main app) ───── + def update_vitals(self, hr, spo2, etco2, nibp_s, nibp_d): + self._hr = hr + self._spo2 = spo2 + self._etco2 = etco2 + self._nibp_s = nibp_s + self._nibp_d = nibp_d + + self.hr_panel.set_value(hr) + self.spo2_panel.set_value(spo2) + self.etco2_panel.set_value(etco2) + self.nibp_panel.set_values(nibp_s, nibp_d) + + def set_state(self, state: str): + self.paused = (state == "PAUSED") + colors = {"ON": COLOR_HR, "PAUSED": RTI_ORANGE, "OFF": "#FF4444"} + c = colors.get(state, "#888") + self.state_lbl.setText(state) + self.state_lbl.setStyleSheet( + f"color: #000; background-color: {c}; font-size: 20px; " + f"font-weight: bold; padding: 3px 10px; border-radius: 4px; margin-left: 10px;" + ) + + +# ─── Application class ──────────────────────────────────────────────────── +class PatientMonitorApp: + def __init__(self): + self.pm_status = None + self.status_writer = None + self.hb_writer = None + self.vitals_reader = None + self.cmd_reader = None + self.bridge = DdsBridge() + self.window = None + + # ── DDS heartbeat thread ───────────────────────────────────────── + def write_hb(self): + while self.pm_status.status != Common.DeviceStatuses.OFF: + hb = Common.DeviceHeartbeat() + hb.device = Common.DeviceType.PATIENT_MONITOR + self.hb_writer.write(hb) + time.sleep(0.05) + + # ── DDS polling (called by Qt timer every 150 ms) ──────────────── + def _poll_dds(self): + # Vitals + if self.pm_status.status == Common.DeviceStatuses.ON: + samples = self.vitals_reader.take_data() + for sample in samples: + self.window.update_vitals( + float(sample.hr), + float(sample.spo2), + float(sample.etco2), + float(sample.nibp_s), + float(sample.nibp_d), + ) + # Commands + cmd_samples = self.cmd_reader.take_data() + for sample in cmd_samples: + if sample.command == Orchestrator.DeviceCommands.START: + print("Patient Monitor received Start command") + self.pm_status.status = Common.DeviceStatuses.ON + self.window.set_state("ON") + elif sample.command == Orchestrator.DeviceCommands.PAUSE: + print("Patient Monitor received Pause command") + self.pm_status.status = Common.DeviceStatuses.PAUSED + self.window.set_state("PAUSED") + else: + print("Patient Monitor received Shutdown command") + self.pm_status.status = Common.DeviceStatuses.OFF + self.window.set_state("OFF") + QApplication.quit() + self.status_writer.write(self.pm_status) + + # ── Connext setup ──────────────────────────────────────────────── + def connext_setup(self): + entities = DdsEntities.Constants + register_type(Common.DeviceStatus) + register_type(Common.DeviceHeartbeat) + register_type(Orchestrator.DeviceCommand) + register_type(PatientMonitor.Vitals) + + qos_provider = dds.QosProvider.default + participant = qos_provider.create_participant_from_config( + entities.PATIENT_MONITOR_DP + ) + + self.status_writer = dds.DataWriter( + participant.find_datawriter(entities.STATUS_DW) + ) + self.hb_writer = dds.DataWriter( + participant.find_datawriter(entities.HB_DW) + ) + self.vitals_reader = dds.DataReader( + participant.find_datareader(entities.VITALS_DR) + ) + self.cmd_reader = dds.DataReader( + participant.find_datareader(entities.DEVICE_COMMAND_DR) + ) + self.pm_status = Common.DeviceStatus( + device=Common.DeviceType.PATIENT_MONITOR, + status=Common.DeviceStatuses.ON, + ) + self.status_writer.write(self.pm_status) + + # ── Entry point ────────────────────────────────────────────────── + def run(self): + app = QApplication(sys.argv) + app.setStyle("Fusion") + _icon = QIcon(QPixmap("../../resource/images/Patient-Monitor.png")) + if not _icon.isNull(): + app.setWindowIcon(_icon) + + self.window = PatientMonitorWindow() + self.connext_setup() + + # DDS polling timer (Qt timer — same thread, no locking needed) + dds_timer = QTimer() + dds_timer.timeout.connect(self._poll_dds) + dds_timer.start(150) + + # Heartbeat in background thread + hb_thread = threading.Thread(target=self.write_hb, daemon=True) + hb_thread.start() + + # Allow Ctrl+C to cleanly quit the Qt event loop. + # The QTimer is needed so the event loop periodically yields control + # back to Python, enabling signal delivery. + signal.signal(signal.SIGINT, lambda *_: app.quit()) + _sig_timer = QTimer() + _sig_timer.timeout.connect(lambda: None) + _sig_timer.start(300) + app.aboutToQuit.connect(self._cleanup) + + self.window.show() + print("Started Patient Monitor") + + app.exec() + + self.pm_status.status = Common.DeviceStatuses.OFF + + def _cleanup(self): + print("Shutting down Patient Monitor") + + +if __name__ == "__main__": + PatientMonitorApp().run() diff --git a/modules/01-operating-room/PatientSensor.cxx b/modules/01-operating-room/PatientSensor.cxx new file mode 100644 index 0000000..090cc2c --- /dev/null +++ b/modules/01-operating-room/PatientSensor.cxx @@ -0,0 +1,194 @@ +// +// (c) 2024 Copyright, Real-Time Innovations, Inc. (RTI) All rights reserved. +// +// RTI grants Licensee a license to use, modify, compile, and create derivative +// works of the software solely for use with RTI Connext DDS. Licensee may +// redistribute copies of the software provided that all such copies are +// subject to this license. The software is provided "as is", with no warranty +// of any type, including any warranty for fitness for any purpose. RTI is +// under no obligation to maintain or support the software. RTI shall not be +// liable for any incidental or consequential damages arising out of the use or +// inability to use the software. + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "Types.hpp" + +using namespace DdsEntities::Constants; + +static std::atomic g_shutdown { false }; + +static void handle_signal(int) +{ + g_shutdown = true; +} + +class PatientSensor { +public: + void run() + { + // We need to register the types before we start creating DDS entities + rti::domain::register_type(); + rti::domain::register_type(); + rti::domain::register_type(); + rti::domain::register_type(); + + // Connext will load XML files through the default provider from the + // NDDS_QOS_PROFILES environment variable + auto default_provider = dds::core::QosProvider::Default(); + + dds::domain::DomainParticipant participant = + default_provider.extensions().create_participant_from_config( + PATIENT_SENSOR_DP); + + // Initialize DataWriters + dds::pub::DataWriter vitals_writer = + rti::pub::find_datawriter_by_name< + dds::pub::DataWriter>( + participant, + VITALS_DW); + + dds::pub::DataWriter status_writer = + rti::pub::find_datawriter_by_name< + dds::pub::DataWriter>( + participant, + STATUS_DW); + + dds::pub::DataWriter hb_writer = + rti::pub::find_datawriter_by_name< + dds::pub::DataWriter>( + participant, + HB_DW); + + // Initialize DataReader + dds::sub::DataReader cmd_reader = + rti::sub::find_datareader_by_name< + dds::sub::DataReader>( + participant, + DEVICE_COMMAND_DR); + + current_status.device(Common::DeviceType::PATIENT_SENSOR); + current_status.status(Common::DeviceStatuses::ON); + + // Read condition to process commands + dds::sub::cond::ReadCondition command_read_condition( + cmd_reader, + dds::sub::status::DataState::any(), + [this, &cmd_reader, &status_writer]() { + process_command(cmd_reader, status_writer); + }); + + dds::core::cond::WaitSet waitset_command; + waitset_command += command_read_condition; + + std::cout << "Launching Patient Sensor" << std::endl; + + // Start heartbeat thread + std::thread hb_thread(&PatientSensor::write_hb, this, hb_writer); + write_status(status_writer); + + // Main loop + while (!g_shutdown + && current_status.status() != Common::DeviceStatuses::OFF) { + try { + waitset_command.dispatch(dds::core::Duration(1)); + } catch (const dds::core::Error &) { + break; + } + write_vitals(vitals_writer); + } + + if (g_shutdown) { + std::cout << "Shutting Down Patient Sensor (signal)" << std::endl; + current_status.status(Common::DeviceStatuses::OFF); + write_status(status_writer); + } + + hb_thread.join(); + } + +private: + Common::DeviceStatus current_status; + + // Function to publish vitals + void write_vitals( + dds::pub::DataWriter vitals_writer) + { + PatientMonitor::Vitals sample; + sample.patient_id("ab1234"); + sample.hr(55 + rand() % 11); + sample.spo2(90 + rand() % 11); + sample.etco2(35 + rand() % 11); + sample.nibp_s(115 + rand() % 11); + sample.nibp_d(75 + rand() % 11); + + if (current_status.status() == Common::DeviceStatuses::ON) + vitals_writer.write(sample); + } + + // Function to publish heartbeats every 50ms + void write_hb(dds::pub::DataWriter hb_writer) + { + while (current_status.status() != Common::DeviceStatuses::OFF) { + Common::DeviceHeartbeat hb(Common::DeviceType::PATIENT_SENSOR); + hb_writer.write(hb); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + } + + // Function to publish device status + void write_status(dds::pub::DataWriter status_writer) + { + status_writer.write(current_status); + } + + // Function to process commands sent from the Orchestrator + void process_command( + dds::sub::DataReader cmd_reader, + dds::pub::DataWriter status_writer) + { + dds::sub::LoanedSamples samples = + cmd_reader.take(); + + for (const auto &sample : samples) { + if (sample.info().valid()) { + if (sample.data().command() + == Orchestrator::DeviceCommands::PAUSE) { + std::cout << "Pausing Patient Sensor" << std::endl; + current_status.status(Common::DeviceStatuses::PAUSED); + } else if ( + sample.data().command() + == Orchestrator::DeviceCommands::START) { + std::cout << "Starting Patient Sensor" << std::endl; + current_status.status(Common::DeviceStatuses::ON); + } else { // shutdown + std::cout << "Shutting Down Patient Sensor" << std::endl; + current_status.status(Common::DeviceStatuses::OFF); + } + write_status(status_writer); + } + } + } +}; + +// Main function creates an instance of PatientMonitor and runs it +int main(int argc, char const *argv[]) +{ + std::signal(SIGINT, handle_signal); + std::signal(SIGTERM, handle_signal); + PatientSensor patient_sensor; + patient_sensor.run(); + return 0; +} diff --git a/resource/images/Arm-Controller.png b/resource/images/Arm-Controller.png new file mode 100644 index 0000000000000000000000000000000000000000..373bccf35b07488618724ad1fd8be10a3eab6d49 GIT binary patch literal 2763 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 LNkvXXu0mjfL;IgL literal 0 HcmV?d00001 diff --git a/resource/images/Orchestrator.png b/resource/images/Orchestrator.png new file mode 100644 index 0000000000000000000000000000000000000000..aa7f053d9c0f9a32e3b4a39c368fa5e3b7bb3bff GIT binary patch literal 3032 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 a*#8H?Pj|1X935K#00006 z(-!qZ$xOAjJB5DHZWnQ;jtXno@OZP)aN_ff3y*CN6`ORb|A?G~jxqolYdCqh0@htfD*Ix<5wQKfez@~{; zE|46s()?xwxC9&rj;mDazCaZg_!IitHG3|g2mU0JgY%UyfpV2feQCA=GYWm}nq3L# zfWL;#wfV^*K&Mivom0PlqjkhYmXjRZngSDj^h9)VF={T&Zyr&p)R#PWv_7h?%y0An3+(p(Wxx;7^=01k zP#o^rUX1XvDJFDe*c695cM&7J~r+>^@%YzXIZvv8K+L2 zM4?ci(P*&QY~E`R6-k4khm9LI;cz%mtHlo%axpqO%JA?oiHV7%q@+l)<#0GCDbaG~ z%xTGf$s+WfJ9oJM{`)bTO>&r%L}OzkI$bq6IV)*xZIxszKfi#Hkr7$^ip9}>u-omj z*^0Q(7Rxs*U;cZxZQDL;nf`t|D^_H)VZ#Q=?G+bqK&?&->Qz%Jj zpV84#KL7mlSlWQkAR;dG zl`B^PxPALJ4Gj&v_S$RH%A7xc9srligvmNxt$o6t9I+#o-{ zz;Bt`w{HVbR8+*-vu7C`97GTV>g(%iYdg-_v;Rh~KLo(al`FY&16V8;dU|>| zaG;iigap5BjE|4|d`?bI#%{M`GMNKjLm=BykWJ{Zv9X{=X{A!h?%liT>FL30wNhLA zHf?QfIGs+atN%_{R~GIaY6NHDE9pf+|~9Ru+=& zP>@Y%wOSog9R2dkFSBdcE?Qbzym#RzpL~KK2rON?l+4Ubs;Vk^`spINy1E!19wsFv zC14w#INF0kp$Myh!4kDNddrs0q@<+y{L}@(-B%VC76Oo&naR?nOBo&>24MN}jF8(g z7+P7iYBl-!1>V=HRjV-=q)lc*C8`q^k8ttv@#N;_c;AGgGwvefxAX zPFAc|8wU^8iMAUmvI#8+g5OC&q!=9ZUB4_VE5&Z_pH{A{tTa+C0Wu36%%mXFXfzth z=~6k6P3Y9rRJL#5Cdr0QS4~Pv3U9pe7tuD%W;3Tx|F*nlFtk!v7kn3yoSaN+t3meu zTQ;E+5)vd&40O6`5)%_8+nFr1-QFKkUU__coQ@6?&pfkUk`Ggf$Y3Z40#>UPwK^^2 zlxKH$H&s%X0u^58fUz-_wCcs)YKHT&uX7YD>6yu`}ot=)#5ZtXbnf-7AK$34P|wXy|q^GCT+uJ*qolNHcLMn(o#uU_SY4?f_jr=FVjz0qi-y}ccPy?ZNXv|+Vc(dl$S`>jwUbMRo@ z+zs!hl9#udB}8=FF!NU7E8yDPl2SS*ZNKCfPyIX-Q_I-x_Mqwo!elbbVrTN=uoPqwx~1hKN=wVUee-;wu&|KY+5@uKlC6(! zYHF0r)Vw6tM@NX5&=EqC&{Ln>oX>b3R}+gUp^J-)0XTQ=92Sd3k^^@G7K??Bjt=0r zYY!FCTa?$Wdy=bHuM!_0FKY5}H!(Ccgv;e3BO`-z=Vsp$;t$b63xdF_uf9rGm(^zt zZ$6Tgl*Eb^8k(D%MdztS3mqvu`%4N6@~1rrQ&Qs2aksV_yh~kkl~~jfA!0&Dh?vk3 zBI=LrPNu)#&R1WZ_gVG!{ErDYZrr4?(RZ4E0qf{#C==e`W zw^B?cs)ON<9Thm8PO`GHIC0{HtU@Rh3Qn9jK~`24PN$PSd-lp^TMV)chO=$R2}EaS zCji;m+0zC(xya7W#%8l&u~#@=#RWnV%f;oZ30{^ta1Vac6#VnMtR= zd4--hQF1LYvE^XeDV;tu!8X+IR Z{y$7)Q3Yjid&2+#002ovPDHLkV1ii=cNYKv literal 0 HcmV?d00001 From 19b47d9b69136cb6f7244eb67a46f10f55d52ded Mon Sep 17 00:00:00 2001 From: Andy Krassowski Date: Thu, 23 Apr 2026 10:21:45 -0400 Subject: [PATCH 2/4] link warning fix on Mac --- modules/01-operating-room/CMakeLists.txt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/modules/01-operating-room/CMakeLists.txt b/modules/01-operating-room/CMakeLists.txt index 79378d3..4ece396 100644 --- a/modules/01-operating-room/CMakeLists.txt +++ b/modules/01-operating-room/CMakeLists.txt @@ -102,7 +102,7 @@ add_library(refArchTypes OBJECT ${SECURITY_CXX_SOURCES} ) target_link_libraries(refArchTypes - PRIVATE + PUBLIC RTIConnextDDS::cpp2_api ) @@ -114,7 +114,6 @@ add_executable(PatientSensor ) target_link_libraries(PatientSensor PRIVATE - RTIConnextDDS::cpp2_api refArchTypes ) target_include_directories(PatientSensor @@ -161,4 +160,4 @@ endif() # Convenience target: build all Module 01 executables set(APPLICATIONS PatientSensor ${GTK_APPLICATIONS}) -add_custom_target(module-01 DEPENDS ${APPLICATIONS}) \ No newline at end of file +add_custom_target(module-01 DEPENDS ${APPLICATIONS}) From de524054e8ff0a562d822a85ae30b53083431b59 Mon Sep 17 00:00:00 2001 From: Andy Krassowski Date: Thu, 23 Apr 2026 10:36:39 -0400 Subject: [PATCH 3/4] linkFix --- modules/01-operating-room/CMakeLists.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/01-operating-room/CMakeLists.txt b/modules/01-operating-room/CMakeLists.txt index 4ece396..bf9958c 100644 --- a/modules/01-operating-room/CMakeLists.txt +++ b/modules/01-operating-room/CMakeLists.txt @@ -137,7 +137,6 @@ if(GTKMM_FOUND) target_link_libraries(${application} PRIVATE ${GTKMM_LIBRARIES} - RTIConnextDDS::cpp2_api refArchTypes ) if(APPLE) From 1750c36308891a938f13c539e768f540988e2614 Mon Sep 17 00:00:00 2001 From: Will Coleman Date: Thu, 30 Apr 2026 15:26:08 -0400 Subject: [PATCH 4/4] fix: move modified module 01 src files to src/ dir --- modules/01-operating-room/Arm.py | 621 ------------------ modules/01-operating-room/ArmController.cxx | 479 -------------- modules/01-operating-room/Orchestrator.cxx | 530 --------------- modules/01-operating-room/PatientMonitor.py | 583 ---------------- modules/01-operating-room/PatientSensor.cxx | 194 ------ modules/01-operating-room/src/Arm.py | 10 +- .../01-operating-room/src/ArmController.cxx | 3 +- .../01-operating-room/src/Orchestrator.cxx | 2 +- .../01-operating-room/src/PatientMonitor.py | 6 +- 9 files changed, 11 insertions(+), 2417 deletions(-) delete mode 100644 modules/01-operating-room/Arm.py delete mode 100644 modules/01-operating-room/ArmController.cxx delete mode 100644 modules/01-operating-room/Orchestrator.cxx delete mode 100644 modules/01-operating-room/PatientMonitor.py delete mode 100644 modules/01-operating-room/PatientSensor.cxx diff --git a/modules/01-operating-room/Arm.py b/modules/01-operating-room/Arm.py deleted file mode 100644 index 4f26570..0000000 --- a/modules/01-operating-room/Arm.py +++ /dev/null @@ -1,621 +0,0 @@ -# -# (c) 2024 Copyright, Real-Time Innovations, Inc. (RTI) All rights reserved. -# -# RTI grants Licensee a license to use, modify, compile, and create derivative -# works of the software solely for use with RTI Connext DDS. Licensee may -# redistribute copies of the software provided that all such copies are -# subject to this license. The software is provided "as is", with no warranty -# of any type, including any warranty for fitness for any purpose. RTI is -# under no obligation to maintain or support the software. RTI shall not be -# liable for any incidental or consequential damages arising out of the use or -# inability to use the software. - -import sys -import math -import time -import threading -import signal - -from PySide6.QtWidgets import ( - QApplication, QMainWindow, QWidget, QLabel, QFrame, - QHBoxLayout, QVBoxLayout, QSizePolicy, QScrollArea -) -from PySide6.QtCore import Qt, QTimer, QRectF, QPointF, Signal, QObject -from PySide6.QtGui import ( - QPainter, QColor, QPen, QBrush, QFont, QConicalGradient, QPainterPath, - QPixmap, QIcon -) - -import rti.connextdds as dds -from Types import Common, SurgicalRobot, Orchestrator, DdsEntities -from DdsUtils import register_type - -# ─── RTI Brand Colors ──────────────────────────────────────────────────── -RTI_BLUE = "#004C97" -RTI_ORANGE = "#ED8B00" -BG_MAIN = "#0A0E17" -BG_PANEL = "#0F1822" -BG_ROW_ALT = "#0D1520" -BG_HEADER = "#071020" -BORDER_DIM = "#1A2A3A" - -# Per-joint color palette -JOINT_COLORS = { - SurgicalRobot.Motors.BASE: RTI_BLUE, - SurgicalRobot.Motors.SHOULDER: RTI_ORANGE, - SurgicalRobot.Motors.ELBOW: "#00BFFF", # Electric blue - SurgicalRobot.Motors.WRIST: "#7CFC00", # Lime green - SurgicalRobot.Motors.HAND: "#DA70D6", # Orchid -} - -JOINT_NAMES = { - SurgicalRobot.Motors.BASE: "BASE", - SurgicalRobot.Motors.SHOULDER: "SHOULDER", - SurgicalRobot.Motors.ELBOW: "ELBOW", - SurgicalRobot.Motors.WRIST: "WRIST", - SurgicalRobot.Motors.HAND: "HAND", -} - -UPDATE_MS = 100 # refresh rate ms (~10 fps) - -# Starting joint angles shown before the first DDS sample arrives -INITIAL_ANGLES = { - SurgicalRobot.Motors.BASE: 204.0, - SurgicalRobot.Motors.SHOULDER: 176.0, - SurgicalRobot.Motors.ELBOW: 156.0, - SurgicalRobot.Motors.WRIST: 165.0, - SurgicalRobot.Motors.HAND: 151.0, -} - - -# ─── Circular Arc Gauge ────────────────────────────────────────────────── -class ArcGauge(QWidget): - """Draws a 270° arc gauge showing angle 0-360°.""" - - def __init__(self, color: str, parent=None): - super().__init__(parent) - self.color = QColor(color) - self._value = 180.0 - self.setFixedSize(110, 110) - self.setStyleSheet("background: transparent;") - - def set_value(self, v: float): - self._value = v % 360.0 - self.update() - - def paintEvent(self, event): - p = QPainter(self) - p.setRenderHint(QPainter.RenderHint.Antialiasing) - - side = min(self.width(), self.height()) - 10 - rect = QRectF((self.width() - side) / 2, - (self.height() - side) / 2, - side, side) - - # ── Background track ────────────────────────────────────── - pen_bg = QPen(QColor("#1A2A40"), 8, Qt.PenStyle.SolidLine, Qt.PenCapStyle.FlatCap) - p.setPen(pen_bg) - p.drawArc(rect, 225 * 16, -270 * 16) # 270° arc, starts at 225° - - # ── Foreground arc (value / 360 × 270°) ─────────────────── - span = int((self._value / 360.0) * 270 * 16) - grad_color = self.color - pen_fg = QPen(grad_color, 8, Qt.PenStyle.SolidLine, Qt.PenCapStyle.FlatCap) - p.setPen(pen_fg) - p.drawArc(rect, 225 * 16, -span) - - # ── Needle ──────────────────────────────────────────────── - import math as _math - cx, cy = self.width() / 2, self.height() / 2 - needle_r = (side / 2) * 0.72 - hub_r = (side / 2) * 0.12 - # Qt arc: 0°=east, positive=CCW; our arc starts at 225° spanning -270° - angle_deg = 225.0 - (self._value / 360.0) * 270.0 - angle_rad = _math.radians(angle_deg) - tip_x = cx + needle_r * _math.cos(angle_rad) - tip_y = cy - needle_r * _math.sin(angle_rad) - # Back stub in opposite direction - back_x = cx - hub_r * _math.cos(angle_rad) - back_y = cy + hub_r * _math.sin(angle_rad) - # Draw shadow - pen_shadow = QPen(QColor("#000000"), 4, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap) - p.setPen(pen_shadow) - p.drawLine(QPointF(back_x + 1, back_y + 1), QPointF(tip_x + 1, tip_y + 1)) - # Draw needle - pen_needle = QPen(grad_color, 2.5, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap) - p.setPen(pen_needle) - p.drawLine(QPointF(back_x, back_y), QPointF(tip_x, tip_y)) - - # ── Centre dot ──────────────────────────────────────────── - p.setPen(Qt.PenStyle.NoPen) - p.setBrush(QBrush(grad_color)) - p.drawEllipse(QPointF(cx, cy), 4, 4) - - p.end() - - -# ─── Direction indicator ───────────────────────────────────────────────── -class DirectionBadge(QLabel): - def __init__(self, parent=None): - super().__init__(" — ", parent) - self._dir = "STATIONARY" - self._update_style() - - def set_direction(self, direction: str): - self._dir = direction - symbols = {"INCREMENT": " ▲ INC", "DECREMENT": " ▼ DEC", "STATIONARY": " — "} - self.setText(symbols.get(direction, " — ")) - self._update_style() - - def _update_style(self): - colors = { - "INCREMENT": (RTI_ORANGE, "#1A0F00"), - "DECREMENT": ("#FF4444", "#1A0505"), - "STATIONARY": ("#445566", "#0F151C"), - } - fg, bg = colors.get(self._dir, ("#445566", "#0F151C")) - self.setStyleSheet( - f"color: {fg}; background-color: {bg}; font-size: 26px; font-weight: bold; " - f"padding: 4px 10px; border-radius: 4px; border: 1px solid {fg}44;" - ) - - -# ─── Single joint row widget ────────────────────────────────────────────── -class JointRow(QFrame): - def __init__(self, motor: SurgicalRobot.Motors, parent=None): - super().__init__(parent) - self.motor = motor - self.color = JOINT_COLORS[motor] - self.name = JOINT_NAMES[motor] - self._angle = 180.0 - - self.setFrameShape(QFrame.Shape.Box) - self.setStyleSheet(f""" - JointRow {{ - background-color: {BG_PANEL}; - border: 1px solid {self.color}33; - border-radius: 6px; - }} - """) - self._build_ui() - - def _build_ui(self): - row = QHBoxLayout(self) - row.setContentsMargins(8, 4, 8, 4) - row.setSpacing(8) - - # Joint name - name_lbl = QLabel(self.name) - name_lbl.setFixedWidth(190) - name_lbl.setStyleSheet( - f"color: {self.color}; font-size: 26px; font-weight: bold; " - f"background: transparent; letter-spacing: 1px;" - ) - row.addWidget(name_lbl) - - # Arc gauge + value label below - gauge_col = QVBoxLayout() - gauge_col.setContentsMargins(0, 0, 0, 0) - gauge_col.setSpacing(2) - self.gauge = ArcGauge(self.color) - self.gauge.set_value(180.0) - gauge_col.addWidget(self.gauge, alignment=Qt.AlignmentFlag.AlignHCenter) - self.angle_lbl = QLabel("180.0°") - self.angle_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.angle_lbl.setStyleSheet( - f"color: {self.color}; font-size: 20px; font-weight: bold; " - f"background: transparent;" - ) - gauge_col.addWidget(self.angle_lbl) - row.addLayout(gauge_col) - - # Direction badge - self.dir_badge = DirectionBadge() - self.dir_badge.setVisible(False) - - def update_data(self, angle: float, direction: str): - self._angle = angle - self.gauge.set_value(angle) - self.angle_lbl.setText(f"{angle:.1f}°") - self.dir_badge.set_direction(direction) - - -# ─── 2-D Kinematic Arm Visualisation ────────────────────────────────────── -# Forward-kinematics: each joint angle is *relative* to the previous segment. -# 180° = no bend (straight continuation); values > 180 bend counter-clockwise, -# values < 180 bend clockwise, mirroring the arc-gauge convention in JointRow. -# -# Joint order: BASE → SHOULDER → ELBOW → WRIST → HAND (5 links, same as joints) -# A 6th "link" past HAND represents the end-effector stub. - -_MOTORS_ORDERED = [ - SurgicalRobot.Motors.BASE, - SurgicalRobot.Motors.SHOULDER, - SurgicalRobot.Motors.ELBOW, - SurgicalRobot.Motors.WRIST, - SurgicalRobot.Motors.HAND, -] - - -class ArmVizWidget(QWidget): - """Draws a simplified 2-D stick-figure robotic arm using QPainter. - - The arm is rooted at the bottom-centre of the widget and extends - upward. Each joint bends by (angle - 180°) relative to the incoming - segment direction, so at 180° all segments are collinear (straight up). - """ - - SEGMENT_LEN = 70 # px — length of each arm link (scaled dynamically) - JOINT_R = 12 # px — radius of joint circles - EE_SIZE = 16 # px — half-size of end-effector marker - GROUND_W = 80 # px — half-width of ground hatch - GROUND_LINES = 6 # number of hatch lines under base - - def __init__(self, parent=None): - super().__init__(parent) - self._angles: dict = {m: 180.0 for m in _MOTORS_ORDERED} - self.setMinimumWidth(260) - self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - self.setStyleSheet(f"background-color: {BG_PANEL};") - - def update_angles(self, angles: dict): - """Receive updated angle dict and schedule a repaint.""" - self._angles = dict(angles) - self.update() # schedules paintEvent on next event-loop iteration - - # ── painting ────────────────────────────────────────────────────────── - def paintEvent(self, event): - w, h = self.width(), self.height() - p = QPainter(self) - p.setRenderHint(QPainter.RenderHint.Antialiasing) - - # Background - p.fillRect(self.rect(), QColor(BG_PANEL)) - - # Title - p.setPen(QPen(QColor("#445566"))) - p.setFont(QFont("Courier New", 11, QFont.Weight.Bold)) - p.drawText(12, 22, "ARM VISUALIZATION") - - # Scale segment length so the full arm (5 links) fills ~90 % of the - # widget height, leaving room for the title at top and ground at bottom. - usable_h = h - 70 # subtract top title space + bottom ground space - seg = int(usable_h * 0.90 / len(_MOTORS_ORDERED)) - seg = max(seg, 40) - - # Base point — bottom centre (leave room for ground symbol) - bx = w / 2.0 - by = h - 36.0 - - # ── Ground symbol ────────────────────────────────────────────── - gw = self.GROUND_W - ground_pen = QPen(QColor("#334455"), 2) - p.setPen(ground_pen) - p.drawLine(QPointF(bx - gw, by), QPointF(bx + gw, by)) - hatch_dx = gw / (self.GROUND_LINES + 1) - for i in range(self.GROUND_LINES): - hx = bx - gw + hatch_dx * (i + 1) - p.drawLine(QPointF(hx, by), QPointF(hx - 10, by + 12)) - - # ── Forward kinematics ───────────────────────────────────────── - # Start pointing straight up (π/2 in unit-circle convention; - # screen Y is inverted, so subtract when computing screen coords). - cumul_dir = math.pi / 2.0 - x, y = bx, by - - points = [(x, y)] # joint positions - - for motor in _MOTORS_ORDERED: - angle = self._angles.get(motor, 180.0) - delta_rad = (angle - 180.0) * math.pi / 180.0 - cumul_dir += delta_rad - nx = x + seg * math.cos(cumul_dir) - ny = y - seg * math.sin(cumul_dir) # screen Y inverted - points.append((nx, ny)) - x, y = nx, ny - - # ── Draw links ──────────────────────────────────────────────── - link_pen_width = 7 - for i, motor in enumerate(_MOTORS_ORDERED): - color = QColor(JOINT_COLORS[motor]) - x0, y0 = points[i] - x1, y1 = points[i + 1] - pen = QPen(color, link_pen_width, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap) - p.setPen(pen) - p.drawLine(QPointF(x0, y0), QPointF(x1, y1)) - - # ── Draw end-effector stub (past HAND) ──────────────────────── - ee_color = QColor(JOINT_COLORS[SurgicalRobot.Motors.HAND]) - ee_pen = QPen(ee_color, 2) - p.setPen(ee_pen) - ex, ey = points[-1] - # Small diamond - es = self.EE_SIZE - diamond = QPainterPath() - diamond.moveTo(ex, ey - es) - diamond.lineTo(ex + es, ey) - diamond.lineTo(ex, ey + es) - diamond.lineTo(ex - es, ey) - diamond.closeSubpath() - p.setBrush(QBrush(ee_color.darker(180))) - p.drawPath(diamond) - - # ── Draw joint circles + labels ─────────────────────────────── - jr = self.JOINT_R - for i, motor in enumerate(_MOTORS_ORDERED): - color = QColor(JOINT_COLORS[motor]) - cx, cy = points[i] - # filled circle - p.setPen(QPen(color, 2)) - p.setBrush(QBrush(color.darker(200))) - p.drawEllipse(QPointF(cx, cy), jr, jr) - # joint name label (abbreviated, 3 chars) - name = JOINT_NAMES[motor][:3] - p.setPen(QPen(color)) - p.setFont(QFont("Courier New", 16, QFont.Weight.Bold)) - label_x = cx + jr + 8 - label_y = cy + 6 - p.drawText(QPointF(label_x, label_y), name) - - # ── Angle readouts next to each link midpoint ───────────────── - p.setFont(QFont("Courier New", 14)) - for i, motor in enumerate(_MOTORS_ORDERED): - color = QColor(JOINT_COLORS[motor]) - p.setPen(QPen(color.lighter(130))) - mx = (points[i][0] + points[i + 1][0]) / 2 + 10 - my = (points[i][1] + points[i + 1][1]) / 2 - p.drawText(QPointF(mx, my), f"{self._angles.get(motor, 180.0):.0f}°") - - p.end() - - -# ─── Main Window ───────────────────────────────────────────────────────── -class ArmWindow(QMainWindow): - def __init__(self): - super().__init__() - self.setWindowTitle("RTI Connext — Surgical Arm Monitor") - self.setMinimumSize(640, 650) - self.setStyleSheet(f"background-color: {BG_MAIN};") - _icon_px = QPixmap("../../resource/images/Arm-Monitor.png") - if not _icon_px.isNull(): - self.setWindowIcon(QIcon(_icon_px)) - - self._build_ui() - - def _build_ui(self): - central = QWidget() - central.setStyleSheet(f"background-color: {BG_MAIN};") - self.setCentralWidget(central) - root = QVBoxLayout(central) - root.setContentsMargins(0, 0, 0, 0) - root.setSpacing(0) - - # ── Header ─────────────────────────────────────────────── - header = QWidget() - header.setFixedHeight(80) - header.setStyleSheet( - f"background-color: {BG_HEADER}; border-bottom: 2px solid {RTI_BLUE};" - ) - h_layout = QHBoxLayout(header) - h_layout.setContentsMargins(20, 0, 20, 0) - - _logo_px = QPixmap("../../resource/images/Arm-Monitor.png") - if not _logo_px.isNull(): - logo_lbl = QLabel() - logo_lbl.setStyleSheet("background: transparent;") - logo_lbl.setPixmap(_logo_px.scaled(56, 56, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)) - h_layout.addWidget(logo_lbl) - - rti_lbl = QLabel("RTI Connext") - rti_lbl.setStyleSheet( - f"color: {RTI_BLUE}; font-size: 28px; font-weight: bold; background: transparent;" - ) - h_layout.addWidget(rti_lbl) - - bar = QLabel("|") - bar.setStyleSheet("color: #334455; font-size: 30px; background: transparent;") - h_layout.addWidget(bar) - - title_lbl = QLabel("Surgical Arm Monitor") - title_lbl.setStyleSheet( - "color: #E0E8F0; font-size: 38px; font-weight: bold; background: transparent;" - ) - h_layout.addWidget(title_lbl) - h_layout.addStretch() - - self.state_lbl = QLabel("ON") - self.state_lbl.setStyleSheet( - f"color: #000; background-color: #00E676; font-size: 22px; " - f"font-weight: bold; padding: 3px 12px; border-radius: 4px;" - ) - h_layout.addWidget(self.state_lbl) - - qos_lbl = QLabel("Command QoS") - qos_lbl.setStyleSheet( - f"color: {RTI_ORANGE}88; font-size: 22px; background: transparent; margin-left: 12px;" - ) - h_layout.addWidget(qos_lbl) - - root.addWidget(header) - - # ── Body: arm visualisation only ───────────────────────── - self._arm_angles = dict(INITIAL_ANGLES) - self.arm_viz = ArmVizWidget() - self.arm_viz.update_angles(self._arm_angles) - root.addWidget(self.arm_viz, 1) - - # ── Footer ─────────────────────────────────────────────── - footer = QWidget() - footer.setFixedHeight(44) - footer.setStyleSheet( - f"background-color: {BG_HEADER}; border-top: 1px solid {BORDER_DIM};" - ) - f_layout = QHBoxLayout(footer) - f_layout.setContentsMargins(20, 0, 20, 0) - f_lbl = QLabel( - "Real-Time Innovations · RTI Connext · MedTech Reference Architecture" - ) - f_lbl.setStyleSheet("color: #445566; font-size: 20px; background: transparent;") - f_layout.addWidget(f_lbl) - f_layout.addStretch() - root.addWidget(footer) - - def update_joint(self, motor: SurgicalRobot.Motors, angle: float, direction: str): - self._arm_angles[motor] = angle - self.arm_viz.update_angles(self._arm_angles) - - def set_state(self, state: str): - colors = {"ON": "#00E676", "PAUSED": RTI_ORANGE, "OFF": "#FF4444"} - c = colors.get(state, "#888") - self.state_lbl.setText(state) - self.state_lbl.setStyleSheet( - f"color: #000; background-color: {c}; font-size: 22px; " - f"font-weight: bold; padding: 3px 12px; border-radius: 4px;" - ) - - -# ─── Application class ──────────────────────────────────────────────────── -class ArmApp: - def __init__(self): - self.angles = { - SurgicalRobot.Motors.BASE: 204.0, - SurgicalRobot.Motors.SHOULDER: 176.0, - SurgicalRobot.Motors.ELBOW: 156.0, - SurgicalRobot.Motors.WRIST: 165.0, - SurgicalRobot.Motors.HAND: 151.0, - } - self.directions = { - SurgicalRobot.Motors.BASE: "STATIONARY", - SurgicalRobot.Motors.SHOULDER: "STATIONARY", - SurgicalRobot.Motors.ELBOW: "STATIONARY", - SurgicalRobot.Motors.WRIST: "STATIONARY", - SurgicalRobot.Motors.HAND: "STATIONARY", - } - - self.arm_status = None - self.status_writer = None - self.hb_writer = None - self.motor_control_reader = None - self.cmd_reader = None - self.window = None - self.cmd_waitset = None - - # ── DDS heartbeat thread ───────────────────────────────────────── - def write_hb(self, hb_writer): - while self.arm_status.status != Common.DeviceStatuses.OFF: - hb = Common.DeviceHeartbeat() - hb.device = Common.DeviceType.ARM - hb_writer.write(hb) - time.sleep(0.05) - - # ── DDS poll timer callback (Qt main thread) ───────────────────── - def _poll_dds(self): - # Motor control samples - samples = self.motor_control_reader.take_data() - for sample in samples: - if self.arm_status.status == Common.DeviceStatuses.ON: - if sample.direction == SurgicalRobot.MotorDirections.INCREMENT: - self.angles[sample.id] = (self.angles[sample.id] + 0.3) % 360.0 - self.directions[sample.id] = "INCREMENT" - elif sample.direction == SurgicalRobot.MotorDirections.DECREMENT: - self.angles[sample.id] = (self.angles[sample.id] - 0.3) % 360.0 - self.directions[sample.id] = "DECREMENT" - else: - self.directions[sample.id] = "STATIONARY" - self.window.update_joint( - sample.id, self.angles[sample.id], self.directions[sample.id] - ) - - # Command samples - cmd_samples = self.cmd_reader.take_data() - for sample in cmd_samples: - if sample.command == Orchestrator.DeviceCommands.START: - print("Arm received Start Command") - self.arm_status.status = Common.DeviceStatuses.ON - self.window.set_state("ON") - elif sample.command == Orchestrator.DeviceCommands.PAUSE: - print("Arm received Pause Command") - self.arm_status.status = Common.DeviceStatuses.PAUSED - self.window.set_state("PAUSED") - else: - print("Arm received Shutdown Command") - self.arm_status.status = Common.DeviceStatuses.OFF - self.window.set_state("OFF") - QApplication.quit() - self.status_writer.write(self.arm_status) - - def _cleanup(self): - print("Shutting down Arm") - - # ── Connext setup ───────────────────────────────────────────────── - def connext_setup(self): - entities = DdsEntities.Constants - register_type(Common.DeviceStatus) - register_type(Common.DeviceHeartbeat) - register_type(Orchestrator.DeviceCommand) - register_type(SurgicalRobot.MotorControl) - - qos_provider = dds.QosProvider.default - participant = qos_provider.create_participant_from_config(entities.ARM_DP) - - self.status_writer = dds.DataWriter( - participant.find_datawriter(entities.STATUS_DW) - ) - self.hb_writer = dds.DataWriter( - participant.find_datawriter(entities.HB_DW) - ) - self.arm_status = Common.DeviceStatus( - device=Common.DeviceType.ARM, status=Common.DeviceStatuses.ON - ) - self.status_writer.write(self.arm_status) - - self.motor_control_reader = dds.DataReader( - participant.find_datareader(entities.MOTOR_CONTROL_DR) - ) - self.cmd_reader = dds.DataReader( - participant.find_datareader(entities.DEVICE_COMMAND_DR) - ) - - # ── Entry point ─────────────────────────────────────────────────── - def run(self): - app = QApplication(sys.argv) - app.setStyle("Fusion") - _icon = QIcon(QPixmap("../../resource/images/Arm-Monitor.png")) - if not _icon.isNull(): - app.setWindowIcon(_icon) - - self.window = ArmWindow() - self.connext_setup() - - # DDS poll timer - dds_timer = QTimer() - dds_timer.timeout.connect(self._poll_dds) - dds_timer.start(UPDATE_MS) - - # Heartbeat in background thread - hb_thread = threading.Thread( - target=self.write_hb, args=[self.hb_writer], daemon=True - ) - hb_thread.start() - - # Allow Ctrl+C to cleanly quit the Qt event loop. - # The QTimer is needed so the event loop periodically yields control - # back to Python, enabling signal delivery. - signal.signal(signal.SIGINT, lambda *_: app.quit()) - _sig_timer = QTimer() - _sig_timer.timeout.connect(lambda: None) - _sig_timer.start(300) - app.aboutToQuit.connect(self._cleanup) - - self.window.show() - print("Started Arm") - - app.exec() - - self.arm_status.status = Common.DeviceStatuses.OFF - - -if __name__ == "__main__": - arm = ArmApp() - arm.run() - diff --git a/modules/01-operating-room/ArmController.cxx b/modules/01-operating-room/ArmController.cxx deleted file mode 100644 index 95c943d..0000000 --- a/modules/01-operating-room/ArmController.cxx +++ /dev/null @@ -1,479 +0,0 @@ -// -// (c) 2024 Copyright, Real-Time Innovations, Inc. (RTI) All rights reserved. -// -// RTI grants Licensee a license to use, modify, compile, and create derivative -// works of the software solely for use with RTI Connext DDS. Licensee may -// redistribute copies of the software provided that all such copies are -// subject to this license. The software is provided "as is", with no warranty -// of any type, including any warranty for fitness for any purpose. RTI is -// under no obligation to maintain or support the software. RTI shall not be -// liable for any incidental or consequential damages arising out of the use or -// inability to use the software. - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -#include "Types.hpp" - -#ifdef __APPLE__ - #include "MacOsDockIcon.h" -#endif - -#ifndef _WIN32 - #include -#endif - -using namespace DdsEntities::Constants; - -class SurgicalArmController { -public: - SurgicalArmController() - : current_status( - Common::DeviceType::ARM_CONTROLLER, - Common::DeviceStatuses::ON) - { - // Initialize Connext entities - initialize_connext(); - } - - void run(int argc, char const *argv[]) - { - // Start threads - std::thread hb_thread(&SurgicalArmController::write_hb, this); - std::thread play_thread(&SurgicalArmController::playing, this); - waitset_command.start(); - write_status(); - - // Run GTK UI - app = Gtk::Application::create("armcontroller.armcontroller"); - app->signal_activate().connect( - sigc::mem_fun(*this, &SurgicalArmController::ui_setup)); - -#ifndef _WIN32 - // Route SIGINT/SIGTERM through the GLib main loop so GTK functions - // can be called safely from the callback. - g_unix_signal_add( - SIGINT, - [](gpointer data) -> gboolean { - static_cast(data) - ->window_close_from_signal(); - return G_SOURCE_REMOVE; - }, - this); - g_unix_signal_add( - SIGTERM, - [](gpointer data) -> gboolean { - static_cast(data) - ->window_close_from_signal(); - return G_SOURCE_REMOVE; - }, - this); -#endif - - app->run(argc, const_cast(argv)); - - // Join threads before exiting - hb_thread.join(); - play_thread.join(); - } - -private: - // Connext entities - dds::pub::DataWriter status_writer = dds::core::null; - dds::pub::DataWriter hb_writer = dds::core::null; - dds::pub::DataWriter arm_writer = - dds::core::null; - dds::sub::DataReader cmd_reader = - dds::core::null; - rti::core::cond::AsyncWaitSet waitset_command; - - Common::DeviceStatus current_status; - - // GTK UI entities - Gtk::Window *window = nullptr; - Glib::RefPtr app; - std::map motor_play_btns; - std::map motor_dir_labels; - std::map inc_dec_timers; - Gtk::TextView *console = nullptr; - - // Initialize Connext participants, readers, and writers - void initialize_connext() - { - // We need to register the types before we start creating DDS entities - rti::domain::register_type(); - rti::domain::register_type(); - rti::domain::register_type(); - rti::domain::register_type(); - - // Connext will load XML files through the default provider from the - // NDDS_QOS_PROFILES environment variable - auto default_provider = dds::core::QosProvider::Default(); - - dds::domain::DomainParticipant participant = - default_provider.extensions().create_participant_from_config( - ARM_CONTROLLER_DP); - - // Initialize DataWriters - status_writer = rti::pub::find_datawriter_by_name< - dds::pub::DataWriter>( - participant, - STATUS_DW); - hb_writer = rti::pub::find_datawriter_by_name< - dds::pub::DataWriter>( - participant, - HB_DW); - arm_writer = rti::pub::find_datawriter_by_name< - dds::pub::DataWriter>( - participant, - MOTOR_CONTROL_DW); - - // Initialize DataReader - cmd_reader = rti::sub::find_datareader_by_name< - dds::sub::DataReader>( - participant, - DEVICE_COMMAND_DR); - - // Setup command handling with a WaitSet - dds::sub::cond::ReadCondition command_read_condition( - cmd_reader, - dds::sub::status::DataState::any(), - [this]() { process_command(); }); - - waitset_command += command_read_condition; - } - - // Publish heartbeat every 50ms - void write_hb() - { - while (current_status.status() != Common::DeviceStatuses::OFF) { - Common::DeviceHeartbeat hb(Common::DeviceType::ARM_CONTROLLER); - hb_writer.write(hb); - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - } - } - - // Publish status - void write_status() - { - status_writer.write(current_status); - } - - // Write motor command - void write_command( - SurgicalRobot::Motors motor, - SurgicalRobot::MotorDirections dir) - { - if (current_status.status() == Common::DeviceStatuses::ON) { - SurgicalRobot::MotorControl sample(motor, dir); - arm_writer.write(sample); - } - } - - // Publish random motor controls for the motors that have been marked as - // playing - void playing() - { - while (current_status.status() != Common::DeviceStatuses::OFF) { - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - for (const auto &btn : motor_play_btns) { - if (btn.second->get_active()) { - write_command( - btn.first, - static_cast( - rand() % 3)); - } - } - } - } - - // Process received device commands from Orchestrator - void process_command() - { - dds::sub::LoanedSamples samples = - cmd_reader.take(); - - for (const auto &sample : samples) { - if (sample.info().valid()) { - if (sample.data().command() - == Orchestrator::DeviceCommands::PAUSE) { - log_alert("Received PAUSE Command from Orchestrator"); - current_status.status(Common::DeviceStatuses::PAUSED); - } else if ( - sample.data().command() - == Orchestrator::DeviceCommands::START) { - log_alert("Received START Command from Orchestrator"); - current_status.status(Common::DeviceStatuses::ON); - } else { // shutdown - log_alert("Received SHUTDOWN Command from Orchestrator"); - std::cout << "Arm Controller shutting down" << std::endl; - current_status.status(Common::DeviceStatuses::OFF); - app->quit(); - } - } - } - - write_status(); - } - - // Logic for play all and stop all - void set_all(bool play_all) - { - if (play_all) - log_alert("Playing All"); - else - log_alert("Stopping All"); - - for (auto &btn : motor_play_btns) { - btn.second->set_active(play_all); - } - } - - // Setup UI - void ui_setup() - { - // Load CSS stylesheet - auto css_provider = Gtk::CssProvider::create(); - try { - css_provider->load_from_path("ui/armcontroller.css"); - } catch (const Glib::Error &e) { - std::cerr << "Warning: could not load armcontroller.css: " - << e.what() << std::endl; - } - Gtk::StyleContext::add_provider_for_screen( - Gdk::Screen::get_default(), - css_provider, - GTK_STYLE_PROVIDER_PRIORITY_USER); - - auto builder = Gtk::Builder::create_from_file("ui/armcontroller.glade"); - builder->get_widget("window", window); - - // Load RTI logo into header and set as dock/taskbar icon - { - Gtk::Box *hdr = nullptr; - builder->get_widget("header_bar", hdr); - try { - auto pb = Gdk::Pixbuf::create_from_file( - "../../resource/images/Arm-Controller.png"); - window->set_icon(pb); -#ifdef __APPLE__ - set_macos_dock_icon(pb); -#endif - if (hdr) { - auto scaled = - pb->scale_simple(56, 56, Gdk::INTERP_BILINEAR); - auto *logo = Gtk::manage(new Gtk::Image(scaled)); - logo->set_visible(true); - logo->set_margin_end(8); - hdr->pack_start(*logo, false, false, 0); - hdr->reorder_child(*logo, 0); - } - } catch (...) { - std::cerr << "Warning: artwork failure " << std::endl; - } - } - - window->signal_delete_event().connect( - sigc::mem_fun(*this, &SurgicalArmController::on_window_close)); - - builder->get_widget( - "base_play", - motor_play_btns[SurgicalRobot::Motors::BASE]); - builder->get_widget( - "shoulder_play", - motor_play_btns[SurgicalRobot::Motors::SHOULDER]); - builder->get_widget( - "elbow_play", - motor_play_btns[SurgicalRobot::Motors::ELBOW]); - builder->get_widget( - "wrist_play", - motor_play_btns[SurgicalRobot::Motors::WRIST]); - builder->get_widget( - "hand_play", - motor_play_btns[SurgicalRobot::Motors::HAND]); - - builder->get_widget("console", console); - - // Force dark background on the text view (CSS alone is unreliable - // for GtkTextView internals in GTK3) - { - Gdk::RGBA bg, fg; - bg.set("#060F0A"); - fg.set("#00CC66"); - console->override_background_color(bg); - console->override_color(fg); - console->override_font( - Pango::FontDescription("Courier New Bold 20")); - } - - connect_buttons(builder); - - app->add_window(*window); - window->set_visible(true); - - log_alert("Started Arm Controller"); - } - - // Handle window close event - bool on_window_close(GdkEventAny *event) - { - std::cout << "Arm Controller UI closed, shutting down" << std::endl; - current_status.status(Common::DeviceStatuses::OFF); - return false; - } - - // Close the window from within the GLib main loop (e.g. on SIGINT). - void window_close_from_signal() - { - if (window) - window->close(); - else if (app) - app->quit(); - } - - // Connect buttons to their respective signal handlers. - // INC/DEC buttons: - // - On press: deactivate AUTO for that joint, send one command - // immediately, - // then repeat at 20 Hz (every 50 ms) while held. - // - On release: stop repeating. - // - AUTO / PLAY ALL can re-enable automatic mode. - void connect_buttons(const Glib::RefPtr &builder) - { - auto connect_inc_dec = [this, &builder]( - const std::string &btn_name, - SurgicalRobot::Motors motor, - SurgicalRobot::MotorDirections - direction) { - Gtk::Button *button = nullptr; - builder->get_widget(btn_name, button); - if (!button) - return; - - button->signal_button_press_event().connect( - [this, motor, direction, btn_name]( - GdkEventButton *ev) -> bool { - if (ev->button == 1) { - // Deactivate AUTO for this joint - motor_play_btns[motor]->set_active(false); - // Send one command right away - write_command(motor, direction); - // Start 20 Hz repeat timer - inc_dec_timers[btn_name] = - Glib::signal_timeout().connect( - [this, motor, direction]() -> bool { - write_command(motor, direction); - return true; - }, - 50); - } - return false; - }, - false); - - button->signal_button_release_event().connect( - [this, btn_name](GdkEventButton *ev) -> bool { - if (ev->button == 1) { - auto it = inc_dec_timers.find(btn_name); - if (it != inc_dec_timers.end()) { - it->second.disconnect(); - inc_dec_timers.erase(it); - } - } - return false; - }, - false); - }; - - connect_inc_dec( - "base_inc", - SurgicalRobot::Motors::BASE, - SurgicalRobot::MotorDirections::INCREMENT); - connect_inc_dec( - "base_dec", - SurgicalRobot::Motors::BASE, - SurgicalRobot::MotorDirections::DECREMENT); - connect_inc_dec( - "shoulder_inc", - SurgicalRobot::Motors::SHOULDER, - SurgicalRobot::MotorDirections::INCREMENT); - connect_inc_dec( - "shoulder_dec", - SurgicalRobot::Motors::SHOULDER, - SurgicalRobot::MotorDirections::DECREMENT); - connect_inc_dec( - "elbow_inc", - SurgicalRobot::Motors::ELBOW, - SurgicalRobot::MotorDirections::INCREMENT); - connect_inc_dec( - "elbow_dec", - SurgicalRobot::Motors::ELBOW, - SurgicalRobot::MotorDirections::DECREMENT); - connect_inc_dec( - "wrist_inc", - SurgicalRobot::Motors::WRIST, - SurgicalRobot::MotorDirections::INCREMENT); - connect_inc_dec( - "wrist_dec", - SurgicalRobot::Motors::WRIST, - SurgicalRobot::MotorDirections::DECREMENT); - connect_inc_dec( - "hand_inc", - SurgicalRobot::Motors::HAND, - SurgicalRobot::MotorDirections::INCREMENT); - connect_inc_dec( - "hand_dec", - SurgicalRobot::Motors::HAND, - SurgicalRobot::MotorDirections::DECREMENT); - - Gtk::Button *playall = nullptr; - builder->get_widget("playall", playall); - if (playall) { - playall->signal_clicked().connect([this]() { set_all(true); }); - } - - Gtk::Button *stopall = nullptr; - builder->get_widget("stopall", stopall); - if (stopall) { - stopall->signal_clicked().connect([this]() { set_all(false); }); - } - } - - // Print message to the alerts console in the UI - void log_alert(const std::string &msg) - { - Glib::RefPtr buffer = console->get_buffer(); - Gtk::TextBuffer::iterator iter = buffer->end(); - - std::time_t now = std::time(nullptr); - std::tm *local_time = std::localtime(&now); - char time_str[100]; - std::strftime( - time_str, - sizeof(time_str), - "%Y-%m-%d %H:%M:%S", - local_time); - - std::stringstream ss; - ss << "\n" << time_str << " - " << msg; - buffer->insert(iter, ss.str()); - } -}; - -int main(int argc, char const *argv[]) -{ - SurgicalArmController arm_controller; - arm_controller.run(argc, argv); - return 0; -} diff --git a/modules/01-operating-room/Orchestrator.cxx b/modules/01-operating-room/Orchestrator.cxx deleted file mode 100644 index b2c5ed4..0000000 --- a/modules/01-operating-room/Orchestrator.cxx +++ /dev/null @@ -1,530 +0,0 @@ -// -// (c) 2024 Copyright, Real-Time Innovations, Inc. (RTI) All rights reserved. -// -// RTI grants Licensee a license to use, modify, compile, and create derivative -// works of the software solely for use with RTI Connext DDS. Licensee may -// redistribute copies of the software provided that all such copies are -// subject to this license. The software is provided "as is", with no warranty -// of any type, including any warranty for fitness for any purpose. RTI is -// under no obligation to maintain or support the software. RTI shall not be -// liable for any incidental or consequential damages arising out of the use or -// inability to use the software. - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -#include "Types.hpp" - -#ifdef __APPLE__ - #include "MacOsDockIcon.h" -#endif - -#ifndef _WIN32 - #include -#endif - -using namespace DdsEntities::Constants; -#ifdef RTI_SECURITY_AVAILABLE - #include "SecureLogUtils.hpp" -#endif - -// Heartbeat listener to automatically monitor other applications -class HeartbeatListener - : public dds::sub::NoOpDataReaderListener { -public: - HeartbeatListener( - std::map &statMap, - std::function logAlert) - : stat_map(statMap), log_alert(logAlert) - { - } - - void on_requested_deadline_missed( - dds::sub::DataReader &reader, - const dds::core::status::RequestedDeadlineMissedStatus &status) - override - { - Common::DeviceHeartbeat sample; - reader.key_value(sample, status.last_instance_handle()); - - Gtk::Label *lbl = stat_map.at(sample.device()); - if (lbl->get_text() != "DeviceStatuses::OFF") { - std::stringstream ss; - ss << sample.device() - << " is no longer sending heartbeats. Updating Status to OFF."; - log_alert(ss.str()); - Glib::signal_idle().connect([lbl]() -> bool { - lbl->set_text("DeviceStatuses::OFF"); - auto ctx = lbl->get_style_context(); - ctx->remove_class("status-on"); - ctx->remove_class("status-paused"); - ctx->add_class("status-off"); - return false; - }); - } - } - -private: - std::map &stat_map; - std::function log_alert; -}; - -// Main application class -class OrchestratorApp { -public: - OrchestratorApp(int argc, char const *argv[]) : running(true) - { - // We need to register the types before we start creating DDS entities - rti::domain::register_type(); - rti::domain::register_type(); - rti::domain::register_type(); - - // Connext will load XML files through the default provider from the - // NDDS_QOS_PROFILES environment variable - auto default_provider = dds::core::QosProvider::Default(); - - participant = - default_provider.extensions().create_participant_from_config( - ORCHESTRATOR_DP); - - // Initialize DataWriter - command_writer = rti::pub::find_datawriter_by_name< - dds::pub::DataWriter>( - participant, - DEVICE_COMMAND_DW); - - // Initialize DataReaders - status_reader = rti::sub::find_datareader_by_name< - dds::sub::DataReader>( - participant, - STATUS_DR); - hb_reader = rti::sub::find_datareader_by_name< - dds::sub::DataReader>( - participant, - HB_DR); - - hb_listener = std::make_shared( - stat_map, - [this](std::string msg) { log_alert(msg); }); - hb_reader.set_listener(hb_listener); - - status_read_condition = dds::sub::cond::ReadCondition( - status_reader, - dds::sub::status::DataState::any(), - [this]() { process_status(); }); - - waitset_status += status_read_condition; - -#ifdef RTI_SECURITY_AVAILABLE - if (SecureLogUtils::is_secure(participant)) { - securelog_reader = SecureLogUtils::setup_secure_log_reader( - std::bind( - &OrchestratorApp::process_secure_log, - this, - std::placeholders::_1), - default_provider); - } -#endif - - app = Gtk::Application::create("orchestrator.orchestrator"); - app->signal_activate().connect([this]() { ui_setup(); }); - } - - void run() - { -#ifndef _WIN32 - // Route SIGINT/SIGTERM through the GLib main loop so GTK functions - // can be called safely from the callback. - g_unix_signal_add( - SIGINT, - [](gpointer data) -> gboolean { - static_cast(data) - ->window_close_from_signal(); - return G_SOURCE_REMOVE; - }, - this); - g_unix_signal_add( - SIGTERM, - [](gpointer data) -> gboolean { - static_cast(data) - ->window_close_from_signal(); - return G_SOURCE_REMOVE; - }, - this); -#endif - app->run(); - } - - // Close the window from within the GLib main loop (e.g. on SIGINT). - void window_close_from_signal() - { - if (window) - window->close(); - else if (app) - app->quit(); - } - -private: - // Member variables - - bool running; - // UI - Gtk::Window *window; - Glib::RefPtr app; - std::map stat_map; - std::map device_map; - Gtk::ScrolledWindow *scroll; - Glib::RefPtr buffer; - Gtk::Label *security_indicator = nullptr; - sigc::connection security_flash_connection; - bool security_flash_on = false; - int security_flash_ticks_remaining = 0; - - // Connext entities - dds::domain::DomainParticipant participant = dds::core::null; - dds::pub::DataWriter command_writer = - dds::core::null; - dds::sub::DataReader status_reader = dds::core::null; - dds::sub::DataReader hb_reader = dds::core::null; - dds::sub::cond::ReadCondition status_read_condition = dds::core::null; - rti::core::cond::AsyncWaitSet waitset_status; - std::shared_ptr hb_listener; - -#ifdef RTI_SECURITY_AVAILABLE - // Connext secure logging entities - SecureLogUtils::SecureLogReader securelog_reader = { dds::core::null, - dds::core::null }; -#endif - - void log_alert(std::string msg) - { - Glib::signal_idle().connect([this, msg]() -> bool { - Gtk::TextBuffer::iterator iter = buffer->end(); - - // timestamp - std::time_t now = std::time(nullptr); - std::tm *local_time = std::localtime(&now); - char time_str[100]; - std::strftime( - time_str, - sizeof(time_str), - "%Y-%m-%d %H:%M:%S", - local_time); - - // write to buffer - std::stringstream ss; - ss << "\n" << time_str << " - " << msg; - buffer->insert(iter, ss.str()); - - // scroll window down - auto v_adjustment = scroll->get_vadjustment(); - v_adjustment->set_value( - v_adjustment->get_upper() - v_adjustment->get_page_size()); - - return false; - }); - } - - void btn_handler(Orchestrator::DeviceCommands cmd) - { - Common::DeviceType device; - - for (auto const &btn : device_map) { - if (btn.second->get_active()) { - device = btn.first; - break; - } - } - - std::stringstream ss; - ss << "Writing " << cmd << " to " << device; - log_alert(ss.str()); - - Orchestrator::DeviceCommand command(device, cmd); - command_writer.write(command); - } - - void set_security_indicator_ok() - { - if (security_indicator == nullptr) { - return; - } - - auto ctx = security_indicator->get_style_context(); - ctx->remove_class("security-threat-on"); - ctx->remove_class("security-threat-off"); - ctx->add_class("security-ok"); - security_indicator->set_text("SECURITY: OK"); - } - - void trigger_security_flash() - { - Glib::signal_idle().connect([this]() -> bool { - if (security_indicator == nullptr) { - return false; - } - - // Keep flashing for ~8s after the latest threat event. - security_flash_ticks_remaining = 16; - - if (security_flash_connection.connected()) { - return false; - } - - security_flash_on = false; - security_flash_connection = Glib::signal_timeout().connect( - [this]() -> bool { - if (security_indicator == nullptr) { - return false; - } - - auto ctx = security_indicator->get_style_context(); - ctx->remove_class("security-ok"); - ctx->remove_class("security-threat-on"); - ctx->remove_class("security-threat-off"); - - security_flash_on = !security_flash_on; - if (security_flash_on) { - security_indicator->set_text("SECURITY THREAT"); - ctx->add_class("security-threat-on"); - } else { - security_indicator->set_text("SECURITY THREAT"); - ctx->add_class("security-threat-off"); - } - - --security_flash_ticks_remaining; - if (security_flash_ticks_remaining <= 0) { - set_security_indicator_ok(); - security_flash_connection.disconnect(); - return false; - } - - return true; - }, - 500); - - return false; - }); - } - -#ifdef RTI_SECURITY_AVAILABLE - bool is_security_threat(const DDSSecurity::BuiltinLoggingTypeV2 &sample) - { - return static_cast(sample.severity()) - // return sample.value("severity") - <= static_cast( - DDSSecurity::LoggingLevel::WARNING_LEVEL); - } - - void process_secure_log(const SecureLogUtils::SecureLogType &log) - { - if (is_security_threat(log)) { - std::stringstream ss; - ss << "SECURITY THREAT [" << log.appname() << "] " << log.message(); - log_alert(ss.str()); - trigger_security_flash(); - } - } -#endif - - void process_status() - { - dds::sub::LoanedSamples samples = - status_reader.take(); - - for (const auto &sample : samples) { - if (sample.info().valid()) { - // update the label - std::stringstream ss_label; - ss_label << sample.data().status(); - Gtk::Label *lbl = stat_map.at(sample.data().device()); - lbl->set_text(ss_label.str()); - apply_status_class(lbl, ss_label.str()); - - // print alert - std::stringstream ss_log; - ss_log << "Received " << sample.data().status() - << " status message from " << sample.data().device(); - log_alert(ss_log.str()); - } - } - } - - // Switches status-on/status-paused/status-off CSS class on a label - void apply_status_class(Gtk::Label *lbl, const std::string &status_str) - { - auto ctx = lbl->get_style_context(); - ctx->remove_class("status-on"); - ctx->remove_class("status-paused"); - ctx->remove_class("status-off"); - if (status_str.find("ON") != std::string::npos) { - ctx->add_class("status-on"); - } else if (status_str.find("PAUSED") != std::string::npos) { - ctx->add_class("status-paused"); - } else { - ctx->add_class("status-off"); - } - } - - void ui_setup() - { - // Load CSS stylesheet - auto css_provider = Gtk::CssProvider::create(); - try { - css_provider->load_from_path("ui/orchestrator.css"); - } catch (const Glib::Error &e) { - std::cerr << "Warning: could not load orchestrator.css: " - << e.what() << std::endl; - } - Gtk::StyleContext::add_provider_for_screen( - Gdk::Screen::get_default(), - css_provider, - GTK_STYLE_PROVIDER_PRIORITY_USER); - - auto builder = Gtk::Builder::create_from_file("ui/orchestrator.glade"); - builder->get_widget("window", window); - - // Load RTI logo into header and set as dock/taskbar icon - { - Gtk::Box *hdr = nullptr; - builder->get_widget("header_bar", hdr); - try { - auto pb = Gdk::Pixbuf::create_from_file( - "../../resource/images/Orchestrator.png"); - window->set_icon(pb); -#ifdef __APPLE__ - set_macos_dock_icon(pb); -#endif - if (hdr) { - auto scaled = - pb->scale_simple(56, 56, Gdk::INTERP_BILINEAR); - auto *logo = Gtk::manage(new Gtk::Image(scaled)); - logo->set_visible(true); - logo->set_margin_end(8); - hdr->pack_start(*logo, false, false, 0); - hdr->reorder_child(*logo, 0); - } - } catch (...) { - } - - if (hdr) { - security_indicator = Gtk::manage(new Gtk::Label("")); - { - auto ctx = security_indicator->get_style_context(); - ctx->add_class("security-indicator"); -#ifdef RTI_SECURITY_AVAILABLE - if (SecureLogUtils::is_secure(participant)) { - ctx->add_class("security-ok"); - security_indicator->set_text("SECURITY: OK"); - } else { - ctx->add_class("security-unsecure"); - security_indicator->set_text("UNSECURE MODE"); - } -#else - ctx->add_class("security-unsecure"); - security_indicator->set_text("UNSECURE MODE"); -#endif - } - security_indicator->set_margin_start(10); - security_indicator->set_margin_end(4); - security_indicator->set_visible(true); - hdr->pack_end(*security_indicator, false, false, 0); - } - } - - window->signal_delete_event().connect([this](GdkEventAny *event) { - std::cout << "Orchestrator UI closed" << std::endl; - running = false; - return false; - }); - - builder->get_widget( - "arm_label", - stat_map[Common::DeviceType::ARM]); - builder->get_widget( - "armctrl_label", - stat_map[Common::DeviceType::ARM_CONTROLLER]); - builder->get_widget( - "p_sensor_label", - stat_map[Common::DeviceType::PATIENT_SENSOR]); - builder->get_widget( - "p_monitor_label", - stat_map[Common::DeviceType::PATIENT_MONITOR]); - - builder->get_widget( - "arm", - device_map[Common::DeviceType::ARM]); - builder->get_widget( - "armctrl", - device_map[Common::DeviceType::ARM_CONTROLLER]); - builder->get_widget( - "p_sensor", - device_map[Common::DeviceType::PATIENT_SENSOR]); - builder->get_widget( - "p_monitor", - device_map[Common::DeviceType::PATIENT_MONITOR]); - - Gtk::TextView *console; - builder->get_widget("console", console); - buffer = console->get_buffer(); - - // Force dark background on the text view (CSS alone is unreliable - // for GtkTextView internals in GTK3) - { - Gdk::RGBA bg, fg; - bg.set("#060F0A"); - fg.set("#00CC66"); - console->override_background_color(bg); - console->override_color(fg); - console->override_font( - Pango::FontDescription("Courier New Bold 20")); - } - - builder->get_widget("scroll", scroll); - - Gtk::Button *start; - Orchestrator::DeviceCommands startmsg = - Orchestrator::DeviceCommands::START; - builder->get_widget("start", start); - start->signal_clicked().connect( - [this, startmsg]() { btn_handler(startmsg); }); - - Gtk::Button *pause; - Orchestrator::DeviceCommands pausemsg = - Orchestrator::DeviceCommands::PAUSE; - builder->get_widget("pause", pause); - pause->signal_clicked().connect( - [this, pausemsg]() { btn_handler(pausemsg); }); - - Gtk::Button *off; - Orchestrator::DeviceCommands offmsg = - Orchestrator::DeviceCommands::SHUTDOWN; - builder->get_widget("off", off); - off->signal_clicked().connect( - [this, offmsg]() { btn_handler(offmsg); }); - - app->add_window(*window); - window->set_visible(true); - log_alert("Started Orchestrator"); - - waitset_status.start(); - } -}; - -int main(int argc, char const *argv[]) -{ - OrchestratorApp app(argc, argv); - app.run(); - return 0; -} diff --git a/modules/01-operating-room/PatientMonitor.py b/modules/01-operating-room/PatientMonitor.py deleted file mode 100644 index eaf9c7b..0000000 --- a/modules/01-operating-room/PatientMonitor.py +++ /dev/null @@ -1,583 +0,0 @@ -# -# (c) 2024 Copyright, Real-Time Innovations, Inc. (RTI) All rights reserved. -# -# RTI grants Licensee a license to use, modify, compile, and create derivative -# works of the software solely for use with RTI Connext DDS. Licensee may -# redistribute copies of the software provided that all such copies are -# subject to this license. The software is provided "as is", with no warranty -# of any type, including any warranty for fitness for any purpose. RTI is -# under no obligation to maintain or support the software. RTI shall not be -# liable for any incidental or consequential damages arising out of the use or -# inability to use the software. - -import sys -import math -import time -import threading -import signal -import numpy as np - -from PySide6.QtWidgets import ( - QApplication, QMainWindow, QWidget, QLabel, QFrame, - QGridLayout, QHBoxLayout, QVBoxLayout, QSizePolicy -) -from PySide6.QtCore import Qt, QTimer, Signal, QObject -from PySide6.QtGui import QFont, QColor, QPalette, QFontDatabase, QPixmap, QIcon - -import pyqtgraph as pg - -import rti.connextdds as dds -from Types import Common, PatientMonitor, Orchestrator, DdsEntities -from DdsUtils import register_type - -# ─── RTI Brand Colors ─────────────────────────────────────────────────────── -RTI_BLUE = "#004C97" -RTI_ORANGE = "#ED8B00" -BG_MAIN = "#0A0E17" # Very dark navy -BG_PANEL = "#0F1822" # Panel background -BG_HEADER = "#071020" # Header strip -BORDER_DIM = "#1A2A3A" # Subtle panel borders - -# Per-vital colour scheme (matches real ICU monitors) -COLOR_HR = "#00E676" # Bright green – ECG -COLOR_SPO2 = "#00B0FF" # Cyan-blue – SpO2 / plethysmograph -COLOR_ETCO2 = "#FFD600" # Amber-yellow – Capnography -COLOR_NIBP = "#FF7043" # Warm orange – NiBP - -# ─── Waveform generators ──────────────────────────────────────────────────── -SAMPLE_RATE = 200 # samples / second for waveform buffer -DISPLAY_SECS = 6 # seconds visible in the strip -BUFFER_LEN = SAMPLE_RATE * DISPLAY_SECS - - -def _ecg_template(n_pts: int = SAMPLE_RATE) -> np.ndarray: - """One beat of a synthetic PQRST waveform (normalised 0-1).""" - t = np.linspace(0, 1, n_pts) - # P wave - p = 0.15 * np.exp(-((t - 0.12) ** 2) / (2 * 0.008 ** 2)) - # Q dip - q = -0.08 * np.exp(-((t - 0.22) ** 2) / (2 * 0.004 ** 2)) - # R spike - r = 1.00 * np.exp(-((t - 0.26) ** 2) / (2 * 0.003 ** 2)) - # S dip - s = -0.15 * np.exp(-((t - 0.30) ** 2) / (2 * 0.004 ** 2)) - # T wave - tw = 0.30 * np.exp(-((t - 0.42) ** 2) / (2 * 0.015 ** 2)) - return p + q + r + s + tw - - -def _pleth_template(n_pts: int = SAMPLE_RATE) -> np.ndarray: - """One beat of a plethysmograph waveform (smooth hill).""" - t = np.linspace(0, 1, n_pts) - return np.clip( - np.exp(-((t - 0.35) ** 2) / (2 * 0.05 ** 2)) - + 0.25 * np.exp(-((t - 0.55) ** 2) / (2 * 0.04 ** 2)), - 0, 1 - ) - - -def _capno_template(n_pts: int = SAMPLE_RATE) -> np.ndarray: - """One respiratory cycle capnography waveform.""" - t = np.linspace(0, 1, n_pts) - # rise phase (0.3-0.6), plateau (0.6-0.85), fall (0.85-0.95) - w = np.zeros(n_pts) - rise = (t >= 0.30) & (t < 0.60) - plat = (t >= 0.60) & (t < 0.85) - fall = (t >= 0.85) & (t < 0.95) - w[rise] = (t[rise] - 0.30) / 0.30 - w[plat] = 1.0 - w[fall] = 1.0 - (t[fall] - 0.85) / 0.10 - return np.clip(w, 0, 1) - - -# ─── DDS → Qt bridge ──────────────────────────────────────────────────────── -class DdsBridge(QObject): - vitals_received = Signal(float, float, float, float, float) # hr, spo2, etco2, nibp_s, nibp_d - shutdown_received = Signal() - status_changed = Signal(str) # "ON" / "PAUSED" - - -# ─── Waveform strip panel ───────────────────────────────────────────────── -class VitalPanel(QFrame): - """A self-contained vital-signs panel: large numeric + scrolling waveform.""" - - def __init__(self, vital_name: str, unit: str, color: str, - y_min: float, y_max: float, parent=None): - super().__init__(parent) - self.vital_name = vital_name - self.unit = unit - self.color = color - self.y_min = y_min - self.y_max = y_max - - # Waveform state - self.buf = np.zeros(BUFFER_LEN) - self.buf_ptr = 0 # write pointer (circular) - self.phase = 0.0 # current beat phase (0–1) - self.beat_rate = 1.0 # beats per second (driven by live value) - self.live_value = 0.0 - self.amplitude = 1.0 - - self._build_ui() - - # ── UI construction ────────────────────────────────────────────────── - def _build_ui(self): - self.setFrameShape(QFrame.Shape.Box) - self.setStyleSheet(f""" - VitalPanel {{ - background-color: {BG_PANEL}; - border: 1px solid {self.color}55; - border-radius: 8px; - }} - """) - - root = QVBoxLayout(self) - root.setContentsMargins(10, 8, 10, 8) - root.setSpacing(4) - - # ── Top row: name + value ───────────────────────────────────── - top = QHBoxLayout() - top.setSpacing(0) - - name_lbl = QLabel(self.vital_name) - name_lbl.setStyleSheet(f"color: {self.color}; font-size: 20px; font-weight: bold; background: transparent;") - top.addWidget(name_lbl) - - top.addStretch() - - self.unit_lbl = QLabel(self.unit) - self.unit_lbl.setStyleSheet(f"color: {self.color}88; font-size: 16px; background: transparent;") - self.unit_lbl.setAlignment(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignRight) - top.addWidget(self.unit_lbl) - root.addLayout(top) - - # ── Numeric value ───────────────────────────────────────────── - self.value_lbl = QLabel("---") - self.value_lbl.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) - self.value_lbl.setStyleSheet( - f"color: {self.color}; font-size: 54px; font-weight: bold; " - f"font-family: 'Courier New', monospace; background: transparent; " - f"letter-spacing: -2px;" - ) - root.addWidget(self.value_lbl) - - # ── Waveform plot ───────────────────────────────────────────── - self.plot_widget = pg.PlotWidget(background=BG_PANEL) - self.plot_widget.setMinimumHeight(150) - self.plot_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - self.plot_widget.hideAxis("left") - self.plot_widget.hideAxis("bottom") - self.plot_widget.setMouseEnabled(x=False, y=False) - self.plot_widget.setMenuEnabled(False) - pen = pg.mkPen(color=self.color, width=2) - self.curve = self.plot_widget.plot(pen=pen) - self.plot_widget.setYRange(self.y_min, self.y_max, padding=0.05) - root.addWidget(self.plot_widget) - - # ── Waveform advance ────────────────────────────────────────────── - def advance_waveform(self, template: np.ndarray, n_new: int): - """Advance n_new samples through the cyclic waveform template.""" - t_len = len(template) - for _ in range(n_new): - idx = int(self.phase * t_len) % t_len - self.buf[self.buf_ptr % BUFFER_LEN] = template[idx] * self.amplitude - self.buf_ptr += 1 - self.phase += self.beat_rate / SAMPLE_RATE - if self.phase >= 1.0: - self.phase -= 1.0 - - def get_display_buffer(self) -> np.ndarray: - """Return the circular buffer in chronological order.""" - ptr = self.buf_ptr % BUFFER_LEN - return np.roll(self.buf, -ptr) - - def set_value(self, val, fmt=".0f"): - self.value_lbl.setText(f"{val:{fmt}}") - self.live_value = float(val) - - def update_curve(self): - data = self.get_display_buffer() - self.curve.setData(data) - - -# ─── NiBP Panel (no waveform — spot measurement readout) ───────────────── -class NiBPPanel(QFrame): - def __init__(self, parent=None): - super().__init__(parent) - self.setFrameShape(QFrame.Shape.Box) - self.setStyleSheet(f""" - NiBPPanel {{ - background-color: {BG_PANEL}; - border: 1px solid {COLOR_NIBP}55; - border-radius: 8px; - }} - """) - root = QVBoxLayout(self) - root.setContentsMargins(10, 8, 10, 8) - root.setSpacing(6) - - top = QHBoxLayout() - name_lbl = QLabel("NiBP") - name_lbl.setStyleSheet(f"color: {COLOR_NIBP}; font-size: 20px; font-weight: bold; background: transparent;") - top.addWidget(name_lbl) - top.addStretch() - unit_lbl = QLabel("mmHg") - unit_lbl.setStyleSheet(f"color: {COLOR_NIBP}88; font-size: 16px; background: transparent;") - top.addWidget(unit_lbl) - root.addLayout(top) - - root.addStretch() - - # sys / dia - bp_row = QHBoxLayout() - bp_row.setAlignment(Qt.AlignmentFlag.AlignCenter) - - self.sys_lbl = QLabel("---") - self.sys_lbl.setStyleSheet( - f"color: {COLOR_NIBP}; font-size: 54px; font-weight: bold; " - f"font-family: 'Courier New', monospace; background: transparent;" - ) - bp_row.addWidget(self.sys_lbl) - - sep = QLabel("/") - sep.setStyleSheet(f"color: {COLOR_NIBP}88; font-size: 42px; background: transparent;") - bp_row.addWidget(sep) - - self.dia_lbl = QLabel("---") - self.dia_lbl.setStyleSheet( - f"color: {COLOR_NIBP}; font-size: 54px; font-weight: bold; " - f"font-family: 'Courier New', monospace; background: transparent;" - ) - bp_row.addWidget(self.dia_lbl) - root.addLayout(bp_row) - - sub_row = QHBoxLayout() - sub_row.setAlignment(Qt.AlignmentFlag.AlignCenter) - sub_lbl = QLabel("Systolic / Diastolic") - sub_lbl.setStyleSheet(f"color: {COLOR_NIBP}66; font-size: 20px; background: transparent;") - sub_row.addWidget(sub_lbl) - root.addLayout(sub_row) - - root.addStretch() - - # MAP estimate - self.map_lbl = QLabel("MAP: ---") - self.map_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.map_lbl.setStyleSheet( - f"color: {COLOR_NIBP}AA; font-size: 20px; background: transparent;" - ) - root.addWidget(self.map_lbl) - - def set_values(self, s, d): - self.sys_lbl.setText(f"{s:.0f}") - self.dia_lbl.setText(f"{d:.0f}") - _map = (s + 2 * d) / 3 - self.map_lbl.setText(f"MAP: {_map:.0f}") - - -# ─── Main Window ────────────────────────────────────────────────────────── -class PatientMonitorWindow(QMainWindow): - def __init__(self): - super().__init__() - self.setWindowTitle("RTI Connext — Patient Monitor") - self.setMinimumSize(900, 600) - self.resize(1100, 720) - self._apply_global_style() - - # ── Build vitals panels ────────────────────────────────────── - self.hr_panel = VitalPanel("ECG / Heart Rate", "bpm", COLOR_HR, -0.2, 1.1) - self.spo2_panel = VitalPanel("SpO₂", "%", COLOR_SPO2, -0.1, 1.1) - self.etco2_panel = VitalPanel("EtCO₂", "mmHg", COLOR_ETCO2, -0.1, 1.1) - self.nibp_panel = NiBPPanel() - - # Waveform templates (precomputed once) - self._ecg_tpl = _ecg_template(SAMPLE_RATE) - self._pleth_tpl = _pleth_template(SAMPLE_RATE) - self._capno_tpl = _capno_template(SAMPLE_RATE) - - # DDS-driven values - self._hr = 60.0 - self._spo2 = 98.0 - self._etco2 = 38.0 - self._nibp_s = 120.0 - self._nibp_d = 80.0 - self.paused = False - - # Frames per tick derived from timer interval - self._timer_ms = 40 - self._new_per_tick = int(SAMPLE_RATE * self._timer_ms / 1000) - - self._build_ui() - self._set_icon() - - # ── Animation timer ────────────────────────────────────────── - self.anim_timer = QTimer(self) - self.anim_timer.timeout.connect(self._tick) - self.anim_timer.start(self._timer_ms) # 25 fps - - # ── Window icon ───────────────────────────────────────────────── - def _set_icon(self): - _px = QPixmap("../../resource/images/Patient-Monitor.png") - if not _px.isNull(): - self.setWindowIcon(QIcon(_px)) - - # ── Style ──────────────────────────────────────────────────────── - def _apply_global_style(self): - self.setStyleSheet(f""" - QMainWindow, QWidget#central {{ - background-color: {BG_MAIN}; - }} - """) - - # ── Layout ─────────────────────────────────────────────────────── - def _build_ui(self): - central = QWidget() - central.setObjectName("central") - central.setStyleSheet(f"background-color: {BG_MAIN};") - self.setCentralWidget(central) - - root = QVBoxLayout(central) - root.setContentsMargins(0, 0, 0, 0) - root.setSpacing(0) - - # ── Header bar ─────────────────────────────────────────────── - header = QWidget() - header.setFixedHeight(80) - header.setStyleSheet(f"background-color: {BG_HEADER}; border-bottom: 2px solid {RTI_BLUE};") - h_layout = QHBoxLayout(header) - h_layout.setContentsMargins(20, 0, 20, 0) - - _logo_px = QPixmap("../../resource/images/Patient-Monitor.png") - if not _logo_px.isNull(): - logo_lbl = QLabel() - logo_lbl.setStyleSheet("background: transparent;") - logo_lbl.setPixmap(_logo_px.scaled(56, 56, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)) - h_layout.addWidget(logo_lbl) - - rti_lbl = QLabel("RTI Connext") - rti_lbl.setStyleSheet(f"color: {RTI_BLUE}; font-size: 34px; font-weight: bold; background: transparent;") - h_layout.addWidget(rti_lbl) - - bar = QLabel("|") - bar.setStyleSheet(f"color: #334455; font-size: 34px; background: transparent;") - h_layout.addWidget(bar) - - title_lbl = QLabel("Patient Monitor") - title_lbl.setStyleSheet("color: #E0E8F0; font-size: 34px; font-weight: bold; background: transparent;") - h_layout.addWidget(title_lbl) - - h_layout.addStretch() - - self.status_lbl = QLabel("● CONNECTED — Streaming QoS") - self.status_lbl.setStyleSheet(f"color: {COLOR_HR}; font-size: 20px; background: transparent;") - h_layout.addWidget(self.status_lbl) - - self.state_lbl = QLabel("ON") - self.state_lbl.setStyleSheet( - f"color: #000; background-color: {COLOR_HR}; font-size: 20px; " - f"font-weight: bold; padding: 3px 10px; border-radius: 4px; margin-left: 10px;" - ) - h_layout.addWidget(self.state_lbl) - - root.addWidget(header) - - # ── Vital grid ──────────────────────────────────────────────── - grid_container = QWidget() - grid_container.setStyleSheet(f"background-color: {BG_MAIN};") - grid = QGridLayout(grid_container) - grid.setContentsMargins(12, 12, 12, 12) - grid.setSpacing(10) - - grid.addWidget(self.hr_panel, 0, 0) - grid.addWidget(self.spo2_panel, 0, 1) - grid.addWidget(self.etco2_panel, 1, 0) - grid.addWidget(self.nibp_panel, 1, 1) - - grid.setRowStretch(0, 1) - grid.setRowStretch(1, 1) - grid.setColumnStretch(0, 1) - grid.setColumnStretch(1, 1) - - root.addWidget(grid_container, 1) - - # ── Footer bar ──────────────────────────────────────────────── - footer = QWidget() - footer.setFixedHeight(44) - footer.setStyleSheet(f"background-color: {BG_HEADER}; border-top: 1px solid {BORDER_DIM};") - f_layout = QHBoxLayout(footer) - f_layout.setContentsMargins(20, 0, 20, 0) - f_lbl = QLabel("Real-Time Innovations · RTI Connext · MedTech Reference Architecture") - f_lbl.setStyleSheet("color: #445566; font-size: 20px; background: transparent;") - f_layout.addWidget(f_lbl) - f_layout.addStretch() - root.addWidget(footer) - - # ── Animation tick ─────────────────────────────────────────────── - def _tick(self): - if self.paused: - return - # ECG — rate driven by HR (beats per minute → bps) - self.hr_panel.beat_rate = self._hr / 60.0 - # SpO2 — same rate as HR (plethysmograph synced to cardiac cycle) - self.spo2_panel.beat_rate = self._hr / 60.0 - # EtCO2 — respiratory rate ≈ HR/4, capped 8-30 breaths/min - rr = max(8.0, min(30.0, self._hr / 4.0)) - self.etco2_panel.beat_rate = rr / 60.0 - - self.hr_panel.advance_waveform(self._ecg_tpl, self._new_per_tick) - self.spo2_panel.advance_waveform(self._pleth_tpl, self._new_per_tick) - self.etco2_panel.advance_waveform(self._capno_tpl, self._new_per_tick) - # EtCO2 - capnogram height, capped to 60 mmHg for stability in the UI - self.etco2_panel.amplitude = max(0.0, min(60.0, self._etco2)) / 60.0 - - self.hr_panel.update_curve() - self.spo2_panel.update_curve() - self.etco2_panel.update_curve() - - # ── DDS value update (called from polling timer in main app) ───── - def update_vitals(self, hr, spo2, etco2, nibp_s, nibp_d): - self._hr = hr - self._spo2 = spo2 - self._etco2 = etco2 - self._nibp_s = nibp_s - self._nibp_d = nibp_d - - self.hr_panel.set_value(hr) - self.spo2_panel.set_value(spo2) - self.etco2_panel.set_value(etco2) - self.nibp_panel.set_values(nibp_s, nibp_d) - - def set_state(self, state: str): - self.paused = (state == "PAUSED") - colors = {"ON": COLOR_HR, "PAUSED": RTI_ORANGE, "OFF": "#FF4444"} - c = colors.get(state, "#888") - self.state_lbl.setText(state) - self.state_lbl.setStyleSheet( - f"color: #000; background-color: {c}; font-size: 20px; " - f"font-weight: bold; padding: 3px 10px; border-radius: 4px; margin-left: 10px;" - ) - - -# ─── Application class ──────────────────────────────────────────────────── -class PatientMonitorApp: - def __init__(self): - self.pm_status = None - self.status_writer = None - self.hb_writer = None - self.vitals_reader = None - self.cmd_reader = None - self.bridge = DdsBridge() - self.window = None - - # ── DDS heartbeat thread ───────────────────────────────────────── - def write_hb(self): - while self.pm_status.status != Common.DeviceStatuses.OFF: - hb = Common.DeviceHeartbeat() - hb.device = Common.DeviceType.PATIENT_MONITOR - self.hb_writer.write(hb) - time.sleep(0.05) - - # ── DDS polling (called by Qt timer every 150 ms) ──────────────── - def _poll_dds(self): - # Vitals - if self.pm_status.status == Common.DeviceStatuses.ON: - samples = self.vitals_reader.take_data() - for sample in samples: - self.window.update_vitals( - float(sample.hr), - float(sample.spo2), - float(sample.etco2), - float(sample.nibp_s), - float(sample.nibp_d), - ) - # Commands - cmd_samples = self.cmd_reader.take_data() - for sample in cmd_samples: - if sample.command == Orchestrator.DeviceCommands.START: - print("Patient Monitor received Start command") - self.pm_status.status = Common.DeviceStatuses.ON - self.window.set_state("ON") - elif sample.command == Orchestrator.DeviceCommands.PAUSE: - print("Patient Monitor received Pause command") - self.pm_status.status = Common.DeviceStatuses.PAUSED - self.window.set_state("PAUSED") - else: - print("Patient Monitor received Shutdown command") - self.pm_status.status = Common.DeviceStatuses.OFF - self.window.set_state("OFF") - QApplication.quit() - self.status_writer.write(self.pm_status) - - # ── Connext setup ──────────────────────────────────────────────── - def connext_setup(self): - entities = DdsEntities.Constants - register_type(Common.DeviceStatus) - register_type(Common.DeviceHeartbeat) - register_type(Orchestrator.DeviceCommand) - register_type(PatientMonitor.Vitals) - - qos_provider = dds.QosProvider.default - participant = qos_provider.create_participant_from_config( - entities.PATIENT_MONITOR_DP - ) - - self.status_writer = dds.DataWriter( - participant.find_datawriter(entities.STATUS_DW) - ) - self.hb_writer = dds.DataWriter( - participant.find_datawriter(entities.HB_DW) - ) - self.vitals_reader = dds.DataReader( - participant.find_datareader(entities.VITALS_DR) - ) - self.cmd_reader = dds.DataReader( - participant.find_datareader(entities.DEVICE_COMMAND_DR) - ) - self.pm_status = Common.DeviceStatus( - device=Common.DeviceType.PATIENT_MONITOR, - status=Common.DeviceStatuses.ON, - ) - self.status_writer.write(self.pm_status) - - # ── Entry point ────────────────────────────────────────────────── - def run(self): - app = QApplication(sys.argv) - app.setStyle("Fusion") - _icon = QIcon(QPixmap("../../resource/images/Patient-Monitor.png")) - if not _icon.isNull(): - app.setWindowIcon(_icon) - - self.window = PatientMonitorWindow() - self.connext_setup() - - # DDS polling timer (Qt timer — same thread, no locking needed) - dds_timer = QTimer() - dds_timer.timeout.connect(self._poll_dds) - dds_timer.start(150) - - # Heartbeat in background thread - hb_thread = threading.Thread(target=self.write_hb, daemon=True) - hb_thread.start() - - # Allow Ctrl+C to cleanly quit the Qt event loop. - # The QTimer is needed so the event loop periodically yields control - # back to Python, enabling signal delivery. - signal.signal(signal.SIGINT, lambda *_: app.quit()) - _sig_timer = QTimer() - _sig_timer.timeout.connect(lambda: None) - _sig_timer.start(300) - app.aboutToQuit.connect(self._cleanup) - - self.window.show() - print("Started Patient Monitor") - - app.exec() - - self.pm_status.status = Common.DeviceStatuses.OFF - - def _cleanup(self): - print("Shutting down Patient Monitor") - - -if __name__ == "__main__": - PatientMonitorApp().run() diff --git a/modules/01-operating-room/PatientSensor.cxx b/modules/01-operating-room/PatientSensor.cxx deleted file mode 100644 index 090cc2c..0000000 --- a/modules/01-operating-room/PatientSensor.cxx +++ /dev/null @@ -1,194 +0,0 @@ -// -// (c) 2024 Copyright, Real-Time Innovations, Inc. (RTI) All rights reserved. -// -// RTI grants Licensee a license to use, modify, compile, and create derivative -// works of the software solely for use with RTI Connext DDS. Licensee may -// redistribute copies of the software provided that all such copies are -// subject to this license. The software is provided "as is", with no warranty -// of any type, including any warranty for fitness for any purpose. RTI is -// under no obligation to maintain or support the software. RTI shall not be -// liable for any incidental or consequential damages arising out of the use or -// inability to use the software. - -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -#include "Types.hpp" - -using namespace DdsEntities::Constants; - -static std::atomic g_shutdown { false }; - -static void handle_signal(int) -{ - g_shutdown = true; -} - -class PatientSensor { -public: - void run() - { - // We need to register the types before we start creating DDS entities - rti::domain::register_type(); - rti::domain::register_type(); - rti::domain::register_type(); - rti::domain::register_type(); - - // Connext will load XML files through the default provider from the - // NDDS_QOS_PROFILES environment variable - auto default_provider = dds::core::QosProvider::Default(); - - dds::domain::DomainParticipant participant = - default_provider.extensions().create_participant_from_config( - PATIENT_SENSOR_DP); - - // Initialize DataWriters - dds::pub::DataWriter vitals_writer = - rti::pub::find_datawriter_by_name< - dds::pub::DataWriter>( - participant, - VITALS_DW); - - dds::pub::DataWriter status_writer = - rti::pub::find_datawriter_by_name< - dds::pub::DataWriter>( - participant, - STATUS_DW); - - dds::pub::DataWriter hb_writer = - rti::pub::find_datawriter_by_name< - dds::pub::DataWriter>( - participant, - HB_DW); - - // Initialize DataReader - dds::sub::DataReader cmd_reader = - rti::sub::find_datareader_by_name< - dds::sub::DataReader>( - participant, - DEVICE_COMMAND_DR); - - current_status.device(Common::DeviceType::PATIENT_SENSOR); - current_status.status(Common::DeviceStatuses::ON); - - // Read condition to process commands - dds::sub::cond::ReadCondition command_read_condition( - cmd_reader, - dds::sub::status::DataState::any(), - [this, &cmd_reader, &status_writer]() { - process_command(cmd_reader, status_writer); - }); - - dds::core::cond::WaitSet waitset_command; - waitset_command += command_read_condition; - - std::cout << "Launching Patient Sensor" << std::endl; - - // Start heartbeat thread - std::thread hb_thread(&PatientSensor::write_hb, this, hb_writer); - write_status(status_writer); - - // Main loop - while (!g_shutdown - && current_status.status() != Common::DeviceStatuses::OFF) { - try { - waitset_command.dispatch(dds::core::Duration(1)); - } catch (const dds::core::Error &) { - break; - } - write_vitals(vitals_writer); - } - - if (g_shutdown) { - std::cout << "Shutting Down Patient Sensor (signal)" << std::endl; - current_status.status(Common::DeviceStatuses::OFF); - write_status(status_writer); - } - - hb_thread.join(); - } - -private: - Common::DeviceStatus current_status; - - // Function to publish vitals - void write_vitals( - dds::pub::DataWriter vitals_writer) - { - PatientMonitor::Vitals sample; - sample.patient_id("ab1234"); - sample.hr(55 + rand() % 11); - sample.spo2(90 + rand() % 11); - sample.etco2(35 + rand() % 11); - sample.nibp_s(115 + rand() % 11); - sample.nibp_d(75 + rand() % 11); - - if (current_status.status() == Common::DeviceStatuses::ON) - vitals_writer.write(sample); - } - - // Function to publish heartbeats every 50ms - void write_hb(dds::pub::DataWriter hb_writer) - { - while (current_status.status() != Common::DeviceStatuses::OFF) { - Common::DeviceHeartbeat hb(Common::DeviceType::PATIENT_SENSOR); - hb_writer.write(hb); - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - } - } - - // Function to publish device status - void write_status(dds::pub::DataWriter status_writer) - { - status_writer.write(current_status); - } - - // Function to process commands sent from the Orchestrator - void process_command( - dds::sub::DataReader cmd_reader, - dds::pub::DataWriter status_writer) - { - dds::sub::LoanedSamples samples = - cmd_reader.take(); - - for (const auto &sample : samples) { - if (sample.info().valid()) { - if (sample.data().command() - == Orchestrator::DeviceCommands::PAUSE) { - std::cout << "Pausing Patient Sensor" << std::endl; - current_status.status(Common::DeviceStatuses::PAUSED); - } else if ( - sample.data().command() - == Orchestrator::DeviceCommands::START) { - std::cout << "Starting Patient Sensor" << std::endl; - current_status.status(Common::DeviceStatuses::ON); - } else { // shutdown - std::cout << "Shutting Down Patient Sensor" << std::endl; - current_status.status(Common::DeviceStatuses::OFF); - } - write_status(status_writer); - } - } - } -}; - -// Main function creates an instance of PatientMonitor and runs it -int main(int argc, char const *argv[]) -{ - std::signal(SIGINT, handle_signal); - std::signal(SIGTERM, handle_signal); - PatientSensor patient_sensor; - patient_sensor.run(); - return 0; -} diff --git a/modules/01-operating-room/src/Arm.py b/modules/01-operating-room/src/Arm.py index b55b905..4f26570 100644 --- a/modules/01-operating-room/src/Arm.py +++ b/modules/01-operating-room/src/Arm.py @@ -41,8 +41,8 @@ # Per-joint color palette JOINT_COLORS = { - SurgicalRobot.Motors.BASE: "#004C97", # RTI Blue - SurgicalRobot.Motors.SHOULDER: "#ED8B00", # RTI Orange + SurgicalRobot.Motors.BASE: RTI_BLUE, + SurgicalRobot.Motors.SHOULDER: RTI_ORANGE, SurgicalRobot.Motors.ELBOW: "#00BFFF", # Electric blue SurgicalRobot.Motors.WRIST: "#7CFC00", # Lime green SurgicalRobot.Motors.HAND: "#DA70D6", # Orchid @@ -376,7 +376,7 @@ def __init__(self): self.setWindowTitle("RTI Connext — Surgical Arm Monitor") self.setMinimumSize(640, 650) self.setStyleSheet(f"background-color: {BG_MAIN};") - _icon_px = QPixmap("../../resource/images/rti_logo.png") + _icon_px = QPixmap("../../resource/images/Arm-Monitor.png") if not _icon_px.isNull(): self.setWindowIcon(QIcon(_icon_px)) @@ -399,7 +399,7 @@ def _build_ui(self): h_layout = QHBoxLayout(header) h_layout.setContentsMargins(20, 0, 20, 0) - _logo_px = QPixmap("../../resource/images/rti_logo.png") + _logo_px = QPixmap("../../resource/images/Arm-Monitor.png") if not _logo_px.isNull(): logo_lbl = QLabel() logo_lbl.setStyleSheet("background: transparent;") @@ -580,7 +580,7 @@ def connext_setup(self): def run(self): app = QApplication(sys.argv) app.setStyle("Fusion") - _icon = QIcon(QPixmap("../../resource/images/rti_logo.png")) + _icon = QIcon(QPixmap("../../resource/images/Arm-Monitor.png")) if not _icon.isNull(): app.setWindowIcon(_icon) diff --git a/modules/01-operating-room/src/ArmController.cxx b/modules/01-operating-room/src/ArmController.cxx index 7687278..95c943d 100644 --- a/modules/01-operating-room/src/ArmController.cxx +++ b/modules/01-operating-room/src/ArmController.cxx @@ -265,7 +265,7 @@ class SurgicalArmController { builder->get_widget("header_bar", hdr); try { auto pb = Gdk::Pixbuf::create_from_file( - "../../resource/images/rti_logo.png"); + "../../resource/images/Arm-Controller.png"); window->set_icon(pb); #ifdef __APPLE__ set_macos_dock_icon(pb); @@ -280,6 +280,7 @@ class SurgicalArmController { hdr->reorder_child(*logo, 0); } } catch (...) { + std::cerr << "Warning: artwork failure " << std::endl; } } diff --git a/modules/01-operating-room/src/Orchestrator.cxx b/modules/01-operating-room/src/Orchestrator.cxx index 50e3e9f..b2c5ed4 100644 --- a/modules/01-operating-room/src/Orchestrator.cxx +++ b/modules/01-operating-room/src/Orchestrator.cxx @@ -401,7 +401,7 @@ class OrchestratorApp { builder->get_widget("header_bar", hdr); try { auto pb = Gdk::Pixbuf::create_from_file( - "../../resource/images/rti_logo.png"); + "../../resource/images/Orchestrator.png"); window->set_icon(pb); #ifdef __APPLE__ set_macos_dock_icon(pb); diff --git a/modules/01-operating-room/src/PatientMonitor.py b/modules/01-operating-room/src/PatientMonitor.py index 5dbdac8..eaf9c7b 100644 --- a/modules/01-operating-room/src/PatientMonitor.py +++ b/modules/01-operating-room/src/PatientMonitor.py @@ -317,7 +317,7 @@ def __init__(self): # ── Window icon ───────────────────────────────────────────────── def _set_icon(self): - _px = QPixmap("../../resource/images/rti_logo.png") + _px = QPixmap("../../resource/images/Patient-Monitor.png") if not _px.isNull(): self.setWindowIcon(QIcon(_px)) @@ -347,7 +347,7 @@ def _build_ui(self): h_layout = QHBoxLayout(header) h_layout.setContentsMargins(20, 0, 20, 0) - _logo_px = QPixmap("../../resource/images/rti_logo.png") + _logo_px = QPixmap("../../resource/images/Patient-Monitor.png") if not _logo_px.isNull(): logo_lbl = QLabel() logo_lbl.setStyleSheet("background: transparent;") @@ -543,7 +543,7 @@ def connext_setup(self): def run(self): app = QApplication(sys.argv) app.setStyle("Fusion") - _icon = QIcon(QPixmap("../../resource/images/rti_logo.png")) + _icon = QIcon(QPixmap("../../resource/images/Patient-Monitor.png")) if not _icon.isNull(): app.setWindowIcon(_icon)