Initial commit: Javis project

This commit is contained in:
Javis committed 2026-09-21 21:44:00 +02:00
commit 435c645f36
164 files changed
+35279

No files matched your search

+584
View File
@@ -0,0 +1,584 @@
"""Touch-optimized PySide6 UI for javis-slave (Surface Pro 7 kiosk style).
Same VS Code Dark Modern palette as the Javis master GUI, with all touch
targets ≥ 48 px.
"""
from __future__ import annotations
from typing import Callable, Optional
from PySide6 import QtCore, QtGui, QtWidgets
from PySide6.QtCore import QTimer
# ── palette (mirrors javis/gui/main_window.py) ────────────────────────────────
CL_BG = "#1e1e1e"
CL_SURFACE = "#252526"
CL_SURFACE2 = "#2d2d2d"
CL_INPUT = "#3c3c3c"
CL_ACCENT = "#DA7756"
CL_ACCENT2 = "#e0886a"
CL_TEXT = "#cccccc"
CL_MUTED = "#858585"
CL_BORDER = "#3c3c3c"
# Gain applied to raw mic RMS before driving the orb pulse (A-12: was magic 8.0).
# Typical speech RMS is ~0.01–0.1, so ×8 maps it into the visible 0..1 range.
_RMS_GAIN = 8.0
STATE_COLORS = {
"idle": "#3c3c3c",
"listening": "#4ec994",
"thinking": CL_ACCENT,
"speaking": "#4ec994",
"muted": "#f14c4c",
"error": "#f14c4c",
}
STATE_LABELS = {
"idle": "Sag das Wake-Word …",
"listening": "Ich höre zu …",
"thinking": "Einen Moment …",
"speaking": "Antwort wird abgespielt …",
"muted": "Stumm geschaltet",
"error": "Fehler",
}
_INPUT_QSS = (
f"QLineEdit {{ background: {CL_INPUT}; color: {CL_TEXT}; border: 1px solid {CL_BORDER};"
f" border-radius: 12px; padding: 14px 18px; font-size: 16pt; }}"
f"QLineEdit:focus {{ border: 2px solid {CL_ACCENT}; }}"
)
_PRIMARY_BTN_QSS = (
f"QPushButton {{ background: {CL_ACCENT}; color: #1e1e1e; font-weight: 700;"
f" border: 0; border-radius: 14px; padding: 16px 32px; font-size: 16pt; }}"
f"QPushButton:pressed {{ background: {CL_ACCENT2}; }}"
f"QPushButton:disabled {{ background: {CL_INPUT}; color: {CL_MUTED}; }}"
)
_GHOST_BTN_QSS = (
f"QPushButton {{ background: transparent; border: 1px solid {CL_BORDER}; color: {CL_MUTED};"
f" padding: 12px 24px; border-radius: 12px; font-size: 13pt; }}"
f"QPushButton:pressed {{ background: {CL_SURFACE2}; color: {CL_TEXT}; }}"
)
class StateOrb(QtWidgets.QWidget):
"""Large animated circle reflecting the agent state.
Alexa-style visual feedback:
- Color indicates device state (idle/listening/thinking/speaking/error)
- Pulse ring shows mic activity level in real-time
- Mute overlay (red X) when microphone is disabled
- Retry indicator (spinning dots) during reconnection attempts
"""
def __init__(self, parent=None):
super().__init__(parent)
self._state = "idle"
self._level = 0.0 # mic RMS 0..1 (pulse while idle/listening)
self._muted = False # explicit mute state overlay
self._reconnecting = False # showing retry indicator
self.setMinimumSize(260, 260)
def set_state(self, state: str) -> None:
self._state = state
self.update()
def set_level(self, level: float) -> None:
self._level = max(0.0, min(1.0, level))
self.update()
def set_muted(self, muted: bool) -> None:
"""Set the mute overlay state (Alexa-style mute indicator)."""
self._muted = muted
self.update()
def set_reconnecting(self, reconnecting: bool) -> None:
"""Show/hide reconnection retry indicator."""
self._reconnecting = reconnecting
self.update()
def is_muted(self) -> bool:
return self._muted
def paintEvent(self, event): # noqa: N802
p = QtGui.QPainter(self)
p.setRenderHint(QtGui.QPainter.Antialiasing)
rect = self.rect()
size = min(rect.width(), rect.height())
base_r = size * 0.32
pulse = base_r * (1.0 + 0.35 * self._level)
center = rect.center()
color = QtGui.QColor(STATE_COLORS.get(self._state, CL_INPUT))
# Outer pulse ring.
ring = QtGui.QColor(color)
ring.setAlpha(60)
p.setBrush(ring)
p.setPen(QtCore.Qt.NoPen)
p.drawEllipse(center, pulse, pulse)
# Core.
p.setBrush(color)
p.drawEllipse(center, base_r, base_r)
# Icon glyph — dark on bright cores, light on the dark idle core so it stays visible.
bright_core = self._state in ("listening", "thinking", "speaking", "muted", "error")
p.setPen(QtGui.QPen(QtGui.QColor("#1e1e1e" if bright_core else CL_TEXT), 3))
font = p.font()
font.setPointSizeF(size * 0.11)
p.setFont(font)
glyph = {"idle": "🎤", "listening": "🎤", "thinking": "…",
"speaking": "🔊", "muted": "🔇", "error": "!"}.get(self._state, "")
p.drawText(QtCore.QRectF(rect), QtCore.Qt.AlignCenter, glyph)
# Mute overlay (Alexa-style red X indicator).
if self._muted:
mute_r = base_r * 0.5
mute_center = QtCore.QPointF(center.x() + size * 0.28,
center.y() - size * 0.28)
p.setPen(QtGui.QPen(QtGui.QColor("#f14c4c"), max(2, size // 80)))
p.drawLine(
QtCore.QPointF(mute_center.x() - mute_r, mute_center.y() - mute_r),
QtCore.QPointF(mute_center.x() + mute_r, mute_center.y() + mute_r))
p.drawLine(
QtCore.QPointF(mute_center.x() + mute_r, mute_center.y() - mute_r),
QtCore.QPointF(mute_center.x() - mute_r, mute_center.y() + mute_r))
# Reconnecting indicator (pulsing outer ring).
if self._reconnecting:
p.setPen(QtGui.QPen(QtGui.QColor("#f59e0b"), max(2, size // 60)))
ring_r = base_r * 1.35
p.drawEllipse(center, ring_r, ring_r)
p.end()
class SetupScreen(QtWidgets.QWidget):
"""First-run pairing: master address + pairing code."""
pairRequested = QtCore.Signal(str, int, str, str) # host, port, code, name
def __init__(self, parent=None):
super().__init__(parent)
self.setStyleSheet(f"background: {CL_BG};")
outer = QtWidgets.QVBoxLayout(self)
outer.setAlignment(QtCore.Qt.AlignCenter)
card = QtWidgets.QFrame()
card.setMaximumWidth(620)
card.setStyleSheet(
f"QFrame {{ background: {CL_SURFACE}; border: 1px solid {CL_BORDER};"
f" border-radius: 18px; }}")
v = QtWidgets.QVBoxLayout(card)
v.setContentsMargins(40, 36, 40, 36)
v.setSpacing(18)
title = QtWidgets.QLabel("Javis Slave koppeln")
title.setStyleSheet(
f"color: {CL_TEXT}; font-size: 24pt; font-weight: 800; border: 0;")
title.setAlignment(QtCore.Qt.AlignCenter)
v.addWidget(title)
hint = QtWidgets.QLabel(
"Erzeuge auf dem Javis-Master im Tab „Devices“ einen Pairing-Code\n"
"und trage hier die angezeigte Adresse und den Code ein.")
hint.setStyleSheet(f"color: {CL_MUTED}; font-size: 12pt; border: 0;")
hint.setAlignment(QtCore.Qt.AlignCenter)
v.addWidget(hint)
self.host_edit = QtWidgets.QLineEdit()
self.host_edit.setPlaceholderText("Master-IP (z. B. 192.168.1.20)")
self.host_edit.setStyleSheet(_INPUT_QSS)
v.addWidget(self.host_edit)
self.port_edit = QtWidgets.QLineEdit("8765")
self.port_edit.setPlaceholderText("Port")
self.port_edit.setValidator(QtGui.QIntValidator(1, 65535, self))
self.port_edit.setStyleSheet(_INPUT_QSS)
v.addWidget(self.port_edit)
self.code_edit = QtWidgets.QLineEdit()
self.code_edit.setPlaceholderText("Pairing-Code")
self.code_edit.setMaxLength(6)
self.code_edit.setStyleSheet(_INPUT_QSS + "QLineEdit { letter-spacing: 6px; }")
v.addWidget(self.code_edit)
self.name_edit = QtWidgets.QLineEdit()
self.name_edit.setPlaceholderText("Gerätename (z. B. Tablet Küche)")
self.name_edit.setStyleSheet(_INPUT_QSS)
v.addWidget(self.name_edit)
self.status_lbl = QtWidgets.QLabel("")
self.status_lbl.setStyleSheet("color: #f14c4c; font-size: 12pt; border: 0;")
self.status_lbl.setAlignment(QtCore.Qt.AlignCenter)
self.status_lbl.setWordWrap(True)
v.addWidget(self.status_lbl)
self.pair_btn = QtWidgets.QPushButton("Koppeln")
self.pair_btn.setStyleSheet(_PRIMARY_BTN_QSS)
self.pair_btn.setCursor(QtCore.Qt.PointingHandCursor)
self.pair_btn.clicked.connect(self._submit)
v.addWidget(self.pair_btn)
outer.addWidget(card)
def _submit(self) -> None:
host = self.host_edit.text().strip()
port = int(self.port_edit.text() or "8765")
code = self.code_edit.text().strip()
name = self.name_edit.text().strip()
if not host or not code:
self.status_lbl.setText("Bitte Master-IP und Pairing-Code eingeben.")
return
self.status_lbl.setStyleSheet(f"color: {CL_MUTED}; font-size: 12pt; border: 0;")
self.status_lbl.setText("Verbinde …")
self.pair_btn.setEnabled(False)
self.pairRequested.emit(host, port, code, name)
def show_error(self, message: str) -> None:
self.status_lbl.setStyleSheet("color: #f14c4c; font-size: 12pt; border: 0;")
self.status_lbl.setText(message)
self.pair_btn.setEnabled(True)
class MainScreen(QtWidgets.QWidget):
"""Paired mode: state orb, transcript card, status badges.
Alexa-style UX features:
- Mute toggle button on the orb
- Connection health monitoring with retry countdown
- Transcript history (scrollable conversation log)
- Visual mute indicator overlay
"""
settingsRequested = QtCore.Signal()
muteToggled = QtCore.Signal(bool) # new signal for mute toggle
def __init__(self, parent=None):
super().__init__(parent)
self.setStyleSheet(f"background: {CL_BG};")
outer = QtWidgets.QVBoxLayout(self)
outer.setContentsMargins(28, 20, 28, 24)
outer.setSpacing(16)
# ── top bar: room badge · connection health · mute · settings ──────
top = QtWidgets.QHBoxLayout()
self.room_lbl = QtWidgets.QLabel("Raum: —")
self.room_lbl.setStyleSheet(
f"color: {CL_TEXT}; background: {CL_SURFACE2}; border-radius: 14px;"
f"padding: 10px 22px; font-size: 14pt; font-weight: 600;")
top.addWidget(self.room_lbl)
top.addStretch(1)
# Connection status with retry countdown
self.conn_lbl = QtWidgets.QLabel("● verbunden")
self.conn_lbl.setStyleSheet(
"color: #4ec994; font-size: 13pt; padding: 10px;")
top.addWidget(self.conn_lbl)
# Mute toggle button (Alexa-style red ring when muted)
self.mute_btn = QtWidgets.QPushButton("🎤")
self.mute_btn.setFixedSize(56, 56)
self.mute_btn.setCursor(QtCore.Qt.PointingHandCursor)
self.mute_btn.setStyleSheet(
f"QPushButton {{ background: {CL_SURFACE2}; color: {CL_TEXT}; border: 0;"
f" border-radius: 14px; font-size: 18pt; }}"
f"QPushButton:checked {{ background: #f14c4c; color: white; }}"
f"QPushButton:pressed {{ background: {CL_INPUT}; color: {CL_TEXT}; }}"
f"QPushButton:disabled {{ background: {CL_SURFACE}; color: {CL_MUTED}; }}")
self.mute_btn.setCheckable(True) # Alexa-style toggle state
self.mute_btn.clicked.connect(self._on_mute_toggled)
top.addWidget(self.mute_btn)
gear = QtWidgets.QPushButton("⚙")
gear.setFixedSize(56, 56)
gear.setCursor(QtCore.Qt.PointingHandCursor)
gear.setStyleSheet(
f"QPushButton {{ background: {CL_SURFACE2}; color: {CL_MUTED}; border: 0;"
f" border-radius: 14px; font-size: 18pt; }}"
f"QPushButton:pressed {{ background: {CL_INPUT}; color: {CL_TEXT}; }}")
gear.clicked.connect(self.settingsRequested.emit)
top.addWidget(gear)
outer.addLayout(top)
# Retry countdown label (hidden by default, shown during reconnection)
self.retry_lbl = QtWidgets.QLabel("")
self.retry_lbl.setStyleSheet(
f"color: #f59e0b; font-size: 12pt; font-weight: 600; border: 0;")
self.retry_lbl.setAlignment(QtCore.Qt.AlignCenter)
self.retry_lbl.setVisible(False)
outer.addWidget(self.retry_lbl)
# ── state orb + label ─────────────────────────────────────────────
self.orb = StateOrb()
outer.addWidget(self.orb, 2)
self.state_lbl = QtWidgets.QLabel(STATE_LABELS["idle"])
self.state_lbl.setAlignment(QtCore.Qt.AlignCenter)
self.state_lbl.setStyleSheet(f"color: {CL_MUTED}; font-size: 16pt;")
outer.addWidget(self.state_lbl)
# ── transcript card (scrollable conversation history) ──────────────
card = QtWidgets.QFrame()
card.setStyleSheet(
f"QFrame {{ background: {CL_SURFACE}; border: 1px solid {CL_BORDER};"
f" border-radius: 18px; }}")
cv = QtWidgets.QVBoxLayout(card)
cv.setContentsMargins(28, 22, 28, 22)
cv.setSpacing(10)
# Header label
history_header = QtWidgets.QLabel("Konversation")
history_header.setStyleSheet(f"color: {CL_MUTED}; font-size: 11pt; border: 0;")
cv.addWidget(history_header)
# Transcript history (replaces single-turn display).
# NOTE: this layout must belong to *exactly one* parent — we set it on
# ``transcript_container`` (inside the scroll area) below, so it must
# NOT also be added to ``cv`` (Qt raises RuntimeError: "Attempting to
# set QLayout ... when the QLayout already has a parent").
self.transcript_layout = QtWidgets.QVBoxLayout()
self.transcript_layout.setSpacing(8)
# Scroll area for transcript
self.transcript_scroll = QtWidgets.QScrollArea()
self.transcript_scroll.setWidgetResizable(True)
self.transcript_scroll.setFrameShape(QtWidgets.QFrame.NoFrame)
self.transcript_scroll.setStyleSheet("background: transparent; border: 0;")
self.transcript_container = QtWidgets.QWidget()
self.transcript_container.setLayout(self.transcript_layout)
self.transcript_scroll.setWidget(self.transcript_container)
QtWidgets.QScroller.grabGesture(
self.transcript_scroll.viewport(), QtWidgets.QScroller.TouchGesture)
cv.addWidget(self.transcript_scroll, 1)
outer.addWidget(card, 3)
# Track last state for change detection
self._last_state = "idle"
def _on_mute_toggled(self) -> None:
"""Handle mute button toggle."""
is_muted = self.mute_btn.isChecked()
self.muteToggled.emit(is_muted)
# ── slots (called via Qt signals from the engine bridge) ──────────────
def set_state(self, state: str) -> None:
self.orb.set_state(state)
self.orb.set_muted(state == "muted") # sync mute overlay with state
label = STATE_LABELS.get(state, state)
if label:
self.state_lbl.setText(label)
# Mute toggle stays in place (enabled/disabled) so the top bar never
# jumps while the state cycles; it is only inactive mid-turn.
interactive_states = ("idle", "listening", "speaking", "muted")
self.mute_btn.setEnabled(state in interactive_states)
# Sync the button's checked state to match the engine's mute state.
want_checked = (state == "muted")
if self.mute_btn.isChecked() != want_checked:
self.mute_btn.blockSignals(True)
self.mute_btn.setChecked(want_checked)
self.mute_btn.blockSignals(False)
def set_mic_level(self, rms: float, score: float) -> None:
self.orb.set_level(min(1.0, rms * _RMS_GAIN))
def _ensure_transcript_spacer(self) -> None:
"""Remove existing spacer (if any) and add a fresh one at the end."""
# Walk backwards to find and remove any existing spacer items
while self.transcript_layout.count() > 0:
item = self.transcript_layout.itemAt(self.transcript_layout.count() - 1)
if isinstance(item, QtWidgets.QSpacerItem):
self.transcript_layout.removeItem(item)
else:
break
spacer = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum,
QtWidgets.QSizePolicy.Expanding)
self.transcript_layout.addSpacerItem(spacer)
def _prune_transcript(self, max_bubbles: int = 40) -> None:
"""Keep at most *max_bubbles* bubbles in the transcript layout (oldest first)."""
count = self.transcript_layout.count()
bubbles = count
if count > 0 and isinstance(self.transcript_layout.itemAt(count - 1), QtWidgets.QSpacerItem):
bubbles -= 1
excess = bubbles - max_bubbles
for _ in range(max(0, excess)):
item = self.transcript_layout.takeAt(0)
if item is not None and item.widget():
item.widget().deleteLater()
def set_turn(self, user_text: str, reply_text: str) -> None:
"""Add a new conversation turn to the transcript history (Alexa-style)."""
# User message bubble
user_bubble = QtWidgets.QLabel(f"👤 {user_text}")
user_bubble.setStyleSheet(
f"background: {CL_SURFACE2}; color: {CL_TEXT}; border-radius: 12px;"
f"padding: 12px 18px; font-size: 14pt;")
user_bubble.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
user_bubble.setWordWrap(True)
self.transcript_layout.addWidget(user_bubble)
# Assistant message bubble (if any)
if reply_text:
reply_bubble = QtWidgets.QLabel(f"🤖 {reply_text}")
reply_bubble.setStyleSheet(
f"background: {CL_ACCENT}; color: #1e1e1e; border-radius: 12px;"
f"padding: 12px 18px; font-size: 14pt; font-weight: 500;")
reply_bubble.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter)
reply_bubble.setWordWrap(True)
self.transcript_layout.addWidget(reply_bubble)
self._prune_transcript()
# Spacer to push content to bottom (like Alexa's conversation history)
self._ensure_transcript_spacer()
# Auto-scroll to bottom
QTimer.singleShot(50, self._scroll_transcript_to_bottom)
def _scroll_transcript_to_bottom(self) -> None:
"""Auto-scroll transcript to show latest messages."""
vbar = self.transcript_scroll.verticalScrollBar()
vbar.setValue(vbar.maximum())
def set_connection(self, ok: bool, retry_count: int = 0, backoff: float = 0.0) -> None:
if ok:
self.conn_lbl.setText("● verbunden")
self.conn_lbl.setStyleSheet("color: #4ec994; font-size: 13pt; padding: 10px;")
self.retry_lbl.setVisible(False)
self.orb.set_reconnecting(False)
else:
if retry_count > 0 and backoff > 0:
# Show retry countdown (Alexa-style progressive feedback)
text = f"○ trennung · versuch {retry_count} in {backoff:.0f}s"
self.conn_lbl.setText(text)
self.conn_lbl.setStyleSheet("color: #f59e0b; font-size: 13pt; padding: 10px;")
self.retry_lbl.setVisible(True)
self.retry_lbl.setText(f"Wiederverbindungsversuch in {backoff:.0f}s …")
self.orb.set_reconnecting(True)
else:
self.conn_lbl.setText("● getrennt")
self.conn_lbl.setStyleSheet("color: #f14c4c; font-size: 13pt; padding: 10px;")
def set_room(self, room: Optional[str]) -> None:
self.room_lbl.setText(f"Raum: {room}" if room else "Raum: —")
def show_error(self, message: str) -> None:
"""Show a recoverable error to the user (Alexa-style progressive disclosure)."""
self._prune_transcript()
error_bubble = QtWidgets.QLabel(f"⚠ {message}")
error_bubble.setStyleSheet(
f"color: #f14c4c; font-size: 14pt; border: 0; padding: 12px;")
error_bubble.setWordWrap(True)
self.transcript_layout.addWidget(error_bubble)
self._ensure_transcript_spacer()
QTimer.singleShot(50, self._scroll_transcript_to_bottom)
class SlaveWindow(QtWidgets.QMainWindow):
"""Top-level window switching between setup and main screens.
Wired to engine callbacks that bridge asyncio events to Qt signals:
- engineState: device state changes (idle/listening/thinking/speaking/error)
- engineMicLevel: real-time audio level for orb pulse animation
- engineTurn: conversation turn results (user text + assistant reply)
- engineError: error messages from the engine
- engineConnection: connection health updates with retry countdown
- engineRoom: room assignment changes
"""
# Bridged engine events (emitted from asyncio, consumed on the Qt side).
engineState = QtCore.Signal(str)
engineMicLevel = QtCore.Signal(float, float)
engineTurn = QtCore.Signal(str, str)
engineError = QtCore.Signal(str)
engineConnection = QtCore.Signal(bool, int, float) # (ok, retry_count, backoff)
engineRoom = QtCore.Signal(object)
def __init__(self):
super().__init__()
self.setWindowTitle("Javis Slave")
self.resize(1368, 912) # Surface Pro 7 logical resolution @150 %
self.setStyleSheet(f"background: {CL_BG};")
self.stack = QtWidgets.QStackedWidget()
self.setup_screen = SetupScreen()
self.main_screen = MainScreen()
self.stack.addWidget(self.setup_screen)
self.stack.addWidget(self.main_screen)
self.setCentralWidget(self.stack)
self.engineState.connect(self.main_screen.set_state)
self.engineMicLevel.connect(self.main_screen.set_mic_level)
self.engineTurn.connect(self.main_screen.set_turn)
self.engineError.connect(self.main_screen.show_error)
self.engineConnection.connect(self.main_screen.set_connection)
self.engineRoom.connect(lambda r: self.main_screen.set_room(r))
QtGui.QShortcut(QtGui.QKeySequence("F11"), self, activated=self._toggle_fullscreen)
# Kiosk: fullscreen with a hidden cursor (touch panels); Esc leaves fullscreen.
self._kiosk = False
QtGui.QShortcut(QtGui.QKeySequence("Esc"), self, activated=self._leave_fullscreen)
def set_kiosk(self, enabled: bool) -> None:
self._kiosk = enabled
if enabled:
self.showFullScreen()
QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.CursorShape.BlankCursor)
def _leave_fullscreen(self) -> None:
if self.isFullScreen():
self.showNormal()
if self._kiosk:
QtWidgets.QApplication.restoreOverrideCursor()
def show_setup(self) -> None:
self.stack.setCurrentWidget(self.setup_screen)
def show_main(self) -> None:
self.stack.setCurrentWidget(self.main_screen)
def _toggle_fullscreen(self) -> None:
if self.isFullScreen():
self.showNormal()
else:
self.showFullScreen()
def show_settings_dialog(self, device_id: str, device_name: str,
on_repair: Callable[[], None]) -> None:
dlg = QtWidgets.QDialog(self)
dlg.setWindowTitle("Einstellungen")
dlg.setStyleSheet(f"background: {CL_SURFACE}; color: {CL_TEXT};")
v = QtWidgets.QVBoxLayout(dlg)
v.setContentsMargins(28, 24, 28, 24)
v.setSpacing(14)
info = QtWidgets.QLabel(
f"Gerät: {device_name or '—'}\nGeräte-ID: {device_id}")
info.setStyleSheet(f"color: {CL_MUTED}; font-size: 12pt;")
info.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse)
v.addWidget(info)
btn_fs = QtWidgets.QPushButton("Vollbild umschalten (F11)")
btn_fs.setStyleSheet(_GHOST_BTN_QSS)
btn_fs.clicked.connect(lambda: (self._toggle_fullscreen(), dlg.accept()))
v.addWidget(btn_fs)
btn_repair = QtWidgets.QPushButton("Neu koppeln …")
btn_repair.setStyleSheet(_GHOST_BTN_QSS)
def _do_repair():
ret = QtWidgets.QMessageBox.question(
dlg, "Neu koppeln",
"Verbindung zum Master trennen und neu koppeln?")
if ret == QtWidgets.QMessageBox.Yes:
dlg.accept()
on_repair()
btn_repair.clicked.connect(_do_repair)
v.addWidget(btn_repair)
btn_close = QtWidgets.QPushButton("Schließen")
btn_close.setStyleSheet(_PRIMARY_BTN_QSS)
btn_close.clicked.connect(dlg.accept)
v.addWidget(btn_close)
dlg.exec()