M32: Automatisches Wiederverbinden nach Verbindungsabbruch

ConnectionService: Herzschlag-Task (alle 10s /system identity) erkennt
einen Verbindungsverlust während der Sitzung; bei Ausfall Retry-Schleife
(REST->SSH, alle 5s, unbegrenzt bis Erfolg oder manuellem "Trennen").
state bleibt bewusst .connected währenddessen, damit andere Tabs nicht
auf "Nicht verbunden" umspringen — isReconnecting/reconnectAttemptCount/
secondsUntilNextReconnectAttempt treiben einen Banner im Verbinden-Tab
mit Versuchszähler + Countdown.

Live bestätigt, zusätzlich beim echten Firmware-Update-Neustart erneut
gegengetestet. README/Manual (DE+EN) aktualisiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kay
2026-09-17 17:32:31 +02:00
co-authored by Claude Sonnet 5
parent 3a26f50800
commit 25401cde78
11 changed files with 182 additions and 1 deletions
+11
View File
@@ -125,6 +125,17 @@ The dot in front of each interface in the device overview is gray (no
link), green (link, but no traffic), or pulsing green (actively link), green (link, but no traffic), or pulsing green (actively
transferring data right now). transferring data right now).
### Automatic Reconnection
If the connection drops during an active session (router reboot,
cable/Wi-Fi briefly interrupted), the app tries to restore it on its
own — a heartbeat check every 10 seconds, and on failure a new connect
attempt every 5 seconds (REST first, then SSH), indefinitely until it
succeeds or "Disconnect" is clicked. An orange notice with a spinner
appears in the Connect tab meanwhile, including an attempt counter and a
countdown to the next try. Other tabs stay usable during a brief outage
instead of immediately switching to "Not connected".
--- ---
## 2. Setup (Wizard) ## 2. Setup (Wizard)
+12
View File
@@ -127,6 +127,18 @@ Der Punkt vor jedem Interface in der Geräte-Übersicht ist grau (kein
Link), grün (Link, aber kein Datenverkehr) oder pulsierend grün (überträgt Link), grün (Link, aber kein Datenverkehr) oder pulsierend grün (überträgt
gerade tatsächlich Daten). gerade tatsächlich Daten).
### Automatisches Wiederverbinden
Fällt die Verbindung während einer aktiven Sitzung weg (Router-Neustart,
Kabel/WLAN kurz unterbrochen), versucht die App selbständig, sie
wiederherzustellen — alle 10 Sekunden ein Herzschlag-Test, bei Ausfall
alle 5 Sekunden ein neuer Verbindungsversuch (REST zuerst, dann SSH),
unbegrenzt bis zum Erfolg oder bis „Trennen“ geklickt wird. Ein oranger
Hinweis mit Spinner erscheint währenddessen im Verbinden-Tab, inkl.
Versuchszähler und Countdown bis zum nächsten Versuch. Andere Tabs
bleiben während eines kurzen Aussetzers nutzbar, statt sofort auf „Nicht
verbunden“ umzuspringen.
--- ---
## 2. Einrichten (Wizard) ## 2. Einrichten (Wizard)
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+5
View File
@@ -31,6 +31,10 @@ Befehl, der ausgeführt wird — nichts passiert unangekündigt.
freiem Standort-Feld (Etage/Raum/Zweck), Liste scrollt ab ca. 4 freiem Standort-Feld (Etage/Raum/Zweck), Liste scrollt ab ca. 4
Einträgen in sich selbst. Interface-Liste zeigt **Live-Traffic**: Punkt Einträgen in sich selbst. Interface-Liste zeigt **Live-Traffic**: Punkt
grau/grün/pulsierend-grün je nachdem, ob der Port Verkehr führt. grau/grün/pulsierend-grün je nachdem, ob der Port Verkehr führt.
**Automatisches Wiederverbinden**: fällt die Verbindung während der
Sitzung weg, versucht die App selbständig, sie wiederherzustellen
(Herzschlag alle 10s, Retry alle 5s, unbegrenzt), mit Versuchszähler +
Countdown im Tab.
- **Einrichten** — geführter Wizard (WAN → LAN → VLAN → WLAN → Firewall - **Einrichten** — geführter Wizard (WAN → LAN → VLAN → WLAN → Firewall
→ Review/Apply) mit Einfach- und Experte-Modus. Mehrere LAN-Interfaces → Review/Apply) mit Einfach- und Experte-Modus. Mehrere LAN-Interfaces
mit eigenem DHCP-Server und optionaler Netzwerk-Isolation mit eigenem DHCP-Server und optionaler Netzwerk-Isolation
@@ -171,6 +175,7 @@ nur die zugehörigen Passwörter liegen weiterhin im macOS-Schlüsselbund.
| M29 | Einstellungen-Fenster (⌘,): Sprache, Update-Auto-Check, Farbschema, Textgröße, Bedienelement-Größe, LAN-Scanner-Refreshraten | ✅ live verifiziert | | M29 | Einstellungen-Fenster (⌘,): Sprache, Update-Auto-Check, Farbschema, Textgröße, Bedienelement-Größe, LAN-Scanner-Refreshraten | ✅ live verifiziert |
| M30 | Experte-Tab: "Mode-Taste"-Menü (`/system routerboard mode-button`), inkl. SSH-Zwangsweg für REST-Deckungslücken | ✅ live verifiziert | | M30 | Experte-Tab: "Mode-Taste"-Menü (`/system routerboard mode-button`), inkl. SSH-Zwangsweg für REST-Deckungslücken | ✅ live verifiziert |
| M31 | Handbuch in der App (⌘? -Buttons, Textanker, Übersicht/Wizard/45 Experte-Menüs), DE+EN vollständig übersetzt | ✅ live verifiziert | | M31 | Handbuch in der App (⌘? -Buttons, Textanker, Übersicht/Wizard/45 Experte-Menüs), DE+EN vollständig übersetzt | ✅ live verifiziert |
| M32 | Automatisches Wiederverbinden bei Verbindungsabbruch (Herzschlag+Retry-Schleife, Versuchszähler+Countdown im Verbinden-Tab) | ✅ live verifiziert |
Ausführlicher Stand inkl. aller gefundenen Bugs, offener Punkte und Ausführlicher Stand inkl. aller gefundenen Bugs, offener Punkte und
Session-Verlauf: [`HANDOFF.md`](HANDOFF.md) / [`CHATLOG.md`](CHATLOG.md). Session-Verlauf: [`HANDOFF.md`](HANDOFF.md) / [`CHATLOG.md`](CHATLOG.md).
@@ -16,6 +16,10 @@ enum L10n {
private static let translations: [String: String] = [ private static let translations: [String: String] = [
"Verbinden": "Connect", "Verbinden": "Connect",
"Verbindung unterbrochen — versuche automatisch, erneut zu verbinden…":
"Connection lost — trying to reconnect automatically…",
"Versuch": "Attempt",
"nächster in": "next in",
"Handbuch": "Manual", "Handbuch": "Manual",
"Hilfe zu diesem Bereich im Handbuch öffnen": "Open help for this area in the manual", "Hilfe zu diesem Bereich im Handbuch öffnen": "Open help for this area in the manual",
"Einrichten": "Setup", "Einrichten": "Setup",
@@ -29,6 +29,19 @@ final class ConnectionService: ObservableObject {
/// Credentials of the current (or last attempted) connection, shared with features /// Credentials of the current (or last attempted) connection, shared with features
/// that need their own dedicated connection, e.g. BackupService's SSH export. /// that need their own dedicated connection, e.g. BackupService's SSH export.
@Published private(set) var credentials: RouterOSCredentials? @Published private(set) var credentials: RouterOSCredentials?
/// True while a lost connection is being silently re-established in the background
/// see `startHealthMonitoring()`'s doc comment.
@Published private(set) var isReconnecting = false
/// How many REST+SSH reconnect rounds have failed so far this episode reset to 0
/// whenever reconnecting starts/succeeds. Purely informational (shown in the banner).
@Published private(set) var reconnectAttemptCount = 0
/// Ticks down once a second between reconnect attempts, `nil` while an attempt is
/// actually in flight or reconnecting isn't happening lets the banner show "next
/// attempt in Xs" instead of a bare spinner.
@Published private(set) var secondsUntilNextReconnectAttempt: Int?
private var healthMonitorTask: Task<Void, Never>?
private static let healthCheckInterval: Duration = .seconds(10)
private static let reconnectRetryInterval: Duration = .seconds(5)
private let certificateTrust: CertificateTrustStore private let certificateTrust: CertificateTrustStore
private let sshHostKeyTrust: SSHHostKeyTrustStore private let sshHostKeyTrust: SSHHostKeyTrustStore
@@ -230,11 +243,87 @@ final class ConnectionService: ObservableObject {
// whole connection over it. // whole connection over it.
routerBoardInfo = try? await Self.fetchRouterBoardInfo(using: transport) routerBoardInfo = try? await Self.fetchRouterBoardInfo(using: transport)
state = .connected(kind: transport.kind) state = .connected(kind: transport.kind)
if healthMonitorTask == nil {
startHealthMonitoring()
}
} catch { } catch {
state = .failed(error.localizedDescription) state = .failed(error.localizedDescription)
} }
} }
/// Per explicit request: a connection lost mid-session (router rebooted, Wi-Fi
/// dropped, cable unplugged) should try to recover itself instead of just sitting
/// there disconnected. Runs for as long as `state` is `.connected`, using a trivial
/// read (`/system identity`, the smallest possible singleton menu) as a heartbeat
/// cheap enough on the router to poll every `healthCheckInterval` indefinitely.
/// `state` deliberately stays `.connected` throughout a lost-and-recovering episode
/// so other tabs don't flash to their "not connected" placeholders over what's often
/// just a brief hiccup; only `isReconnecting` (a small banner) reflects it. Cancelled
/// in `disconnect()` an explicit user disconnect must not keep retrying.
private func startHealthMonitoring() {
healthMonitorTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: Self.healthCheckInterval)
guard !Task.isCancelled else { return }
await self?.checkConnectionHealthAndReconnectIfNeeded()
}
}
}
private func checkConnectionHealthAndReconnectIfNeeded() async {
guard case .connected = state, !isReconnecting,
let activeTransport, let credentials else { return }
do {
_ = try await activeTransport.fetchMenuItems(menuPath: "/system identity", restPath: "system/identity")
} catch {
await reconnectLoop(credentials: credentials)
}
}
/// Retries REST-then-SSH (same order as a normal `connect()`) every
/// `reconnectRetryInterval` until one succeeds or the connection is cancelled from
/// under it (user hit "Trennen" `disconnect()` cancels `healthMonitorTask`, which
/// is this loop's own parent Task, and resets `state` to `.idle`, tripping the guard
/// below on the next iteration regardless).
private func reconnectLoop(credentials: RouterOSCredentials) async {
isReconnecting = true
reconnectAttemptCount = 0
while !Task.isCancelled {
guard case .connected = state else { break }
reconnectAttemptCount += 1
let rest = RestTransport(credentials: credentials, certificateTrust: certificateTrust)
if (try? await rest.connect()) != nil {
await finishConnecting(using: rest)
resetReconnectState()
return
}
let ssh = SSHTransport(credentials: credentials, hostKeyTrust: sshHostKeyTrust)
if (try? await ssh.connect()) != nil {
await finishConnecting(using: ssh)
resetReconnectState()
return
}
await countdownToNextReconnectAttempt()
}
resetReconnectState()
}
private func resetReconnectState() {
isReconnecting = false
reconnectAttemptCount = 0
secondsUntilNextReconnectAttempt = nil
}
private func countdownToNextReconnectAttempt() async {
let totalSeconds = Int(Self.reconnectRetryInterval.components.seconds)
for remaining in stride(from: totalSeconds, through: 1, by: -1) {
guard !Task.isCancelled else { return }
secondsUntilNextReconnectAttempt = remaining
try? await Task.sleep(for: .seconds(1))
}
secondsUntilNextReconnectAttempt = nil
}
/// Field names confirmed live (hEX, RouterOS 6.49.16, see `RouterBoardInfo`'s doc comment). /// Field names confirmed live (hEX, RouterOS 6.49.16, see `RouterBoardInfo`'s doc comment).
private static func fetchRouterBoardInfo(using transport: RouterOSTransport) async throws -> RouterBoardInfo { private static func fetchRouterBoardInfo(using transport: RouterOSTransport) async throws -> RouterBoardInfo {
let items = try await transport.fetchMenuItems(menuPath: "/system routerboard", restPath: "system/routerboard") let items = try await transport.fetchMenuItems(menuPath: "/system routerboard", restPath: "system/routerboard")
@@ -251,6 +340,9 @@ final class ConnectionService: ObservableObject {
} }
func disconnect() async { func disconnect() async {
healthMonitorTask?.cancel()
healthMonitorTask = nil
resetReconnectState()
await activeTransport?.disconnect() await activeTransport?.disconnect()
activeTransport = nil activeTransport = nil
deviceInfo = nil deviceInfo = nil
@@ -177,6 +177,15 @@ struct ConnectView: View {
return nil return nil
} }
/// "Versuch 3 · nächster in 4s" / "Versuch 3 " (while an attempt is actually in
/// flight, between the countdown reaching 0 and the next REST/SSH connect call
/// resolving) one line, so the reconnect banner stays compact.
private var reconnectStatusLine: String {
let attempt = L10n.t("Versuch", appLanguage) + " \(connectionService.reconnectAttemptCount)"
guard let seconds = connectionService.secondsUntilNextReconnectAttempt else { return attempt + "" }
return attempt + " · " + L10n.t("nächster in", appLanguage) + " \(seconds)s"
}
private var certificateAlertBinding: Binding<Bool> { private var certificateAlertBinding: Binding<Bool> {
Binding( Binding(
get: { certificateFingerprint != nil }, get: { certificateFingerprint != nil },
@@ -258,6 +267,21 @@ struct ConnectView: View {
private var deviceDetail: some View { private var deviceDetail: some View {
if let info = connectionService.deviceInfo { if let info = connectionService.deviceInfo {
Form { Form {
if connectionService.isReconnecting {
Section {
VStack(alignment: .leading, spacing: 4) {
Label {
Text(L10n.t("Verbindung unterbrochen — versuche automatisch, erneut zu verbinden…", appLanguage))
} icon: {
ProgressView().controlSize(.small)
}
Text(reconnectStatusLine)
.appFont(.caption)
.foregroundStyle(.secondary)
}
.foregroundStyle(.orange)
}
}
Section(L10n.t("Gerät", appLanguage)) { Section(L10n.t("Gerät", appLanguage)) {
LabeledContent(L10n.t("Modell", appLanguage), value: info.boardName) LabeledContent(L10n.t("Modell", appLanguage), value: info.boardName)
LabeledContent(L10n.t("RouterOS-Version", appLanguage), value: info.routerOSVersion) LabeledContent(L10n.t("RouterOS-Version", appLanguage), value: info.routerOSVersion)
+10
View File
@@ -142,6 +142,16 @@ Liste in sich selbst.</p>
<p>Der Punkt vor jedem Interface in der Geräte-Übersicht ist grau (kein <p>Der Punkt vor jedem Interface in der Geräte-Übersicht ist grau (kein
Link), grün (Link, aber kein Datenverkehr) oder pulsierend grün (überträgt Link), grün (Link, aber kein Datenverkehr) oder pulsierend grün (überträgt
gerade tatsächlich Daten).</p> gerade tatsächlich Daten).</p>
<h3>Automatisches Wiederverbinden</h3>
<p>Fällt die Verbindung während einer aktiven Sitzung weg (Router-Neustart,
Kabel/WLAN kurz unterbrochen), versucht die App selbständig, sie
wiederherzustellen — alle 10 Sekunden ein Herzschlag-Test, bei Ausfall
alle 5 Sekunden ein neuer Verbindungsversuch (REST zuerst, dann SSH),
unbegrenzt bis zum Erfolg oder bis „Trennen“ geklickt wird. Ein oranger
Hinweis mit Spinner erscheint währenddessen im Verbinden-Tab, inkl.
Versuchszähler und Countdown bis zum nächsten Versuch. Andere Tabs
bleiben während eines kurzen Aussetzers nutzbar, statt sofort auf „Nicht
verbunden“ umzuspringen.</p>
<hr /> <hr />
<h2>2. Einrichten (Wizard)</h2> <h2>2. Einrichten (Wizard)</h2>
<p>Geführter Schritt-für-Schritt-Assistent für die Grundkonfiguration. Ein <p>Geführter Schritt-für-Schritt-Assistent für die Grundkonfiguration. Ein
@@ -140,6 +140,15 @@ list scrolls in place past about 4 entries.</p>
<p>The dot in front of each interface in the device overview is gray (no <p>The dot in front of each interface in the device overview is gray (no
link), green (link, but no traffic), or pulsing green (actively link), green (link, but no traffic), or pulsing green (actively
transferring data right now).</p> transferring data right now).</p>
<h3>Automatic Reconnection</h3>
<p>If the connection drops during an active session (router reboot,
cable/Wi-Fi briefly interrupted), the app tries to restore it on its
own — a heartbeat check every 10 seconds, and on failure a new connect
attempt every 5 seconds (REST first, then SSH), indefinitely until it
succeeds or &quot;Disconnect&quot; is clicked. An orange notice with a spinner
appears in the Connect tab meanwhile, including an attempt counter and a
countdown to the next try. Other tabs stay usable during a brief outage
instead of immediately switching to &quot;Not connected&quot;.</p>
<hr /> <hr />
<h2>2. Setup (Wizard)</h2> <h2>2. Setup (Wizard)</h2>
<p>A guided step-by-step assistant for basic configuration. A mode switch <p>A guided step-by-step assistant for basic configuration. A mode switch
+15 -1
View File
@@ -211,6 +211,20 @@ Nachbesserung 2 (User-Feedback: auch das "Weitere Parameter"-Grid im Experte-Bea
Build grün, alle 99 Unit-Tests grün. Bitte erneut live testen. Build grün, alle 99 Unit-Tests grün. Bitte erneut live testen.
### 10. Selbständiger Wiederverbindungsversuch nach Disconnect ### 10. Selbständiger Wiederverbindungsversuch nach Disconnect
**Status:** offen **Status:** fixed (live bestätigt, zusätzlich beim echten Firmware-Update-Neustart mitgetestet)
Wunsch: fällt die Verbindung zum Router weg (z.B. während einer laufenden Sitzung), soll die App selbständig versuchen, die Verbindung wiederherzustellen, statt einfach im getrennten Zustand zu bleiben. Wunsch: fällt die Verbindung zum Router weg (z.B. während einer laufenden Sitzung), soll die App selbständig versuchen, die Verbindung wiederherzustellen, statt einfach im getrennten Zustand zu bleiben.
Ist-Zustand vor dem Fix: `ConnectionService.state` blieb `.connected`, bis der Nutzer explizit "Trennen" klickte — es gab überhaupt keinen Mechanismus, der einen tatsächlichen Verbindungsverlust (Router aus, Kabel raus, WLAN weg) erkannt hätte. Einzelne fehlschlagende Anfragen zeigten nur lokale Fehlermeldungen pro Tab, der App-weite Zustand blieb unberührt.
Umsetzung (`ConnectionService.swift`):
- Neuer Hintergrund-Task (`healthMonitorTask`), gestartet bei jeder erfolgreichen Verbindung: alle 10s ein minimaler Lese-Test (`/system identity`, kleinstmögliches Singleton-Menü) als Herzschlag.
- Schlägt der Herzschlag fehl: `isReconnecting = true`, danach Retry-Schleife alle 5s (REST zuerst, dann SSH-Fallback, dieselbe Reihenfolge wie ein normaler Connect) — läuft unbegrenzt weiter, bis entweder die Verbindung wiederhergestellt ist oder der Nutzer explizit "Trennen" klickt (bricht den Task sofort ab).
- `state` bleibt bewusst durchgehend `.connected`, damit andere Tabs während eines kurzen Aussetzers nicht auf ihre "Nicht verbunden"-Platzhalter umspringen — nur `isReconnecting` (neues `@Published`) spiegelt den Zustand, sichtbar als oranger Hinweis mit Spinner oben im Verbinden-Tab ("Verbindung unterbrochen — versuche automatisch, erneut zu verbinden…").
- Reconnect nutzt bei Erfolg denselben `finishConnecting(using:)`-Pfad wie ein normaler Connect — Geräte-/Routerboard-Info und Interface-Liste werden dabei automatisch neu geladen.
Build grün, alle 99 Unit-Tests grün. Live bestätigt ("funktion").
Nachbesserung 1 (User-Wunsch: "ein countdown, während wiederverbindens noch mit einbauen und wieviel versuche bereits gelaufen sind"): zwei neue `@Published`-Werte in `ConnectionService``reconnectAttemptCount` (hochgezählt pro REST+SSH-Runde) und `secondsUntilNextReconnectAttempt` (sekündlich runtergezählt zwischen den Versuchen, `nil` während ein Versuch tatsächlich läuft). Banner im Verbinden-Tab zeigt jetzt eine zweite, kleinere Zeile: "Versuch 3 · nächster in 4s" (bzw. "Versuch 3 …" während der Verbindungsversuch selbst läuft).
Build grün, alle 99 Unit-Tests grün. Bitte nochmal live testen.