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
@@ -16,6 +16,10 @@ enum L10n {
private static let translations: [String: String] = [
"Verbinden": "Connect",
"Verbindung unterbrochen — versuche automatisch, erneut zu verbinden…":
"Connection lost — trying to reconnect automatically…",
"Versuch": "Attempt",
"nächster in": "next in",
"Handbuch": "Manual",
"Hilfe zu diesem Bereich im Handbuch öffnen": "Open help for this area in the manual",
"Einrichten": "Setup",
@@ -29,6 +29,19 @@ final class ConnectionService: ObservableObject {
/// Credentials of the current (or last attempted) connection, shared with features
/// that need their own dedicated connection, e.g. BackupService's SSH export.
@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 sshHostKeyTrust: SSHHostKeyTrustStore
@@ -230,11 +243,87 @@ final class ConnectionService: ObservableObject {
// whole connection over it.
routerBoardInfo = try? await Self.fetchRouterBoardInfo(using: transport)
state = .connected(kind: transport.kind)
if healthMonitorTask == nil {
startHealthMonitoring()
}
} catch {
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).
private static func fetchRouterBoardInfo(using transport: RouterOSTransport) async throws -> RouterBoardInfo {
let items = try await transport.fetchMenuItems(menuPath: "/system routerboard", restPath: "system/routerboard")
@@ -251,6 +340,9 @@ final class ConnectionService: ObservableObject {
}
func disconnect() async {
healthMonitorTask?.cancel()
healthMonitorTask = nil
resetReconnectState()
await activeTransport?.disconnect()
activeTransport = nil
deviceInfo = nil
@@ -177,6 +177,15 @@ struct ConnectView: View {
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> {
Binding(
get: { certificateFingerprint != nil },
@@ -258,6 +267,21 @@ struct ConnectView: View {
private var deviceDetail: some View {
if let info = connectionService.deviceInfo {
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)) {
LabeledContent(L10n.t("Modell", appLanguage), value: info.boardName)
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
Link), grün (Link, aber kein Datenverkehr) oder pulsierend grün (überträgt
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 />
<h2>2. Einrichten (Wizard)</h2>
<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
link), green (link, but no traffic), or pulsing green (actively
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 />
<h2>2. Setup (Wizard)</h2>
<p>A guided step-by-step assistant for basic configuration. A mode switch