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:
@@ -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
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
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)
|
||||
|
||||
BIN
Binary file not shown.
Binary file not shown.
@@ -31,6 +31,10 @@ Befehl, der ausgeführt wird — nichts passiert unangekündigt.
|
||||
freiem Standort-Feld (Etage/Raum/Zweck), Liste scrollt ab ca. 4
|
||||
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.
|
||||
**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
|
||||
→ Review/Apply) mit Einfach- und Experte-Modus. Mehrere LAN-Interfaces
|
||||
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 |
|
||||
| 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 |
|
||||
| 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
|
||||
Session-Verlauf: [`HANDOFF.md`](HANDOFF.md) / [`CHATLOG.md`](CHATLOG.md).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 "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".</p>
|
||||
<hr />
|
||||
<h2>2. Setup (Wizard)</h2>
|
||||
<p>A guided step-by-step assistant for basic configuration. A mode switch
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 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.
|
||||
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user