From 99d90567c9a4ad3381f46cd31c64388a41615e2d Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Tue, 16 Jun 2026 08:49:03 +0200 Subject: [PATCH 01/43] feat(installer): add InstallState model for MDB-side state.json --- lib/models/install_state.dart | 107 ++++++++++++++++++++++++++++ test/models/install_state_test.dart | 64 +++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 lib/models/install_state.dart create mode 100644 test/models/install_state_test.dart diff --git a/lib/models/install_state.dart b/lib/models/install_state.dart new file mode 100644 index 0000000..c0ca7db --- /dev/null +++ b/lib/models/install_state.dart @@ -0,0 +1,107 @@ +import 'dart:convert'; + +/// Phases recorded in /data/installer/state.json on the MDB. Kebab-case JSON +/// values keep the door open for future CLI parity (the Go CLI uses the same +/// shape); btPaired/keycardEnrolled are flags layered on top so a Stage-1 +/// interruption does not redo pairing/enrollment. +enum InstallPhase { + mdbFlashed('mdb-flashed'), + mdbBooted('mdb-booted'), + btPaired('bt-paired'), + keycardEnrolled('keycard-enrolled'), + dbcStaged('dbc-staged'), + trampolineArmed('trampoline-armed'), + trampolineOk('trampoline-ok'), + trampolineErr('trampoline-err'), + finished('finished'), + /// Sentinel for an absent or unrecognized phase string; not a progression step. + unknown('unknown'); + + const InstallPhase(this.wire); + final String wire; + + static InstallPhase fromWire(String? s) => + InstallPhase.values.firstWhere((p) => p.wire == s, + orElse: () => InstallPhase.unknown); +} + +class InstallState { + InstallState({ + required this.phase, + this.channel, + this.releaseTag, + this.dbcImage, + this.targetDbcVersion, + this.osmTiles, + this.valhallaTiles, + this.language, + this.serial, + this.btPaired = false, + this.keycardEnrolled = false, + }); + + final InstallPhase phase; + final String? channel; + final String? releaseTag; + final String? dbcImage; + final String? targetDbcVersion; + final String? osmTiles; + final String? valhallaTiles; + final String? language; + final String? serial; + final bool btPaired; + final bool keycardEnrolled; + + /// Only fields that change after the initial write are exposed; the config + /// fields (channel, releaseTag, image, tiles, language, serial) are fixed. + InstallState copyWith({ + InstallPhase? phase, + bool? btPaired, + bool? keycardEnrolled, + }) => + InstallState( + phase: phase ?? this.phase, + channel: channel, + releaseTag: releaseTag, + dbcImage: dbcImage, + targetDbcVersion: targetDbcVersion, + osmTiles: osmTiles, + valhallaTiles: valhallaTiles, + language: language, + serial: serial, + btPaired: btPaired ?? this.btPaired, + keycardEnrolled: keycardEnrolled ?? this.keycardEnrolled, + ); + + Map toJson() => { + 'phase': phase.wire, + if (channel != null) 'channel': channel, + if (releaseTag != null) 'release_tag': releaseTag, + if (dbcImage != null) 'dbc_image': dbcImage, + if (targetDbcVersion != null) 'target_dbc_version': targetDbcVersion, + if (osmTiles != null) 'osm_tiles': osmTiles, + if (valhallaTiles != null) 'valhalla_tiles': valhallaTiles, + if (language != null) 'language': language, + if (serial != null) 'serial': serial, + 'bt_paired': btPaired, + 'keycard_enrolled': keycardEnrolled, + }; + + factory InstallState.fromJson(Map j) => InstallState( + phase: InstallPhase.fromWire(j['phase'] as String?), + channel: j['channel'] as String?, + releaseTag: j['release_tag'] as String?, + dbcImage: j['dbc_image'] as String?, + targetDbcVersion: j['target_dbc_version'] as String?, + osmTiles: j['osm_tiles'] as String?, + valhallaTiles: j['valhalla_tiles'] as String?, + language: j['language'] as String?, + serial: j['serial'] as String?, + btPaired: j['bt_paired'] as bool? ?? false, + keycardEnrolled: j['keycard_enrolled'] as bool? ?? false, + ); + + String encode() => jsonEncode(toJson()); + static InstallState decode(String s) => + InstallState.fromJson(jsonDecode(s) as Map); +} diff --git a/test/models/install_state_test.dart b/test/models/install_state_test.dart new file mode 100644 index 0000000..99009e7 --- /dev/null +++ b/test/models/install_state_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:librescoot_installer/models/install_state.dart'; + +void main() { + group('InstallState', () { + test('round-trips through JSON', () { + final s = InstallState( + phase: InstallPhase.dbcStaged, + channel: 'stable', + releaseTag: 'v1.2.3', + dbcImage: 'librescoot-dbc-v1.2.3.wic.gz', + targetDbcVersion: 'v1.2.3', + osmTiles: 'berlin_brandenburg.osm.tiles', + valhallaTiles: 'berlin_brandenburg.valhalla.tiles', + language: 'de', + serial: 'ABC123', + btPaired: true, + keycardEnrolled: false, + ); + final decoded = InstallState.fromJson(s.toJson()); + expect(decoded.phase, InstallPhase.dbcStaged); + expect(decoded.channel, 'stable'); + expect(decoded.releaseTag, 'v1.2.3'); + expect(decoded.dbcImage, 'librescoot-dbc-v1.2.3.wic.gz'); + expect(decoded.targetDbcVersion, 'v1.2.3'); + expect(decoded.osmTiles, 'berlin_brandenburg.osm.tiles'); + expect(decoded.valhallaTiles, 'berlin_brandenburg.valhalla.tiles'); + expect(decoded.language, 'de'); + expect(decoded.serial, 'ABC123'); + expect(decoded.btPaired, true); + expect(decoded.keycardEnrolled, false); + }); + + test('omits null optional fields from JSON', () { + final json = InstallState(phase: InstallPhase.mdbFlashed).toJson(); + expect(json.containsKey('channel'), isFalse); + expect(json.containsKey('release_tag'), isFalse); + expect(json['bt_paired'], false); + expect(json['keycard_enrolled'], false); + }); + + test('serializes phase as kebab-case string', () { + final json = InstallState(phase: InstallPhase.trampolineArmed).toJson(); + expect(json['phase'], 'trampoline-armed'); + }); + + test('parses kebab-case phase string', () { + final s = InstallState.fromJson({'phase': 'mdb-booted'}); + expect(s.phase, InstallPhase.mdbBooted); + }); + + test('unknown phase string falls back to unknown', () { + final s = InstallState.fromJson({'phase': 'bogus'}); + expect(s.phase, InstallPhase.unknown); + }); + + test('encode/decode is stable across a string trip', () { + final s = InstallState(phase: InstallPhase.btPaired, channel: 'testing'); + final reparsed = InstallState.decode(s.encode()); + expect(reparsed.phase, InstallPhase.btPaired); + expect(reparsed.channel, 'testing'); + }); + }); +} From bbce3bb3d8ffb6e68dd98eb8f562f8f160114212 Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Tue, 16 Jun 2026 08:57:53 +0200 Subject: [PATCH 02/43] feat(installer): read/write MDB state.json over SSH --- lib/services/ssh_service.dart | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/lib/services/ssh_service.dart b/lib/services/ssh_service.dart index 597a28a..4738e81 100644 --- a/lib/services/ssh_service.dart +++ b/lib/services/ssh_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import '../models/install_state.dart'; import '../models/scooter_health.dart'; import '../models/trampoline_status.dart'; import 'package:dartssh2/dartssh2.dart'; @@ -1255,6 +1256,32 @@ class SshService { return true; } + /// Read /data/installer/state.json from the MDB. Returns null if absent or + /// unparseable (e.g. stock OS has no /data). + Future readInstallState() async { + try { + final out = await runCommand( + 'cat /data/installer/state.json 2>/dev/null', + timeout: const Duration(seconds: 10), + ); + if (out.trim().isEmpty) return null; + return InstallState.decode(out.trim()); + } catch (_) { + return null; + } + } + + /// Atomically write /data/installer/state.json on the MDB. + Future writeInstallState(InstallState state) async { + final json = state.encode().replaceAll("'", "'\\''"); + await runCommand( + "mkdir -p /data/installer && " + "printf '%s' '$json' > /data/installer/state.json.tmp && " + "sync && mv /data/installer/state.json.tmp /data/installer/state.json && sync", + timeout: const Duration(seconds: 10), + ); + } + /// Read the trampoline status file from MDB. Future readTrampolineStatus() async { try { From ea8a6efcd5d902dcaec5f4e322a232e55f97a23d Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Tue, 16 Jun 2026 09:20:28 +0200 Subject: [PATCH 03/43] feat(installer): two-stage flow, move BT+keycard into MDB prep Reorder install phases into two stages: a Stage-1 dashboardPrep step that runs Bluetooth pairing and keycard enrollment in the foreground while the DBC image/tiles upload in the background, then a standalone DBC swap-and-flash step. The Begin button unlocks once BT and keycard are done-or-skipped and the upload has completed. - Replace dbcPrep/dbcFlash phases with dashboardPrep and dbcSwapAndFlash; group BT+keycard under the new mdbPrep major step; make reconnect a hidden failure-resume phase. - Extract BT/keycard builders into content methods reused by both the standalone phases and the dashboardPrep screen (no duplicated UI). - Drop the host-side DBC-flash MDB reconnect poll on the happy path; the user confirms via the dashboard-lit-up button, failures route to verify. - Persist resume checkpoints (mdb-booted, dbc-staged, bt-paired, keycard-enrolled, trampoline-armed) to /data/installer/state.json. - Add l10n keys for the new phase/major-step labels. --- lib/l10n/app_de.arb | 12 +- lib/l10n/app_en.arb | 12 +- lib/l10n/app_localizations.dart | 36 ++- lib/l10n/app_localizations_de.dart | 20 +- lib/l10n/app_localizations_en.dart | 20 +- lib/l10n/phase_l10n.dart | 11 +- lib/models/installer_phase.dart | 37 ++- lib/screens/installer_screen.dart | 443 +++++++++++++++++++------- test/models/installer_phase_test.dart | 28 ++ 9 files changed, 456 insertions(+), 163 deletions(-) create mode 100644 test/models/installer_phase_test.dart diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index d5f9c5b..da01a15 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -26,12 +26,12 @@ "phaseMdbBootDescription": "AUX wieder anschließen, auf Boot warten", "phaseCbbReconnectTitle": "CBB anschließen", "phaseCbbReconnectDescription": "CBB für DBC-Flash wieder anschließen", - "phaseDbcPrepTitle": "DBC vorbereiten", - "phaseDbcPrepDescription": "DBC-Image und Karten hochladen", - "phaseDbcFlashTitle": "DBC flashen", - "phaseDbcFlashDescription": "Autonome DBC-Installation", + "phaseDashboardPrepTitle": "Dashboard vorbereiten", + "phaseDashboardPrepDescription": "Koppeln, Keycards anlernen, DBC-Image vorbereiten", + "phaseDbcSwapAndFlashTitle": "DBC flashen", + "phaseDbcSwapAndFlashDescription": "Kabel umstecken; der Roller flasht den DBC", "phaseReconnectTitle": "Verbinden", - "phaseReconnectDescription": "DBC-Installation prüfen", + "phaseReconnectDescription": "Nach einem unterbrochenen DBC-Flash prüfen", "phaseBluetoothPairingTitle": "Bluetooth", "phaseBluetoothPairingDescription": "Handy oder andere Geräte koppeln", "phaseFinishTitle": "Fertig", @@ -39,6 +39,7 @@ "majorStepPrepare": "Vorbereitung", "majorStepConnect": "Verbinden", "majorStepMdbFlash": "MDB flashen", + "majorStepMdbPrep": "Dashboard vorbereiten", "majorStepDbcFlash": "DBC flashen", "majorStepFinish": "Abschluss", "majorStepSkippedSuffix": "übersprungen", @@ -199,6 +200,7 @@ "cbbDetectionMayTakeMinutes": "Das kann mehrere Minuten dauern, bitte etwas Geduld.", "preparingDbcFlash": "DBC-Flash wird vorbereitet", "waitingForDownloads": "Warte auf Abschluss der Downloads...", + "finishStepsAboveToContinue": "Schließe die Schritte oben ab, um fortzufahren.", "startingTrampoline": "Trampoline-Skript wird gestartet...", "uploadError": "Upload-Fehler: {error}", "dbcReadyButton": "DBC-Flashen beginnen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 216b719..f91d3be 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -29,12 +29,12 @@ "phaseMdbBootDescription": "Reconnect AUX, wait for boot", "phaseCbbReconnectTitle": "Reconnect CBB & Battery", "phaseCbbReconnectDescription": "Reconnect CBB for DBC flash", - "phaseDbcPrepTitle": "Upload Files", - "phaseDbcPrepDescription": "Upload DBC image and tiles", - "phaseDbcFlashTitle": "Flash Image", - "phaseDbcFlashDescription": "Autonomous DBC installation", + "phaseDashboardPrepTitle": "Dashboard Prep", + "phaseDashboardPrepDescription": "Pair, enroll keycards, stage DBC image", + "phaseDbcSwapAndFlashTitle": "Flash Image", + "phaseDbcSwapAndFlashDescription": "Swap cable; scooter flashes the DBC", "phaseReconnectTitle": "Verify", - "phaseReconnectDescription": "Verify DBC installation", + "phaseReconnectDescription": "Verify after an interrupted DBC flash", "phaseBluetoothPairingTitle": "Bluetooth", "phaseBluetoothPairingDescription": "Pair phone or other devices", "phaseFinishTitle": "Finish", @@ -43,6 +43,7 @@ "majorStepPrepare": "Prepare", "majorStepConnect": "Connect", "majorStepMdbFlash": "Flash MDB", + "majorStepMdbPrep": "Dashboard Prep", "majorStepDbcFlash": "Flash DBC", "majorStepFinish": "Finish", "majorStepSkippedSuffix": "skipped", @@ -251,6 +252,7 @@ "preparingDbcFlash": "Preparing DBC Flash", "waitingForDownloads": "Waiting for downloads to complete...", + "finishStepsAboveToContinue": "Finish the steps above to continue.", "startingTrampoline": "Starting trampoline script...", "uploadError": "Upload error: {error}", "@uploadError": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index f676e96..1d0577f 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -254,29 +254,29 @@ abstract class AppLocalizations { /// **'Reconnect CBB for DBC flash'** String get phaseCbbReconnectDescription; - /// No description provided for @phaseDbcPrepTitle. + /// No description provided for @phaseDashboardPrepTitle. /// /// In en, this message translates to: - /// **'Upload Files'** - String get phaseDbcPrepTitle; + /// **'Dashboard Prep'** + String get phaseDashboardPrepTitle; - /// No description provided for @phaseDbcPrepDescription. + /// No description provided for @phaseDashboardPrepDescription. /// /// In en, this message translates to: - /// **'Upload DBC image and tiles'** - String get phaseDbcPrepDescription; + /// **'Pair, enroll keycards, stage DBC image'** + String get phaseDashboardPrepDescription; - /// No description provided for @phaseDbcFlashTitle. + /// No description provided for @phaseDbcSwapAndFlashTitle. /// /// In en, this message translates to: /// **'Flash Image'** - String get phaseDbcFlashTitle; + String get phaseDbcSwapAndFlashTitle; - /// No description provided for @phaseDbcFlashDescription. + /// No description provided for @phaseDbcSwapAndFlashDescription. /// /// In en, this message translates to: - /// **'Autonomous DBC installation'** - String get phaseDbcFlashDescription; + /// **'Swap cable; scooter flashes the DBC'** + String get phaseDbcSwapAndFlashDescription; /// No description provided for @phaseReconnectTitle. /// @@ -287,7 +287,7 @@ abstract class AppLocalizations { /// No description provided for @phaseReconnectDescription. /// /// In en, this message translates to: - /// **'Verify DBC installation'** + /// **'Verify after an interrupted DBC flash'** String get phaseReconnectDescription; /// No description provided for @phaseBluetoothPairingTitle. @@ -332,6 +332,12 @@ abstract class AppLocalizations { /// **'Flash MDB'** String get majorStepMdbFlash; + /// No description provided for @majorStepMdbPrep. + /// + /// In en, this message translates to: + /// **'Dashboard Prep'** + String get majorStepMdbPrep; + /// No description provided for @majorStepDbcFlash. /// /// In en, this message translates to: @@ -1262,6 +1268,12 @@ abstract class AppLocalizations { /// **'Waiting for downloads to complete...'** String get waitingForDownloads; + /// No description provided for @finishStepsAboveToContinue. + /// + /// In en, this message translates to: + /// **'Finish the steps above to continue.'** + String get finishStepsAboveToContinue; + /// No description provided for @startingTrampoline. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 0cb1ab7..8711701 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -92,22 +92,25 @@ class AppLocalizationsDe extends AppLocalizations { 'CBB für DBC-Flash wieder anschließen'; @override - String get phaseDbcPrepTitle => 'DBC vorbereiten'; + String get phaseDashboardPrepTitle => 'Dashboard vorbereiten'; @override - String get phaseDbcPrepDescription => 'DBC-Image und Karten hochladen'; + String get phaseDashboardPrepDescription => + 'Koppeln, Keycards anlernen, DBC-Image vorbereiten'; @override - String get phaseDbcFlashTitle => 'DBC flashen'; + String get phaseDbcSwapAndFlashTitle => 'DBC flashen'; @override - String get phaseDbcFlashDescription => 'Autonome DBC-Installation'; + String get phaseDbcSwapAndFlashDescription => + 'Kabel umstecken; der Roller flasht den DBC'; @override String get phaseReconnectTitle => 'Verbinden'; @override - String get phaseReconnectDescription => 'DBC-Installation prüfen'; + String get phaseReconnectDescription => + 'Nach einem unterbrochenen DBC-Flash prüfen'; @override String get phaseBluetoothPairingTitle => 'Bluetooth'; @@ -131,6 +134,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get majorStepMdbFlash => 'MDB flashen'; + @override + String get majorStepMdbPrep => 'Dashboard vorbereiten'; + @override String get majorStepDbcFlash => 'DBC flashen'; @@ -664,6 +670,10 @@ class AppLocalizationsDe extends AppLocalizations { @override String get waitingForDownloads => 'Warte auf Abschluss der Downloads...'; + @override + String get finishStepsAboveToContinue => + 'Schließe die Schritte oben ab, um fortzufahren.'; + @override String get startingTrampoline => 'Trampoline-Skript wird gestartet...'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 726addf..eafb471 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -89,22 +89,25 @@ class AppLocalizationsEn extends AppLocalizations { String get phaseCbbReconnectDescription => 'Reconnect CBB for DBC flash'; @override - String get phaseDbcPrepTitle => 'Upload Files'; + String get phaseDashboardPrepTitle => 'Dashboard Prep'; @override - String get phaseDbcPrepDescription => 'Upload DBC image and tiles'; + String get phaseDashboardPrepDescription => + 'Pair, enroll keycards, stage DBC image'; @override - String get phaseDbcFlashTitle => 'Flash Image'; + String get phaseDbcSwapAndFlashTitle => 'Flash Image'; @override - String get phaseDbcFlashDescription => 'Autonomous DBC installation'; + String get phaseDbcSwapAndFlashDescription => + 'Swap cable; scooter flashes the DBC'; @override String get phaseReconnectTitle => 'Verify'; @override - String get phaseReconnectDescription => 'Verify DBC installation'; + String get phaseReconnectDescription => + 'Verify after an interrupted DBC flash'; @override String get phaseBluetoothPairingTitle => 'Bluetooth'; @@ -127,6 +130,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get majorStepMdbFlash => 'Flash MDB'; + @override + String get majorStepMdbPrep => 'Dashboard Prep'; + @override String get majorStepDbcFlash => 'Flash DBC'; @@ -657,6 +663,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get waitingForDownloads => 'Waiting for downloads to complete...'; + @override + String get finishStepsAboveToContinue => + 'Finish the steps above to continue.'; + @override String get startingTrampoline => 'Starting trampoline script...'; diff --git a/lib/l10n/phase_l10n.dart b/lib/l10n/phase_l10n.dart index eac2329..9131185 100644 --- a/lib/l10n/phase_l10n.dart +++ b/lib/l10n/phase_l10n.dart @@ -6,7 +6,8 @@ extension MajorStepL10n on MajorStep { MajorStep.prepare => l10n.majorStepPrepare, MajorStep.connect => l10n.majorStepConnect, MajorStep.mdbFlash => l10n.majorStepMdbFlash, - MajorStep.dbcFlash => l10n.majorStepDbcFlash, + MajorStep.mdbPrep => l10n.majorStepMdbPrep, + MajorStep.dbc => l10n.majorStepDbcFlash, MajorStep.finish => l10n.majorStepFinish, }; } @@ -25,8 +26,8 @@ extension InstallerPhaseL10n on InstallerPhase { InstallerPhase.scooterPrep => l10n.phaseScooterPrepTitle, InstallerPhase.mdbBoot => l10n.phaseMdbBootTitle, InstallerPhase.cbbReconnect => l10n.phaseCbbReconnectTitle, - InstallerPhase.dbcPrep => l10n.phaseDbcPrepTitle, - InstallerPhase.dbcFlash => l10n.phaseDbcFlashTitle, + InstallerPhase.dashboardPrep => l10n.phaseDashboardPrepTitle, + InstallerPhase.dbcSwapAndFlash => l10n.phaseDbcSwapAndFlashTitle, InstallerPhase.reconnect => l10n.phaseReconnectTitle, InstallerPhase.bluetoothPairing => l10n.phaseBluetoothPairingTitle, InstallerPhase.keycardSetup => l10n.phaseKeycardSetupTitle, @@ -46,8 +47,8 @@ extension InstallerPhaseL10n on InstallerPhase { InstallerPhase.scooterPrep => l10n.phaseScooterPrepDescription, InstallerPhase.mdbBoot => l10n.phaseMdbBootDescription, InstallerPhase.cbbReconnect => l10n.phaseCbbReconnectDescription, - InstallerPhase.dbcPrep => l10n.phaseDbcPrepDescription, - InstallerPhase.dbcFlash => l10n.phaseDbcFlashDescription, + InstallerPhase.dashboardPrep => l10n.phaseDashboardPrepDescription, + InstallerPhase.dbcSwapAndFlash => l10n.phaseDbcSwapAndFlashDescription, InstallerPhase.reconnect => l10n.phaseReconnectDescription, InstallerPhase.bluetoothPairing => l10n.phaseBluetoothPairingDescription, InstallerPhase.keycardSetup => l10n.phaseKeycardSetupDescription, diff --git a/lib/models/installer_phase.dart b/lib/models/installer_phase.dart index f56352f..aaf0afa 100644 --- a/lib/models/installer_phase.dart +++ b/lib/models/installer_phase.dart @@ -60,21 +60,11 @@ enum InstallerPhase { description: 'Reconnect CBB for DBC flash', isManual: true, ), - dbcPrep( - title: 'DBC Prep', - description: 'Upload DBC image and tiles', + dashboardPrep( + title: 'Dashboard Prep', + description: 'Pair, enroll keycards, stage DBC image', isManual: false, ), - dbcFlash( - title: 'DBC Flash', - description: 'Autonomous DBC installation', - isManual: false, - ), - reconnect( - title: 'Reconnect', - description: 'Verify DBC installation', - isManual: true, - ), bluetoothPairing( title: 'Bluetooth', description: 'Pair phone or other devices', @@ -85,6 +75,17 @@ enum InstallerPhase { description: 'Register master and user keycards', isManual: true, ), + dbcSwapAndFlash( + title: 'DBC Flash', + description: 'Swap cable; scooter flashes the DBC', + isManual: true, + ), + reconnect( + title: 'Reconnect', + description: 'Verify after an interrupted DBC flash', + isManual: true, + hiddenUnlessActive: true, + ), finish( title: 'Finish', description: 'Reassemble and welcome', @@ -112,8 +113,9 @@ enum MajorStep { prepare('Prepare', [InstallerPhase.welcome, InstallerPhase.notices, InstallerPhase.physicalPrep]), connect('Connect', [InstallerPhase.mdbConnect, InstallerPhase.resumeDetected, InstallerPhase.healthCheck]), mdbFlash('Flash MDB', [InstallerPhase.batteryRemoval, InstallerPhase.mdbToUms, InstallerPhase.mdbFlash, InstallerPhase.scooterPrep, InstallerPhase.mdbBoot, InstallerPhase.cbbReconnect]), - dbcFlash('Flash DBC', [InstallerPhase.dbcPrep, InstallerPhase.dbcFlash, InstallerPhase.reconnect]), - finish('Finish', [InstallerPhase.bluetoothPairing, InstallerPhase.keycardSetup, InstallerPhase.finish]); + mdbPrep('Dashboard Prep', [InstallerPhase.dashboardPrep, InstallerPhase.bluetoothPairing, InstallerPhase.keycardSetup]), + dbc('Flash DBC', [InstallerPhase.dbcSwapAndFlash]), + finish('Finish', [InstallerPhase.finish]); const MajorStep(this.title, this.phases); @@ -130,6 +132,9 @@ enum MajorStep { } static MajorStep forPhase(InstallerPhase phase) { - return MajorStep.values.firstWhere((s) => s.containsPhase(phase)); + return MajorStep.values.firstWhere( + (s) => s.containsPhase(phase), + orElse: () => MajorStep.connect, + ); } } diff --git a/lib/screens/installer_screen.dart b/lib/screens/installer_screen.dart index 53a5d9b..bc628eb 100644 --- a/lib/screens/installer_screen.dart +++ b/lib/screens/installer_screen.dart @@ -9,6 +9,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../main.dart' show LaunchArgs, installerLog, launchArgs, showElevationRequiredDialog; import '../l10n/app_localizations.dart'; import '../models/download_state.dart'; +import '../models/install_state.dart'; import '../models/installer_phase.dart'; import '../models/region.dart'; import '../models/scooter_health.dart'; @@ -59,8 +60,17 @@ class _InstallerScreenState extends State { bool _mdbToUmsStarted = false; bool _mdbFlashStarted = false; bool _mdbBootStarted = false; - bool _dbcPrepStarted = false; bool _dbcUploadReady = false; // upload done, waiting for "Begin flashing DBC" + // Stage-1 dashboardPrep tracking. The background DBC upload runs while + // Bluetooth pairing + keycard enrollment happen in the foreground; the + // "Begin flashing DBC" button only unlocks once all three are satisfied. + bool _dashboardPrepStarted = false; // background upload kicked off + bool _btDone = false; + bool _btSkipped = false; + bool _keycardDone = false; + bool _keycardSkipped = false; + // Which interactive sub-step the dashboardPrep screen is showing. + _DashboardPrepStep _dashboardPrepStep = _DashboardPrepStep.bluetooth; bool _reconnectStarted = false; bool _showElevatedHandoff = false; bool _dbcFlashSimulateError = false; @@ -265,21 +275,48 @@ class _InstallerScreenState extends State { phase != InstallerPhase.keycardSetup) { _keycardTearDown(); } + // The keycard sub-step inside dashboardPrep subscribes to keycard events + // the same way the standalone phase does, so tear it down when we leave. + if (leaving == InstallerPhase.dashboardPrep && + phase != InstallerPhase.dashboardPrep) { + _keycardTearDown(); + } if (phase == InstallerPhase.keycardSetup) { _onEnterKeycardSetup(); } if (phase == InstallerPhase.finish) { _onEnterFinish(); } - if (phase == InstallerPhase.dbcFlash) { + if (phase == InstallerPhase.dbcSwapAndFlash) { _dbcFlashWatchStarted = false; _dbcUsbDisconnected = false; } - if (phase == InstallerPhase.bluetoothPairing) { + if (phase == InstallerPhase.dashboardPrep || phase == InstallerPhase.bluetoothPairing) { _fetchBleMac(); } } + /// Build an [InstallState] for the given [phase], carrying the fixed install + /// config (channel, release, image, tiles, language, serial) plus the current + /// Stage-1 progress flags. Used for every resume checkpoint write so the + /// state file always reflects the full install context. + InstallState _baseInstallState(InstallPhase phase) { + final dbcItem = _downloadState.itemOfType(DownloadItemType.dbcFirmware); + final osmItem = _downloadState.itemOfType(DownloadItemType.osmTiles); + final valhallaItem = _downloadState.itemOfType(DownloadItemType.valhallaTiles); + return InstallState( + phase: phase, + channel: _downloadState.channel.name, + releaseTag: _downloadState.releaseTag, + dbcImage: dbcItem?.filename, + osmTiles: osmItem?.filename, + valhallaTiles: valhallaItem?.filename, + serial: _mdbInfo?.serialNumber, + btPaired: _btDone, + keycardEnrolled: _keycardDone, + ); + } + /// Read the scooter's BLE MAC from the MDB so the user can match it against /// the device they're pairing to. Best-effort; leaves _bleMac null on error. Future _fetchBleMac() async { @@ -760,8 +797,8 @@ class _InstallerScreenState extends State { InstallerPhase.scooterPrep => _buildScooterPrep(l10n), InstallerPhase.mdbBoot => _buildMdbBoot(l10n), InstallerPhase.cbbReconnect => _buildCbbReconnect(l10n), - InstallerPhase.dbcPrep => _buildDbcPrep(l10n), - InstallerPhase.dbcFlash => _buildDbcFlash(l10n), + InstallerPhase.dashboardPrep => _buildDashboardPrep(l10n), + InstallerPhase.dbcSwapAndFlash => _buildDbcSwapAndFlash(l10n), InstallerPhase.reconnect => _buildReconnect(l10n), InstallerPhase.bluetoothPairing => _buildBluetoothPairing(l10n), InstallerPhase.keycardSetup => _buildKeycardSetup(l10n), @@ -1916,10 +1953,13 @@ class _InstallerScreenState extends State { _skippedPhases.add(phase); } if (_skipDbcFlash) { - for (final phase in MajorStep.dbcFlash.phases) { + // DBC flash itself is skipped, but Bluetooth pairing + keycard + // enrollment still run as Stage 1. dashboardPrep handles that and + // the "skip DBC, finish" button when _skipDbcFlash is set. + for (final phase in MajorStep.dbc.phases) { _skippedPhases.add(phase); } - _setPhase(InstallerPhase.bluetoothPairing); + _setPhase(InstallerPhase.dashboardPrep); } else { _setPhase(InstallerPhase.cbbReconnect); } @@ -2673,6 +2713,15 @@ class _InstallerScreenState extends State { try { await _sshService.connectToMdb(); + // MDB has booted the freshly-flashed image and we have SSH back: persist + // the resume point so an interruption during Stage 1 (BT/keycard/DBC + // upload) resumes from here instead of re-flashing the MDB. + try { + await _sshService.writeInstallState(_baseInstallState(InstallPhase.mdbBooted)); + } catch (e) { + debugPrint('UI: failed to write install state (mdb-booted), non-fatal: $e'); + } + // Disable keycard-service for the rest of the install. A freshly flashed // MDB boots into auto-master-learn mode; any tap before the explicit // keycard-setup phase would silently teach in a master card. We re-start @@ -2706,7 +2755,8 @@ class _InstallerScreenState extends State { } if (_skipDbcFlash) { - _setPhase(InstallerPhase.bluetoothPairing); + // Even when skipping the DBC flash, Stage 1 (BT + keycard) still runs. + _setPhase(InstallerPhase.dashboardPrep); } else { _setPhase(InstallerPhase.cbbReconnect); } @@ -2812,7 +2862,7 @@ class _InstallerScreenState extends State { } if (mounted) { setState(() => _batteryDetected = bat); - if (bat) _setPhase(InstallerPhase.dbcPrep); + if (bat) _setPhase(InstallerPhase.dashboardPrep); } } }); @@ -2929,7 +2979,7 @@ class _InstallerScreenState extends State { await _sshService.logScooterStats('cbb-and-battery-reconnected'); setState(() { _batteryDetected = true; _isProcessing = false; }); await Future.delayed(const Duration(seconds: 1)); - if (mounted) _setPhase(InstallerPhase.dbcPrep); + if (mounted) _setPhase(InstallerPhase.dashboardPrep); } else { _setStatus(l10n.cbbNotDetected); setState(() => _isProcessing = false); @@ -2939,7 +2989,7 @@ class _InstallerScreenState extends State { ), const SizedBox(height: 12), TextButton( - onPressed: () => _setPhase(InstallerPhase.dbcPrep), + onPressed: () => _setPhase(InstallerPhase.dashboardPrep), child: Text(l10n.proceedWithoutCbb, style: TextStyle(color: Colors.grey.shade600, fontSize: 12)), ), @@ -2963,7 +3013,7 @@ class _InstallerScreenState extends State { if (_isDryRun) { _setStatus('[DRY RUN] CBB connected'); await Future.delayed(const Duration(seconds: 1)); - _setPhase(InstallerPhase.dbcPrep); + _setPhase(InstallerPhase.dashboardPrep); return; } _setStatus(l10n.checkingCbbAndBattery); @@ -2972,7 +3022,7 @@ class _InstallerScreenState extends State { if (await _sshService.isCbbPresent()) { _setStatus(l10n.cbbConnected); await Future.delayed(const Duration(seconds: 1)); - _setPhase(InstallerPhase.dbcPrep); + _setPhase(InstallerPhase.dashboardPrep); return; } attempts++; @@ -2987,11 +3037,35 @@ class _InstallerScreenState extends State { }); } - Widget _buildDbcPrep(AppLocalizations l10n) { - if (!_dbcPrepStarted && !_isProcessing) { - _dbcPrepStarted = true; - Future.microtask(_uploadDbcFiles); + /// Stage 1: pair Bluetooth and enroll keycards in the foreground while the + /// DBC image/tiles upload runs in the background. The "Begin flashing DBC" + /// button unlocks only once BT is done-or-skipped, keycard is + /// done-or-skipped, and the background upload has completed. + Widget _buildDashboardPrep(AppLocalizations l10n) { + // Kick off the background upload once, on first entry. The upload itself + // doesn't navigate; it just flips _dbcUploadReady when finished. When the + // DBC flash is skipped there is nothing to upload, so treat it as ready. + if (!_dashboardPrepStarted) { + _dashboardPrepStarted = true; + if (_skipDbcFlash) { + _dbcUploadReady = true; + } else { + Future.microtask(_uploadDbcFiles); + } + } + + final btSatisfied = _btDone || _btSkipped; + final keycardSatisfied = _keycardDone || _keycardSkipped; + final beginEnabled = + btSatisfied && keycardSatisfied && _dbcUploadReady && !_isProcessing; + + final Widget interactive; + if (_dashboardPrepStep == _DashboardPrepStep.bluetooth) { + interactive = _bluetoothPairingContent(l10n); + } else { + interactive = _keycardSetupContent(l10n); } + return Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560), @@ -2999,60 +3073,106 @@ class _InstallerScreenState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text(l10n.preparingDbcFlash, - style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold), - textAlign: TextAlign.center), - const SizedBox(height: 16), - LinearProgressIndicator(value: _progress, minHeight: 6), - const SizedBox(height: 16), - if (_dbcPrepSubsteps.isNotEmpty) - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: const Color(0xFF1A1A1A), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey.shade800), - ), - child: SubstepList(substeps: _dbcPrepSubsteps), - ) - else - Text(_statusMessage, - style: TextStyle(color: Colors.grey.shade400, fontSize: 13), - textAlign: TextAlign.center), - if (_dbcUploadReady) ...[ - const SizedBox(height: 20), + // No upload to show when the DBC flash is being skipped. + if (!_skipDbcFlash) ...[ + _dbcUploadProgressStrip(l10n), + const SizedBox(height: 24), + ], + interactive, + const SizedBox(height: 24), + Center( + child: FilledButton.icon( + onPressed: beginEnabled ? _onBeginFlashingDbc : null, + icon: Icon(_skipDbcFlash ? Icons.arrow_forward : Icons.bolt), + label: Text(_skipDbcFlash ? l10n.skipDbcFlashOption : l10n.dbcReadyButton), + ), + ), + if (!beginEnabled) ...[ + const SizedBox(height: 8), Center( - child: FilledButton.icon( - onPressed: _isProcessing ? null : _startTrampoline, - icon: const Icon(Icons.bolt), - label: Text(l10n.dbcReadyButton), + child: Text( + !_dbcUploadReady ? l10n.waitingForDownloads : l10n.finishStepsAboveToContinue, + style: TextStyle(color: Colors.grey.shade500, fontSize: 12), + textAlign: TextAlign.center, ), ), - ] else if (!_isProcessing) ...[ - const SizedBox(height: 16), - Center( - child: FilledButton.icon( + ], + ], + ), + ), + ); + } + + /// Small persistent strip showing the background DBC upload progress while + /// the user works through BT pairing + keycard enrollment. + Widget _dbcUploadProgressStrip(AppLocalizations l10n) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade800), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + _dbcUploadReady ? Icons.check_circle : Icons.cloud_upload_outlined, + size: 18, + color: _dbcUploadReady ? kAccent : Colors.grey.shade400, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _dbcUploadReady ? l10n.dbcFlashSuccessful : l10n.preparingDbcFlash, + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14), + ), + ), + ], + ), + const SizedBox(height: 10), + if (!_dbcUploadReady) ...[ + LinearProgressIndicator(value: _progress > 0 ? _progress : null, minHeight: 6), + const SizedBox(height: 8), + if (_dbcPrepSubsteps.isNotEmpty) + SubstepList(substeps: _dbcPrepSubsteps) + else + Text(_statusMessage, + style: TextStyle(color: Colors.grey.shade400, fontSize: 12)), + // Upload failed (not processing, not ready): offer a retry. + if (!_isProcessing) ...[ + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton.icon( onPressed: () { - setState(() { - _dbcPrepStarted = false; - _dbcPrepSubsteps = const []; - }); - Future.microtask(() { - setState(() => _dbcPrepStarted = true); - _uploadDbcFiles(); - }); + setState(() => _dbcPrepSubsteps = const []); + Future.microtask(_uploadDbcFiles); }, - icon: const Icon(Icons.refresh), + icon: const Icon(Icons.refresh, size: 16), label: Text(l10n.retryDbcPrep), ), ), ], ], - ), + ], ), ); } + /// "Begin flashing DBC" handler for the Stage-1 screen. Arms the trampoline + /// (last thing over SSH) and hands off to the cable-swap screen. When the + /// user opted to skip the DBC flash entirely, jump straight to finish. + Future _onBeginFlashingDbc() async { + if (_skipDbcFlash) { + _setPhase(InstallerPhase.finish); + return; + } + await _startTrampoline(); + } + Future _uploadDbcFiles() async { final l10n = AppLocalizations.of(context)!; setState(() => _isProcessing = true); @@ -3102,28 +3222,34 @@ class _InstallerScreenState extends State { // Upload is done, but DON'T start the trampoline yet. The trampoline's // first act is to wait for the laptop to disconnect, after which the // install runs autonomously and we lose SSH. Stay on this page and - // surface the "Ready to flash DBC" button instead, so the user + // surface the "Begin flashing DBC" button instead, so the user // explicitly confirms before that point of no return. The cable-swap // instructions only appear on the next screen, after the trampoline // has started, so nobody can swap the cable before start() runs. + try { + await _sshService.writeInstallState(_baseInstallState(InstallPhase.dbcStaged)); + } catch (e) { + debugPrint('UI: failed to write install state (dbc-staged), non-fatal: $e'); + } _setCritical(false); - setState(() { _isProcessing = false; _dbcUploadReady = true; }); + if (mounted) setState(() { _isProcessing = false; _dbcUploadReady = true; }); } catch (e) { _setCritical(false); _setStatus(l10n.uploadError(e.toString())); debugPrint('DBC prep error: $e'); - setState(() => _isProcessing = false); - // Don't reset _dbcPrepStarted: retry button handles that + if (mounted) setState(() => _isProcessing = false); + // _dbcUploadReady stays false; the upload-progress strip's retry button + // re-runs _uploadDbcFiles. } } - /// Confirm handler for the "Ready to flash DBC" button on the prep page: + /// Confirm handler for the "Begin flashing DBC" button on the Stage-1 page: /// fire the trampoline (the last thing we do over SSH) and hand off to the /// swap-cables screen, which is the first place the user is told to touch /// the cable. Future _startTrampoline() async { final l10n = AppLocalizations.of(context)!; - setState(() { _isProcessing = true; _dbcUploadReady = false; }); + setState(() { _isProcessing = true; }); _setCritical(true); if (_isDryRun) { @@ -3131,24 +3257,30 @@ class _InstallerScreenState extends State { await Future.delayed(const Duration(seconds: 1)); _setCritical(false); setState(() => _isProcessing = false); - _setPhase(InstallerPhase.dbcFlash); + _setPhase(InstallerPhase.dbcSwapAndFlash); return; } try { _setStatus(l10n.startingTrampoline); await TrampolineService(_sshService).start(); + try { + await _sshService.writeInstallState(_baseInstallState(InstallPhase.trampolineArmed)); + } catch (e) { + debugPrint('UI: failed to write install state (trampoline-armed), non-fatal: $e'); + } _setCritical(false); setState(() => _isProcessing = false); await Future.delayed(const Duration(seconds: 1)); - _setPhase(InstallerPhase.dbcFlash); + _setPhase(InstallerPhase.dbcSwapAndFlash); } catch (e) { _setCritical(false); _setStatus(l10n.uploadError(e.toString())); debugPrint('Trampoline start error: $e'); - // The upload is still intact; re-offer the begin button instead of - // demoting the user to a full prep retry over a transient SSH error. - setState(() { _isProcessing = false; _dbcUploadReady = true; }); + // The upload is still intact; the Begin button stays enabled so the user + // can retry instead of being demoted to a full prep retry over a + // transient SSH error. + setState(() { _isProcessing = false; }); } } @@ -3161,8 +3293,10 @@ class _InstallerScreenState extends State { bool _reconnectShowDiagnostics = false; String? _reconnectDiagnostics; - Widget _buildDbcFlash(AppLocalizations l10n) { - // Start watching for USB disconnect and MDB reconnect + Widget _buildDbcSwapAndFlash(AppLocalizations l10n) { + // Watch for the laptop USB cable being unplugged so we can flip from the + // swap-cables instructions to the in-progress view. On the happy path we + // do NOT poll the MDB or auto-advance; the user confirms via the buttons. if (!_dbcFlashWatchStarted) { _dbcFlashWatchStarted = true; _watchDbcFlash(); @@ -3336,19 +3470,26 @@ class _InstallerScreenState extends State { ], ), const SizedBox(height: 16), + // The flash runs autonomously on the scooter; the laptop is no + // longer in the loop. The user can walk away and come back. + Text(l10n.dbcFlashDurationHeadline, + style: TextStyle(color: Colors.grey.shade400, fontSize: 13), + textAlign: TextAlign.center), + const SizedBox(height: 16), Wrap( spacing: 12, runSpacing: 8, alignment: WrapAlignment.center, children: [ + // Happy path: the dashboard powered on. No MDB reconnect, no + // verify; we trust the lit display and finish. FilledButton.icon( - onPressed: () { - _dbcFlashSimulateError = false; - _setPhase(InstallerPhase.reconnect); - }, + onPressed: () => _setPhase(InstallerPhase.finish), icon: const Icon(Icons.check_circle, color: Colors.green), label: Text(l10n.ledIsGreen), ), + // Failure path: reconnect the laptop and run the verify logic + // to surface the trampoline error log. OutlinedButton.icon( onPressed: () { _dbcFlashSimulateError = true; @@ -3386,20 +3527,10 @@ class _InstallerScreenState extends State { final l10n = AppLocalizations.of(context)!; setState(() => _dbcUsbDisconnected = true); _setStatus(l10n.mdbDisconnectedFlashingDbc); - - // Poll for MDB reconnect every 10s: only while still on dbcFlash phase - while (mounted && _currentPhase == InstallerPhase.dbcFlash) { - await Future.delayed(const Duration(seconds: 10)); - if (_currentPhase != InstallerPhase.dbcFlash) return; - if (_device != null && _device!.mode == DeviceMode.ethernet) { - _setStatus(l10n.mdbReconnectedVerifying); - await Future.delayed(const Duration(seconds: 2)); - if (mounted && _currentPhase == InstallerPhase.dbcFlash) { - _setPhase(InstallerPhase.reconnect); - } - return; - } - } + // Happy path stops here: the laptop is out of the loop and the scooter + // flashes the DBC on its own. The user confirms completion with the + // "dashboard lit up" button; the failure affordance routes to the verify + // logic. We deliberately do NOT poll the MDB or auto-advance here anymore. } Widget _ledSignal(String signal, String meaning) { @@ -3549,20 +3680,26 @@ class _InstallerScreenState extends State { ), OutlinedButton.icon( onPressed: () { + // Re-arm from Stage 1: BT + keycard are already done + // (their flags persist), so dashboardPrep re-runs only + // the upload, then the user re-confirms Begin. setState(() { - _dbcPrepStarted = false; + _dashboardPrepStarted = false; + _dashboardPrepStep = _DashboardPrepStep.bluetooth; + _dbcUploadReady = false; _reconnectStarted = false; _reconnectShowDiagnostics = false; _reconnectDiagnostics = null; _reconnectSubsteps = const []; + _dbcPrepSubsteps = const []; }); - _setPhase(InstallerPhase.dbcPrep); + _setPhase(InstallerPhase.dashboardPrep); }, icon: const Icon(Icons.replay), label: Text(l10n.retryDbcFlash), ), TextButton( - onPressed: () => _setPhase(InstallerPhase.bluetoothPairing), + onPressed: () => _setPhase(InstallerPhase.finish), child: Text(l10n.skipToFinish), ), ], @@ -3698,7 +3835,7 @@ class _InstallerScreenState extends State { return; } _setStatus('[DRY RUN] DBC flash successful!'); - _setPhase(InstallerPhase.bluetoothPairing); + _setPhase(InstallerPhase.finish); return; } @@ -3756,8 +3893,7 @@ class _InstallerScreenState extends State { // Poll for trampoline status. A slow DBC first-boot (resize2fs on a // fresh filesystem) can take 5–10 minutes. Give it 5 minutes of quiet // polling, then surface the diagnostic panel — user can keep waiting, - // retry, or skip. The user can also bail by yanking USB and re-plugging; - // _watchDbcFlash picks that up and puts them back on the prep screen. + // retry, or skip. _setStatus(l10n.readingTrampolineStatus); _reconnectStatusWaitStart = DateTime.now(); TrampolineStatus status; @@ -3792,12 +3928,13 @@ class _InstallerScreenState extends State { // The green success-blink onboot.sh started means "safe to swap the // MDB's USB port back to the laptop". We only get here because the // laptop is already back on USB (that's how we read the status), so - // the cue has done its job — stop it now instead of letting it run - // decoratively through pairing + keycard setup. + // the cue has done its job — stop it now. BT pairing and keycard setup + // already happened in Stage 1, so a confirmed flash goes straight to + // finish. await _stopBootLedBlink(); _setStatus(l10n.dbcFlashSuccessful); await Future.delayed(const Duration(seconds: 2)); - _setPhase(InstallerPhase.bluetoothPairing); + _setPhase(InstallerPhase.finish); } else if (status.result == TrampolineResult.error) { // Quiet the failure indicators (red blink + hazards) now that we're // about to surface the actual error to the user. The helper also @@ -3832,8 +3969,14 @@ class _InstallerScreenState extends State { } Widget _buildBluetoothPairing(AppLocalizations l10n) { - return Center( - child: Column( + return Center(child: _bluetoothPairingContent(l10n)); + } + + /// Inner content of the Bluetooth pairing step, without the outer page + /// scaffold. Reused both as the standalone phase and as the first + /// interactive sub-step of the Stage-1 dashboardPrep screen. + Widget _bluetoothPairingContent(AppLocalizations l10n) { + return Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.bluetooth, size: 48, color: Colors.blueAccent), @@ -3876,7 +4019,7 @@ class _InstallerScreenState extends State { ), const SizedBox(height: 12), TextButton( - onPressed: () => _setPhase(InstallerPhase.keycardSetup), + onPressed: _skipBluetoothPairing, child: Text(l10n.skipPairing), ), ], @@ -3956,8 +4099,7 @@ class _InstallerScreenState extends State { ), ], ], - ), - ); + ); } Future _startBluetoothPairing() async { @@ -4016,6 +4158,51 @@ class _InstallerScreenState extends State { _blePinCode = null; _bleConnected = false; }); + await _bluetoothComplete(skipped: false); + } + + /// Skip handler for the Bluetooth step. Stops any active pairing first. + Future _skipBluetoothPairing() async { + if (_btPairingActive) { + _blePinPollTimer?.cancel(); + _blePinPollTimer = null; + try { + await _sshService.redisLpush('scooter:state', 'lock'); + } catch (_) {} + setState(() { + _btPairingActive = false; + _blePinCode = null; + _bleConnected = false; + }); + } + await _bluetoothComplete(skipped: true); + } + + /// Bluetooth step finished (done or skipped). Inside the Stage-1 + /// dashboardPrep screen this records the result and advances the interactive + /// sub-step to keycard enrollment, persisting the resume checkpoint on a real + /// pairing. As the standalone phase it just advances to keycardSetup. + Future _bluetoothComplete({required bool skipped}) async { + if (_currentPhase == InstallerPhase.dashboardPrep) { + setState(() { + if (skipped) { + _btSkipped = true; + } else { + _btDone = true; + } + _dashboardPrepStep = _DashboardPrepStep.keycard; + }); + if (!skipped) { + try { + await _sshService.writeInstallState(_baseInstallState(InstallPhase.btPaired)); + } catch (e) { + debugPrint('UI: failed to write install state (bt-paired), non-fatal: $e'); + } + } + // Spin up the keycard sub-step the same way the standalone phase does. + await _onEnterKeycardSetup(); + return; + } _setPhase(InstallerPhase.keycardSetup); } @@ -4371,7 +4558,7 @@ class _InstallerScreenState extends State { if (!mounted) return; await _keycardTearDown(); if (!mounted) return; - _setPhase(InstallerPhase.finish); + await _keycardComplete(skipped: false); }); } else if (payload.startsWith('rejected:already-authorized:')) { _keycardShowToast(l10n.keycardMasterStageRejectedToast, Colors.redAccent); @@ -4404,7 +4591,7 @@ class _InstallerScreenState extends State { await _keycardTearDown(); if (!mounted) return; if (advance) { - _setPhase(InstallerPhase.finish); + await _keycardComplete(skipped: false); } else { setState(() { _keycardStage = _KeycardStage.cardsReview; @@ -4463,12 +4650,44 @@ class _InstallerScreenState extends State { await _stopKeycardLearning(advance: false); } await _keycardTearDown(); + await _keycardComplete(skipped: true); + } + + /// Keycard step finished (done or skipped). Inside the Stage-1 dashboardPrep + /// screen this records the result and stays on the screen so the "Begin + /// flashing DBC" button can unlock; on a real enrollment it persists the + /// resume checkpoint. As the standalone phase it advances to finish. + Future _keycardComplete({required bool skipped}) async { + if (_currentPhase == InstallerPhase.dashboardPrep) { + setState(() { + if (skipped) { + _keycardSkipped = true; + } else { + _keycardDone = true; + } + }); + if (!skipped) { + try { + await _sshService + .writeInstallState(_baseInstallState(InstallPhase.keycardEnrolled)); + } catch (e) { + debugPrint('UI: failed to write install state (keycard-enrolled), non-fatal: $e'); + } + } + return; + } if (mounted) _setPhase(InstallerPhase.finish); } Widget _buildKeycardSetup(AppLocalizations l10n) { - return Center( - child: ConstrainedBox( + return Center(child: _keycardSetupContent(l10n)); + } + + /// Inner content of the keycard enrollment step, without the outer page + /// scaffold. Reused both as the standalone phase and as the second + /// interactive sub-step of the Stage-1 dashboardPrep screen. + Widget _keycardSetupContent(AppLocalizations l10n) { + return ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480), child: Column( mainAxisSize: MainAxisSize.min, @@ -4497,8 +4716,7 @@ class _InstallerScreenState extends State { }, ], ), - ), - ); + ); } String _keycardStageHeading(AppLocalizations l10n) { @@ -4524,7 +4742,7 @@ class _InstallerScreenState extends State { ), const SizedBox(height: 24), FilledButton.icon( - onPressed: () => _setPhase(InstallerPhase.finish), + onPressed: () => _keycardComplete(skipped: false), icon: const Icon(Icons.arrow_forward), label: Text(l10n.keycardEntryContinueButton), ), @@ -4659,7 +4877,7 @@ class _InstallerScreenState extends State { ), const SizedBox(height: 16), FilledButton.icon( - onPressed: () => _setPhase(InstallerPhase.finish), + onPressed: () => _keycardComplete(skipped: false), icon: const Icon(Icons.arrow_forward), label: Text(l10n.keycardCardsStageContinueButton), ), @@ -5116,3 +5334,8 @@ enum _KeycardStage { master, done, } + +/// Interactive sub-step shown inside the Stage-1 dashboardPrep screen. The +/// background DBC upload progresses independently of this; this only tracks +/// which foreground task (BT pairing, then keycard enrollment) is visible. +enum _DashboardPrepStep { bluetooth, keycard } diff --git a/test/models/installer_phase_test.dart b/test/models/installer_phase_test.dart new file mode 100644 index 0000000..119894e --- /dev/null +++ b/test/models/installer_phase_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:librescoot_installer/models/installer_phase.dart'; + +void main() { + group('phase model (two-stage)', () { + test('dashboardPrep is in the MDB-prep major step', () { + expect(MajorStep.forPhase(InstallerPhase.dashboardPrep), MajorStep.mdbPrep); + }); + test('bluetooth and keycard are Stage 1, not Finish', () { + expect(MajorStep.forPhase(InstallerPhase.bluetoothPairing), MajorStep.mdbPrep); + expect(MajorStep.forPhase(InstallerPhase.keycardSetup), MajorStep.mdbPrep); + }); + test('dbcSwapAndFlash is the only happy-path DBC phase', () { + expect(MajorStep.dbc.phases, contains(InstallerPhase.dbcSwapAndFlash)); + expect(MajorStep.dbc.phases, isNot(contains(InstallerPhase.reconnect))); + }); + test('finish major step only holds finish', () { + expect(MajorStep.finish.phases, [InstallerPhase.finish]); + }); + test('every non-hidden phase belongs to exactly one MajorStep', () { + for (final p in InstallerPhase.values) { + if (p.hiddenUnlessActive) continue; + final hits = MajorStep.values.where((s) => s.containsPhase(p)).length; + expect(hits, 1, reason: '$p should be in exactly one MajorStep'); + } + }); + }); +} From 0d4adf7a75db32719ef2dc6d03e790aa86106416 Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Tue, 16 Jun 2026 09:35:56 +0200 Subject: [PATCH 04/43] feat(installer): add pure resume-resolution policy --- lib/models/trampoline_status.dart | 5 +- lib/services/resume_resolver.dart | 89 +++++++++++++++++++++++++ test/services/resume_resolver_test.dart | 56 ++++++++++++++++ 3 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 lib/services/resume_resolver.dart create mode 100644 test/services/resume_resolver_test.dart diff --git a/lib/models/trampoline_status.dart b/lib/models/trampoline_status.dart index b790066..d3bf5aa 100644 --- a/lib/models/trampoline_status.dart +++ b/lib/models/trampoline_status.dart @@ -15,7 +15,8 @@ class TrampolineStatus { final lines = content.trim().split('\n'); if (lines.isEmpty) return TrampolineStatus(result: TrampolineResult.unknown); - final resultLine = lines.first.trim().toLowerCase(); + final rawLine = lines.first.trim(); + final resultLine = rawLine.toLowerCase(); if (resultLine == 'success' || resultLine == 'rebooting') { return TrampolineStatus( result: TrampolineResult.success, @@ -24,7 +25,7 @@ class TrampolineStatus { } else if (resultLine.startsWith('error')) { return TrampolineStatus( result: TrampolineResult.error, - message: resultLine, + message: rawLine, errorLog: lines.length > 1 ? lines.sublist(1).join('\n') : null, ); } diff --git a/lib/services/resume_resolver.dart b/lib/services/resume_resolver.dart new file mode 100644 index 0000000..b0dde41 --- /dev/null +++ b/lib/services/resume_resolver.dart @@ -0,0 +1,89 @@ +import '../models/install_state.dart'; +import '../models/installer_phase.dart'; +import '../models/trampoline_status.dart'; + +class ResumeDecision { + ResumeDecision({ + required this.phase, + this.skipUnlockGate = false, + this.bluetoothDone = false, + this.keycardDone = false, + this.previousError, + }); + + final InstallerPhase phase; + final bool skipUnlockGate; + final bool bluetoothDone; + final bool keycardDone; + final String? previousError; +} + +/// Pure resume policy. [state] is /data/installer/state.json (null if absent, +/// e.g. stock OS or a fresh device). [status] is /data/installer/trampoline-status +/// (null if absent). USB-mode pre-flash detection is handled by the caller; this +/// function covers the post-flash regime plus the legacy "leftover files" case. +ResumeDecision resolveResume({ + required InstallState? state, + required TrampolineStatus? status, +}) { + final err = status?.result == TrampolineResult.error + ? (status?.message ?? status?.errorLog) + : null; + + // Legacy: no state.json but trampoline artifacts exist (older builds). + if (state == null) { + if (status == null) { + return ResumeDecision(phase: InstallerPhase.healthCheck); + } + return ResumeDecision( + phase: status.result == TrampolineResult.success + ? InstallerPhase.finish + : InstallerPhase.dbcSwapAndFlash, + skipUnlockGate: true, + previousError: err, + ); + } + + switch (state.phase) { + case InstallPhase.mdbFlashed: + case InstallPhase.mdbBooted: + return ResumeDecision( + phase: InstallerPhase.dashboardPrep, + skipUnlockGate: true, + ); + case InstallPhase.btPaired: + return ResumeDecision( + phase: InstallerPhase.dashboardPrep, + skipUnlockGate: true, + bluetoothDone: true, + ); + case InstallPhase.keycardEnrolled: + case InstallPhase.dbcStaged: + return ResumeDecision( + phase: InstallerPhase.dashboardPrep, + skipUnlockGate: true, + bluetoothDone: true, + keycardDone: true, + ); + case InstallPhase.trampolineArmed: + if (status?.result == TrampolineResult.success) { + return ResumeDecision(phase: InstallerPhase.finish, skipUnlockGate: true); + } + return ResumeDecision( + phase: InstallerPhase.dbcSwapAndFlash, + skipUnlockGate: true, + previousError: err, + ); + case InstallPhase.trampolineOk: + case InstallPhase.finished: + return ResumeDecision(phase: InstallerPhase.finish, skipUnlockGate: true); + case InstallPhase.trampolineErr: + return ResumeDecision( + phase: InstallerPhase.dbcSwapAndFlash, + skipUnlockGate: true, + previousError: err, + ); + case InstallPhase.unknown: + return ResumeDecision(phase: InstallerPhase.healthCheck); + } +} diff --git a/test/services/resume_resolver_test.dart b/test/services/resume_resolver_test.dart new file mode 100644 index 0000000..5f3e125 --- /dev/null +++ b/test/services/resume_resolver_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:librescoot_installer/models/install_state.dart'; +import 'package:librescoot_installer/models/installer_phase.dart'; +import 'package:librescoot_installer/models/trampoline_status.dart'; +import 'package:librescoot_installer/services/resume_resolver.dart'; + +void main() { + group('resolveResume', () { + test('no state and no trampoline status -> fresh connect', () { + final d = resolveResume(state: null, status: null); + expect(d.phase, InstallerPhase.healthCheck); + expect(d.skipUnlockGate, false); + }); + test('mid Stage-1 (keycard-enrolled) resumes at dashboardPrep, skips gate', () { + final d = resolveResume( + state: InstallState(phase: InstallPhase.keycardEnrolled), + status: null, + ); + expect(d.phase, InstallerPhase.dashboardPrep); + expect(d.skipUnlockGate, true); + expect(d.bluetoothDone, true); + expect(d.keycardDone, true); + }); + test('trampoline armed + status success -> finished', () { + final d = resolveResume( + state: InstallState(phase: InstallPhase.trampolineArmed), + status: TrampolineStatus.parse('success\nok'), + ); + expect(d.phase, InstallerPhase.finish); + }); + test('trampoline armed + status error -> swap/flash with error surfaced', () { + final d = resolveResume( + state: InstallState(phase: InstallPhase.trampolineArmed), + status: TrampolineStatus.parse('error: DBC UMS not found\nlog'), + ); + expect(d.phase, InstallerPhase.dbcSwapAndFlash); + expect(d.previousError, contains('DBC UMS not found')); + }); + test('trampoline armed + status unknown -> still running, swap/flash, no error', () { + final d = resolveResume( + state: InstallState(phase: InstallPhase.trampolineArmed), + status: TrampolineStatus.parse(''), + ); + expect(d.phase, InstallerPhase.dbcSwapAndFlash); + expect(d.previousError, isNull); + }); + test('legacy leftover (no state.json) but trampoline-status error -> skip gate, surface', () { + final d = resolveResume( + state: null, + status: TrampolineStatus.parse('error: stalled in host mode'), + ); + expect(d.skipUnlockGate, true); + expect(d.previousError, contains('stalled in host mode')); + }); + }); +} From e6e98272bc272f341d781acb228915cb38a467f7 Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Tue, 16 Jun 2026 09:39:32 +0200 Subject: [PATCH 05/43] fix(installer): debounce trampoline laptop-disconnect gate --- assets/trampoline.sh.template | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/assets/trampoline.sh.template b/assets/trampoline.sh.template index 57958c5..573ffb5 100644 --- a/assets/trampoline.sh.template +++ b/assets/trampoline.sh.template @@ -847,8 +847,23 @@ log "Image: $(ls -lh "$DBC_IMAGE" | awk '{print $5}')" # Step 1: Wait for laptop disconnect step_begin "Step 1: wait for laptop disconnect" -while cat /sys/class/udc/ci_hdrc.0/state 2>/dev/null | grep -q configured; do sleep 1; done -log "Laptop disconnected" +# The laptop is gone when VBUS drops: udc state -> "not attached". Require it to +# hold stable so a transient blip (host autosuspend, RNDIS re-enumeration) can't +# flip us to host mode while the laptop is still attached. ~250ms of stable reads; +# the swap itself takes seconds, so a short hold loses nothing. Tune GONE_NEEDED on +# hardware to the real sleep granularity. +UDC_STATE=/sys/class/udc/ci_hdrc.0/state +GONE_NEEDED=3 +GONE=0 +while [ "$GONE" -lt "$GONE_NEEDED" ]; do + if grep -q "not attached" "$UDC_STATE" 2>/dev/null; then + GONE=$((GONE + 1)) + else + GONE=0 + fi + sleep 0.1 2>/dev/null || sleep 1 +done +log "Laptop disconnected (debounced)" step_end # Step 2: Re-init g_ether for DBC From b3fead8e659e4b18551a47d89eb62939a53e257a Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Tue, 16 Jun 2026 10:18:26 +0200 Subject: [PATCH 06/43] feat(installer): read DBC identity and skip flash when already at target --- assets/trampoline.sh.template | 22 ++++++++++++++++++++++ lib/screens/installer_screen.dart | 6 ++++++ lib/services/trampoline_service.dart | 8 ++++++++ 3 files changed, 36 insertions(+) diff --git a/assets/trampoline.sh.template b/assets/trampoline.sh.template index 573ffb5..614fed4 100644 --- a/assets/trampoline.sh.template +++ b/assets/trampoline.sh.template @@ -901,6 +901,28 @@ if [ "$DBC_ALREADY_UMS" != "yes" ]; then log "DBC reachable" step_end + # Identify the DBC over SSH while it still runs its own OS. This is the only + # reliable identity signal (no DBC-unique USB id exists). Skip the destructive + # flash if it is already at the exact target Librescoot version. + DBC_OS=$(dbc_ssh 'cat /etc/os-release 2>/dev/null') + DBC_VERSION=$(printf '%s\n' "$DBC_OS" | sed -n 's/^VERSION_ID=//p' | tr -d '"') + DBC_ID=$(printf '%s\n' "$DBC_OS" | sed -n 's/^ID=//p' | tr -d '"') + log "DBC identity: ID='${DBC_ID:-unknown}' VERSION_ID='${DBC_VERSION:-unknown}'" + + TARGET_DBC_VERSION='{{TARGET_DBC_VERSION}}' + FORCE_REFLASH='{{FORCE_DBC_REFLASH}}' + if [ "$FORCE_REFLASH" != "true" ] && [ -n "$TARGET_DBC_VERSION" ] && [ -n "$DBC_VERSION" ] \ + && [ "$DBC_VERSION" = "$TARGET_DBC_VERSION" ] \ + && printf '%s' "$DBC_ID" | grep -q '^librescoot'; then + log "DBC already at target $TARGET_DBC_VERSION; skipping DBC flash" + echo "success" > "$STATUS_FILE" + restore_gadget + signal_progress_off + bootled_guard_stop + bootled_blink_green + exit 0 + fi + # Step 4: Configure DBC for UMS (retry up to 3x if DBC shuts down) step_begin "Step 4: configure DBC bootloader for UMS" STEP4_OK="" diff --git a/lib/screens/installer_screen.dart b/lib/screens/installer_screen.dart index bc628eb..a3b4870 100644 --- a/lib/screens/installer_screen.dart +++ b/lib/screens/installer_screen.dart @@ -3211,6 +3211,12 @@ class _InstallerScreenState extends State { osmTilesLocalPath: osmItem?.localPath, valhallaTilesLocalPath: valhallaItem?.localPath, region: _downloadState.selectedRegion, + // Identity-based idempotent skip: the trampoline compares this against + // the DBC's os-release VERSION_ID over SSH and skips the destructive + // flash if they match. releaseTag is the version/tag of the release + // being installed; an empty tag simply never triggers the skip. + targetDbcVersion: _downloadState.releaseTag ?? '', + forceDbcReflash: false, onProgress: (status, progress) { _setStatus(status, progress: progress); }, diff --git a/lib/services/trampoline_service.dart b/lib/services/trampoline_service.dart index f253030..ca968b2 100644 --- a/lib/services/trampoline_service.dart +++ b/lib/services/trampoline_service.dart @@ -20,12 +20,16 @@ class TrampolineService { required String dbcImagePath, Region? region, bool installTiles = false, + String targetDbcVersion = '', + bool forceDbcReflash = false, }) async { var template = await rootBundle.loadString('assets/trampoline.sh.template'); template = template .replaceAll('{{DBC_IMAGE_PATH}}', dbcImagePath) .replaceAll('{{INSTALL_TILES}}', installTiles ? 'true' : 'false') + .replaceAll('{{TARGET_DBC_VERSION}}', targetDbcVersion) + .replaceAll('{{FORCE_DBC_REFLASH}}', forceDbcReflash ? 'true' : 'false') .replaceAll( '{{OSM_TILES_FILE}}', installTiles && region != null @@ -260,6 +264,8 @@ http.server.HTTPServer(('0.0.0.0', 8080), H).serve_forever() String? osmTilesLocalPath, String? valhallaTilesLocalPath, Region? region, + String targetDbcVersion = '', + bool forceDbcReflash = false, void Function(String status, double progress)? onProgress, void Function(List steps)? onSubsteps, }) async { @@ -436,6 +442,8 @@ http.server.HTTPServer(('0.0.0.0', 8080), H).serve_forever() dbcImagePath: dbcRemotePath, region: region, installTiles: osmTilesLocalPath != null || valhallaTilesLocalPath != null, + targetDbcVersion: targetDbcVersion, + forceDbcReflash: forceDbcReflash, ); // Ensure Unix line endings (LF only): Windows may introduce CRLF which // breaks the shebang line and prevents execution on Linux. From ee0a43930752864ed38935f1b9cab1711122c8a1 Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Tue, 16 Jun 2026 10:26:17 +0200 Subject: [PATCH 07/43] fix(installer): sanity-check DBC target before destructive write --- assets/trampoline.sh.template | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/assets/trampoline.sh.template b/assets/trampoline.sh.template index 614fed4..351a06f 100644 --- a/assets/trampoline.sh.template +++ b/assets/trampoline.sh.template @@ -1076,9 +1076,6 @@ fi log "DBC device: $DBC_DEV ($(cat /sys/block/$(basename $DBC_DEV)/size 2>/dev/null || echo '?') sectors)" step_end -# Safety -echo "$DBC_DEV" | grep -q "mmcblk" && fail "SAFETY: $DBC_DEV is mmcblk — refusing!" - # Prep complete — advance to phase 1 (FL filled, FR breathing). set_progress_phase 1 @@ -1091,6 +1088,30 @@ led_off 1 # front ring off during flash FLASH_TOTAL=$(python3 -c "import struct; f=open('$DBC_IMAGE','rb'); f.seek(-4,2); print(struct.unpack('/dev/null || echo 2500000000) log " image uncompressed size: $FLASH_TOTAL" +# Pre-write sanity: confirm we have a sane UMS volume of expected geometry before +# going destructive. The host's 1-16GB isSafeToFlash check is dead in walk-away, +# so the MDB must guard itself. fail() handles the abort (status + LEDs + restore). +sanity_check_target() { + dev="$1"; min_total="$2" + name=$(basename "$dev") + case "$name" in + mmcblk*) fail "SAFETY: $dev is mmcblk - refusing"; return 1;; + esac + dd if="$dev" bs=512 count=1 of=/dev/null 2>/dev/null \ + || { fail "Target $dev not readable - not a ready UMS volume"; return 1; } + sectors=$(cat "/sys/block/$name/size" 2>/dev/null || echo 0) + bytes=$((sectors * 512)) + if [ "$bytes" -lt 1000000000 ] || [ "$bytes" -gt 17000000000 ]; then + fail "Target $dev size ${bytes}B outside 1-16GB band - refusing"; return 1 + fi + if [ -n "$min_total" ] && [ "$bytes" -lt "$min_total" ]; then + fail "Target $dev (${bytes}B) smaller than image (${min_total}B) - refusing"; return 1 + fi + log "Sanity OK: $dev ${bytes}B" + return 0 +} +sanity_check_target "$DBC_DEV" "$FLASH_TOTAL" || exit 1 + FLASH_OK="" ATTEMPT=0 while [ $ATTEMPT -lt 3 ]; do From 31cf46f43ce154b7996b62b77096cd38fbcc6e2a Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Tue, 16 Jun 2026 10:31:41 +0200 Subject: [PATCH 08/43] feat(installer): grow DBC data + copy tiles over UMS, keep onboot fallback --- assets/trampoline.sh.template | 90 +++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/assets/trampoline.sh.template b/assets/trampoline.sh.template index 351a06f..2b358c0 100644 --- a/assets/trampoline.sh.template +++ b/assets/trampoline.sh.template @@ -598,6 +598,66 @@ flash_progress_monitor_stop() { FLASH_MON_PID="" } +# V3: grow the DBC data partition + fs over UMS and copy tiles, so the DBC +# boots once already-complete. The DBC ships /data at 256M and grows it on +# first boot (mender grow-data + systemd-growfs); doing it here makes that a +# no-op. Tiles land at the exact on-disk paths the onboot HTTP push targets: +# OSM display tiles -> /data/maps/map.mbtiles, Valhalla routing tiles -> +# /data/valhalla/tiles.tar (the DBC boots fresh afterward, so valhalla picks +# up its tiles on start; no service restart needed here). Returns 0 on success +# (grown + tiles copied, or no tiles to copy), non-zero on ANY failure so the +# caller falls back to the proven onboot path. Never proceeds on partial +# failure. NOTE: partition number ($dev4 -> DBC mmcblk3p4) and the presence of +# resize2fs/parted/sfdisk on the MDB image are unverified in this environment; +# any mismatch returns non-zero and the proven onboot path takes over. +grow_and_populate_dbc() { + dev="$1" + command -v resize2fs >/dev/null 2>&1 || { log "V3: resize2fs missing"; return 1; } + partprobe "$dev" 2>/dev/null || blockdev --rereadpt "$dev" 2>/dev/null || true + sleep 1 + data_part="${dev}4" # DBC data is mmcblk3p4 -> sdX4 over UMS; verify on hardware + [ -b "$data_part" ] || { log "V3: data partition $data_part not found"; return 1; } + # Grow the partition to fill the disk, then the filesystem. + if command -v parted >/dev/null 2>&1; then + parted -s "$dev" resizepart 4 100% 2>/dev/null || { log "V3: parted resizepart failed"; return 1; } + elif command -v sfdisk >/dev/null 2>&1; then + echo ", +" | sfdisk --no-reread -N 4 "$dev" 2>/dev/null || { log "V3: sfdisk grow failed"; return 1; } + else + log "V3: no parted/sfdisk"; return 1 + fi + partprobe "$dev" 2>/dev/null || blockdev --rereadpt "$dev" 2>/dev/null || true + sleep 1 + e2fsck -fy "$data_part" 2>/dev/null || true # tolerate fsck rc<8; resize2fs will fail if truly bad + resize2fs "$data_part" 2>/dev/null || { log "V3: resize2fs failed"; return 1; } + if [ "$INSTALL_TILES" = "true" ]; then + mkdir -p /mnt/dbcdata 2>/dev/null + mount -t ext4 "$data_part" /mnt/dbcdata 2>/dev/null || { log "V3: mount failed"; return 1; } + mkdir -p /mnt/dbcdata/maps /mnt/dbcdata/valhalla 2>/dev/null + rc=0 + # (source, dest) pairs mirror the proven onboot HTTP push exactly: + # OSM -> /data/maps/map.mbtiles, Valhalla -> /data/valhalla/tiles.tar. + if [ -n "$OSM_TILES_FILE" ] && [ -f "$OSM_TILES_FILE" ]; then + if cp "$OSM_TILES_FILE" /mnt/dbcdata/maps/map.mbtiles 2>/dev/null; then + log "V3: copied display tiles -> maps/map.mbtiles" + else + log "V3: copy display tiles failed"; rc=1 + fi + fi + if [ -n "$VALHALLA_TILES_FILE" ] && [ -f "$VALHALLA_TILES_FILE" ]; then + if cp "$VALHALLA_TILES_FILE" /mnt/dbcdata/valhalla/tiles.tar 2>/dev/null; then + log "V3: copied routing tiles -> valhalla/tiles.tar" + else + log "V3: copy routing tiles failed"; rc=1 + fi + fi + sync + umount /mnt/dbcdata 2>/dev/null || true + [ "$rc" = 0 ] || return 1 + fi + log "V3: grow+tiles complete" + return 0 +} + # ========================================================================= # Drop a small helper the installer can invoke via SSH after a failure to # stop the boot-LED blink and the hazard flash, reset the LED state, and @@ -1186,6 +1246,36 @@ step_end # before continuing to phase 3. set_progress_phase 2 +# V3 happy path: while STILL in UMS/host mode (DBC exposed as $DBC_DEV), grow +# the DBC data partition + fs and copy tiles directly, so the DBC boots once +# already-complete — no MDB reboot, no onboot HTTP tile push. If V3 fails for +# ANY reason (tools missing, partition not found, grow/mount/copy failed) we +# fall through to the proven onboot.sh + reboot path below, unchanged. +step_begin "Step 8a: grow DBC data + copy tiles over UMS (V3)" +if grow_and_populate_dbc "$DBC_DEV"; then + log "DBC complete via UMS (V3); no MDB reboot, no onboot tile push needed" + step_end + # Power the DBC on so its dashboard comes up = done. The DBC's own first-boot + # grow now no-ops (already grown). Mirror the clean-success LED sequence used + # by the "DBC already at target" early-exit above. + log " syncing..." + sync + if ! restore_gadget; then + gadget_rescue_start + fi + trampoline_watchdog_stop + log " powering DBC on..." + lsc --redis-addr localhost:6379 dbc on 2>/dev/null || true + echo "success" > "$STATUS_FILE" + cat "$LOG_FILE" >> "$STATUS_FILE" + signal_progress_off + bootled_guard_stop + bootled_blink_green + exit 0 +fi +log "V3 grow/tiles failed or unavailable; falling back to onboot tile push + MDB reboot" +step_end + # Step 8: Sync, power off DBC, write onboot script, reboot MDB step_begin "Step 8: post-flash cleanup" From 85971823206c17f95e36e0f445ea87fd9f87a9b4 Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Tue, 16 Jun 2026 10:40:06 +0200 Subject: [PATCH 09/43] feat(installer): retry DBC flash until done, short-circuit on SDP brick --- assets/trampoline.sh.template | 429 ++++++++++++++++++++++------------ 1 file changed, 279 insertions(+), 150 deletions(-) diff --git a/assets/trampoline.sh.template b/assets/trampoline.sh.template index 2b358c0..dd91451 100644 --- a/assets/trampoline.sh.template +++ b/assets/trampoline.sh.template @@ -1061,185 +1061,314 @@ if [ "$DBC_ALREADY_UMS" != "yes" ]; then step_end fi # end of DBC_ALREADY_UMS != yes -# Step 5: Switch MDB to USB host (may already be in host mode if DBC was in UMS) -step_begin "Step 5: switch MDB to USB host mode" -CURRENT_ROLE=$(cat /sys/kernel/debug/ci_hdrc.0/role 2>/dev/null) -if [ "$CURRENT_ROLE" != "host" ]; then - rmmod g_ether 2>/dev/null || true - sleep 1 - role_write host || fail "Failed to switch to host mode" - sleep 3 -else - log " already in host mode" -fi -log " role: $(cat /sys/kernel/debug/ci_hdrc.0/role 2>/dev/null)" -log " lsusb: $(lsusb 2>/dev/null | grep 0525 || echo 'no 0525')" -step_end - -# Step 6: Find DBC block device (DBC may still be rebooting from step 4). -# This window absorbs two variable delays: the DBC reboot into u-boot UMS, -# AND the human swapping the single USB cable from the laptop over to the -# DBC. A slow swap is the common case, and timing out here is expensive to -# recover from — step 4 already armed the DBC bootcmd to `ums 0 mmc 2` with -# bootdelay=0, so a DBC that never got flashed will keep booting straight -# into UMS until it's reflashed. Give the user real time (5 min) before -# giving up. -# We don't peek at gadget mode during the wait — flipping roles can -# race the DBC enumeration. Only after the full timeout, if there's -# really nothing on the bus, do a single gadget probe to disambiguate -# "DBC absent" vs "laptop reconnected"; that gives the user a clear -# error message instead of a generic "UMS not found". -step_begin "Step 6: wait for DBC UMS device" -TIMEOUT=300 -ELAPSED=0 -DBC_DEV="" -# Remember where the captured journal stood when the wait started, so the -# timeout analysis below only looks at bus activity from THIS window. -# Earlier host-mode stints (step 3 probe) would otherwise pollute it. -STEP6_JNL_START=$(wc -l < "$INSTALLER_DIR/trampoline-journal.log" 2>/dev/null || echo 0) -STEP6_REPOWERED="" -while [ $ELAPSED -lt $TIMEOUT ]; do - DBC_DEV="$(find_dbc_block_device 2>/dev/null)" && break - ELAPSED=$((ELAPSED + 2)) - if [ $((ELAPSED % 10)) -eq 0 ]; then - log " ${ELAPSED}s... $(lsusb 2>/dev/null | grep 0525 || echo 'no USB')" - fi - # Halfway nudge: re-power the DBC once. Recovers a DBC that hung before - # reaching UMS; bootcmd is armed with bootdelay=0, so a healthy DBC just - # lands back in UMS. Harmless if the cable simply isn't connected yet. - if [ $ELAPSED -ge $((TIMEOUT / 2)) ] && [ -z "$STEP6_REPOWERED" ]; then - STEP6_REPOWERED="yes" - log " halfway through the wait, re-powering DBC once..." - lsc --redis-addr localhost:6379 dbc off-wait --force >> "$LOG_FILE" 2>&1 || log " DBC off failed" - lsc --redis-addr localhost:6379 dbc on >> "$LOG_FILE" 2>&1 || log " DBC on failed" - fi - sleep 2 -done -if [ -z "$DBC_DEV" ]; then - # Last-ditch: see if the user yanked the DBC and connected the laptop. - # If so, probe_for_laptop_then_restore_host calls fail() with a clear - # message and never returns. - probe_for_laptop_then_restore_host - # Classify what (if anything) the bus saw during the wait, from the - # journal we're already capturing. An unplugged cable and an unpowered - # device are electrically identical (no D+ pullup), so total silence - # means the cable most likely never reached the DBC; enumeration errors - # mean something is plugged but the link is bad. - STEP6_JNL=$(tail -n +$((STEP6_JNL_START + 1)) "$INSTALLER_DIR/trampoline-journal.log" 2>/dev/null | grep "usb 1-1") - if [ -z "$STEP6_JNL" ]; then - fail "No USB device was detected while waiting for the DBC. The USB cable is probably not plugged into the DBC. Connect the MDB's USB cable to the DBC and retry." - elif echo "$STEP6_JNL" | grep -qiE "device descriptor read|device not accepting|unable to enumerate|error"; then - fail "A USB device was detected but failed to enumerate. Reseat the USB cable between MDB and DBC and retry." - fi - fail "DBC UMS not found in ${TIMEOUT}s" -fi -log "DBC device: $DBC_DEV ($(cat /sys/block/$(basename $DBC_DEV)/size 2>/dev/null || echo '?') sectors)" -step_end - -# Prep complete — advance to phase 1 (FL filled, FR breathing). -set_progress_phase 1 - -# Step 7: Flash DBC -step_begin "Step 7: flash DBC" -breathe_stop -led_off 1 # front ring off during flash - -# Read uncompressed size from gzip footer (last 4 bytes, little-endian uint32) -FLASH_TOTAL=$(python3 -c "import struct; f=open('$DBC_IMAGE','rb'); f.seek(-4,2); print(struct.unpack('/dev/null || echo 2500000000) -log " image uncompressed size: $FLASH_TOTAL" - # Pre-write sanity: confirm we have a sane UMS volume of expected geometry before # going destructive. The host's 1-16GB isSafeToFlash check is dead in walk-away, -# so the MDB must guard itself. fail() handles the abort (status + LEDs + restore). +# so the MDB must guard itself. +# +# Terminal vs retryable in the retry world (see the outer loop below): +# - mmcblk / size-out-of-band / smaller-than-image are TERMINAL: a wrong or +# oversized device won't fix itself by re-powering, so these still call +# fail() (which exits) directly. +# - "not readable" is RETRYABLE: the UMS volume can be present-but-not-yet- +# ready right after enumeration, and a re-power often settles it. We signal +# that with return code 2 so do_dbc_flash_round() treats it as a round +# failure instead of a hard abort. +# Return: 0 = OK, 2 = retryable (device not readable yet). Terminal cases never +# return (fail() exits). sanity_check_target() { dev="$1"; min_total="$2" name=$(basename "$dev") case "$name" in - mmcblk*) fail "SAFETY: $dev is mmcblk - refusing"; return 1;; + mmcblk*) fail "SAFETY: $dev is mmcblk - refusing";; esac - dd if="$dev" bs=512 count=1 of=/dev/null 2>/dev/null \ - || { fail "Target $dev not readable - not a ready UMS volume"; return 1; } + if ! dd if="$dev" bs=512 count=1 of=/dev/null 2>/dev/null; then + log "Sanity: $dev not readable yet - treating as retryable" + return 2 + fi sectors=$(cat "/sys/block/$name/size" 2>/dev/null || echo 0) bytes=$((sectors * 512)) if [ "$bytes" -lt 1000000000 ] || [ "$bytes" -gt 17000000000 ]; then - fail "Target $dev size ${bytes}B outside 1-16GB band - refusing"; return 1 + fail "Target $dev size ${bytes}B outside 1-16GB band - refusing" fi if [ -n "$min_total" ] && [ "$bytes" -lt "$min_total" ]; then - fail "Target $dev (${bytes}B) smaller than image (${min_total}B) - refusing"; return 1 + fail "Target $dev (${bytes}B) smaller than image (${min_total}B) - refusing" fi log "Sanity OK: $dev ${bytes}B" return 0 } -sanity_check_target "$DBC_DEV" "$FLASH_TOTAL" || exit 1 - -FLASH_OK="" -ATTEMPT=0 -while [ $ATTEMPT -lt 3 ]; do - ATTEMPT=$((ATTEMPT + 1)) - log " flash attempt $ATTEMPT/3..." - flash_progress_monitor_start "$DBC_DEV" - - FLASHER="$INSTALLER_DIR/librescoot-flasher" - USING_DD="" - if [ -x "$FLASHER" ] && [ -f "$DBC_BMAP" ]; then - log " using librescoot-flasher (bmap: $DBC_BMAP)" - $FLASHER --image "$DBC_IMAGE" --bmap "$DBC_BMAP" --device "$DBC_DEV" >> "$LOG_FILE" 2>&1 - FLASH_RC=$? - elif [ -x "$FLASHER" ]; then - log " using librescoot-flasher (sequential)" - $FLASHER --image "$DBC_IMAGE" --device "$DBC_DEV" >> "$LOG_FILE" 2>&1 - FLASH_RC=$? + +# One flash round: flip MDB to USB host (step 5), wait for the DBC UMS block +# device (step 6), run the pre-write sanity gate, then the inner 3-attempt +# write+verify loop (step 7). Returns 0 only on a verified flash. Returns +# non-zero on a retryable failure (no DBC device on the bus, device not +# readable yet, or all 3 inner write attempts failed) so the outer loop can +# re-power the DBC and try again. Terminal conditions (laptop reconnected, +# SDP brick, wrong/oversized device) still call fail() and exit from within. +# On success, leaves the verified device path in DBC_DEV for the V3/onboot +# paths that run after a successful round. +do_dbc_flash_round() { + # Step 5: Switch MDB to USB host (may already be in host mode if DBC was in UMS) + step_begin "Step 5: switch MDB to USB host mode" + CURRENT_ROLE=$(cat /sys/kernel/debug/ci_hdrc.0/role 2>/dev/null) + if [ "$CURRENT_ROLE" != "host" ]; then + rmmod g_ether 2>/dev/null || true + sleep 1 + if ! role_write host; then + log " failed to switch to host mode this round" + step_end + return 1 + fi + sleep 3 else - USING_DD="yes" - log " using dd (oflag=direct), image size: $FLASH_TOTAL" - gunzip -c "$DBC_IMAGE" | dd bs=4M iflag=fullblock of=$DBC_DEV oflag=direct 2>> "$LOG_FILE" - # $? of a pipeline is dd's status (PIPESTATUS is bash-only and a bad - # substitution under busybox ash). A gunzip failure truncates the - # stream without failing dd; the boot-sector verify below catches it. - FLASH_RC=$? + log " already in host mode" fi + log " role: $(cat /sys/kernel/debug/ci_hdrc.0/role 2>/dev/null)" + log " lsusb: $(lsusb 2>/dev/null | grep 0525 || echo 'no 0525')" + step_end - flash_progress_monitor_stop - if [ "${FLASH_RC:-0}" -ne 0 ]; then - log " flash failed (rc=$FLASH_RC), retrying..." - sleep 3 - DBC_DEV="$(find_dbc_block_device 2>/dev/null)" || { log " device gone, waiting..."; sleep 5; DBC_DEV="$(find_dbc_block_device 2>/dev/null)" || continue; } - log " re-probed device: $DBC_DEV" - continue + # Step 6: Find DBC block device (DBC may still be rebooting from step 4). + # This window absorbs two variable delays: the DBC reboot into u-boot UMS, + # AND the human swapping the single USB cable from the laptop over to the + # DBC. A slow swap is the common case, and timing out here is expensive to + # recover from — step 4 already armed the DBC bootcmd to `ums 0 mmc 2` with + # bootdelay=0, so a DBC that never got flashed will keep booting straight + # into UMS until it's reflashed. Give the user real time (5 min) before + # giving up. + # We don't peek at gadget mode during the wait — flipping roles can + # race the DBC enumeration. Only after the full timeout, if there's + # really nothing on the bus, do a single gadget probe to disambiguate + # "DBC absent" vs "laptop reconnected"; that gives the user a clear + # error message instead of a generic "UMS not found". + step_begin "Step 6: wait for DBC UMS device" + TIMEOUT=300 + ELAPSED=0 + DBC_DEV="" + # Remember where the captured journal stood when the wait started, so the + # timeout analysis below only looks at bus activity from THIS window. + # Earlier host-mode stints (step 3 probe) would otherwise pollute it. + STEP6_JNL_START=$(wc -l < "$INSTALLER_DIR/trampoline-journal.log" 2>/dev/null || echo 0) + STEP6_REPOWERED="" + while [ $ELAPSED -lt $TIMEOUT ]; do + DBC_DEV="$(find_dbc_block_device 2>/dev/null)" && break + ELAPSED=$((ELAPSED + 2)) + if [ $((ELAPSED % 10)) -eq 0 ]; then + log " ${ELAPSED}s... $(lsusb 2>/dev/null | grep 0525 || echo 'no USB')" + fi + # Halfway nudge: re-power the DBC once. Recovers a DBC that hung before + # reaching UMS; bootcmd is armed with bootdelay=0, so a healthy DBC just + # lands back in UMS. Harmless if the cable simply isn't connected yet. + if [ $ELAPSED -ge $((TIMEOUT / 2)) ] && [ -z "$STEP6_REPOWERED" ]; then + STEP6_REPOWERED="yes" + log " halfway through the wait, re-powering DBC once..." + lsc --redis-addr localhost:6379 dbc off-wait --force >> "$LOG_FILE" 2>&1 || log " DBC off failed" + lsc --redis-addr localhost:6379 dbc on >> "$LOG_FILE" 2>&1 || log " DBC on failed" + fi + sleep 2 + done + if [ -z "$DBC_DEV" ]; then + # Last-ditch: see if the user yanked the DBC and connected the laptop. + # If so, probe_for_laptop_then_restore_host calls fail() with a clear + # message and never returns (a laptop reconnect is terminal, not a + # round-failure — retrying can't help if the DBC is no longer attached). + probe_for_laptop_then_restore_host + # No laptop, no DBC device. Classify what (if anything) the bus saw + # during the wait, from the journal we're already capturing, and log it + # so the eventual outer-exhaustion fail() carries the best message. This + # is a RETRYABLE round failure: a re-power may bring the DBC back. + STEP6_JNL=$(tail -n +$((STEP6_JNL_START + 1)) "$INSTALLER_DIR/trampoline-journal.log" 2>/dev/null | grep "usb 1-1") + if [ -z "$STEP6_JNL" ]; then + ROUND_FAIL_MSG="No USB device was detected while waiting for the DBC. The USB cable is probably not plugged into the DBC. Connect the MDB's USB cable to the DBC and retry." + elif echo "$STEP6_JNL" | grep -qiE "device descriptor read|device not accepting|unable to enumerate|error"; then + ROUND_FAIL_MSG="A USB device was detected but failed to enumerate. Reseat the USB cable between MDB and DBC and retry." + else + ROUND_FAIL_MSG="DBC UMS not found in ${TIMEOUT}s" + fi + log " round failed: $ROUND_FAIL_MSG" + step_end + return 1 fi + log "DBC device: $DBC_DEV ($(cat /sys/block/$(basename $DBC_DEV)/size 2>/dev/null || echo '?') sectors)" + step_end - sync + # Prep complete — advance to phase 1 (FL filled, FR breathing). + set_progress_phase 1 - # Go flasher paths (bmap + sequential) already verify source/bmap integrity - # per range and surface write errors as non-zero exit; trust the exit code. - if [ -z "$USING_DD" ]; then - log " flasher reports success" - FLASH_OK="yes" - break + # Step 7: Flash DBC + step_begin "Step 7: flash DBC" + breathe_stop + led_off 1 # front ring off during flash + + # Read uncompressed size from gzip footer (last 4 bytes, little-endian uint32) + FLASH_TOTAL=$(python3 -c "import struct; f=open('$DBC_IMAGE','rb'); f.seek(-4,2); print(struct.unpack('/dev/null || echo 2500000000) + log " image uncompressed size: $FLASH_TOTAL" + + sanity_check_target "$DBC_DEV" "$FLASH_TOTAL" + SANITY_RC=$? + # rc 0 = OK (fall through), rc 2 = device not readable yet (retryable round + # failure), terminal cases never return (fail() exited). Defensive: any + # other non-zero is also treated as a retryable round failure. + if [ "$SANITY_RC" -ne 0 ]; then + ROUND_FAIL_MSG="DBC UMS volume not ready (sanity rc=$SANITY_RC)" + log " round failed: $ROUND_FAIL_MSG" + step_end + return 1 + fi + + FLASH_OK="" + ATTEMPT=0 + while [ $ATTEMPT -lt 3 ]; do + ATTEMPT=$((ATTEMPT + 1)) + log " flash attempt $ATTEMPT/3..." + flash_progress_monitor_start "$DBC_DEV" + + FLASHER="$INSTALLER_DIR/librescoot-flasher" + USING_DD="" + if [ -x "$FLASHER" ] && [ -f "$DBC_BMAP" ]; then + log " using librescoot-flasher (bmap: $DBC_BMAP)" + $FLASHER --image "$DBC_IMAGE" --bmap "$DBC_BMAP" --device "$DBC_DEV" >> "$LOG_FILE" 2>&1 + FLASH_RC=$? + elif [ -x "$FLASHER" ]; then + log " using librescoot-flasher (sequential)" + $FLASHER --image "$DBC_IMAGE" --device "$DBC_DEV" >> "$LOG_FILE" 2>&1 + FLASH_RC=$? + else + USING_DD="yes" + log " using dd (oflag=direct), image size: $FLASH_TOTAL" + gunzip -c "$DBC_IMAGE" | dd bs=4M iflag=fullblock of=$DBC_DEV oflag=direct 2>> "$LOG_FILE" + # $? of a pipeline is dd's status (PIPESTATUS is bash-only and a bad + # substitution under busybox ash). A gunzip failure truncates the + # stream without failing dd; the boot-sector verify below catches it. + FLASH_RC=$? + fi + + flash_progress_monitor_stop + if [ "${FLASH_RC:-0}" -ne 0 ]; then + log " flash failed (rc=$FLASH_RC), retrying..." + sleep 3 + DBC_DEV="$(find_dbc_block_device 2>/dev/null)" || { log " device gone, waiting..."; sleep 5; DBC_DEV="$(find_dbc_block_device 2>/dev/null)" || continue; } + log " re-probed device: $DBC_DEV" + continue + fi + + sync + + # Go flasher paths (bmap + sequential) already verify source/bmap integrity + # per range and surface write errors as non-zero exit; trust the exit code. + if [ -z "$USING_DD" ]; then + log " flasher reports success" + FLASH_OK="yes" + break + fi + + # dd fallback: sanity-check boot sector (24 MB). iflag=direct bypasses page + # cache, which otherwise serves stale data populated by udev at enumeration. + log " verifying boot sector ($BOOT_AREA_BLOCKS blocks = 24 MB)..." + SRC_HASH=$(gunzip -c "$DBC_IMAGE" | dd bs=4M iflag=fullblock count=$BOOT_AREA_BLOCKS 2>/dev/null | md5sum | cut -d' ' -f1) + DEV_HASH=$(dd if=$DBC_DEV bs=4M iflag=direct count=$BOOT_AREA_BLOCKS 2>/dev/null | md5sum | cut -d' ' -f1) + log " src=$SRC_HASH dev=$DEV_HASH" + + if [ "$SRC_HASH" = "$DEV_HASH" ]; then + log " verified OK" + FLASH_OK="yes" + break + else + log " checksum mismatch, retrying..." + sleep 3 + DBC_DEV="$(find_dbc_block_device 2>/dev/null)" || { log " device gone, waiting..."; sleep 5; DBC_DEV="$(find_dbc_block_device 2>/dev/null)" || continue; } + log " re-probed device: $DBC_DEV" + fi + done + + if [ "$FLASH_OK" != "yes" ]; then + ROUND_FAIL_MSG="DBC flash failed after 3 attempts" + log " round failed: $ROUND_FAIL_MSG" + step_end + return 1 fi + log "DBC flash complete" + step_end + return 0 +} - # dd fallback: sanity-check boot sector (24 MB). iflag=direct bypasses page - # cache, which otherwise serves stale data populated by udev at enumeration. - log " verifying boot sector ($BOOT_AREA_BLOCKS blocks = 24 MB)..." - SRC_HASH=$(gunzip -c "$DBC_IMAGE" | dd bs=4M iflag=fullblock count=$BOOT_AREA_BLOCKS 2>/dev/null | md5sum | cut -d' ' -f1) - DEV_HASH=$(dd if=$DBC_DEV bs=4M iflag=direct count=$BOOT_AREA_BLOCKS 2>/dev/null | md5sum | cut -d' ' -f1) - log " src=$SRC_HASH dev=$DEV_HASH" +# Outer retry loop: keep flashing the DBC until a round verifies, bounded by +# BOTH an attempt cap and a wall-clock budget. The host is gone during the +# autonomous flash, so nobody can retry for the user — but a DBC that fell +# into i.MX BootROM/SDP recovery (USB id 15a2:) is a hard brick that can never +# present as the UMS device, so we short-circuit that as terminal before every +# round instead of retrying forever. Between rounds we re-power the DBC (the +# step-4 bootcmd lands it back in UMS) and back off, emitting a log line at +# each round start and around every sleep so the 15-min host-mode stall +# watchdog (which keys on trampoline.log mtime) stays inert during legitimate +# retries. +DBC_FLASH_MAX_ROUNDS=8 +DBC_FLASH_BUDGET_S=3600 # wall-clock ceiling across all rounds +DBC_FLASH_T0=$(date +%s) +ROUND=0 +ROUND_FAIL_MSG="DBC flash did not complete" +DBC_FLASH_DONE="" +while [ $ROUND -lt $DBC_FLASH_MAX_ROUNDS ]; do + ROUND=$((ROUND + 1)) + NOW=$(date +%s) + ELAPSED_TOTAL=$((NOW - DBC_FLASH_T0)) + log "=== DBC flash round $ROUND/$DBC_FLASH_MAX_ROUNDS (T+${ELAPSED_TOTAL}s of ${DBC_FLASH_BUDGET_S}s budget) ===" + + # Terminal SDP short-circuit: a DBC in BootROM/SDP recovery (VID 15a2) is a + # hard brick — it can never present as the UMS device 0525:a4a5, so retrying + # is pointless. This is the one unrecoverable case; fail() exits. + if lsusb 2>/dev/null | grep -qi '15a2:'; then + fail "DBC is in BootROM (SDP) recovery mode - manual rescue required (imx_usb_loader). The installer cannot fix that." + fi - if [ "$SRC_HASH" = "$DEV_HASH" ]; then - log " verified OK" - FLASH_OK="yes" + if do_dbc_flash_round; then + DBC_FLASH_DONE="yes" + break + fi + + # Round failed (retryable). Stop here if we've spent the budget or used the + # last attempt; surface the best classified message via the terminal fail(). + NOW=$(date +%s) + ELAPSED_TOTAL=$((NOW - DBC_FLASH_T0)) + if [ $ROUND -ge $DBC_FLASH_MAX_ROUNDS ]; then + log " exhausted $DBC_FLASH_MAX_ROUNDS rounds" + break + fi + if [ $ELAPSED_TOTAL -ge $DBC_FLASH_BUDGET_S ]; then + log " wall-clock budget (${DBC_FLASH_BUDGET_S}s) exhausted after $ROUND rounds" break - else - log " checksum mismatch, retrying..." - sleep 3 - DBC_DEV="$(find_dbc_block_device 2>/dev/null)" || { log " device gone, waiting..."; sleep 5; DBC_DEV="$(find_dbc_block_device 2>/dev/null)" || continue; } - log " re-probed device: $DBC_DEV" fi + + # Re-power the DBC between rounds so a hung/half-booted DBC gets a clean + # cycle; the step-4 bootcmd (ums 0 mmc 2, bootdelay=0) lands it straight + # back in UMS for the next round. Same lsc invocation form as elsewhere. + log " round $ROUND failed: $ROUND_FAIL_MSG" + log " re-powering DBC before retry..." + lsc --redis-addr localhost:6379 dbc off-wait --force >> "$LOG_FILE" 2>&1 || log " DBC off failed" + lsc --redis-addr localhost:6379 dbc on >> "$LOG_FILE" 2>&1 || log " DBC on failed" + + # Back off, capped, with a heartbeat: sleep in 5s slices and log each so + # trampoline.log mtime stays fresh and the stall watchdog never false-trips + # during a legitimate backoff. + case $ROUND in + 1) BACKOFF=5;; + 2) BACKOFF=15;; + 3) BACKOFF=30;; + *) BACKOFF=60;; + esac + log " backing off ${BACKOFF}s before round $((ROUND + 1))..." + SLEPT=0 + while [ $SLEPT -lt $BACKOFF ]; do + sleep 5 + SLEPT=$((SLEPT + 5)) + log " ...backoff ${SLEPT}/${BACKOFF}s" + done done -[ "$FLASH_OK" != "yes" ] && fail "DBC flash failed after 3 attempts" -log "DBC flash complete" -step_end +if [ "$DBC_FLASH_DONE" != "yes" ]; then + # All retryable avenues exhausted (attempt cap or wall-clock budget). + # Surface the failure via the terminal fail() as before, carrying the best + # message we classified from the last round. + fail "$ROUND_FAIL_MSG" +fi # Flash done — advance to phase 2 (FL/FR filled, RR breathing). onboot.sh # reads PHASE_FILE after the MDB reboot and re-lights the same segments From c27d11210beb4363f042aab2dc9f36d1d7699f0e Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Tue, 16 Jun 2026 10:48:39 +0200 Subject: [PATCH 10/43] refactor(installer): drop trampoline progress LEDs, keep binary cues --- assets/trampoline.sh.template | 182 +--------------------------------- 1 file changed, 2 insertions(+), 180 deletions(-) diff --git a/assets/trampoline.sh.template b/assets/trampoline.sh.template index dd91451..d1621d8 100644 --- a/assets/trampoline.sh.template +++ b/assets/trampoline.sh.template @@ -5,7 +5,6 @@ INSTALLER_DIR="/data/installer" STATUS_FILE="$INSTALLER_DIR/trampoline-status" LOG_FILE="$INSTALLER_DIR/trampoline.log" -PHASE_FILE="$INSTALLER_DIR/trampoline-phase" DBC_IMAGE="{{DBC_IMAGE_PATH}}" DBC_BMAP="${DBC_IMAGE%.sdimg.gz}.sdimg.bmap" DBC_IP="192.168.7.2" @@ -117,81 +116,6 @@ led_duty() { $IOCTL /dev/pwm_led$1 0x0000754A -v $2 2>/dev/null; } led_fade() { $IOCTL /dev/pwm_led$1 0x00007545 -v $2 2>/dev/null; } led_off() { led_duty $1 0; led_deactivate $1; } -# --- Front ring breathing (prep phase indicator) --- -BREATHE_PID="" -breathe_start() { - local ch=$1 - led_activate $ch - (while true; do - led_fade $ch 0 # fade0: parking-smooth-on (3s) - sleep 3 - led_fade $ch 1 # fade1: smooth-off (2.3s) - sleep 3 - done) & - BREATHE_PID=$! -} -breathe_stop() { - [ -n "$BREATHE_PID" ] && kill $BREATHE_PID 2>/dev/null; BREATHE_PID="" -} - -# --- Blinker progress bar (4 blinkers = 4 phases) --- -# Order: FL(3) -> FR(4) -> RR(7) -> RL(6) -# Phase semantics: each segment represents one overall trampoline phase. -# phase 0 = entering Prep (FL breathing) -# phase 1 = Prep done, Flash (FL filled, FR breathing) -# phase 2 = Flash done, 1st-boot (FL/FR filled, RR breathing) -# phase 3 = 1st-boot done, Tiles (FL/FR/RR filled, RL breathing) -# phase 4 = all done (all four solid dim) -# The phase number is persisted to $PHASE_FILE so onboot.sh can re-light -# the right segments after the MDB reboot between Flash and 1st-boot. -# Filled segments: static dim glow (DIM_DUTY). Active segment: breathing on -# fade4/fade9 (brake-dim-on/off, duty ~48..180 of 12000) so it stays -# distinguishable from filled. DIM_DUTY sits just under fade4's peak (156) -# so the breathing segment reads brighter at its peak than the filled trail. -BLINKER_LEDS="3 7 4 6" -DIM_DUTY=150 -PROGRESS_BREATHE_PID="" -PROGRESS_LAST_PHASE=-1 - -# Light the segments for a given phase (0-4). Idempotent. -set_progress_phase() { - local phase="$1" - [ "$phase" -lt 0 ] && phase=0 - [ "$phase" -gt 4 ] && phase=4 - - echo "$phase" > "$PHASE_FILE" 2>/dev/null - - [ "$phase" -eq "$PROGRESS_LAST_PHASE" ] && return - PROGRESS_LAST_PHASE=$phase - - [ -n "$PROGRESS_BREATHE_PID" ] && kill $PROGRESS_BREATHE_PID 2>/dev/null; PROGRESS_BREATHE_PID="" - - local i=0 - for l in $BLINKER_LEDS; do - if [ $i -lt $phase ]; then - led_activate $l - led_duty $l $DIM_DUTY - elif [ $i -eq $phase ] && [ $phase -lt 4 ]; then - led_activate $l - (while true; do - led_fade $l 4; sleep 3 - led_fade $l 9; sleep 3 - done) & - PROGRESS_BREATHE_PID=$! - else - led_off $l - fi - i=$((i + 1)) - done -} - -signal_progress_off() { - [ -n "$PROGRESS_BREATHE_PID" ] && kill $PROGRESS_BREATHE_PID 2>/dev/null; PROGRESS_BREATHE_PID="" - for l in $BLINKER_LEDS; do led_off $l; done - rm -f "$PHASE_FILE" - PROGRESS_LAST_PHASE=-1 -} - # --- DBC boot-LED defender --- # vehicle-service also drives the LP5562 (i2c-2 @ 0x30) for blinker # brightness via VehicleSystem.runBlinker (vehicle-service: dbc_led.go + @@ -231,8 +155,6 @@ bootled_guard_stop() { # and stopped early by the installer when it reconnects. HAZARDS_UNIT="librescoot-bootled-hazards" signal_error() { - breathe_stop - signal_progress_off bootled_guard_stop signal_error_stop systemd-run --unit="$HAZARDS_UNIT" --collect --quiet --slice=system.slice /bin/sh -c ' @@ -897,10 +819,6 @@ JOURNAL_PID=$! # arming this early is free; it covers every host-mode stint from here on. trampoline_watchdog_start -# Phase 0 — Prep: front ring breathes, FL blinker breathes. -set_progress_phase 0 -breathe_start 1 - # Verify image [ -f "$DBC_IMAGE" ] || fail "DBC image not found: $DBC_IMAGE" log "Image: $(ls -lh "$DBC_IMAGE" | awk '{print $5}')" @@ -977,7 +895,6 @@ if [ "$DBC_ALREADY_UMS" != "yes" ]; then log "DBC already at target $TARGET_DBC_VERSION; skipping DBC flash" echo "success" > "$STATUS_FILE" restore_gadget - signal_progress_off bootled_guard_stop bootled_blink_green exit 0 @@ -1190,13 +1107,8 @@ do_dbc_flash_round() { log "DBC device: $DBC_DEV ($(cat /sys/block/$(basename $DBC_DEV)/size 2>/dev/null || echo '?') sectors)" step_end - # Prep complete — advance to phase 1 (FL filled, FR breathing). - set_progress_phase 1 - # Step 7: Flash DBC step_begin "Step 7: flash DBC" - breathe_stop - led_off 1 # front ring off during flash # Read uncompressed size from gzip footer (last 4 bytes, little-endian uint32) FLASH_TOTAL=$(python3 -c "import struct; f=open('$DBC_IMAGE','rb'); f.seek(-4,2); print(struct.unpack('/dev/null || echo 2500000000) @@ -1370,11 +1282,6 @@ if [ "$DBC_FLASH_DONE" != "yes" ]; then fail "$ROUND_FAIL_MSG" fi -# Flash done — advance to phase 2 (FL/FR filled, RR breathing). onboot.sh -# reads PHASE_FILE after the MDB reboot and re-lights the same segments -# before continuing to phase 3. -set_progress_phase 2 - # V3 happy path: while STILL in UMS/host mode (DBC exposed as $DBC_DEV), grow # the DBC data partition + fs and copy tiles directly, so the DBC boots once # already-complete — no MDB reboot, no onboot HTTP tile push. If V3 fails for @@ -1397,7 +1304,6 @@ if grow_and_populate_dbc "$DBC_DEV"; then lsc --redis-addr localhost:6379 dbc on 2>/dev/null || true echo "success" > "$STATUS_FILE" cat "$LOG_FILE" >> "$STATUS_FILE" - signal_progress_off bootled_guard_stop bootled_blink_green exit 0 @@ -1437,7 +1343,6 @@ INSTALLER_DIR="/data/installer" LOG="$INSTALLER_DIR/trampoline.log" DBC_IP="192.168.7.2" STATUS_FILE="$INSTALLER_DIR/trampoline-status" -PHASE_FILE="$INSTALLER_DIR/trampoline-phase" mkdir -p "$INSTALLER_DIR" log() { echo "$(date '+%H:%M:%S') $1" >> "$LOG"; } @@ -1446,45 +1351,6 @@ exec 2>>"$LOG" dbc_ssh() { local t=0; while [ $t -lt 3 ]; do ssh -y -y root@$DBC_IP "$@" && return 0; t=$((t+1)); sleep 3; done; return 1; } dbc_scp() { local t=0; while [ $t -lt 3 ]; do scp "$@" && return 0; t=$((t+1)); sleep 3; done; return 1; } -# Re-light the blinker progress bar FIRST, before the slower service stops -# and LP5562 init below, so the bar returns as early as possible after the -# MDB reboot instead of staying dark until the tail of onboot. Phase is -# whatever the pre-reboot half persisted to $PHASE_FILE (typically 2 = -# FL/FR filled, RR breathing); phase 4 = all-done is set at success below. -PHASE="$(cat "$PHASE_FILE" 2>/dev/null || echo 2)" -case "$PHASE" in 0|1|2|3|4) :;; *) PHASE=2 ;; esac -BLINKER_LEDS="3 7 4 6" -DIM_DUTY=150 -PROGRESS_BREATHE_PID="" -relight_progress() { - local phase="$1" - [ "$phase" -lt 0 ] && phase=0 - [ "$phase" -gt 4 ] && phase=4 - echo "$phase" > "$PHASE_FILE" 2>/dev/null - [ -n "$PROGRESS_BREATHE_PID" ] && kill $PROGRESS_BREATHE_PID 2>/dev/null - PROGRESS_BREATHE_PID="" - local i=0 - for l in $BLINKER_LEDS; do - if [ $i -lt $phase ]; then - /usr/bin/ioctl /dev/pwm_led$l 0x00007549 -v 1 2>/dev/null - /usr/bin/ioctl /dev/pwm_led$l 0x0000754A -v $DIM_DUTY 2>/dev/null - elif [ $i -eq $phase ] && [ $phase -lt 4 ]; then - /usr/bin/ioctl /dev/pwm_led$l 0x00007549 -v 1 2>/dev/null - (while true; do - /usr/bin/ioctl /dev/pwm_led$l 0x00007545 -v 4 2>/dev/null; sleep 3 - /usr/bin/ioctl /dev/pwm_led$l 0x00007545 -v 9 2>/dev/null; sleep 3 - done) & - PROGRESS_BREATHE_PID=$! - else - /usr/bin/ioctl /dev/pwm_led$l 0x0000754A -v 0 2>/dev/null - /usr/bin/ioctl /dev/pwm_led$l 0x00007549 -v 0 2>/dev/null - fi - i=$((i + 1)) - done -} -log "Onboot: relighting progress phase=$PHASE" -relight_progress "$PHASE" - log "Onboot: post-reboot DBC tile installation" # Stop+mask keycard / bluetooth / ums services so they can't race us on the @@ -1510,26 +1376,6 @@ i2cset -f -y 2 0x30 0x05 0xAF 2>/dev/null i2cset -f -y 2 0x30 0x06 0xAF 2>/dev/null i2cset -f -y 2 0x30 0x07 0xAF 2>/dev/null -# Smoothly fade the whole progress bar out. Used once at all-done so the -# blinkers release with a soft ramp instead of sitting lit. Ramp starts at -# DIM_DUTY (150) and steps down to 0 over ~1s, then deactivates each channel. -fade_progress_off() { - [ -n "$PROGRESS_BREATHE_PID" ] && kill $PROGRESS_BREATHE_PID 2>/dev/null - PROGRESS_BREATHE_PID="" - for l in $BLINKER_LEDS; do - /usr/bin/ioctl /dev/pwm_led$l 0x00007549 -v 1 2>/dev/null - done - for d in 150 110 75 48 28 14 6 0; do - for l in $BLINKER_LEDS; do - /usr/bin/ioctl /dev/pwm_led$l 0x0000754A -v $d 2>/dev/null - done - sleep 0.12 - done - for l in $BLINKER_LEDS; do - /usr/bin/ioctl /dev/pwm_led$l 0x00007549 -v 0 2>/dev/null - done -} - # Boot LED + hazards helpers. Spawned via systemd-run so they run in their # own transient unit under system.slice — librescoot-onboot.service is # Type=oneshot with KillMode=control-group, so once /data/onboot.sh exits, @@ -1685,12 +1531,6 @@ while [ $STABLE -lt 10 ] && [ $ELAPSED -lt 300 ]; do done log " DBC stable after ${ELAPSED}s (stable_count=$STABLE)" -# Stay on phase 2 (BR breathing = "MDB & DBC restart") through the SSH wait. -# Re-establishing the DBC connection is part of the restart, not the copy, so -# advancing to phase 3 (BL = "maps") happens only once the actual upload -# starts. That keeps the two distinguishable: a BR blinker stuck breathing -# means the DBC never came back (USB/boot issue), a stuck BL means the data -# copy itself is slow. # Wait for SSH, re-asserting DBC power as we go. A freshly flashed DBC # reboots once on first boot to apply its boot-partition (u-boot) update, # and that update's `complete-dbc` makes vehicle-service power the DBC off @@ -1715,13 +1555,8 @@ if [ $ELAPSED -ge 300 ]; then echo "success" > "$STATUS_FILE" cat "$LOG" >> "$STATUS_FILE" lsc --redis-addr localhost:6379 dbc off 2>/dev/null || true - # No tiles to do, jump straight to all-done. - relight_progress 4 bootled_guard_stop bootled_blink_green - # Let the completed bar register, then fade it out so it doesn't sit lit. - sleep 1 - fade_progress_off systemctl unmask librescoot-keycard keycard-service 2>/dev/null || true systemctl unmask librescoot-bluetooth 2>/dev/null || true systemctl start librescoot-bluetooth 2>/dev/null || true @@ -1735,10 +1570,6 @@ ONBOOT # Append tile installation commands with actual paths if [ "$INSTALL_TILES" = "true" ]; then cat >> /data/onboot.sh << TILES -# DBC connection is up — now advance to phase 3 (BL breathing = "maps"). From -# here a slow BL blinker means the tile copy itself is slow, not a connection -# problem. -relight_progress 3 dbc_ssh "mkdir -p /data/maps /data/valhalla" || log "WARNING: mkdir failed" # Detect whether DBC has data-server running (new firmware) or needs the @@ -1847,15 +1678,10 @@ else # All MDB<->DBC work is done (image flashed, tiles uploaded, DBC powered # off). Now and only now signal the user it's safe to swap the MDB's # single USB port back to the laptop. - # Phase 4: all four segments filled, no active breathing. - relight_progress 4 # Stop the LED guard before starting the green blink, otherwise the # guard re-asserts amber every 2s and we get an ugly amber/green flicker. bootled_guard_stop bootled_blink_green - # Let the completed bar register, then fade it out so it doesn't sit lit. - sleep 1 - fade_progress_off # Unmask keycard-service. The installer's keycardSetup phase will start # it explicitly. We deliberately do NOT start it now: a freshly flashed # device with no master would auto-enter master-learning and silently @@ -1880,12 +1706,8 @@ cat "$LOG_FILE" >> "$STATUS_FILE" step_end # close step 8 log "Total trampoline wall-clock: $(($(date +%s) - SCRIPT_T0))s" -# Phase 2 (FL/FR filled, RR breathing) is now the user-visible "flash -# done, MDB rebooting" cue — onboot.sh reads PHASE_FILE and re-lights -# the same state after the reboot, so the progress bar looks continuous -# across MDB reboot. bootled_guard is left running so amber stays solid -# right up until systemd-shutdown tears down our cgroup. -breathe_stop +# bootled_guard is left running so amber stays solid right up until +# systemd-shutdown tears down our cgroup. [ -n "$JOURNAL_PID" ] && kill $JOURNAL_PID 2>/dev/null log " rebooting MDB..." reboot From bc97b594b02b3245251082c963f528cbca5adb1c Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Tue, 16 Jun 2026 10:51:07 +0200 Subject: [PATCH 11/43] test(installer): syntax-check rendered trampoline script --- lib/services/trampoline_service.dart | 58 +++++++++++++++-------- test/services/trampoline_render_test.dart | 40 ++++++++++++++++ 2 files changed, 77 insertions(+), 21 deletions(-) create mode 100644 test/services/trampoline_render_test.dart diff --git a/lib/services/trampoline_service.dart b/lib/services/trampoline_service.dart index ca968b2..2adb2ae 100644 --- a/lib/services/trampoline_service.dart +++ b/lib/services/trampoline_service.dart @@ -9,6 +9,29 @@ import '../models/substep.dart'; import '../models/trampoline_status.dart'; import 'ssh_service.dart'; +/// Pure placeholder substitution for the trampoline template, separated from +/// asset loading so it is unit-testable. [generateScript] loads the template +/// via rootBundle then calls this. +String renderTrampoline( + String template, { + required String dbcImagePath, + String osmTilesFile = '', + String valhallaTilesFile = '', + bool installTiles = false, + String targetDbcVersion = '', + bool forceDbcReflash = false, +}) { + return template + .replaceAll('{{DBC_IMAGE_PATH}}', dbcImagePath) + .replaceAll('{{INSTALL_TILES}}', installTiles ? 'true' : 'false') + .replaceAll('{{TARGET_DBC_VERSION}}', targetDbcVersion) + .replaceAll('{{FORCE_DBC_REFLASH}}', forceDbcReflash ? 'true' : 'false') + .replaceAll('{{OSM_TILES_FILE}}', osmTilesFile) + .replaceAll('{{VALHALLA_TILES_FILE}}', valhallaTilesFile) + .replaceAll('\r\n', '\n') + .replaceAll('\r', '\n'); +} + class TrampolineService { final SshService _ssh; bool _pythonServerStarted = false; @@ -23,27 +46,20 @@ class TrampolineService { String targetDbcVersion = '', bool forceDbcReflash = false, }) async { - var template = await rootBundle.loadString('assets/trampoline.sh.template'); - - template = template - .replaceAll('{{DBC_IMAGE_PATH}}', dbcImagePath) - .replaceAll('{{INSTALL_TILES}}', installTiles ? 'true' : 'false') - .replaceAll('{{TARGET_DBC_VERSION}}', targetDbcVersion) - .replaceAll('{{FORCE_DBC_REFLASH}}', forceDbcReflash ? 'true' : 'false') - .replaceAll( - '{{OSM_TILES_FILE}}', - installTiles && region != null - ? '/data/installer/${region.osmTilesFilename}' - : '', - ) - .replaceAll( - '{{VALHALLA_TILES_FILE}}', - installTiles && region != null - ? '/data/installer/${region.valhallaTilesFilename}' - : '', - ); - - return template; + final template = await rootBundle.loadString('assets/trampoline.sh.template'); + return renderTrampoline( + template, + dbcImagePath: dbcImagePath, + osmTilesFile: installTiles && region != null + ? '/data/installer/${region.osmTilesFilename}' + : '', + valhallaTilesFile: installTiles && region != null + ? '/data/installer/${region.valhallaTilesFilename}' + : '', + installTiles: installTiles, + targetDbcVersion: targetDbcVersion, + forceDbcReflash: forceDbcReflash, + ); } /// Check if a remote file exists and matches the local file's md5. diff --git a/test/services/trampoline_render_test.dart b/test/services/trampoline_render_test.dart new file mode 100644 index 0000000..01c7921 --- /dev/null +++ b/test/services/trampoline_render_test.dart @@ -0,0 +1,40 @@ +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:librescoot_installer/services/trampoline_service.dart'; + +void main() { + // flutter test runs with cwd at the package root, so the asset is readable + // directly from disk without the Flutter asset bundle. + final template = File('assets/trampoline.sh.template').readAsStringSync(); + + group('renderTrampoline', () { + test('substitutes every placeholder', () { + final out = renderTrampoline( + template, + dbcImagePath: '/data/installer/dbc.wic.gz', + osmTilesFile: '/data/installer/berlin.osm.tiles', + valhallaTilesFile: '/data/installer/berlin.valhalla.tiles', + installTiles: true, + targetDbcVersion: 'v1.2.3', + forceDbcReflash: false, + ); + expect(out.contains('{{'), isFalse); + expect(out, contains('/data/installer/dbc.wic.gz')); + expect(out, contains('v1.2.3')); + }); + + test('rendered script passes sh -n', () async { + final out = renderTrampoline(template, dbcImagePath: '/data/installer/dbc.wic.gz'); + final tmp = File('${Directory.systemTemp.path}/trampoline_render_test.sh') + ..writeAsStringSync(out); + final res = await Process.run('sh', ['-n', tmp.path]); + tmp.deleteSync(); + expect(res.exitCode, 0, reason: 'sh -n failed: ${res.stderr}'); + }); + + test('no CRLF survives rendering', () { + final out = renderTrampoline(template, dbcImagePath: '/x'); + expect(out.contains('\r'), isFalse); + }); + }); +} From b2f67d2a5b050389ba6dae66dacf001166537c25 Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Tue, 16 Jun 2026 10:55:55 +0200 Subject: [PATCH 12/43] feat(installer): walk-away DBC finish screen, drop LED legend --- lib/l10n/app_de.arb | 6 ++ lib/l10n/app_en.arb | 6 ++ lib/l10n/app_localizations.dart | 36 +++++++ lib/l10n/app_localizations_de.dart | 22 ++++ lib/l10n/app_localizations_en.dart | 21 ++++ lib/screens/installer_screen.dart | 168 +++++++---------------------- 6 files changed, 131 insertions(+), 128 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index da01a15..d2f1b5c 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -451,6 +451,12 @@ "ledIsGreen": "LED blinkt grün", "ledIsRed": "LED blinkt rot", "ledAmberWaitNotice": "Wichtig: USB und Strom NICHT trennen, solange das hier läuft. Solange die Tacho-LED gelb-orange leuchtet, läuft der Flash noch. Finger weg, nichts anklicken. Wenn der Flash durch ist, fängt die LED an zu blinken: grün = Erfolg, rot = Fehler. Erst weitermachen, wenn sie blinkt.", + "dbcWalkAwayHeadline": "Umstecken erledigt. Du kannst den Laptop jetzt abziehen.", + "dbcWalkAwayBody": "Lass den Roller ein paar Minuten in Ruhe, während er das DBC selbstständig flasht. Das kann 10 bis 20 Minuten dauern.", + "dbcWalkAwayDashboardLit": "Wenn der Tacho angeht, ist die Installation fertig: das DBC-Kabel festschrauben, alles wieder zumachen und den Roller entriegeln.", + "dbcWalkAwayFailure": "Wenn der Roller stattdessen mit dem Warnblinker blinkt oder du ein rotes Licht siehst, ist etwas schiefgelaufen: den Laptop wieder anschließen.", + "dbcWalkAwayDashboardLitButton": "Der Tacho ist angegangen", + "dbcWalkAwayWentWrongButton": "Etwas ist schiefgelaufen", "phaseKeycardSetupTitle": "Schlüsselkarten einrichten", "phaseKeycardSetupDescription": "Schlüsselkarten anlernen", "usingLocalFirmwareImages": "Lokale Firmware-Images werden verwendet", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f91d3be..ed01d73 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -582,6 +582,12 @@ "ledIsGreen": "LED blinking green", "ledIsRed": "LED blinking red", "ledAmberWaitNotice": "Most important: do NOT disconnect USB or power while this is running. While the dashboard LED is amber/orange, flashing is still in progress. Hands off, don't click anything. The LED will start blinking once it's done: green = success, red = error. Only continue once it's blinking.", + "dbcWalkAwayHeadline": "Swap done. You can unplug the laptop now.", + "dbcWalkAwayBody": "Leave the scooter alone for a few minutes while it flashes the dashboard on its own. This can take 10 to 20 minutes.", + "dbcWalkAwayDashboardLit": "When the dashboard lights up, the install is finished: screw the DBC cable down, close everything up, and unlock the scooter.", + "dbcWalkAwayFailure": "If the scooter flashes its hazard lights or you see a red light instead, something went wrong: plug the laptop back in.", + "dbcWalkAwayDashboardLitButton": "The dashboard lit up", + "dbcWalkAwayWentWrongButton": "Something went wrong", "phaseKeycardSetupTitle": "Keycard Setup", "phaseKeycardSetupDescription": "Register your keycards", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 1d0577f..c0f6d41 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2618,6 +2618,42 @@ abstract class AppLocalizations { /// **'Most important: do NOT disconnect USB or power while this is running. While the dashboard LED is amber/orange, flashing is still in progress. Hands off, don\'t click anything. The LED will start blinking once it\'s done: green = success, red = error. Only continue once it\'s blinking.'** String get ledAmberWaitNotice; + /// No description provided for @dbcWalkAwayHeadline. + /// + /// In en, this message translates to: + /// **'Swap done. You can unplug the laptop now.'** + String get dbcWalkAwayHeadline; + + /// No description provided for @dbcWalkAwayBody. + /// + /// In en, this message translates to: + /// **'Leave the scooter alone for a few minutes while it flashes the dashboard on its own. This can take 10 to 20 minutes.'** + String get dbcWalkAwayBody; + + /// No description provided for @dbcWalkAwayDashboardLit. + /// + /// In en, this message translates to: + /// **'When the dashboard lights up, the install is finished: screw the DBC cable down, close everything up, and unlock the scooter.'** + String get dbcWalkAwayDashboardLit; + + /// No description provided for @dbcWalkAwayFailure. + /// + /// In en, this message translates to: + /// **'If the scooter flashes its hazard lights or you see a red light instead, something went wrong: plug the laptop back in.'** + String get dbcWalkAwayFailure; + + /// No description provided for @dbcWalkAwayDashboardLitButton. + /// + /// In en, this message translates to: + /// **'The dashboard lit up'** + String get dbcWalkAwayDashboardLitButton; + + /// No description provided for @dbcWalkAwayWentWrongButton. + /// + /// In en, this message translates to: + /// **'Something went wrong'** + String get dbcWalkAwayWentWrongButton; + /// No description provided for @phaseKeycardSetupTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 8711701..1eb3b80 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1464,6 +1464,28 @@ class AppLocalizationsDe extends AppLocalizations { String get ledAmberWaitNotice => 'Wichtig: USB und Strom NICHT trennen, solange das hier läuft. Solange die Tacho-LED gelb-orange leuchtet, läuft der Flash noch. Finger weg, nichts anklicken. Wenn der Flash durch ist, fängt die LED an zu blinken: grün = Erfolg, rot = Fehler. Erst weitermachen, wenn sie blinkt.'; + @override + String get dbcWalkAwayHeadline => + 'Umstecken erledigt. Du kannst den Laptop jetzt abziehen.'; + + @override + String get dbcWalkAwayBody => + 'Lass den Roller ein paar Minuten in Ruhe, während er das DBC selbstständig flasht. Das kann 10 bis 20 Minuten dauern.'; + + @override + String get dbcWalkAwayDashboardLit => + 'Wenn der Tacho angeht, ist die Installation fertig: das DBC-Kabel festschrauben, alles wieder zumachen und den Roller entriegeln.'; + + @override + String get dbcWalkAwayFailure => + 'Wenn der Roller stattdessen mit dem Warnblinker blinkt oder du ein rotes Licht siehst, ist etwas schiefgelaufen: den Laptop wieder anschließen.'; + + @override + String get dbcWalkAwayDashboardLitButton => 'Der Tacho ist angegangen'; + + @override + String get dbcWalkAwayWentWrongButton => 'Etwas ist schiefgelaufen'; + @override String get phaseKeycardSetupTitle => 'Schlüsselkarten einrichten'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index eafb471..699ccbc 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1453,6 +1453,27 @@ class AppLocalizationsEn extends AppLocalizations { String get ledAmberWaitNotice => 'Most important: do NOT disconnect USB or power while this is running. While the dashboard LED is amber/orange, flashing is still in progress. Hands off, don\'t click anything. The LED will start blinking once it\'s done: green = success, red = error. Only continue once it\'s blinking.'; + @override + String get dbcWalkAwayHeadline => 'Swap done. You can unplug the laptop now.'; + + @override + String get dbcWalkAwayBody => + 'Leave the scooter alone for a few minutes while it flashes the dashboard on its own. This can take 10 to 20 minutes.'; + + @override + String get dbcWalkAwayDashboardLit => + 'When the dashboard lights up, the install is finished: screw the DBC cable down, close everything up, and unlock the scooter.'; + + @override + String get dbcWalkAwayFailure => + 'If the scooter flashes its hazard lights or you see a red light instead, something went wrong: plug the laptop back in.'; + + @override + String get dbcWalkAwayDashboardLitButton => 'The dashboard lit up'; + + @override + String get dbcWalkAwayWentWrongButton => 'Something went wrong'; + @override String get phaseKeycardSetupTitle => 'Keycard Setup'; diff --git a/lib/screens/installer_screen.dart b/lib/screens/installer_screen.dart index a3b4870..bb18873 100644 --- a/lib/screens/installer_screen.dart +++ b/lib/screens/installer_screen.dart @@ -3381,10 +3381,10 @@ class _InstallerScreenState extends State { // Step 2: USB disconnected: MDB is flashing autonomously. // // Once the cable is unplugged we have NO link to the MDB until it - // comes back as RNDIS. So this screen is purely informational: - // it tells the user what's happening on the scooter lights and - // gives them a "I see X" button to advance when the boot LED - // settles on green or red. + // comes back as RNDIS. The MDB no longer drives a per-phase progress + // LED, so this is a walk-away screen: tell the user the swap is done, + // they can leave for a few minutes, and how to read the outcome (the + // dashboard lights up = done; hazards/red light = something went wrong). return Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 620), @@ -3412,7 +3412,7 @@ class _InstallerScreenState extends State { const SizedBox(width: 10), Expanded( child: Text( - l10n.dbcFlashDurationHeadline, + l10n.dbcWalkAwayHeadline, style: TextStyle( fontSize: 17, fontWeight: FontWeight.bold, @@ -3424,7 +3424,7 @@ class _InstallerScreenState extends State { ), const SizedBox(height: 8), Text( - l10n.ledAmberWaitNotice, + l10n.dbcWalkAwayBody, style: TextStyle( fontSize: 13, color: Colors.orange.shade100, @@ -3445,43 +3445,21 @@ class _InstallerScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(l10n.watchLightsForProgress, - style: TextStyle(color: Colors.grey.shade300, fontSize: 13)), - const SizedBox(height: 8), - _ledSignal(l10n.ledBootAmber, l10n.ledBootAmberMeaning), - _blinkerPhases(l10n), - _ledSignal(l10n.ledBootGreen, l10n.ledBootGreenMeaning), - _ledSignal(l10n.ledBootRedError, l10n.ledBootRedMeaning), + _walkAwayOutcome( + Icons.check_circle, + Colors.green, + l10n.dbcWalkAwayDashboardLit, + ), + const SizedBox(height: 10), + _walkAwayOutcome( + Icons.error, + Colors.red, + l10n.dbcWalkAwayFailure, + ), ], ), ), const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - const SizedBox(width: 10), - Flexible( - child: Text( - _statusMessage.isEmpty - ? l10n.waitingForMdbToReconnect - : _statusMessage, - style: TextStyle(color: Colors.grey.shade400, fontSize: 13), - ), - ), - ], - ), - const SizedBox(height: 16), - // The flash runs autonomously on the scooter; the laptop is no - // longer in the loop. The user can walk away and come back. - Text(l10n.dbcFlashDurationHeadline, - style: TextStyle(color: Colors.grey.shade400, fontSize: 13), - textAlign: TextAlign.center), - const SizedBox(height: 16), Wrap( spacing: 12, runSpacing: 8, @@ -3492,7 +3470,7 @@ class _InstallerScreenState extends State { FilledButton.icon( onPressed: () => _setPhase(InstallerPhase.finish), icon: const Icon(Icons.check_circle, color: Colors.green), - label: Text(l10n.ledIsGreen), + label: Text(l10n.dbcWalkAwayDashboardLitButton), ), // Failure path: reconnect the laptop and run the verify logic // to surface the trampoline error log. @@ -3502,7 +3480,7 @@ class _InstallerScreenState extends State { _setPhase(InstallerPhase.reconnect); }, icon: const Icon(Icons.error, color: Colors.red), - label: Text(l10n.ledIsRed), + label: Text(l10n.dbcWalkAwayWentWrongButton), ), ], ), @@ -3512,6 +3490,27 @@ class _InstallerScreenState extends State { ); } + // One outcome line on the walk-away screen: an icon plus the guidance for + // that outcome (dashboard lit = done, hazards/red = something went wrong). + Widget _walkAwayOutcome(IconData icon, Color color, String text) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 1), + child: Icon(icon, size: 18, color: color), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + text, + style: TextStyle(color: Colors.grey.shade300, fontSize: 13, height: 1.4), + ), + ), + ], + ); + } + Future _watchDbcFlash() async { // Detect the connected → disconnected transition, not the static null // state. If `_device` happens to be momentarily null when this watcher @@ -3539,93 +3538,6 @@ class _InstallerScreenState extends State { // logic. We deliberately do NOT poll the MDB or auto-advance here anymore. } - Widget _ledSignal(String signal, String meaning) { - return Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(width: 8), - const Padding( - padding: EdgeInsets.only(top: 3), - child: Icon(Icons.circle, size: 8, color: kAccent), - ), - const SizedBox(width: 8), - Expanded(flex: 3, child: Text(signal, style: const TextStyle(fontSize: 13))), - const SizedBox(width: 12), - Expanded( - flex: 2, - child: Text( - meaning, - style: TextStyle(fontSize: 13, color: Colors.grey.shade500), - textAlign: TextAlign.right, - ), - ), - ], - ), - ); - } - - // The four turn-signal LEDs fill in sequence (FL -> FR -> BR -> BL), one per - // trampoline phase. Label each position with the step it represents so the - // user can read progress off the scooter itself. - Widget _blinkerPhases(AppLocalizations l10n) { - Widget phase(int n, String pos, String step) { - return Padding( - padding: const EdgeInsets.only(left: 24, bottom: 3), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 14, - child: Text('$n', - style: const TextStyle( - fontSize: 12, color: kAccent, fontWeight: FontWeight.w600)), - ), - const SizedBox(width: 8), - Expanded( - flex: 2, - child: Text(pos, - style: TextStyle(fontSize: 12.5, color: Colors.grey.shade500)), - ), - const SizedBox(width: 8), - Expanded(flex: 3, child: Text(step, style: const TextStyle(fontSize: 12.5))), - ], - ), - ); - } - - return Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(width: 8), - const Padding( - padding: EdgeInsets.only(top: 3), - child: Icon(Icons.circle, size: 8, color: kAccent), - ), - const SizedBox(width: 8), - Expanded( - child: Text(l10n.ledBlinkerProgress, - style: const TextStyle(fontSize: 13))), - ], - ), - ), - phase(1, l10n.blinkerPosFL, l10n.blinkerStepPrep), - phase(2, l10n.blinkerPosFR, l10n.blinkerStepFlash), - phase(3, l10n.blinkerPosBR, l10n.blinkerStepRestart), - phase(4, l10n.blinkerPosBL, l10n.blinkerStepMaps), - ], - ), - ); - } - Widget _buildReconnect(AppLocalizations l10n) { if (!_reconnectStarted && !_isProcessing) { _reconnectStarted = true; From 02cb49453afe19301de7d1014876b557004c5f7b Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Tue, 16 Jun 2026 11:00:40 +0200 Subject: [PATCH 13/43] feat(installer): drive resume from MDB state.json --- lib/screens/installer_screen.dart | 87 ++++++++++++++++++++----------- 1 file changed, 56 insertions(+), 31 deletions(-) diff --git a/lib/screens/installer_screen.dart b/lib/screens/installer_screen.dart index bb18873..f06e37e 100644 --- a/lib/screens/installer_screen.dart +++ b/lib/screens/installer_screen.dart @@ -15,6 +15,7 @@ import '../models/region.dart'; import '../models/scooter_health.dart'; import '../models/substep.dart'; import '../models/trampoline_status.dart'; +import '../services/resume_resolver.dart'; import '../services/services.dart'; import '../widgets/download_progress.dart'; import '../widgets/health_check_panel.dart'; @@ -107,6 +108,7 @@ class _InstallerScreenState extends State { Timer? _keycardToastTimer; String? _awaitingUnlockState; // null when not awaiting; current vehicle state otherwise String? _resumePreviousError; // first error line from a leftover trampoline-status, if any + ResumeDecision? _resumeDecision; // resolved resume target, applied by _continueFromResume Completer? _unlockCompleter; bool _keepCache = false; bool _isCriticalOperation = false; // prevent quit during flash/upload @@ -1605,21 +1607,29 @@ class _InstallerScreenState extends State { // keycard + bluetooth masked (the trampoline masks them pre-flash). // After a power cycle such a scooter cannot be unlocked at all: no // keycard reader, no BLE, so the parked-state gate below would wait - // forever. The leftovers prove an earlier session already passed the - // gate, so skip it and clean up the masked services / error signals - // before redoing the install. - var resumingUnfinished = false; - try { - final leftover = await _sshService.runCommand( - 'ls /data/installer/trampoline-status /data/installer/trampoline.sh 2>/dev/null; true', - ); - resumingUnfinished = leftover.trim().isNotEmpty; - } catch (e) { - debugPrint('SSH: unfinished-install check failed (ok): $e'); + // forever. state.json (written once the MDB runs Librescoot) proves an + // earlier session already passed the gate; so do the legacy leftover + // trampoline artifacts (older builds wrote no state.json). On either + // signal, skip the gate and clean up the masked services / error + // signals before resuming at the resolved phase. + final st = await _sshService.readInstallState(); + final status = await _sshService.readTrampolineStatus(); + var resumingUnfinished = st != null; + if (!resumingUnfinished) { + try { + final leftover = await _sshService.runCommand( + 'ls /data/installer/trampoline-status /data/installer/trampoline.sh 2>/dev/null; true', + ); + resumingUnfinished = leftover.trim().isNotEmpty; + } catch (e) { + debugPrint('SSH: unfinished-install check failed (ok): $e'); + } } if (resumingUnfinished) { - debugPrint('SSH: unfinished install detected, skipping unlock gate'); + final decision = resolveResume(state: st, status: status); + debugPrint('SSH: unfinished install detected (state=${st?.phase.wire ?? "none"}), ' + 'resuming at ${decision.phase.name}, skipping unlock gate'); _setStatus(l10n.unfinishedInstallDetected); try { await _sshService.runCommand( @@ -1643,21 +1653,13 @@ class _InstallerScreenState extends State { } catch (e) { debugPrint('SSH: service unmask on resume failed (ok): $e'); } - // Surface what the previous run recorded, if anything, then let - // the user acknowledge before continuing. - String? prevError; - try { - final firstLine = await _sshService.runCommand( - 'head -1 /data/installer/trampoline-status 2>/dev/null; true', - ); - final trimmed = firstLine.trim(); - if (trimmed.startsWith('error:')) { - prevError = trimmed.substring('error:'.length).trim(); - } - } catch (_) {} + // Stash the decision for the resume screen's Continue handler, and + // surface what the previous run recorded so the user can acknowledge + // it before continuing. if (!mounted) return; setState(() { - _resumePreviousError = prevError; + _resumeDecision = decision; + _resumePreviousError = decision.previousError; _isProcessing = false; }); _setPhase(InstallerPhase.resumeDetected); @@ -1691,8 +1693,12 @@ class _InstallerScreenState extends State { /// Shared tail of the connect phase: runs after the unlock gate (normal /// flow) or after the user confirms the resume screen. Pins the USB /// gadget, disables alarm/auto-standby, locks the scooter, and moves on - /// to the health check. - Future _completeConnectionSetup(AppLocalizations l10n) async { + /// to [nextPhase] (the health check on a fresh install, or the resolved + /// resume target). + Future _completeConnectionSetup( + AppLocalizations l10n, { + InstallerPhase nextPhase = InstallerPhase.healthCheck, + }) async { // Keep MDB USB gadget powered while the scooter is locked so we don't // lose RNDIS mid-flash. Best-effort: the key may not exist on older // images and `lsc set` returns non-zero in that case. @@ -1719,15 +1725,34 @@ class _InstallerScreenState extends State { _setStatus(l10n.connected); setState(() => _isProcessing = false); - _setPhase(InstallerPhase.healthCheck); + _setPhase(nextPhase); } - /// Continue button on the resume screen. + /// Continue button on the resume screen. Applies the resolved + /// ResumeDecision: seeds the dashboard-prep completion flags from the + /// recorded progress, runs the shared connection-setup side effects + /// (USB policy, hazard disable, lock), and jumps straight to the resolved + /// phase instead of always restarting from the health check. The unlock + /// gate is intentionally not run here: on resume the scooter may have + /// keycard/BLE masked and could not be unlocked at all. Future _continueFromResume() async { final l10n = AppLocalizations.of(context)!; - setState(() => _isProcessing = true); + final decision = _resumeDecision; + setState(() { + _isProcessing = true; + if (decision != null) { + // Treat recorded progress as satisfied so the dashboardPrep gate + // (_btDone || _btSkipped, _keycardDone || _keycardSkipped) passes + // without redoing pairing/enrollment. + if (decision.bluetoothDone) _btDone = true; + if (decision.keycardDone) _keycardDone = true; + } + }); try { - await _completeConnectionSetup(l10n); + await _completeConnectionSetup( + l10n, + nextPhase: decision?.phase ?? InstallerPhase.healthCheck, + ); } catch (e) { _setStatus(l10n.sshConnectionFailed(e.toString())); if (mounted) setState(() => _isProcessing = false); From 091e3e5e714431629ff2702cee7f8b5b8f291f10 Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Thu, 18 Jun 2026 08:23:58 +0200 Subject: [PATCH 14/43] fix(installer): leave blinker channels active at duty 0 in error/cleanup paths The hazards tails, signal_error_stop and stop-error-signals.sh deactivated the four blinker PWM channels. The imx_pwm_led active flag gates output, so a deactivated channel stays dark even while vehicle-service drives it, until vehicle-service re-inits. stop-error-signals.sh runs on reconnect/resume with nothing to re-activate afterwards, so blinkers could stay dead until the finish reboot. Set duty 0 but leave the channels active everywhere. The success path here never deactivates the blinkers (the progress bar that did was dropped), so this is the error/resume-path counterpart of the main fade_progress_off / stop-error-signals fixes. --- assets/trampoline.sh.template | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/assets/trampoline.sh.template b/assets/trampoline.sh.template index d1621d8..abaf54c 100644 --- a/assets/trampoline.sh.template +++ b/assets/trampoline.sh.template @@ -173,8 +173,10 @@ signal_error() { done sleep 0.5 done + # Leave channels active at duty 0 so vehicle-service can drive the blinkers + # again without a reinit; activate=0 would gate its output off. for ch in 3 4 6 7; do - /usr/bin/ioctl /dev/pwm_led$ch 0x00007549 -v 0 2>/dev/null + /usr/bin/ioctl /dev/pwm_led$ch 0x0000754A -v 0 2>/dev/null done ' } @@ -182,9 +184,10 @@ signal_error_stop() { systemctl stop "${HAZARDS_UNIT}.service" 2>/dev/null [ -f /data/error-hazards.pid ] && kill "$(cat /data/error-hazards.pid)" 2>/dev/null rm -f /data/error-hazards.pid + # Blinkers off (duty 0), channels left active so vehicle-service can drive + # them again without a reinit; activate=0 would gate its output off. for ch in 3 4 6 7; do /usr/bin/ioctl /dev/pwm_led$ch 0x0000754A -v 0 2>/dev/null - /usr/bin/ioctl /dev/pwm_led$ch 0x00007549 -v 0 2>/dev/null done } @@ -619,10 +622,14 @@ fi i2cset -f -y 2 0x30 0x02 0x00 2>/dev/null i2cset -f -y 2 0x30 0x03 0x00 2>/dev/null i2cset -f -y 2 0x30 0x04 0x00 2>/dev/null -# Blinkers off + deactivate +# Blinkers off: set duty 0 but leave the channels active. Deactivating +# (activate=0) gates the PWM output off in the driver, and vehicle-service +# only sets activate=1 once at its Init and afterwards just loads duties, so a +# deactivated channel stays dark even while vehicle-service drives it, until it +# re-inits. This helper runs on reconnect/resume, where nothing re-activates +# afterwards, so duty 0 with activate=1 keeps the blinkers ready to fire. for ch in 3 4 6 7; do /usr/bin/ioctl /dev/pwm_led$ch 0x0000754A -v 0 2>/dev/null - /usr/bin/ioctl /dev/pwm_led$ch 0x00007549 -v 0 2>/dev/null done # Unmask keycard-service: the trampoline masked it pre-flash so it couldn't # race us on the LED. The installer's keycardSetup phase will start it @@ -1442,8 +1449,10 @@ hazards_30s() { done sleep 0.5 done + # Leave channels active at duty 0 so vehicle-service can drive the blinkers + # again without a reinit; activate=0 would gate its output off. for ch in 3 4 6 7; do - /usr/bin/ioctl /dev/pwm_led$ch 0x00007549 -v 0 2>/dev/null + /usr/bin/ioctl /dev/pwm_led$ch 0x0000754A -v 0 2>/dev/null done ' } From 9debe6efff45eb04f7e232a8482ad52a71edab77 Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Sun, 28 Jun 2026 15:03:45 +0200 Subject: [PATCH 15/43] feat(installer): dynamic region list + ipwho.is geoip detection Derive the offered map regions from the published OSM and Valhalla tile assets (intersection of both, so a region only shows when display and routing tiles both exist) instead of a hardcoded list. This surfaces Belgium, Netherlands, Luxembourg, Ile-de-France and Italy (northwest), which already had tiles but were never selectable. Group the region dropdown by country, and switch IP preselection from ip-api.com (HTTP, non-commercial only) to ipwho.is (HTTPS) keyed on the ISO 3166-2 region_code. Auto-detect covers the German states plus the neighbour regions. --- lib/models/region.dart | 165 +++++++++++++++------ lib/screens/installer_screen.dart | 87 ++++++++++- lib/services/download_service.dart | 56 +++++++ test/services/download_service_test.dart | 106 +++++++++++++ test/services/trampoline_service_test.dart | 21 ++- 5 files changed, 382 insertions(+), 53 deletions(-) diff --git a/lib/models/region.dart b/lib/models/region.dart index 18a5c46..ba23057 100644 --- a/lib/models/region.dart +++ b/lib/models/region.dart @@ -5,47 +5,126 @@ class Region { const Region({ required this.name, required this.slug, + this.country = '', }); final String name; final String slug; - /// Map ip-api.com regionName to our slug. - static const _ipApiMap = { - 'Baden-Württemberg': 'baden-wuerttemberg', - 'Bavaria': 'bayern', - 'State of Berlin': 'berlin_brandenburg', - 'Brandenburg': 'berlin_brandenburg', - 'Free Hanseatic City of Bremen': 'bremen', - 'Free and Hanseatic City of Hamburg': 'hamburg', - 'Hesse': 'hessen', - 'Mecklenburg-Vorpommern': 'mecklenburg-vorpommern', - 'Lower Saxony': 'niedersachsen', - 'North Rhine-Westphalia': 'nordrhein-westfalen', - 'Rhineland-Palatinate': 'rheinland-pfalz', - 'Saarland': 'saarland', - 'Saxony': 'sachsen', - 'Saxony-Anhalt': 'sachsen-anhalt', - 'Schleswig-Holstein': 'schleswig-holstein', - 'Thuringia': 'thueringen', + /// Country the region belongs to, used to group the dropdown. Empty for + /// regions we don't recognise (they fall under [_unknownCountry]). + final String country; + + static const _unknownCountry = 'Weitere'; + + /// Known slugs, in dropdown order (grouped by country: German states first, + /// then the neighbours). Single source of truth for [all], the offline + /// fallback catalogue. Slugs published by the tile repos but missing here + /// still work via [fromSlug], which humanises the slug. + static const _catalog = { + 'baden-wuerttemberg': (name: 'Baden-Württemberg', country: 'Deutschland'), + 'bayern': (name: 'Bayern', country: 'Deutschland'), + 'berlin_brandenburg': (name: 'Berlin & Brandenburg', country: 'Deutschland'), + 'bremen': (name: 'Bremen', country: 'Deutschland'), + 'hamburg': (name: 'Hamburg', country: 'Deutschland'), + 'hessen': (name: 'Hessen', country: 'Deutschland'), + 'mecklenburg-vorpommern': + (name: 'Mecklenburg-Vorpommern', country: 'Deutschland'), + 'niedersachsen': (name: 'Niedersachsen', country: 'Deutschland'), + 'nordrhein-westfalen': (name: 'Nordrhein-Westfalen', country: 'Deutschland'), + 'rheinland-pfalz': (name: 'Rheinland-Pfalz', country: 'Deutschland'), + 'saarland': (name: 'Saarland', country: 'Deutschland'), + 'sachsen': (name: 'Sachsen', country: 'Deutschland'), + 'sachsen-anhalt': (name: 'Sachsen-Anhalt', country: 'Deutschland'), + 'schleswig-holstein': (name: 'Schleswig-Holstein', country: 'Deutschland'), + 'thueringen': (name: 'Thüringen', country: 'Deutschland'), + 'belgium': (name: 'Belgien', country: 'Belgien'), + 'netherlands': (name: 'Niederlande', country: 'Niederlande'), + 'luxembourg': (name: 'Luxemburg', country: 'Luxemburg'), + 'ile-de-france': (name: 'Île-de-France', country: 'Frankreich'), + 'italy-nord-ovest': (name: 'Italien (Nordwest)', country: 'Italien'), + }; + + /// Map ipwho.is country_code -> region_code -> our slug. Keyed on the ISO + /// 3166-2 subdivision code rather than the English region name, which is + /// stable across the provider's localisation. Both Berlin (BE) and + /// Brandenburg (BB) collapse to the combined berlin_brandenburg region. + /// France and Italy only have partial coverage, so only the subdivisions + /// whose tiles we publish map to a slug: + /// - FR Île-de-France (IDF) + /// - IT northwest (Piemonte 21, Valle d'Aosta 23, Lombardia 25, Liguria 42) + /// Other subdivisions of those countries fall through to no match. + static const _geoMap = >{ + 'DE': { + 'BW': 'baden-wuerttemberg', + 'BY': 'bayern', + 'BE': 'berlin_brandenburg', + 'BB': 'berlin_brandenburg', + 'HB': 'bremen', + 'HH': 'hamburg', + 'HE': 'hessen', + 'MV': 'mecklenburg-vorpommern', + 'NI': 'niedersachsen', + 'NW': 'nordrhein-westfalen', + 'RP': 'rheinland-pfalz', + 'SL': 'saarland', + 'SN': 'sachsen', + 'ST': 'sachsen-anhalt', + 'SH': 'schleswig-holstein', + 'TH': 'thueringen', + }, + 'FR': {'IDF': 'ile-de-france'}, + 'IT': { + '21': 'italy-nord-ovest', + '23': 'italy-nord-ovest', + '25': 'italy-nord-ovest', + '42': 'italy-nord-ovest', + }, + }; + + /// Countries we cover in full: any subdivision maps to the one slug. Checked + /// after [_geoMap], so a region-level match always wins. + static const _countryDefault = { + 'BE': 'belgium', + 'NL': 'netherlands', + 'LU': 'luxembourg', }; - /// Try to detect the user's region from their IP address. - /// Returns null if detection fails or user is outside Germany. - static Future detectFromIp({http.Client? client}) async { + /// Build a Region for a slug, using a known display name and country if we + /// have them and otherwise humanising the slug ("italy-nord-ovest" -> + /// "Italy Nord Ovest") under the catch-all country group. + factory Region.fromSlug(String slug) { + final entry = _catalog[slug]; + return entry == null + ? Region(name: _humanize(slug), slug: slug, country: _unknownCountry) + : Region(name: entry.name, slug: slug, country: entry.country); + } + + static String _humanize(String slug) => slug + .split(RegExp(r'[-_]')) + .where((w) => w.isNotEmpty) + .map((w) => '${w[0].toUpperCase()}${w.substring(1)}') + .join(' '); + + /// Try to detect the user's region from their IP via ipwho.is (HTTPS). + /// Returns the region slug, or null if detection fails or the location is + /// not a region we map. The caller matches the slug against the regions it + /// actually offers before preselecting. + static Future detectSlugFromIp({http.Client? client}) async { try { final c = client ?? http.Client(); - final response = await c.get(Uri.parse('http://ip-api.com/json/?fields=countryCode,regionName')) + final response = await c + .get(Uri.parse( + 'https://ipwho.is/?fields=success,country_code,region_code')) .timeout(const Duration(seconds: 5)); if (client == null) c.close(); if (response.statusCode != 200) return null; final data = jsonDecode(response.body) as Map; - if (data['countryCode'] != 'DE') return null; - final regionName = data['regionName'] as String?; - if (regionName == null) return null; - final slug = _ipApiMap[regionName]; - if (slug == null) return null; - return all.where((r) => r.slug == slug).firstOrNull; + if (data['success'] != true) return null; + final countryCode = data['country_code'] as String?; + final regionCode = data['region_code'] as String?; + if (countryCode == null) return null; + return _geoMap[countryCode]?[regionCode] ?? _countryDefault[countryCode]; } catch (_) { return null; } @@ -56,21 +135,17 @@ class Region { String get valhallaTilesFilename => 'valhalla_tiles_$slug.tar'; String get valhallaTilesChecksumFilename => 'valhalla_tiles_$slug.tar.sha256'; - static const List all = [ - Region(name: 'Baden-Württemberg', slug: 'baden-wuerttemberg'), - Region(name: 'Bayern', slug: 'bayern'), - Region(name: 'Berlin & Brandenburg', slug: 'berlin_brandenburg'), - Region(name: 'Bremen', slug: 'bremen'), - Region(name: 'Hamburg', slug: 'hamburg'), - Region(name: 'Hessen', slug: 'hessen'), - Region(name: 'Mecklenburg-Vorpommern', slug: 'mecklenburg-vorpommern'), - Region(name: 'Niedersachsen', slug: 'niedersachsen'), - Region(name: 'Nordrhein-Westfalen', slug: 'nordrhein-westfalen'), - Region(name: 'Rheinland-Pfalz', slug: 'rheinland-pfalz'), - Region(name: 'Saarland', slug: 'saarland'), - Region(name: 'Sachsen', slug: 'sachsen'), - Region(name: 'Sachsen-Anhalt', slug: 'sachsen-anhalt'), - Region(name: 'Schleswig-Holstein', slug: 'schleswig-holstein'), - Region(name: 'Thüringen', slug: 'thueringen'), - ]; + /// Known regions, used as the offline fallback when the published tile list + /// can't be fetched. Identity is by slug, so a Region built here compares + /// equal to one built by [fromSlug] from the live tile listing. + static final List all = _catalog.entries + .map((e) => + Region(name: e.value.name, slug: e.key, country: e.value.country)) + .toList(growable: false); + + @override + bool operator ==(Object other) => other is Region && other.slug == slug; + + @override + int get hashCode => slug.hashCode; } diff --git a/lib/screens/installer_screen.dart b/lib/screens/installer_screen.dart index f06e37e..3f04e3e 100644 --- a/lib/screens/installer_screen.dart +++ b/lib/screens/installer_screen.dart @@ -54,6 +54,10 @@ class _InstallerScreenState extends State { final List _prerequisiteChecks = [false, false, false, false]; Map? _availableChannels; bool _channelsLoading = true; + // Regions on offer, derived from the published tile assets. Seeded with the + // offline catalogue so the dropdown is populated immediately, then replaced + // once the live listing resolves. + List _availableRegions = Region.all; // Phase guard flags (prevent auto-start methods from re-firing on rebuild) bool _mdbConnectStarted = false; @@ -131,6 +135,7 @@ class _InstallerScreenState extends State { _applyLaunchArgs(); Future.microtask(_detectResumeState); _resolveAvailableChannels(); + _loadAvailableRegions(); _detectRegionFromIp(); } @@ -150,10 +155,68 @@ class _InstallerScreenState extends State { ); } + Future _loadAvailableRegions() async { + final regions = await _downloadService.fetchAvailableRegions(); + if (!mounted || regions.isEmpty) return; + setState(() { + _availableRegions = regions; + // If a region was already chosen (launch args / IP), keep the user's + // pick but swap in the instance from the live list so dropdown identity + // lines up. Equality is by slug, so this is a no-op when slugs match. + final selected = _downloadState.selectedRegion; + if (selected != null) { + _downloadState.selectedRegion = + regions.where((r) => r.slug == selected.slug).firstOrNull; + } + }); + } + + static const _regionHeaderPrefix = '__country__'; + + /// Build dropdown items grouped by country: a disabled bold header per + /// country, followed by its (indented) regions. Relies on [_availableRegions] + /// already being ordered so each country's regions are contiguous. + List> _buildRegionDropdownItems( + List regions) { + final items = >[]; + String? currentCountry; + for (final region in regions) { + if (region.country != currentCountry) { + currentCountry = region.country; + items.add(DropdownMenuItem( + enabled: false, + value: Region( + name: region.country, + slug: '$_regionHeaderPrefix${region.country}'), + child: Text( + region.country, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.grey.shade600), + ), + )); + } + items.add(DropdownMenuItem( + value: region, + child: Padding( + padding: const EdgeInsets.only(left: 12), + child: Text(region.name), + ), + )); + } + return items; + } + Future _detectRegionFromIp() async { if (_downloadState.selectedRegion != null) return; // already set (e.g. from launch args) - final region = await Region.detectFromIp(); - if (region != null && mounted && _downloadState.selectedRegion == null) { + final slug = await Region.detectSlugFromIp(); + if (slug == null || !mounted || _downloadState.selectedRegion != null) { + return; + } + // Only preselect if we actually offer tiles for the detected region. + final region = _availableRegions.where((r) => r.slug == slug).firstOrNull; + if (region != null) { setState(() => _downloadState.selectedRegion = region); } } @@ -898,10 +961,22 @@ class _InstallerScreenState extends State { border: const OutlineInputBorder(), hintText: l10n.selectRegion, ), - items: Region.all - .map((r) => DropdownMenuItem(value: r, child: Text(r.name))) - .toList(), - onChanged: (r) => setState(() => _downloadState.selectedRegion = r), + items: _buildRegionDropdownItems(_availableRegions), + selectedItemBuilder: (context) => + _buildRegionDropdownItems(_availableRegions).map((item) { + final r = item.value!; + final isHeader = r.slug.startsWith(_regionHeaderPrefix); + return Align( + alignment: Alignment.centerLeft, + child: Text(isHeader ? '' : r.name), + ); + }).toList(), + onChanged: (r) { + // Country headers are disabled, so onChanged only fires for real + // regions, but guard against the header sentinel just in case. + if (r == null || r.slug.startsWith(_regionHeaderPrefix)) return; + setState(() => _downloadState.selectedRegion = r); + }, ), const SizedBox(height: 24), diff --git a/lib/services/download_service.dart b/lib/services/download_service.dart index d4e1ee1..7232702 100644 --- a/lib/services/download_service.dart +++ b/lib/services/download_service.dart @@ -185,6 +185,62 @@ class DownloadService { } } + /// Derive the regions on offer from the published tile assets: every slug + /// that has both an OSM display tile and a Valhalla routing tile. Falls back + /// to [Region.all] if the listings can't be fetched, so the dropdown is + /// never empty offline. + Future> fetchAvailableRegions() async { + try { + final osmAssets = await resolveTileAssets(_osmTilesRepo, 'tiles_'); + final valhallaAssets = + await resolveTileAssets(_valhallaTilesRepo, 'valhalla_tiles_'); + final regions = regionsFromAssets(osmAssets, valhallaAssets); + return regions.isEmpty ? Region.all : regions; + } catch (e) { + debugPrint('fetchAvailableRegions failed, using fallback catalogue: $e'); + return Region.all; + } + } + + static final _osmSlugPattern = RegExp(r'^tiles_(.+)\.mbtiles$'); + static final _valhallaSlugPattern = RegExp(r'^valhalla_tiles_(.+)\.tar$'); + + /// Intersect the slugs present in both asset listings and turn them into + /// regions, ordered by the known catalogue (German states first), with any + /// unknown slugs appended alphabetically by display name. Pure: no I/O. + static List regionsFromAssets( + List> osmAssets, + List> valhallaAssets, + ) { + final osmSlugs = _slugsFrom(osmAssets, _osmSlugPattern); + final valhallaSlugs = _slugsFrom(valhallaAssets, _valhallaSlugPattern); + final common = osmSlugs.intersection(valhallaSlugs); + + final order = Region.all.map((r) => r.slug).toList(); + final regions = common.map(Region.fromSlug).toList(); + regions.sort((a, b) { + final ia = order.indexOf(a.slug); + final ib = order.indexOf(b.slug); + if (ia != -1 && ib != -1) return ia.compareTo(ib); + if (ia != -1) return -1; + if (ib != -1) return 1; + return a.name.compareTo(b.name); + }); + return regions; + } + + static Set _slugsFrom( + List> assets, RegExp pattern) { + final slugs = {}; + for (final asset in assets) { + final name = asset['name'] as String?; + if (name == null) continue; + final match = pattern.firstMatch(name); + if (match != null) slugs.add(match.group(1)!); + } + return slugs; + } + /// Build the full download queue based on channel, region, and offline preference. Future> buildDownloadQueue({ required DownloadChannel channel, diff --git a/test/services/download_service_test.dart b/test/services/download_service_test.dart index 178fd78..1ccca07 100644 --- a/test/services/download_service_test.dart +++ b/test/services/download_service_test.dart @@ -91,4 +91,110 @@ void main() { expect(region.valhallaTilesFilename, 'valhalla_tiles_berlin_brandenburg.tar'); }); }); + + group('regionsFromAssets', () { + List> assets(List names) => + names.map((n) => {'name': n}).toList(); + + test('returns the intersection of osm and valhalla slugs', () { + final regions = DownloadService.regionsFromAssets( + assets([ + 'tiles_bayern.mbtiles', + 'tiles_hessen.mbtiles', + 'tiles_belgium.mbtiles', + 'Custom Shortbread Tiles - June 2026', + ]), + assets([ + 'valhalla_tiles_bayern.tar', + 'valhalla_tiles_belgium.tar', + 'valhalla_tiles_netherlands.tar', + ]), + ); + // Only bayern and belgium appear in both listings. + expect(regions.map((r) => r.slug), ['bayern', 'belgium']); + }); + + test('orders by catalogue (German first) then unknown slugs', () { + final regions = DownloadService.regionsFromAssets( + assets([ + 'tiles_zzz-unknown.mbtiles', + 'tiles_belgium.mbtiles', + 'tiles_bayern.mbtiles', + ]), + assets([ + 'valhalla_tiles_zzz-unknown.tar', + 'valhalla_tiles_belgium.tar', + 'valhalla_tiles_bayern.tar', + ]), + ); + expect(regions.map((r) => r.slug), ['bayern', 'belgium', 'zzz-unknown']); + expect(regions.last.country, 'Weitere'); + }); + }); + + group('Region.detectSlugFromIp', () { + test('maps a German region_code to its slug', () async { + final client = http_testing.MockClient((request) async { + expect(request.url.host, 'ipwho.is'); + return http.Response( + jsonEncode({ + 'success': true, + 'country_code': 'DE', + 'region_code': 'BY', + }), + 200); + }); + expect(await Region.detectSlugFromIp(client: client), 'bayern'); + }); + + test('collapses Berlin and Brandenburg to the combined region', () async { + for (final code in ['BE', 'BB']) { + final client = http_testing.MockClient((request) async => http.Response( + jsonEncode({ + 'success': true, + 'country_code': 'DE', + 'region_code': code, + }), + 200)); + expect(await Region.detectSlugFromIp(client: client), + 'berlin_brandenburg'); + } + }); + + Future detect(String country, String? region) { + final client = http_testing.MockClient((request) async => http.Response( + jsonEncode({ + 'success': true, + 'country_code': country, + if (region != null) 'region_code': region, + }), + 200)); + return Region.detectSlugFromIp(client: client); + } + + test('maps neighbour subdivisions to their region', () async { + expect(await detect('FR', 'IDF'), 'ile-de-france'); + expect(await detect('IT', '25'), 'italy-nord-ovest'); // Lombardy + expect(await detect('IT', '21'), 'italy-nord-ovest'); // Piedmont + }); + + test('maps fully-covered countries regardless of subdivision', () async { + expect(await detect('BE', 'VBR'), 'belgium'); + expect(await detect('NL', 'ZH'), 'netherlands'); + expect(await detect('LU', 'LU'), 'luxembourg'); + expect(await detect('NL', null), 'netherlands'); // country default + }); + + test('returns null for uncovered subdivisions and countries', () async { + expect(await detect('FR', 'HDF'), isNull); // Hauts-de-France: no tiles + expect(await detect('IT', '72'), isNull); // Campania: not northwest + expect(await detect('US', 'CA'), isNull); + }); + + test('returns null when the lookup is unsuccessful', () async { + final client = http_testing.MockClient((request) async => + http.Response(jsonEncode({'success': false}), 200)); + expect(await Region.detectSlugFromIp(client: client), isNull); + }); + }); } diff --git a/test/services/trampoline_service_test.dart b/test/services/trampoline_service_test.dart index 515fc1b..b0fd16a 100644 --- a/test/services/trampoline_service_test.dart +++ b/test/services/trampoline_service_test.dart @@ -25,13 +25,30 @@ void main() { }); group('Region', () { - test('has 15 regions', () { - expect(Region.all.length, 15); + test('catalogue covers the 15 German regions plus neighbours', () { + expect(Region.all.length, 20); + final germanCount = + Region.all.where((r) => r.country == 'Deutschland').length; + expect(germanCount, 15); }); test('berlin_brandenburg slug is correct', () { final region = Region.all.firstWhere((r) => r.name.contains('Berlin')); expect(region.slug, 'berlin_brandenburg'); + expect(region.country, 'Deutschland'); + }); + + test('fromSlug humanises unknown slugs under the catch-all country', () { + final region = Region.fromSlug('italy-nord-est'); + expect(region.name, 'Italy Nord Est'); + expect(region.country, 'Weitere'); + }); + + test('equality and hashCode are by slug', () { + const a = Region(name: 'Bayern', slug: 'bayern', country: 'Deutschland'); + final b = Region.fromSlug('bayern'); + expect(a, b); + expect(a.hashCode, b.hashCode); }); }); } From 2a40aaea78b5d51acf57c21220c2b48a9f3b4f27 Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Sun, 28 Jun 2026 15:06:06 +0200 Subject: [PATCH 16/43] test(installer): update download service tests for latest.json manifest The resolveRelease/buildDownloadQueue tests still mocked the old direct GitHub /releases endpoint and the in-resolveRelease stable->testing fallback, both of which were replaced by the per-channel latest.json manifest. Rewrite them against the current behaviour (manifest Map keyed by channel, fallback moved to channel selection) and clear the shared on-disk manifest cache between tests for isolation. --- test/services/download_service_test.dart | 144 ++++++++++++++--------- 1 file changed, 86 insertions(+), 58 deletions(-) diff --git a/test/services/download_service_test.dart b/test/services/download_service_test.dart index 1ccca07..9eba744 100644 --- a/test/services/download_service_test.dart +++ b/test/services/download_service_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; @@ -6,76 +7,103 @@ import 'package:http/testing.dart' as http_testing; import 'package:librescoot_installer/models/download_state.dart'; import 'package:librescoot_installer/models/region.dart'; import 'package:librescoot_installer/services/download_service.dart'; +import 'package:path/path.dart' as p; + +/// One release asset as it appears in the downloads.librescoot.org manifest. +/// Firmware downloads read `url`; tiles read `browser_download_url`. +Map _asset(String name, int size, {String? sha256}) => { + 'name': name, + 'size': size, + 'url': 'https://example.com/$name', + 'browser_download_url': 'https://example.com/$name', + if (sha256 != null) 'sha256': sha256, + }; + +Map _release( + String tag, + List> assets, { + String publishedAt = '2026-01-01T00:00:00Z', +}) => + {'tag_name': tag, 'published_at': publishedAt, 'assets': assets}; + +/// MockClient that serves the given per-channel manifest from latest.json and +/// 404s everything else. +http_testing.MockClient _manifestClient(Map channels) => + http_testing.MockClient((request) async { + if (request.url.path.endsWith('latest.json')) { + return http.Response(jsonEncode(channels), 200); + } + return http.Response('Not found', 404); + }); void main() { group('DownloadService', () { - late http_testing.MockClient mockClient; - - test('resolveRelease finds testing release', () async { - mockClient = http_testing.MockClient((request) async { - if (request.url.path.endsWith('/releases')) { - return http.Response(jsonEncode([ - { - 'tag_name': 'nightly-20260330T013130', - 'assets': [ - {'name': 'librescoot-unu-mdb-nightly-20260330T013130.sdimg.gz', 'size': 141215162, 'browser_download_url': 'https://example.com/mdb.sdimg.gz'}, - {'name': 'librescoot-unu-dbc-nightly-20260330T013130.sdimg.gz', 'size': 197006162, 'browser_download_url': 'https://example.com/dbc.sdimg.gz'}, - ], - }, - { - 'tag_name': 'testing-20260318T114803', - 'assets': [ - {'name': 'librescoot-unu-mdb-testing-20260318T114803.sdimg.gz', 'size': 140000000, 'browser_download_url': 'https://example.com/mdb-test.sdimg.gz'}, - {'name': 'librescoot-unu-dbc-testing-20260318T114803.sdimg.gz', 'size': 196000000, 'browser_download_url': 'https://example.com/dbc-test.sdimg.gz'}, - ], - }, - ]), 200); - } - return http.Response('Not found', 404); - }); + // _fetchLatest caches the manifest on disk (shared across instances), so + // clear it before each test to keep them reading their own mock. + setUp(() async { + final dir = await DownloadService.getCacheDir(); + final cache = File(p.join(dir.path, 'latest.json')); + if (await cache.exists()) await cache.delete(); + }); - final service = DownloadService(client: mockClient); + test('resolveRelease finds the requested channel', () async { + final service = DownloadService( + client: _manifestClient({ + 'nightly': _release('nightly-20260330T013130', [ + _asset('librescoot-unu-mdb-nightly-20260330T013130.sdimg.gz', 141215162), + _asset('librescoot-unu-dbc-nightly-20260330T013130.sdimg.gz', 197006162), + ]), + 'testing': _release('testing-20260318T114803', [ + _asset('librescoot-unu-mdb-testing-20260318T114803.sdimg.gz', 140000000), + _asset('librescoot-unu-dbc-testing-20260318T114803.sdimg.gz', 196000000), + ]), + }), + ); final result = await service.resolveRelease(DownloadChannel.testing); expect(result.tag, 'testing-20260318T114803'); expect(result.assets.length, 2); }); - test('resolveRelease falls back from stable to testing', () async { - mockClient = http_testing.MockClient((request) async { - return http.Response(jsonEncode([ - { - 'tag_name': 'testing-20260318T114803', - 'assets': [ - {'name': 'librescoot-unu-mdb-testing-20260318T114803.sdimg.gz', 'size': 140000000, 'browser_download_url': 'https://example.com/mdb.sdimg.gz'}, - ], - }, - ]), 200); - }); - - final service = DownloadService(client: mockClient); - final result = await service.resolveRelease(DownloadChannel.stable); - expect(result.tag, startsWith('testing-')); + test('resolveRelease throws when the channel is absent', () async { + final service = DownloadService( + client: _manifestClient({ + 'testing': _release('testing-20260318T114803', [ + _asset('librescoot-unu-mdb-testing-20260318T114803.sdimg.gz', 140000000), + ]), + }), + ); + expect(() => service.resolveRelease(DownloadChannel.stable), + throwsA(isA())); }); - test('buildDownloadQueue filters to unu variants only', () async { - mockClient = http_testing.MockClient((request) async { - if (request.url.path.endsWith('/releases')) { - return http.Response(jsonEncode([ - { - 'tag_name': 'testing-20260318T114803', - 'assets': [ - {'name': 'librescoot-unu-mdb-testing-20260318T114803.sdimg.gz', 'size': 100, 'browser_download_url': 'https://example.com/mdb.gz'}, - {'name': 'librescoot-unu-dbc-testing-20260318T114803.sdimg.gz', 'size': 200, 'browser_download_url': 'https://example.com/dbc.gz'}, - {'name': 'librescoot-unu-mdb-testing-20260318T114803.mender', 'size': 300, 'browser_download_url': 'https://example.com/mdb.mender'}, - {'name': 'librescoot-other-mdb-testing-20260318T114803.sdimg.gz', 'size': 400, 'browser_download_url': 'https://example.com/other.gz'}, - ], - }, - ]), 200); - } - return http.Response('Not found', 404); - }); + test('fetchAvailableChannels reports only channels in the manifest', () async { + final service = DownloadService( + client: _manifestClient({ + 'testing': _release('testing-20260318T114803', [], + publishedAt: '2026-03-18T11:48:03Z'), + 'nightly': _release('nightly-20260330T013130', [], + publishedAt: '2026-03-30T01:31:30Z'), + }), + ); + final channels = await service.fetchAvailableChannels(); + expect(channels.containsKey(DownloadChannel.stable), isFalse); + expect(channels.keys, + containsAll([DownloadChannel.testing, DownloadChannel.nightly])); + expect(channels[DownloadChannel.testing]!.tag, 'testing-20260318T114803'); + expect(channels[DownloadChannel.testing]!.date, '2026-03-18'); + }); - final service = DownloadService(client: mockClient); + test('buildDownloadQueue filters to unu firmware variants only', () async { + final service = DownloadService( + client: _manifestClient({ + 'testing': _release('testing-20260318T114803', [ + _asset('librescoot-unu-mdb-testing-20260318T114803.sdimg.gz', 100), + _asset('librescoot-unu-dbc-testing-20260318T114803.sdimg.gz', 200), + _asset('librescoot-unu-mdb-testing-20260318T114803.mender', 300), + _asset('librescoot-other-mdb-testing-20260318T114803.sdimg.gz', 400), + ]), + }), + ); final items = await service.buildDownloadQueue( channel: DownloadChannel.testing, wantsOfflineMaps: false, From 3aecd439359afb3e9fb35e5c544581ab4896dbb2 Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Sun, 28 Jun 2026 15:53:32 +0200 Subject: [PATCH 17/43] docs: remove stale installer design spec from tree --- .../2026-03-30-simple-gui-installer-design.md | 371 ------------------ 1 file changed, 371 deletions(-) delete mode 100644 docs/specs/2026-03-30-simple-gui-installer-design.md diff --git a/docs/specs/2026-03-30-simple-gui-installer-design.md b/docs/specs/2026-03-30-simple-gui-installer-design.md deleted file mode 100644 index d163cf0..0000000 --- a/docs/specs/2026-03-30-simple-gui-installer-design.md +++ /dev/null @@ -1,371 +0,0 @@ -# Librescoot Simple GUI Installer - Design Spec - -## Overview - -A Flutter desktop application (Windows, macOS, Linux) that guides users through a complete Librescoot firmware installation on both MDB and DBC boards. Replaces the existing 6-step MDB-only installer with a 14-phase wizard that handles firmware download, two-phase safe flashing, and autonomous DBC flashing via a trampoline script. - -## Installation Flow - -### Phase 0: Welcome - -- Display prerequisites: PH2 screwdriver or H4 screwdriver for footwell screws, flat head screwdriver or PH1 for USB cable, USB laptop to Mini-B cable, ~45 minutes -- Channel selection: `stable` (default if available) → `testing` (fallback) → `nightly` (opt-in) -- Ask: "Will your scooter be online or offline?" (most are offline) -- If offline: select region from German states for offline map + routing tile download -- If online: ask if user wants offline maps anyway (for faster/reliable navigation) or will use online tiles + routing -- Begin background downloads in priority order: - 1. MDB firmware image (needed first) - 2. DBC firmware image - 3. Display map tiles (`.mbtiles`, 12–500 MB depending on region) - 4. Routing tiles (`.tar`, 12–712 MB depending on region) -- Show download progress; can proceed to Phase 1 while downloading - -**Region selection (15 options):** - -| Region | OSM Tiles | Valhalla Tiles | -|--------|-----------|---------------| -| Baden-Württemberg | 317 MB | 479 MB | -| Bayern | 384 MB | 712 MB | -| Berlin & Brandenburg | 141 MB | 214 MB | -| Bremen | 12 MB | 12 MB | -| Hamburg | 24 MB | 20 MB | -| Hessen | 172 MB | 224 MB | -| Mecklenburg-Vorpommern | 60 MB | 75 MB | -| Niedersachsen | 266 MB | 373 MB | -| Nordrhein-Westfalen | 500 MB | 548 MB | -| Rheinland-Pfalz | 133 MB | 174 MB | -| Saarland | 31 MB | 27 MB | -| Sachsen | 119 MB | 141 MB | -| Sachsen-Anhalt | 88 MB | 97 MB | -| Schleswig-Holstein | 85 MB | 116 MB | -| Thüringen | 79 MB | 84 MB | - -### Phase 1: Physical Prep - -Instructions with descriptions and images (use placeholders for now): - -1. Remove footwell cover (Fußraumabdeckung) - image shows footwell cover and highlights screw locations -2. Unscrew internal DBC USB cable from MDB - image shows USB connector close up -3. Connect laptop USB cable to MDB - -### Phase 2: MDB Connect - -Automatic steps: - -1. Detect RNDIS device (VID `0525`, PID `A4A2`) -2. Install INF driver if needed (Windows only, via `pnputil`) -3. Configure USB network interface (host IP `192.168.7.50`, MDB at `192.168.7.1`, subnet `255.255.255.0`) -4. SSH into MDB (`root@192.168.7.1`), detect firmware version, authenticate with version-specific password - -### Phase 3: Health Check - -Query Redis on MDB to verify scooter readiness: - -| Check | Redis Command | Threshold | -|-------|--------------|-----------| -| AUX battery charge | `HGET aux-battery charge` | ≥ 50% | -| CBB state of health | `HGET cb-battery state-of-health` | ≥ 99% | -| CBB charge | `HGET cb-battery charge` | ≥ 80% | -| Main battery present | `HGET battery:0 present` | if `true`, user is told to remove it in phase 4; if `false`, we continue | - -If any check fails, display current values and instruct user to charge/fix before retrying. - -### Phase 4: Battery Removal - -1. Open seatbox via Redis: `LPUSH scooter:seatbox open` -2. Instruct user to remove main battery (Fahrakku) IF present -3. Poll `HGET battery:0 present` until `false` -4. Seatbox remains open for the rest of the installation - -### Phase 5: MDB → UMS - -1. Upload `fw_setenv` binary and `fw_env.config` to MDB `/tmp` -2. Set bootloader: `fw_setenv bootcmd "ums 0 mmc 1"`, `fw_setenv bootdelay 0` -3. Optionally set fuse bits for legacy boards: `fuse prog -y 0 5 0x00002860; fuse prog -y 0 6 0x00000010` -4. Reboot MDB -5. Wait for UMS device detection (VID `0525`, PID `A4A5`) - -### Phase 6: MDB Flash - -Two-phase write strategy for safety. While U-Boot has `bootcmd "ums 0 mmc 1"`, any interruption during Phase A leaves the device safely looping back to UMS mode. - -**Disk layout (MDB - `/dev/mmcblk1`):** - -| Offset | Content | -|--------|---------| -| 0x000 | MBR | -| 0x400 | U-Boot (`u-boot-dtb.imx`, ~461KB) | -| 0x800000 (8MB) | U-Boot env primary (128KB) | -| 0x1000000 (16MB) | U-Boot env redundant (128KB) | -| Sector 49152 (24MB) | /boot partition (32MB FAT32) | -| 56MB | rootfs-A (512MB ext4) | -| 568MB | rootfs-B (512MB ext4) | -| 1080MB | /data (remaining space, ext4) | - -**Phase A - Write partitions (safe):** -``` -gunzip -c image.sdimg.gz | dd bs=4M skip=6 seek=6 of=/dev/TARGET -``` -Writes everything from 24MB onwards. If interrupted, U-Boot still boots to UMS. - -**Phase B - Write boot sector (commits the flash):** -``` -gunzip -c image.sdimg.gz | dd bs=4M count=6 of=/dev/TARGET -sync -``` -Writes first 24MB including U-Boot and MBR. After this, U-Boot will boot Linux on next power cycle. - -Progress tracking via dd stderr output parsing. - -### Phase 7: Scooter Prep - -MDB is in UMS mode (no OS running) - instructions only, no Redis verification. **Strong warnings required.** - -1. **Disconnect CBB** - Emphasize: "The main battery must already be removed before disconnecting CBB. Failure to follow this order risks electrical damage." -2. **Disconnect one AUX pole** - This removes power from MDB. USB connection will disappear. Emphasize that ONLY one pole, ideally the positive pole (the outermost one, color-coded red) should be removed, so avoid risk of inverting polarity. - -### Phase 8: MDB Boot - -1. Instruct user to reconnect AUX pole -2. Wait for USB re-enumeration: - - If UMS device (PID `A4A5`) appears → flash didn't take, retry from Phase 6 - - If RNDIS device (PID `A4A2`) appears → MDB booted into Librescoot, continue - - If nothing appears → check AUX connection, check cable -3. User visual feedback: DBC boot LED turns orange on startup, green during boot, off when running -4. Ping MDB (`192.168.7.1`) until stable for 10 consecutive seconds (accounts for partition resize reboots) -5. Re-establish SSH connection - -### Phase 9: CBB Reconnect - -1. Instruct user to reconnect CBB (more power capacity for DBC flash phases) -2. Verify via Redis: `HGET cb-battery present` = `true` - -### Phase 10: DBC Prep & Configuration - -Parallel uploads to MDB `/data` via SCP (block if any background downloads still running): - -1. Upload DBC firmware image (`librescoot-unu-dbc-*.sdimg.gz`) -2. Upload display map tiles (`tiles_{region}.mbtiles`) - if offline maps selected -3. Upload routing tiles (`valhalla_tiles_{region}.tar`) - if offline routing selected -4. Upload SHA256 checksums for tile verification -5. Generate and upload trampoline shell script (includes tile installation steps) -6. Start trampoline script on MDB in background (`nohup`) -7. Instruct user: "Please disconnect USB from laptop and reconnect the DBC USB cable to MDB" - - -### Phase 11: DBC Flash - -Trampoline script runs autonomously on MDB. Installer cannot communicate with MDB during this phase. - -**Trampoline sequence:** - -1. Detect laptop USB disconnect (poll `/sys/class/udc/ci_hdrc.0/state`) -2. Boot LED → amber; front ring position light on (constant = working) -3. Wait for USB network to form with DBC (MDB gadget, DBC host → `192.168.7.x`) -4. `lsc dbc on-wait` - power on DBC, wait for it to be reachable -5. Boot LED → green; additional position lights on -6. SSH to DBC (`root@192.168.7.2`): - - Stop dashboard UI: `systemctl stop dbc-dashboard-ui` (or equivalent) - - Upload fw_setenv tools - - Set bootloader: `fw_setenv bootcmd "ums 0 mmc 2"`, `fw_setenv bootdelay 0` - - Optionally set DBC fuse bits: `fuse prog -y 0 5 0x00003860; fuse prog -y 0 6 0x00000010` -7. Reboot DBC -8. Switch MDB USB to host mode (`modprobe -r g_ether && echo host > /sys/kernel/debug/ci_hdrc.0/role`) -9. Wait for DBC block device to appear (`/dev/sdX` on MDB) -10. Two-phase flash DBC (same strategy as MDB, 24MB split): - ``` - gunzip -c /data/dbc-image.sdimg.gz | dd bs=4M skip=6 seek=6 of=/dev/sdX - gunzip -c /data/dbc-image.sdimg.gz | dd bs=4M count=6 of=/dev/sdX - sync - ``` -11. Switch MDB USB back to gadget mode (reload `g_ether`) -12. Power cycle DBC -13. Wait for DBC to boot, SSH in -14. Install map tiles on DBC (if selected): - - Copy `/data/tiles_{region}.mbtiles` → DBC `/data/maps/map.mbtiles` - - Copy `/data/valhalla_tiles_{region}.tar` → DBC `/data/valhalla/tiles.tar` - - Verify SHA256 checksums - - Restart Valhalla routing service: `systemctl restart valhalla` -15. Run firstrun: `systemctl start firstrun` → DBC displays "Erfolgreich!" -16. Write status file to `/data/trampoline-status` (success/failure + details) -17. Boot LED → green (success) or red (error) - -**LED signals during trampoline:** - -| State | Boot LED | Scooter LEDs | -|-------|----------|-------------| -| Started / working | Amber | Front ring constant | -| DBC connected | Green | Front + rear position lights constant | -| DBC flashing | Green | All position lights constant | -| Success | Green | Brief celebration, then off | -| Error | Red | Hazard flashers (`lsc led cue blink-both`) in a loop every 800ms | - -**Timeout/fallback:** -- Each step has a timeout (e.g. 60s for DBC ping, 120s for UMS device) -- On failure: write error to status file, switch USB back to gadget mode, trigger hazard flashers -- On success: write success to status file, boot LED green - -### Phase 12: Reconnect & Verify - -1. Instruct user: "Reconnect USB cable from MDB to laptop" (hazard flashers = error, green boot LED = success) -2. Wait for RNDIS device detection -3. SSH into MDB, read `/data/trampoline-status` -4. If error: display error log, offer retry options -5. If success: continue - -### Phase 13: Finish - -1. Instruct user: - - Reconnect internal DBC USB cable to MDB (screw it back) - - Insert main battery - - Close seatbox - - Replace footwell cover - - Unlock scooter (keycard/BT pairing handled by Librescoot first-run) -2. Offer to delete downloaded firmware images and tiles (show total size) -3. Display: "Welcome to Librescoot!" - -## Firmware Download & Caching - -### Release Resolution - -**Firmware images:** -- GitHub API: `GET /repos/librescoot/librescoot/releases` -- Channel matching: find latest release where tag starts with channel name (`stable-*`, `testing-*`, `nightly-*`) -- Channel priority for default: `stable` → `testing` (only if no stable release exists) -- Asset selection: `librescoot-unu-mdb-.sdimg.gz` and `librescoot-unu-dbc-.sdimg.gz` -- Filter to `unu-*` variants only - -**Map tiles (if offline maps selected):** -- Display tiles: `GET /repos/librescoot/osm-tiles/releases/tags/latest` - - Asset: `tiles_{slug}.mbtiles` + `tiles_{slug}.mbtiles.sha256` -- Routing tiles: `GET /repos/librescoot/valhalla-tiles/releases/tags/latest` - - Asset: `valhalla_tiles_{slug}.tar` + `valhalla_tiles_{slug}.tar.sha256` -- Region slug mapping: lowercase with hyphens (e.g. `schleswig-holstein`), Berlin+Brandenburg combined as `berlin_brandenburg` - -### Caching - -- Cache directory: - - Linux/macOS: `~/.cache/librescoot-installer/` - - Windows: `%LOCALAPPDATA%\Librescoot\Installer\cache\` -- Cache key: full asset filename (unique per channel + timestamp) -- Validation: check file exists and matches expected size from GitHub API -- No automatic cleanup; user offered deletion at end of install - -### Download Behavior - -- Starts in background during Phase 0 after channel selection -- Progress: MB downloaded / total MB with progress bar -- Firmware must complete before Phase 6 (MDB Flash) - firmware downloads can overlap with Phases 1-5 -- Tile downloads must complete before Phase 10 (DBC Prep) - tiles can download during Phases 1-9 -- On failure: retry with exponential backoff, offer local file selection as fallback -- No GitHub authentication required (public releases) - -## Architecture - -### Services (existing, modified) - -- **FlashService** - Add two-phase write capability: `skip`/`seek` and `count` parameters for dd. Support two-pass gunzip streaming. -- **SshService** - Add Redis query commands (`HGET`, `LPUSH`), seatbox control, fw_setenv for DBC (`ums 0 mmc 2`), DBC SSH via MDB proxy. -- **UsbDetector** - No changes. Already detects RNDIS (`A4A2`) and UMS (`A4A5`). -- **NetworkService** - No changes. -- **ElevationService** - No changes. -- **DriverService** - No changes. - -### Services (new) - -- **DownloadService** - GitHub API client, release resolution by channel, download with progress callbacks, cache management. Handles firmware images (librescoot/librescoot), display tiles (librescoot/osm-tiles), and routing tiles (librescoot/valhalla-tiles). -- **TrampolineService** - Generate trampoline shell script (templated with DBC image path, tile paths, timeouts), upload to MDB via SCP, upload DBC image + tiles, parse status file after reconnect. - -### Models - -- **InstallerPhase** - Enum replacing current `InstallerStep` (14 phases) -- **ScooterHealth** - Holds Redis health check values (AUX charge, CBB SoH, CBB charge, battery present) -- **DownloadState** - Channel, selected release tag, download progress, cached status -- **TrampolineStatus** - Parsed from `/data/trampoline-status` (success/error, details, checksums) - -### State Management - -StatefulWidget + setState (current pattern). The flow is linear; a full state management library is not warranted. - -## UI Design - -### Layout - -Vertical stepper wizard: -- **Left sidebar** (~200px): numbered phase list, current phase highlighted (green), completed phases checked (gray), future phases dimmed -- **Main area**: current phase content - instructions, status messages, progress bars, verification checklists -- **Bottom**: Back/Next navigation (disabled during automatic phases) - -### Theme - -Dark theme with teal accent (matching current app). Material 3. - -## Error Handling & Recovery - -### Per-Phase Recovery - -| Phase | Failure | Recovery | -|-------|---------|----------| -| 0 | Download fails | Retry with backoff, offer local file fallback | -| 2 | RNDIS not detected | Retry detection, check cable/driver guidance | -| 2 | SSH auth fails | Try all known passwords, prompt manual entry | -| 3 | Charge too low | Show values, instruct to charge, retry | -| 4 | Seatbox won't open | Retry command | -| 5 | fw_setenv fails | Retry, show SSH error | -| 5 | UMS never appears | Timeout → suggest power cycle | -| 6 | dd fails (Phase A) | Safe - U-Boot loops to UMS, retry | -| 6 | dd fails (Phase B) | Dangerous but rare - retry | -| 8 | UMS appears instead of RNDIS | Flash didn't take → retry from Phase 6 | -| 8 | Nothing appears | Check AUX, check cable | -| 11 | Trampoline error | Read status file, display log, offer retry | -| 11 | USB never reconnects | Timeout → instruct reconnect, read log | - -### Resume Detection - -On app launch, detect current device state: -- RNDIS device → SSH in, check if Librescoot or stock → determine phase -- UMS device → MDB in flash-ready state, resume from Phase 6 -- No device → start from Phase 1 - -## DBC Partition Layout - -Confirmed identical boot area layout to MDB (24MB split applies): - -| Offset | Content | -|--------|---------| -| Sector 49152 (24MB) | /boot (32MB FAT32) | -| 56MB | rootfs-A (1GB ext4) | -| ~1.1GB | rootfs-B (1GB ext4) | -| ~2.1GB | /data (remaining, ext4) | - -U-Boot env at same offsets: 8MB and 16MB (128KB each). -DBC eMMC is `/dev/mmcblk3` in Linux, `mmc 2` in U-Boot. -DBC fuse bits differ: `0x00003860` (vs MDB's `0x00002860`). - -## USB Topology - -MDB has one physical USB connector (ci_hdrc.0, OTG). An internal cable normally connects DBC to this port. - -**Cable swaps during installation:** - -| When | Action | Result | -|------|--------|--------| -| Phase 1 | Disconnect DBC cable, connect laptop | Laptop ↔ MDB (RNDIS) | -| Phase 10 | Disconnect laptop, reconnect DBC cable | MDB ↔ DBC (network, then UMS) | -| Phase 12 | Disconnect DBC cable, connect laptop | Laptop ↔ MDB (verify results) | -| Phase 13 | Disconnect laptop, reconnect DBC cable permanently | Normal operation restored | - -**USB role switching on MDB:** -- Gadget mode: `g_ether` kernel module (RNDIS network) -- To host mode: `modprobe -r g_ether && echo host > /sys/kernel/debug/ci_hdrc.0/role` → `/dev/sda` appears -- Back to gadget: `echo gadget > /sys/kernel/debug/ci_hdrc.0/role && modprobe g_ether` (no reboot needed on Librescoot - `CONFIG_USB_ROLE_SWITCH=y` + `dr_mode = "otg"`) -- OTG capability check: `cat /sys/kernel/debug/ci_hdrc.0/device | grep 'is_otg'` -- Alternative sysfs: `/sys/class/usb_role/` (proper role switch framework) -- Detach detection: poll `/sys/class/udc/ci_hdrc.0/state` - -## Scoped Out / Deferred - -- **MDB backup** (dd of running system before flash) - deferred to future version -- **Keycard enrollment / Bluetooth pairing** - handled by Librescoot first-run experience, not the installer -- **Advanced mode** - use CLI installer or manual steps -- **Redis-verified physical steps during UMS mode** (Phase 7) - impossible, instruction-only From be067c715f4a52e175874e82ed17119cc61bb05f Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Sun, 5 Jul 2026 14:19:47 +0200 Subject: [PATCH 18/43] fix: skip minimal bootstrap images in firmware download queue Releases now ship librescoot-unu-mdb-minimal-* / librescoot-unu-dbc-minimal-* sdimg assets alongside the full images. The unu-mdb-/unu-dbc- substring match classified these as full firmware too, so the installer could queue two "MDB firmware" downloads and flash the bootstrap image onto a scooter. --- lib/services/download_service.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/services/download_service.dart b/lib/services/download_service.dart index 7232702..9bca5ac 100644 --- a/lib/services/download_service.dart +++ b/lib/services/download_service.dart @@ -255,6 +255,9 @@ class DownloadService { for (final asset in release.assets) { final name = asset['name'] as String; if (!name.contains('unu-')) continue; + // Minimal bootstrap images ship in the same release; they must never + // be picked up as full MDB/DBC firmware. + if (name.contains('-minimal-')) continue; final bool isBmap = name.endsWith('.sdimg.bmap'); final bool isFirmware = name.endsWith('.sdimg.gz'); From bca903b846611962996cb9bf01777deff495f66c Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Sun, 5 Jul 2026 23:09:11 +0200 Subject: [PATCH 19/43] fix(installer): detect laptop disconnect via non-configured UDC state The step-1 wait required /sys/class/udc/ci_hdrc.0/state to read 'not attached', but the i.MX6 OTG port has no VBUS sensing from userspace, so that state never appears on unplug: the chipidea suspend irq parks the state at 'suspended' (an idle port reads 'default'). The trampoline hung in step 1 forever and the DBC flash never started. Exit when the state leaves 'configured' instead, held for 3 consecutive 1s samples so an RNDIS re-enumeration blip can't fire it early. Also kill any still-running trampoline instance before arming a new one, so a retry after a failed round can't stack two instances racing over the USB role and DBC power. --- assets/trampoline.sh.template | 20 +++++++++++--------- lib/services/trampoline_service.dart | 6 +++++- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/assets/trampoline.sh.template b/assets/trampoline.sh.template index abaf54c..73b965c 100644 --- a/assets/trampoline.sh.template +++ b/assets/trampoline.sh.template @@ -832,21 +832,23 @@ log "Image: $(ls -lh "$DBC_IMAGE" | awk '{print $5}')" # Step 1: Wait for laptop disconnect step_begin "Step 1: wait for laptop disconnect" -# The laptop is gone when VBUS drops: udc state -> "not attached". Require it to -# hold stable so a transient blip (host autosuspend, RNDIS re-enumeration) can't -# flip us to host mode while the laptop is still attached. ~250ms of stable reads; -# the swap itself takes seconds, so a short hold loses nothing. Tune GONE_NEEDED on -# hardware to the real sleep granularity. +# The laptop is gone when the UDC state leaves "configured". This port has no +# VBUS/BSV sensing from userspace, so "not attached" never appears on unplug — +# the chipidea suspend irq parks the state at "suspended" (or it idles at +# "default") instead. Require the non-configured reading to hold for a few +# seconds so a transient blip (RNDIS re-enumeration cycling through +# default/addressed) can't flip us to host mode while the laptop is still +# attached; the cable swap itself takes seconds, so the hold loses nothing. UDC_STATE=/sys/class/udc/ci_hdrc.0/state GONE_NEEDED=3 GONE=0 while [ "$GONE" -lt "$GONE_NEEDED" ]; do - if grep -q "not attached" "$UDC_STATE" 2>/dev/null; then - GONE=$((GONE + 1)) - else + if grep -q "configured" "$UDC_STATE" 2>/dev/null; then GONE=0 + else + GONE=$((GONE + 1)) fi - sleep 0.1 2>/dev/null || sleep 1 + sleep 1 done log "Laptop disconnected (debounced)" step_end diff --git a/lib/services/trampoline_service.dart b/lib/services/trampoline_service.dart index 2adb2ae..7d1218f 100644 --- a/lib/services/trampoline_service.dart +++ b/lib/services/trampoline_service.dart @@ -476,9 +476,13 @@ http.server.HTTPServer(('0.0.0.0', 8080), H).serve_forever() debugPrint('Trampoline: uploadAll complete'); } - /// Start the trampoline script on MDB in background. + /// Start the trampoline script on MDB in background. Kills any instance + /// still running from an earlier arm (retry after error, resume) first, so + /// two trampolines never race each other over the USB role and DBC power. + /// The [t] bracket keeps pkill's regex from matching this command line. Future start() async { await _ssh.runCommand( + "pkill -f 'installer/[t]rampoline.sh' 2>/dev/null; " 'nohup /data/installer/trampoline.sh > /data/installer/trampoline-stdout.log 2>&1 &', ); } From 8f8c180564b35f655dead8e6e5e05f51431e2288 Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Mon, 13 Jul 2026 21:48:25 +0200 Subject: [PATCH 20/43] chore(installer): add l10n keys for error snackbar and tile labels --- lib/l10n/app_de.arb | 7 ++++++- lib/l10n/app_en.arb | 8 +++++++- lib/l10n/app_localizations.dart | 24 ++++++++++++++++++++++++ lib/l10n/app_localizations_de.dart | 14 ++++++++++++++ lib/l10n/app_localizations_en.dart | 14 ++++++++++++++ 5 files changed, 65 insertions(+), 2 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index d2f1b5c..18c3644 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -515,5 +515,10 @@ "usbDeviceNone": "keines", "collectingUsbInfo": "USB-Geräteinfos werden gesammelt…", "usbInfoUnsupportedPlatform": "USB-Geräteinfos werden auf dieser Plattform nicht unterstützt.", - "usbInfoCollectFailed": "USB-Geräteinfos konnten nicht gesammelt werden" + "usbInfoCollectFailed": "USB-Geräteinfos konnten nicht gesammelt werden", + + "internalError": "Interner Fehler: {error}", + "copyLog": "Log kopieren", + "tileLabelMaps": "Karten", + "tileLabelRoutes": "Routen" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ed01d73..365261e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -654,5 +654,11 @@ "usbDeviceNone": "none", "collectingUsbInfo": "Collecting USB device info…", "usbInfoUnsupportedPlatform": "USB info collection not supported on this platform.", - "usbInfoCollectFailed": "Failed to collect USB info" + "usbInfoCollectFailed": "Failed to collect USB info", + + "internalError": "Internal error: {error}", + "@internalError": { "placeholders": { "error": { "type": "String" } } }, + "copyLog": "Copy log", + "tileLabelMaps": "Maps", + "tileLabelRoutes": "Routes" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c0f6d41..a6b7b29 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2935,6 +2935,30 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Failed to collect USB info'** String get usbInfoCollectFailed; + + /// No description provided for @internalError. + /// + /// In en, this message translates to: + /// **'Internal error: {error}'** + String internalError(String error); + + /// No description provided for @copyLog. + /// + /// In en, this message translates to: + /// **'Copy log'** + String get copyLog; + + /// No description provided for @tileLabelMaps. + /// + /// In en, this message translates to: + /// **'Maps'** + String get tileLabelMaps; + + /// No description provided for @tileLabelRoutes. + /// + /// In en, this message translates to: + /// **'Routes'** + String get tileLabelRoutes; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 1eb3b80..b84c6cd 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1646,4 +1646,18 @@ class AppLocalizationsDe extends AppLocalizations { @override String get usbInfoCollectFailed => 'USB-Geräteinfos konnten nicht gesammelt werden'; + + @override + String internalError(String error) { + return 'Interner Fehler: $error'; + } + + @override + String get copyLog => 'Log kopieren'; + + @override + String get tileLabelMaps => 'Karten'; + + @override + String get tileLabelRoutes => 'Routen'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 699ccbc..6c1be34 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1630,4 +1630,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get usbInfoCollectFailed => 'Failed to collect USB info'; + + @override + String internalError(String error) { + return 'Internal error: $error'; + } + + @override + String get copyLog => 'Copy log'; + + @override + String get tileLabelMaps => 'Maps'; + + @override + String get tileLabelRoutes => 'Routes'; } From 991c51e11304c25ecda57fe59f35c3d3f31b0d2f Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Mon, 13 Jul 2026 21:56:39 +0200 Subject: [PATCH 21/43] fix(installer): harden manifest cache, download sink, and tile integrity - parse latest.json before writing cache so a captive-portal 200 can't poison it - guard both cache reads so a corrupt cache falls through to the bundled fallback - close the download sink in a finally so a mid-transfer error can't leak it - verify OSM/Valhalla tiles against GitHub's per-asset sha256 digest --- lib/models/region.dart | 8 +++- lib/services/download_service.dart | 70 ++++++++++++++++++++++++------ 2 files changed, 62 insertions(+), 16 deletions(-) diff --git a/lib/models/region.dart b/lib/models/region.dart index ba23057..62823e7 100644 --- a/lib/models/region.dart +++ b/lib/models/region.dart @@ -131,9 +131,13 @@ class Region { } String get osmTilesFilename => 'tiles_$slug.mbtiles'; - String get osmTilesChecksumFilename => 'tiles_$slug.mbtiles.sha256'; String get valhallaTilesFilename => 'valhalla_tiles_$slug.tar'; - String get valhallaTilesChecksumFilename => 'valhalla_tiles_$slug.tar.sha256'; + + // No sidecar .sha256 asset is published alongside the tile files (checked + // against the live osm-tiles/valhalla-tiles releases: only the tile itself + // is uploaded). DownloadService verifies tile integrity from the sha256 + // digest GitHub's releases API already reports for every asset instead of + // fetching a separate checksum file, so there's nothing to name here. /// Known regions, used as the offline fallback when the published tile list /// can't be fetched. Identity is by slug, so a Region built here compares diff --git a/lib/services/download_service.dart b/lib/services/download_service.dart index 9bca5ac..3949584 100644 --- a/lib/services/download_service.dart +++ b/lib/services/download_service.dart @@ -45,9 +45,14 @@ class DownloadService { if (await cacheFile.exists()) { final age = DateTime.now().difference(await cacheFile.lastModified()); if (age.inHours < 1) { - _cachedLatest = - jsonDecode(await cacheFile.readAsString()) as Map; - return _cachedLatest!; + try { + _cachedLatest = jsonDecode(await cacheFile.readAsString()) + as Map; + return _cachedLatest!; + } catch (e) { + debugPrint('latest.json: fresh cache unreadable, falling back to ' + 'network: $e'); + } } } @@ -61,8 +66,20 @@ class DownloadService { .get(Uri.parse(_latestManifestUrl)) .timeout(const Duration(seconds: 10)); if (response.statusCode == 200) { + // Parse before writing to disk: a 200 with a non-JSON body (e.g. a + // captive portal login page) must never land in the cache, or + // every retry and future launch re-poisons itself on the same + // garbage. + final Map parsed; + try { + parsed = jsonDecode(response.body) as Map; + } catch (e) { + debugPrint('latest.json fetch returned unparseable body ' + '(attempt ${attempt + 1}/${delays.length}): $e'); + continue; + } await cacheFile.writeAsString(response.body); - _cachedLatest = jsonDecode(response.body) as Map; + _cachedLatest = parsed; return _cachedLatest!; } debugPrint('latest.json fetch HTTP ${response.statusCode} ' @@ -74,10 +91,15 @@ class DownloadService { } if (await cacheFile.exists()) { - debugPrint('latest.json: network unavailable, using stale on-disk cache'); - _cachedLatest = - jsonDecode(await cacheFile.readAsString()) as Map; - return _cachedLatest!; + try { + debugPrint('latest.json: network unavailable, using stale on-disk cache'); + _cachedLatest = jsonDecode(await cacheFile.readAsString()) + as Map; + return _cachedLatest!; + } catch (e) { + debugPrint('latest.json: stale cache unreadable, falling back to ' + 'bundled snapshot: $e'); + } } try { @@ -141,6 +163,21 @@ class DownloadService { return digest.toString(); } + /// Extract the sha256 hex digest GitHub computes server-side for every + /// release asset (`"digest": "sha256:"`). Tile releases don't publish + /// a separate SHA256SUMS or sidecar checksum file, but this field is + /// populated on every asset already returned by the releases API, so it's + /// used as the source of truth for tile integrity instead. Returns null if + /// absent or not a sha256 digest. + static String? _sha256FromAssetDigest(Map asset) { + final digest = asset['digest'] as String?; + if (digest == null || !digest.startsWith('sha256:')) return null; + final hex = digest.substring('sha256:'.length); + // An empty remainder would pass downloadItem's != null gate and then + // false-mismatch every good download; treat it as "no digest". + return hex.isEmpty ? null : hex; + } + /// Resolve tile release assets for a repo, with disk caching. Future>> resolveTileAssets( String repo, @@ -308,6 +345,7 @@ class DownloadService { url: asset['browser_download_url'] as String, filename: name, expectedSize: expectedSize, + expectedSha256: _sha256FromAssetDigest(asset), ); if (await cached.exists() && await cached.length() == expectedSize) { item.localPath = cached.path; @@ -328,6 +366,7 @@ class DownloadService { url: asset['browser_download_url'] as String, filename: name, expectedSize: expectedSize, + expectedSha256: _sha256FromAssetDigest(asset), ); if (await cached.exists() && await cached.length() == expectedSize) { item.localPath = cached.path; @@ -364,13 +403,16 @@ class DownloadService { final sink = partFile.openWrite(); var downloaded = 0; - await for (final chunk in response.stream) { - sink.add(chunk); - downloaded += chunk.length; - item.bytesDownloaded = downloaded; - onProgress?.call(downloaded, item.expectedSize); + try { + await for (final chunk in response.stream) { + sink.add(chunk); + downloaded += chunk.length; + item.bytesDownloaded = downloaded; + onProgress?.call(downloaded, item.expectedSize); + } + } finally { + await sink.close(); } - await sink.close(); // Verify size if (await partFile.length() != item.expectedSize) { From c9d3a382c157cfc9c155cc749e4a051e50eb4bae Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Mon, 13 Jul 2026 21:58:59 +0200 Subject: [PATCH 22/43] fix(installer): elevation, driver cleanup, and error snackbar robustness - detect a declined pkexec/sudo prompt so a cancelled elevation shows the elevation-required dialog instead of silently exiting the app - write the macOS elevation launcher to a unique temp dir, not a fixed world-writable /tmp path (symlink TOCTOU before an admin-privileged run) - delete the Windows driver temp dir recursively so rndis.cat no longer leaks it - drop dead _sanitizeOutput - localize the unhandled-error snackbar instead of hardcoding German --- lib/main.dart | 13 +++++- lib/services/driver_service.dart | 3 +- lib/services/elevation_service.dart | 63 ++++++++++++++++++++++++----- lib/services/network_service.dart | 7 ---- 4 files changed, 64 insertions(+), 22 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 4ace75a..6d862c3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -33,17 +33,26 @@ void reportUnhandledError(Object error, StackTrace? stack, {String? from}) { final messenger = rootScaffoldMessengerKey.currentState; if (messenger == null) return; + + // The MaterialApp isn't necessarily built yet when this fires (e.g. an + // error during startup, before runApp's first frame), so the messenger's + // context may not carry a Localizations ancestor. Fall back to English + // literals rather than the German ones this used to hardcode. + final l10n = AppLocalizations.of(messenger.context); + final internalErrorText = l10n?.internalError(error.toString()) ?? 'Internal error: $error'; + final copyLogText = l10n?.copyLog ?? 'Copy log'; + messenger.hideCurrentSnackBar(); messenger.showSnackBar( SnackBar( backgroundColor: Colors.red.shade900, duration: const Duration(seconds: 8), content: Text( - 'Interner Fehler: $error', + internalErrorText, style: const TextStyle(color: Colors.white), ), action: SnackBarAction( - label: 'Log kopieren', + label: copyLogText, textColor: Colors.white, onPressed: () { Clipboard.setData(ClipboardData(text: installerLog.join('\n'))); diff --git a/lib/services/driver_service.dart b/lib/services/driver_service.dart index b87e117..0905e16 100644 --- a/lib/services/driver_service.dart +++ b/lib/services/driver_service.dart @@ -300,8 +300,7 @@ if ($d) { } finally { if (infPath != null) { try { - await File(infPath).delete(); - await Directory(path.dirname(infPath)).delete(); + await Directory(path.dirname(infPath)).delete(recursive: true); } catch (_) { // Ignore cleanup errors } diff --git a/lib/services/elevation_service.dart b/lib/services/elevation_service.dart index d61851f..70ecb6b 100644 --- a/lib/services/elevation_service.dart +++ b/lib/services/elevation_service.dart @@ -1,6 +1,8 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as path; /// Service for handling privilege elevation across platforms. /// @@ -99,25 +101,44 @@ class ElevationService { static Future _elevateMacOS(String executable, List args) async { // Write a launcher script to avoid shell quoting issues with osascript. - // The script logs to /tmp for debugging and does NOT use exec (so & works). - final launcher = File('/tmp/librescoot-elevate.sh'); + // The script logs to a file for debugging and does NOT use exec (so & works). + // + // The launcher lives in a securely-created, unpredictably-named temp dir + // rather than a fixed /tmp path: a fixed world-writable path lets another + // local user pre-create/symlink it before we write to it (TOCTOU), which + // `do shell script ... with administrator privileges` would then execute + // as root. + final tempDir = await Directory.systemTemp.createTemp('librescoot_elevate_'); + final launcher = File(path.join(tempDir.path, 'elevate.sh')); + final logFile = path.join(tempDir.path, 'elevate.log'); final argLine = args.map((a) => "'${a.replaceAll("'", "'\\''")}'").join(' '); // The launcher script MUST exit immediately. do shell script waits for it. // Only launch the app in background and exit: nothing else. await launcher.writeAsString( '#!/bin/sh\n' - '\'${executable.replaceAll("'", "'\\''")}\' $argLine >> /tmp/librescoot-elevate.log 2>&1 &\n', + '\'${executable.replaceAll("'", "'\\''")}\' $argLine >> \'$logFile\' 2>&1 &\n', ); await Process.run('chmod', ['+x', launcher.path]); try { + final escapedPath = launcher.path.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); final result = await Process.run('osascript', [ '-e', - 'do shell script "/tmp/librescoot-elevate.sh" with administrator privileges', + 'do shell script "$escapedPath" with administrator privileges', ]); return result.exitCode == 0; } catch (_) { return false; + } finally { + // Best-effort cleanup; the unpredictable path is what matters for + // correctness, not whether this succeeds. By the time osascript + // returns, the launcher has already forked the elevated process into + // the background and exited, so it's safe to remove the temp dir here. + unawaited(() async { + try { + await tempDir.delete(recursive: true); + } catch (_) {} + }()); } } @@ -128,14 +149,34 @@ class ElevationService { for (final elevator in elevators) { try { final which = await Process.run('which', [elevator]); - if (which.exitCode == 0) { - await Process.start( - elevator, - [executable, ...args], - ); - // Started successfully, caller should exit - return true; + if (which.exitCode != 0) continue; + + final process = await Process.start( + elevator, + [executable, ...args], + ); + + // Process.start returns as soon as the child is spawned; the + // relaunched app is long-running, so we can't just await exitCode + // like the sync elevation paths do. Instead, race a short window: + // pkexec exits 126 immediately if the user cancels the auth dialog + // and 127 if authentication itself fails, so a quick non-zero exit + // is a reliable "declined" signal. If nothing has happened after + // the window, assume the elevated relaunch is up and running. + final exitCode = await process.exitCode + .timeout(const Duration(seconds: 2), onTimeout: () => 0); + if (exitCode != 0) { + // A fast non-zero exit means the user cancelled/declined the + // prompt (or auth failed), not that this elevator is missing. + // Report failure rather than silently trying the next one, so + // the caller shows the elevation-required dialog instead of + // treating this as "elevation started" and exiting the app. + debugPrint('Elevation: $elevator exited $exitCode within window, treating as declined'); + return false; } + // Still running after the window: assume the elevated relaunch is + // up and the caller should exit. + return true; } catch (_) { continue; } diff --git a/lib/services/network_service.dart b/lib/services/network_service.dart index 7140ba4..5ab1ae6 100644 --- a/lib/services/network_service.dart +++ b/lib/services/network_service.dart @@ -503,11 +503,4 @@ if ($dev) { "$($dev.Name)`t$($dev.NetConnectionID)`t$($dev.NetEnabled)" } debugPrint('Network: nmcli call failed: $e'); } } - - String _sanitizeOutput(String output) { - return output - .replaceAll('\u0000', '') - .replaceAll('\r\n', '\n') - .replaceAll('\r', '\n'); - } } From b3c5161bbb50bc1405dcee629211782bba20ea7f Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Mon, 13 Jul 2026 21:59:24 +0200 Subject: [PATCH 23/43] fix(installer): make DBC flash start reliably and keep usb0 alive - start(): verify the trampoline actually launched (pgrep) and throw otherwise, so a silent launch failure surfaces instead of a stuck screen - hold usb0 up during the pairing window by masking librescoot-ums at upload start, so the OTG UDC isn't grabbed away and SSH survives to the flash - skip path: guard restore_gadget with gadget_rescue_start and stop the watchdog - kill the journalctl capture on the fail, skip, and V3-success exit paths - treat a failed umount in grow_and_populate_dbc as fatal so the USB role is never flipped with the DBC partition still mounted --- assets/trampoline.sh.template | 43 ++++++++++++++++++++++++++-- lib/services/trampoline_service.dart | 36 +++++++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/assets/trampoline.sh.template b/assets/trampoline.sh.template index 73b965c..bcbbe39 100644 --- a/assets/trampoline.sh.template +++ b/assets/trampoline.sh.template @@ -395,6 +395,9 @@ fail() { # retries write to the log, so the 15-min silence the watchdog needs # can never accumulate while the rescue is alive. trampoline_watchdog_stop + # Stop the background journal capture, otherwise it leaks past this + # script exiting (reparented to init) and keeps appending forever. + [ -n "$JOURNAL_PID" ] && kill $JOURNAL_PID 2>/dev/null # Indicators are detached and run until the installer reconnects via SSH # and stops them. Just exit cleanly here. exit 1 @@ -576,7 +579,32 @@ grow_and_populate_dbc() { fi fi sync - umount /mnt/dbcdata 2>/dev/null || true + # A failed unmount must fail the function too, otherwise the caller + # flips the USB OTG role back to gadget with /mnt/dbcdata still + # mounted, yanking the device out from under the mount. Retry a few + # times, then fall back to a lazy unmount; if even that can't detach + # it, bail so the caller falls back to the proven onboot.sh+reboot path + # instead of switching USB roles on a mounted partition. + umount_ok="" + umount_attempt=0 + while [ $umount_attempt -lt 3 ]; do + umount_attempt=$((umount_attempt + 1)) + if umount /mnt/dbcdata 2>/dev/null; then + umount_ok="yes" + break + fi + sleep 1 + done + if [ -z "$umount_ok" ]; then + log "V3: umount /mnt/dbcdata failed after $umount_attempt attempts, trying lazy umount" + if umount -l /mnt/dbcdata 2>/dev/null; then + umount_ok="yes" + fi + fi + if [ -z "$umount_ok" ]; then + log "V3: could not unmount /mnt/dbcdata, refusing to hand back USB role" + return 1 + fi [ "$rc" = 0 ] || return 1 fi log "V3: grow+tiles complete" @@ -902,8 +930,16 @@ if [ "$DBC_ALREADY_UMS" != "yes" ]; then && [ "$DBC_VERSION" = "$TARGET_DBC_VERSION" ] \ && printf '%s' "$DBC_ID" | grep -q '^librescoot'; then log "DBC already at target $TARGET_DBC_VERSION; skipping DBC flash" + # Mirror fail()/the V3 success path: guard the restore_gadget result and + # stop the watchdog before declaring done. A bare restore_gadget here + # left the watchdog armed with no one able to reach it if the role flip + # wedged, and 15 min later it would stomp the "success" we already wrote. + if ! restore_gadget; then + gadget_rescue_start + fi + trampoline_watchdog_stop + [ -n "$JOURNAL_PID" ] && kill $JOURNAL_PID 2>/dev/null echo "success" > "$STATUS_FILE" - restore_gadget bootled_guard_stop bootled_blink_green exit 0 @@ -1315,6 +1351,9 @@ if grow_and_populate_dbc "$DBC_DEV"; then cat "$LOG_FILE" >> "$STATUS_FILE" bootled_guard_stop bootled_blink_green + # Stop the background journal capture, otherwise it leaks past this + # script exiting (reparented to init) and keeps appending forever. + [ -n "$JOURNAL_PID" ] && kill $JOURNAL_PID 2>/dev/null exit 0 fi log "V3 grow/tiles failed or unavailable; falling back to onboot tile push + MDB reboot" diff --git a/lib/services/trampoline_service.dart b/lib/services/trampoline_service.dart index 7d1218f..38b2016 100644 --- a/lib/services/trampoline_service.dart +++ b/lib/services/trampoline_service.dart @@ -290,6 +290,22 @@ http.server.HTTPServer(('0.0.0.0', 8080), H).serve_forever() // but only kicks in on the first PUT. await _ssh.runCommand('mkdir -p /data/installer'); + // librescoot-ums is enabled with Restart=always and can grab the OTG + // UDC (ci_hdrc.0) away from g_ether at any moment, tearing down usb0 + // and killing this SSH connection. Bluetooth pairing and keycard + // teach-in happen during this same pre-trampoline window, so we can't + // mask those services yet, only get librescoot-ums out of the way and + // re-assert usb0 so SSH survives until the trampoline arms and masks + // it properly. If the user aborts before the trampoline starts, ums + // stays masked until the next install run's recovery path unmasks it + // (stop-error-signals.sh / onboot.sh), matching the existing masking + // model used once the trampoline itself is running. + await _ssh.runCommand( + 'systemctl stop librescoot-ums 2>/dev/null; ' + 'systemctl mask librescoot-ums 2>/dev/null; ' + 'ifconfig usb0 192.168.7.1 netmask 255.255.255.0 up 2>/dev/null; true', + ); + final filesToUpload = >[]; final dbcFilename = File(dbcImageLocalPath).uri.pathSegments.last; @@ -480,11 +496,31 @@ http.server.HTTPServer(('0.0.0.0', 8080), H).serve_forever() /// still running from an earlier arm (retry after error, resume) first, so /// two trampolines never race each other over the USB role and DBC power. /// The [t] bracket keeps pkill's regex from matching this command line. + /// + /// After launching, verifies the trampoline process is actually running: + /// a silent launch failure (or a stale SSH connection) would otherwise + /// look identical to "DBC flash never starts", with no feedback. The + /// trampoline's first action is a passive wait for laptop disconnect, so + /// this only confirms the process exists; it does not wait for any + /// status or progress. Throws if no process shows up after a few + /// retries; the caller already surfaces start() failures and keeps the + /// Begin button enabled for retry. Future start() async { await _ssh.runCommand( "pkill -f 'installer/[t]rampoline.sh' 2>/dev/null; " 'nohup /data/installer/trampoline.sh > /data/installer/trampoline-stdout.log 2>&1 &', ); + + for (var attempt = 0; attempt < 5; attempt++) { + await Future.delayed(const Duration(milliseconds: 500)); + final pid = (await _ssh.runCommand( + "pgrep -f 'installer/[t]rampoline.sh' 2>/dev/null", + )).trim(); + if (pid.isNotEmpty) { + return; + } + } + throw Exception('Trampoline did not start on the MDB'); } /// Read trampoline status (call after reconnecting to MDB). From 376e4c2e630202271bf4b6b559ad304b2bef0b9e Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Mon, 13 Jul 2026 21:59:45 +0200 Subject: [PATCH 24/43] fix(installer): close flash-safety gaps on all platforms - treat a CHECKSUM MISMATCH from the flasher as fatal instead of only logging it, so a corrupt write can never report success - add set -o pipefail to the dd fallback scripts (run the Linux one under bash) so a dd/gunzip failure mid-write aborts instead of passing a boot-sector-only verify on a truncated image - cross-check the macOS flash target against the 0525/A4A5 USB identity and refuse to write if it can't be confirmed, so a second external disk can't be picked as the target --- lib/services/flash_service.dart | 21 +++++++++- lib/services/usb_detector.dart | 68 ++++++++++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/lib/services/flash_service.dart b/lib/services/flash_service.dart index aa4b212..8f1beb1 100644 --- a/lib/services/flash_service.dart +++ b/lib/services/flash_service.dart @@ -844,8 +844,11 @@ class FlashService { final totalMb = totalBytes / (1024 * 1024); onProgress?.call(0.0, 'dd fallback (no Go flasher for this CPU)...'); + // macOS /bin/sh is bash, which supports pipefail: without it, a gunzip + // I/O error would be swallowed and only dd's (successful) exit code + // would count, leaving a truncated write reported as success. final cmd = isCompressed - ? 'gunzip -c "$imagePath" | dd of=$rawDevice bs=4m' + ? 'set -o pipefail; gunzip -c "$imagePath" | dd of=$rawDevice bs=4m' : 'dd if="$imagePath" of=$rawDevice bs=4m'; final process = await Process.start('/bin/sh', ['-c', cmd]); @@ -969,6 +972,8 @@ class FlashService { onProgress?.call(0.0, bmapPath != null ? 'Bmap flash...' : 'Waiting for authorization...'); + var sawChecksumMismatch = false; + final Process process; if (Platform.isWindows) { // Windows: run flasher directly (already elevated) @@ -1037,6 +1042,7 @@ class FlashService { } if (line.startsWith('CHECKSUM MISMATCH')) { debugPrint('Flash: $line'); + sawChecksumMismatch = true; } } } @@ -1044,6 +1050,13 @@ class FlashService { final exitCode = await process.exitCode; debugPrint('Flash: Go flasher exit code: $exitCode'); + // Fatal regardless of exit code: a checksum mismatch means the write is + // corrupt even if the flasher process itself exited 0. + if (sawChecksumMismatch) { + debugPrint('Flash: Go flasher output: ${output.toString()}'); + throw Exception('Flash verification FAILED: checksum mismatch detected during write. Check log.'); + } + if (exitCode != 0) { final out = output.toString(); debugPrint('Flash: Go flasher output: $out'); @@ -1153,6 +1166,7 @@ class FlashService { // Single shell script that does Phase A, Phase B, sync, and verify final script = ''' set -e +set -o pipefail echo "PHASE:A" $decompressPrefix dd $inputArg of=$devicePath bs=4M skip=$bootAreaBlocks seek=$bootAreaBlocks oflag=direct status=progress 2>&1 | tr '\\r' '\\n' echo "PHASE:B" @@ -1175,7 +1189,10 @@ echo "VERIFY:OK" await scriptFile.writeAsString(script); await Process.run('chmod', ['+x', scriptFile.path]); - var argv = ['/bin/sh', scriptFile.path]; + // /bin/sh is dash on most Linux distros, which doesn't support + // `set -o pipefail`; run the script under bash so that guard actually + // works instead of being a fatal syntax error under `set -e`. + var argv = ['/bin/bash', scriptFile.path]; Map? env; if (!isRoot) { final elev = await _elevationArgv(); diff --git a/lib/services/usb_detector.dart b/lib/services/usb_detector.dart index 166b5a5..417f022 100644 --- a/lib/services/usb_detector.dart +++ b/lib/services/usb_detector.dart @@ -657,8 +657,26 @@ if ($dev) { "$($dev.Name)`t$($dev.PNPDeviceID)" } final output = listResult.stdout.toString(); final diskPath = _selectBestExternalDisk(output); if (diskPath == null) return null; + + // Cross-check the heuristic pick against the actual USB device + // identity before trusting it. _selectBestExternalDisk only scores by + // size/label; with a second unrelated external disk attached, it can + // pick the wrong one. Resolve which BSD disk number system_profiler + // ties to the Librescoot gadget (VID 0x0525 / PID 0xA4A5) and require + // the heuristic pick to match it. No match (or identity unresolved) + // means we refuse to guess rather than risk flashing the wrong disk. + final usbDiskNum = await _findLibrescootUsbDiskNumber(); + if (usbDiskNum == null) { + debugPrint('USB detector: could not resolve Librescoot USB disk identity, refusing to select a target'); + return null; + } + final pickedDiskNum = int.tryParse(RegExp(r'/dev/disk(\d+)').firstMatch(diskPath)?.group(1) ?? ''); + if (pickedDiskNum != usbDiskNum) { + debugPrint('USB detector: heuristic pick $diskPath does not match USB-identified disk$usbDiskNum, refusing to select a target'); + return null; + } final rawPath = diskPath.replaceFirst('/dev/disk', '/dev/rdisk'); - debugPrint('USB detector: diskutil selected $diskPath'); + debugPrint('USB detector: diskutil selected $diskPath (confirmed via USB identity)'); debugPrint('USB detector: diskutil info $diskPath'); final infoResult = await _runWithFallback( @@ -729,6 +747,54 @@ if ($dev) { "$($dev.Name)`t$($dev.PNPDeviceID)" } return candidates.first['disk'] as String; } + /// Resolve the BSD disk number `system_profiler` ties to the Librescoot + /// USB mass-storage gadget (VID 0x0525 / PID 0xA4A5). Used to cross-check + /// disk selection against actual USB device identity instead of trusting + /// the size/label heuristic alone. Returns null if the device isn't + /// present or `system_profiler` doesn't expose a Media/BSD Name entry for + /// it (e.g. it's still in ethernet mode). + Future _findLibrescootUsbDiskNumber() async { + try { + final result = await _runWithFallback( + ['/usr/sbin/system_profiler', 'system_profiler'], + ['SPUSBDataType'], + ); + if (result == null || result.exitCode != 0) return null; + return _parseUsbDiskNumber(result.stdout.toString()); + } catch (_) { + return null; + } + } + + /// Parse `system_profiler SPUSBDataType` plain-text output for the BSD + /// disk number of the device reporting Vendor ID 0x0525 and Product ID + /// 0xa4a5. Vendor ID and Product ID are adjacent lines in the same device + /// block (order varies by macOS version), and its BSD Name (if any) is a + /// nested "Media" entry further down in that same block, before the next + /// device's Product ID line. + int? _parseUsbDiskNumber(String output) { + final lines = output.split('\n'); + for (var i = 0; i < lines.length; i++) { + final line = lines[i].toLowerCase(); + if (!line.contains('vendor id: 0x0525')) continue; + + final windowStart = (i - 5).clamp(0, lines.length); + final windowEnd = (i + 5).clamp(0, lines.length); + final window = lines.sublist(windowStart, windowEnd).join('\n').toLowerCase(); + if (!window.contains('product id: 0xa4a5')) continue; + + for (var j = i + 1; j < lines.length; j++) { + if (RegExp(r'product id:', caseSensitive: false).hasMatch(lines[j])) break; + final bsdMatch = RegExp(r'BSD Name:\s*disk(\d+)', caseSensitive: false).firstMatch(lines[j]); + if (bsdMatch != null) { + return int.tryParse(bsdMatch.group(1)!); + } + } + return null; + } + return null; + } + bool _isMacOSSystemDisk(String diskInfo, String diskPath) { if (diskPath == '/dev/disk0') return true; final info = diskInfo; From bc05207e32fc3da9a8e0086169873df5d254ff42 Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Mon, 13 Jul 2026 22:01:50 +0200 Subject: [PATCH 25/43] fix(installer): close SSH session on command timeout, tune keepalive - close the SSH session on the timeout/error path in _runCommandOnce and downloadFile so a stalled command no longer leaks a channel until the connection's channel limit is hit - ping every 5s (below dartssh2's 10s default) through the idle pairing window --- lib/services/ssh_service.dart | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/lib/services/ssh_service.dart b/lib/services/ssh_service.dart index 4738e81..f2425b1 100644 --- a/lib/services/ssh_service.dart +++ b/lib/services/ssh_service.dart @@ -256,6 +256,12 @@ class SshService { debugPrint('SSH: parsed version from banner -> $authVersion'); } }, + // Ping more aggressively than dartssh2's 10s default across the + // multi-minute Bluetooth-pairing and keycard teach-in window so the + // MDB's sshd doesn't drop the idle client. The primary defense against + // usb0 going away in that window is MDB-side (masking librescoot-ums so + // it can't grab the OTG UDC); this just covers plain idle drops. + keepAliveInterval: const Duration(seconds: 5), ); try { @@ -519,8 +525,13 @@ class SshService { } }(); - await Future.wait([stdoutDone, stderrDone, session.done]) - .timeout(timeout); + try { + await Future.wait([stdoutDone, stderrDone, session.done]) + .timeout(timeout); + } catch (_) { + session.close(); + rethrow; + } final exitCode = session.exitCode; if (exitCode != null && exitCode != 0) { @@ -567,6 +578,7 @@ class SshService { socket, username: sshUser, onPasswordRequest: () => pw, + keepAliveInterval: const Duration(seconds: 5), ); try { await client.authenticated; @@ -1080,8 +1092,13 @@ class SshService { final stderrDone = () async { await for (final _ in session.stderr) {} }(); - await Future.wait([stdoutDone, stderrDone, session.done]) - .timeout(const Duration(seconds: 30)); + try { + await Future.wait([stdoutDone, stderrDone, session.done]) + .timeout(const Duration(seconds: 30)); + } catch (_) { + session.close(); + rethrow; + } if (session.exitCode != 0) return null; return Uint8List.fromList(chunks); } catch (_) { From 47060b221433d048e51a706e1bd5418d01957a61 Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Mon, 13 Jul 2026 22:02:40 +0200 Subject: [PATCH 26/43] feat(installer): background DBC prep during pairing, promote it when done - Dashboard Prep shows DBC upload as a compact strip while Bluetooth pairing and keycard teach-in are in progress, then promotes the prep + Begin button to the main content once both are done - run the existing FlashService.validateDevice safety check before the MDB write and abort if it fails (it was never called) - sidebar: Dashboard Prep is one phase; Flash DBC shows active (not done) during reconnect - Retry DBC flash no longer forces the user back through Bluetooth pairing - localize the sidebar tile labels; drop dead builders, _waitForCbb, the unused elevation warning, and the unused download_progress widget --- lib/models/installer_phase.dart | 4 +- lib/screens/installer_screen.dart | 231 ++++++++++++++++------------- lib/widgets/download_progress.dart | 65 -------- lib/widgets/phase_sidebar.dart | 18 +-- 4 files changed, 141 insertions(+), 177 deletions(-) delete mode 100644 lib/widgets/download_progress.dart diff --git a/lib/models/installer_phase.dart b/lib/models/installer_phase.dart index aaf0afa..d5b3ae4 100644 --- a/lib/models/installer_phase.dart +++ b/lib/models/installer_phase.dart @@ -113,8 +113,8 @@ enum MajorStep { prepare('Prepare', [InstallerPhase.welcome, InstallerPhase.notices, InstallerPhase.physicalPrep]), connect('Connect', [InstallerPhase.mdbConnect, InstallerPhase.resumeDetected, InstallerPhase.healthCheck]), mdbFlash('Flash MDB', [InstallerPhase.batteryRemoval, InstallerPhase.mdbToUms, InstallerPhase.mdbFlash, InstallerPhase.scooterPrep, InstallerPhase.mdbBoot, InstallerPhase.cbbReconnect]), - mdbPrep('Dashboard Prep', [InstallerPhase.dashboardPrep, InstallerPhase.bluetoothPairing, InstallerPhase.keycardSetup]), - dbc('Flash DBC', [InstallerPhase.dbcSwapAndFlash]), + mdbPrep('Dashboard Prep', [InstallerPhase.dashboardPrep]), + dbc('Flash DBC', [InstallerPhase.dbcSwapAndFlash, InstallerPhase.reconnect]), finish('Finish', [InstallerPhase.finish]); const MajorStep(this.title, this.phases); diff --git a/lib/screens/installer_screen.dart b/lib/screens/installer_screen.dart index 3f04e3e..49a0140 100644 --- a/lib/screens/installer_screen.dart +++ b/lib/screens/installer_screen.dart @@ -17,7 +17,6 @@ import '../models/substep.dart'; import '../models/trampoline_status.dart'; import '../services/resume_resolver.dart'; import '../services/services.dart'; -import '../widgets/download_progress.dart'; import '../widgets/health_check_panel.dart'; import '../widgets/instruction_step.dart'; import '../widgets/phase_sidebar.dart'; @@ -79,7 +78,6 @@ class _InstallerScreenState extends State { bool _reconnectStarted = false; bool _showElevatedHandoff = false; bool _dbcFlashSimulateError = false; - bool _cbbCheckFailed = false; DeviceInfo? _mdbInfo; bool _skipMdbFlash = false; bool _skipDbcFlash = false; @@ -768,24 +766,6 @@ class _InstallerScreenState extends State { ); } - Widget _buildElevationWarning(AppLocalizations l10n) { - return Container( - width: double.infinity, - color: Colors.orange.shade900, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - const Icon(Icons.warning, color: Colors.orange, size: 16), - const SizedBox(width: 8), - Text( - l10n.elevationWarning, - style: const TextStyle(color: Colors.orange, fontSize: 12), - ), - ], - ), - ); - } - Widget _buildStatusBar(AppLocalizations l10n) { return Container( height: 36, @@ -865,8 +845,10 @@ class _InstallerScreenState extends State { InstallerPhase.dashboardPrep => _buildDashboardPrep(l10n), InstallerPhase.dbcSwapAndFlash => _buildDbcSwapAndFlash(l10n), InstallerPhase.reconnect => _buildReconnect(l10n), - InstallerPhase.bluetoothPairing => _buildBluetoothPairing(l10n), - InstallerPhase.keycardSetup => _buildKeycardSetup(l10n), + // bluetoothPairing/keycardSetup are merged into dashboardPrep and never + // become the current phase; kept only so this switch stays exhaustive. + InstallerPhase.bluetoothPairing => Center(child: _bluetoothPairingContent(l10n)), + InstallerPhase.keycardSetup => Center(child: _keycardSetupContent(l10n)), InstallerPhase.finish => _buildFinish(l10n), }; } @@ -2550,6 +2532,23 @@ class _InstallerScreenState extends State { debugPrint('Flash: device path resolved: $devicePath'); final flashService = FlashService()..l10n = l10n; + + final safetyCheck = flashService.validateDevice( + devicePath: devicePath, + sizeBytes: _device?.sizeBytes, + isRemovable: _device?.isRemovable ?? false, + isSystemDisk: _device?.isSystemDisk ?? false, + vendorId: _device?.vendorId ?? 0, + productId: _device?.productId ?? 0, + ); + if (!safetyCheck.passed) { + debugPrint('Flash: safety check failed: ${safetyCheck.errors.join('; ')}'); + _setCritical(false); + _setStatus('${l10n.safetyCheckFailed}: ${l10n.cannotFlashSafety}\n${safetyCheck.errors.join('\n')}'); + setState(() { _isProcessing = false; _mdbFlashStarted = false; }); + return; + } + final bmapPath = _downloadState.bmapPathFor(DownloadItemType.mdbFirmware); await flashService.writeTwoPhase( mdbItem.localPath!, @@ -3107,36 +3106,6 @@ class _InstallerScreenState extends State { ); } - Future _waitForCbb() async { - final l10n = AppLocalizations.of(context)!; - setState(() => _isProcessing = true); - if (_isDryRun) { - _setStatus('[DRY RUN] CBB connected'); - await Future.delayed(const Duration(seconds: 1)); - _setPhase(InstallerPhase.dashboardPrep); - return; - } - _setStatus(l10n.checkingCbbAndBattery); - var attempts = 0; - while (attempts < 30) { - if (await _sshService.isCbbPresent()) { - _setStatus(l10n.cbbConnected); - await Future.delayed(const Duration(seconds: 1)); - _setPhase(InstallerPhase.dashboardPrep); - return; - } - attempts++; - _setStatus(l10n.waitingForCbb(attempts)); - await Future.delayed(const Duration(seconds: 2)); - if (!mounted) return; - } - _setStatus(l10n.cbbNotDetected); - setState(() { - _isProcessing = false; - _cbbCheckFailed = true; - }); - } - /// Stage 1: pair Bluetooth and enroll keycards in the foreground while the /// DBC image/tiles upload runs in the background. The "Begin flashing DBC" /// button unlocks only once BT is done-or-skipped, keycard is @@ -3156,6 +3125,7 @@ class _InstallerScreenState extends State { final btSatisfied = _btDone || _btSkipped; final keycardSatisfied = _keycardDone || _keycardSkipped; + final prepDone = btSatisfied && keycardSatisfied; final beginEnabled = btSatisfied && keycardSatisfied && _dbcUploadReady && !_isProcessing; @@ -3166,46 +3136,113 @@ class _InstallerScreenState extends State { interactive = _keycardSetupContent(l10n); } + final beginButton = Center( + child: FilledButton.icon( + onPressed: beginEnabled ? _onBeginFlashingDbc : null, + icon: Icon(_skipDbcFlash ? Icons.arrow_forward : Icons.bolt), + label: Text(_skipDbcFlash ? l10n.skipDbcFlashOption : l10n.dbcReadyButton), + ), + ); + final beginHint = !beginEnabled + ? Padding( + padding: const EdgeInsets.only(top: 8), + child: Center( + child: Text( + !_dbcUploadReady ? l10n.waitingForDownloads : l10n.finishStepsAboveToContinue, + style: TextStyle(color: Colors.grey.shade500, fontSize: 12), + textAlign: TextAlign.center, + ), + ), + ) + : null; + + // Until BT + keycard are both done-or-skipped, the pairing/enrollment + // work is the visual focus and the DBC prep is just a compact background + // indicator. Once both are satisfied, there's nothing interactive left + // to show, so the DBC prep (upload status + Begin button) becomes the + // main, centered content instead. + final List children = prepDone + ? [ + if (!_skipDbcFlash) ...[ + _dbcUploadProgressStrip(l10n, compact: false), + const SizedBox(height: 24), + ], + beginButton, + if (beginHint != null) beginHint, + ] + : [ + if (!_skipDbcFlash) ...[ + _dbcUploadProgressStrip(l10n, compact: true), + const SizedBox(height: 16), + ], + interactive, + const SizedBox(height: 24), + beginButton, + if (beginHint != null) beginHint, + ]; + return Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ), + ), + ); + } + + /// DBC upload/prep status. Rendered compact (a single de-emphasized line) + /// while BT pairing + keycard enrollment are still the visual focus, and + /// full-size once the DBC flash prep is promoted to the main content. + Widget _dbcUploadProgressStrip(AppLocalizations l10n, {required bool compact}) { + if (compact) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.grey.shade800), + ), + child: Row( children: [ - // No upload to show when the DBC flash is being skipped. - if (!_skipDbcFlash) ...[ - _dbcUploadProgressStrip(l10n), - const SizedBox(height: 24), - ], - interactive, - const SizedBox(height: 24), - Center( - child: FilledButton.icon( - onPressed: beginEnabled ? _onBeginFlashingDbc : null, - icon: Icon(_skipDbcFlash ? Icons.arrow_forward : Icons.bolt), - label: Text(_skipDbcFlash ? l10n.skipDbcFlashOption : l10n.dbcReadyButton), + SizedBox( + width: 14, + height: 14, + child: _dbcUploadReady + ? Icon(Icons.check_circle, size: 14, color: kAccent) + : (_isProcessing + ? CircularProgressIndicator( + strokeWidth: 2, value: _progress > 0 ? _progress : null) + : Icon(Icons.cloud_upload_outlined, size: 14, color: Colors.grey.shade500)), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _dbcUploadReady ? l10n.dbcFlashSuccessful : l10n.preparingDbcFlash, + style: TextStyle(fontSize: 12, color: Colors.grey.shade400), + overflow: TextOverflow.ellipsis, ), ), - if (!beginEnabled) ...[ - const SizedBox(height: 8), - Center( - child: Text( - !_dbcUploadReady ? l10n.waitingForDownloads : l10n.finishStepsAboveToContinue, - style: TextStyle(color: Colors.grey.shade500, fontSize: 12), - textAlign: TextAlign.center, - ), + // Upload failed (not processing, not ready): offer a retry. + if (!_dbcUploadReady && !_isProcessing) + IconButton( + onPressed: () { + setState(() => _dbcPrepSubsteps = const []); + Future.microtask(_uploadDbcFiles); + }, + icon: const Icon(Icons.refresh, size: 14), + tooltip: l10n.retryDbcPrep, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 24, minHeight: 24), + color: Colors.grey.shade500, ), - ], ], ), - ), - ); - } + ); + } - /// Small persistent strip showing the background DBC upload progress while - /// the user works through BT pairing + keycard enrollment. - Widget _dbcUploadProgressStrip(AppLocalizations l10n) { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( @@ -3700,10 +3737,16 @@ class _InstallerScreenState extends State { onPressed: () { // Re-arm from Stage 1: BT + keycard are already done // (their flags persist), so dashboardPrep re-runs only - // the upload, then the user re-confirms Begin. + // the upload, then the user re-confirms Begin. Only + // send the user back through pairing/enrollment for + // whichever of the two isn't actually satisfied yet. setState(() { _dashboardPrepStarted = false; - _dashboardPrepStep = _DashboardPrepStep.bluetooth; + if (!(_btDone || _btSkipped)) { + _dashboardPrepStep = _DashboardPrepStep.bluetooth; + } else if (!(_keycardDone || _keycardSkipped)) { + _dashboardPrepStep = _DashboardPrepStep.keycard; + } _dbcUploadReady = false; _reconnectStarted = false; _reconnectShowDiagnostics = false; @@ -3986,10 +4029,6 @@ class _InstallerScreenState extends State { } } - Widget _buildBluetoothPairing(AppLocalizations l10n) { - return Center(child: _bluetoothPairingContent(l10n)); - } - /// Inner content of the Bluetooth pairing step, without the outer page /// scaffold. Reused both as the standalone phase and as the first /// interactive sub-step of the Stage-1 dashboardPrep screen. @@ -4196,10 +4235,9 @@ class _InstallerScreenState extends State { await _bluetoothComplete(skipped: true); } - /// Bluetooth step finished (done or skipped). Inside the Stage-1 - /// dashboardPrep screen this records the result and advances the interactive - /// sub-step to keycard enrollment, persisting the resume checkpoint on a real - /// pairing. As the standalone phase it just advances to keycardSetup. + /// Bluetooth step finished (done or skipped). Records the result and + /// advances the Stage-1 dashboardPrep screen's interactive sub-step to + /// keycard enrollment, persisting the resume checkpoint on a real pairing. Future _bluetoothComplete({required bool skipped}) async { if (_currentPhase == InstallerPhase.dashboardPrep) { setState(() { @@ -4219,9 +4257,7 @@ class _InstallerScreenState extends State { } // Spin up the keycard sub-step the same way the standalone phase does. await _onEnterKeycardSetup(); - return; } - _setPhase(InstallerPhase.keycardSetup); } // Killed on entry to keycardSetup so that any auto-startup master-learning @@ -4671,10 +4707,9 @@ class _InstallerScreenState extends State { await _keycardComplete(skipped: true); } - /// Keycard step finished (done or skipped). Inside the Stage-1 dashboardPrep - /// screen this records the result and stays on the screen so the "Begin - /// flashing DBC" button can unlock; on a real enrollment it persists the - /// resume checkpoint. As the standalone phase it advances to finish. + /// Keycard step finished (done or skipped). Records the result and stays on + /// the Stage-1 dashboardPrep screen so the "Begin flashing DBC" button can + /// unlock; on a real enrollment it persists the resume checkpoint. Future _keycardComplete({required bool skipped}) async { if (_currentPhase == InstallerPhase.dashboardPrep) { setState(() { @@ -4692,13 +4727,7 @@ class _InstallerScreenState extends State { debugPrint('UI: failed to write install state (keycard-enrolled), non-fatal: $e'); } } - return; } - if (mounted) _setPhase(InstallerPhase.finish); - } - - Widget _buildKeycardSetup(AppLocalizations l10n) { - return Center(child: _keycardSetupContent(l10n)); } /// Inner content of the keycard enrollment step, without the outer page diff --git a/lib/widgets/download_progress.dart b/lib/widgets/download_progress.dart deleted file mode 100644 index ebb782a..0000000 --- a/lib/widgets/download_progress.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:flutter/material.dart'; -import '../l10n/app_localizations.dart'; -import '../models/download_state.dart'; -import '../theme.dart'; - -class DownloadProgressWidget extends StatelessWidget { - const DownloadProgressWidget({super.key, required this.items}); - - final List items; - - @override - Widget build(BuildContext context) { - if (items.isEmpty) return const SizedBox.shrink(); - - final l10n = AppLocalizations.of(context)!; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l10n.downloads, style: const TextStyle(fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - for (final item in items) - Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row( - children: [ - if (item.isComplete) - const Icon(Icons.check_circle, size: 16, color: kAccent) - else - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - value: item.progress > 0 ? item.progress : null, - strokeWidth: 2, - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - _labelFor(item.type, l10n), - style: TextStyle(fontSize: 13, color: Colors.grey.shade300), - ), - ), - Text( - item.isComplete - ? '${(item.expectedSize / 1024 / 1024).toStringAsFixed(0)} MB' - : '${(item.bytesDownloaded / 1024 / 1024).toStringAsFixed(0)} / ${(item.expectedSize / 1024 / 1024).toStringAsFixed(0)} MB', - style: TextStyle(fontSize: 12, color: Colors.grey.shade500), - ), - ], - ), - ), - ], - ); - } - - String _labelFor(DownloadItemType type, AppLocalizations l10n) => switch (type) { - DownloadItemType.mdbFirmware => l10n.downloadMdbFirmware, - DownloadItemType.mdbBmap => 'MDB Bmap', - DownloadItemType.dbcFirmware => l10n.downloadDbcFirmware, - DownloadItemType.dbcBmap => 'DBC Bmap', - DownloadItemType.osmTiles => l10n.downloadMapTiles, - DownloadItemType.valhallaTiles => l10n.downloadRoutingTiles, - }; -} diff --git a/lib/widgets/phase_sidebar.dart b/lib/widgets/phase_sidebar.dart index 1337afc..318aeff 100644 --- a/lib/widgets/phase_sidebar.dart +++ b/lib/widgets/phase_sidebar.dart @@ -291,14 +291,14 @@ class _DownloadStatus extends StatelessWidget { final List items; - static const _labels = { - DownloadItemType.mdbFirmware: 'MDB', - DownloadItemType.mdbBmap: 'Bmap', - DownloadItemType.dbcFirmware: 'DBC', - DownloadItemType.dbcBmap: 'Bmap', - DownloadItemType.osmTiles: 'Maps', - DownloadItemType.valhallaTiles: 'Routes', - }; + static String _labelFor(DownloadItemType type, AppLocalizations l10n) => switch (type) { + DownloadItemType.mdbFirmware => 'MDB', + DownloadItemType.mdbBmap => 'Bmap', + DownloadItemType.dbcFirmware => 'DBC', + DownloadItemType.dbcBmap => 'Bmap', + DownloadItemType.osmTiles => l10n.tileLabelMaps, + DownloadItemType.valhallaTiles => l10n.tileLabelRoutes, + }; @override Widget build(BuildContext context) { @@ -352,7 +352,7 @@ class _DownloadStatus extends StatelessWidget { ), const SizedBox(width: 3), Text( - _labels[item.type] ?? '', + _labelFor(item.type, l10n), style: TextStyle(fontSize: 10, color: Colors.grey.shade500), ), ], From 49807fa44fc5936f6e5956ee3952b1e219a95f90 Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Mon, 13 Jul 2026 22:06:42 +0200 Subject: [PATCH 27/43] chore(installer): drop unreferenced l10n keys Remove 77 strings with no remaining call site: the superseded LED/blinker walkthrough, the old single-device flow, and stale image placeholders. --- lib/l10n/app_de.arb | 115 ----- lib/l10n/app_en.arb | 157 +------ lib/l10n/app_localizations.dart | 690 ----------------------------- lib/l10n/app_localizations_de.dart | 391 ---------------- lib/l10n/app_localizations_en.dart | 388 ---------------- 5 files changed, 1 insertion(+), 1740 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 18c3644..dbce389 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1,7 +1,5 @@ { "@@locale": "de", - "appTitle": "Librescoot Installer", - "elevationWarning": "Ohne Administratorrechte gestartet. Einige Funktionen sind ggf. Eingeschränkt.", "phaseWelcomeTitle": "Willkommen", "phaseWelcomeDescription": "Voraussetzungen und Firmware-Auswahl", "phaseNoticesTitle": "Hinweise", @@ -64,7 +62,6 @@ "elevationRequiredBody": "Der Librescoot Installer benötigt Administratorrechte, um auf den Speicher des Rollers zu schreiben und das Netzwerk-Interface zu konfigurieren. Die Berechtigungsanfrage wurde abgelehnt oder konnte nicht angezeigt werden.\n\nKlicke auf Weiter, um den Dialog zu schließen, und versuche es erneut. Wenn du die Anfrage immer wieder ablehnst, kann der Installer nicht fortfahren.", "elevationNoticeWelcome": "Beim Klick auf Installation starten fragt dein System nach Administratorrechten. Der Installer braucht sie, um auf den Speicher des Rollers zu schreiben und das Netzwerk zu konfigurieren.", "requestingAdminPrivileges": "Administratorrechte werden angefragt...", - "quitButton": "Beenden", "firmwareChannel": "Firmware-Kanal", "channelStable": "Stabil", "channelTesting": "Testing", @@ -83,10 +80,8 @@ "physicalPrepSubheading": "Bereite deinen Roller für die USB-Verbindung vor.", "removeFootwellCover": "Fußraumabdeckung entfernen", "removeFootwellCoverDesc": "Vier Schrauben lösen. Ab Werk PH2 Kreuzschrauben, bei guten Werkstätten H4 Innensechskant oder Torx.", - "removeFootwellCoverImage": "[Foto: Fußraumabdeckung mit markierten Schraubenpositionen]", "unscrewUsbCable": "USB-Kabel vom MDB lösen", "unscrewUsbCableDesc": "Trenne das interne DBC-USB-Kabel vom MDB-Board. Verwende einen Schlitz- oder PH1-Schraubendreher.", - "unscrewUsbCableImage": "[Foto: USB-Mini-B-Anschluss am MDB, Nahaufnahme]", "connectLaptopUsb": "Laptop-USB-Kabel anschließen", "connectLaptopUsbDesc": "Stecke dein USB-Kabel in den MDB-Port und verbinde das andere Ende mit deinem Laptop.", "doneDetectDevice": "Fertig. Gerät erkennen", @@ -94,7 +89,6 @@ "waitingForUsbDevice": "Warte auf USB-Gerät...", "waitingForRndis": "Warte auf USB-Gerät... Stelle sicher, dass dein Laptop per USB mit dem MDB verbunden ist.", "checkingRndisDriver": "RNDIS-Treiber wird geprüft...", - "installingRndisDriver": "RNDIS-Treiber wird installiert...", "configuringNetwork": "Netzwerk wird konfiguriert...", "connectingSsh": "SSH-Verbindung wird aufgebaut...", "waitingForUnlock": "Roller entsperren, um fortzufahren...", @@ -103,7 +97,6 @@ "resumeFoundHeading": "Unterbrochene Installation gefunden", "resumeFoundBody": "Eine frühere Installation auf diesem Roller wurde nicht abgeschlossen. Der Entsperr-Schritt wurde übersprungen und deaktivierte Dienste wurden wieder aktiviert. Die Installation beginnt anschließend von vorn.", "resumeFoundLastError": "Letzter aufgezeichneter Fehler:", - "unlockTimeout": "Zeitlimit beim Warten auf Entsperrung. Roller entsperren und erneut versuchen.", "awaitingUnlockHeading": "Roller entsperren", "awaitingUnlockDetail": "Bitte entsperre deinen Roller, um fortzufahren. Halte deine Schlüsselkarte an den Leser oder benutze ein gekoppeltes Handy.", "awaitingParkHeading": "Roller parken", @@ -160,7 +153,6 @@ "flashingMdb": "MDB wird geflasht", "flashingMdbSubheading": "Zweiphasiges Schreiben: erst Partitionen, dann Bootsektor.", "waitingForMdbFirmware": "Warte auf MDB-Firmware-Download...", - "noDevicePath": "Fehler: kein Gerätepfad verfügbar", "mdbFlashComplete": "MDB-Flash abgeschlossen!", "flashProgressMb": "{mb} MB geschrieben", @@ -174,7 +166,6 @@ "disconnectCbbDesc": "Der Fahrakku muss bereits entnommen sein, bevor du die CBB trennst. Bei falscher Reihenfolge droht ein elektrischer Schaden.", "disconnectAuxPole": "Einen AUX-Pol trennen", "disconnectAuxPoleDesc": "Entferne NUR den Pluspol (außen, rotes Kabel und Pol), um eine Verpolung zu vermeiden. Dadurch wird das MDB stromlos; die USB-Verbindung geht verloren.", - "disconnectAuxPoleImage": "[Foto: AUX-Batteriepole, Pluspol (rot/außen) markiert]", "auxDisconnectWarning": "Die USB-Verbindung geht verloren, wenn du AUX trennst. Das ist normal. Der Installer wartet auf den Neustart des MDB.", "doneCbbAuxDisconnected": "Fertig. CBB und AUX getrennt", "waitingForMdbBoot": "Warte auf MDB-Boot", @@ -189,12 +180,9 @@ "reconnectingSsh": "SSH wird neu verbunden...", "sshReconnectionFailed": "SSH-Neuverbindung fehlgeschlagen: {error}", "reconnectCbbHeading": "CBB & Batterie wieder anschließen", - "reconnectCbb": "Hauptbatterie einsetzen und CBB wieder anschließen", - "reconnectCbbDesc": "Setze die Hauptbatterie wieder in die Sitzbank ein und stecke das CBB-Kabel wieder ein. Der Roller braucht volle Leistung für den DBC-Flash.", "verifyCbbConnection": "CBB-Verbindung prüfen", "verifyBatteryPresence": "Akku prüfen", "checkingCbb": "CBB wird geprüft...", - "cbbConnected": "CBB verbunden!", "waitingForCbb": "Warte auf CBB... ({attempts})", "cbbNotDetected": "CBB nicht erkannt. Bitte Verbindung prüfen.", "cbbDetectionMayTakeMinutes": "Das kann mehrere Minuten dauern, bitte etwas Geduld.", @@ -210,29 +198,6 @@ "disconnectUsbFromLaptopDesc": "Ziehe das Laptop-USB-Kabel vom MDB ab, damit der Port für das DBC-Kabel frei wird.", "reconnectDbcUsbToMdb": "DBC-USB-Kabel mit MDB verbinden", "reconnectDbcUsbToMdbDesc": "Stecke das interne DBC-USB-Kabel in den MDB-Port. Noch nicht festschrauben.", - "mdbFlashingDbcAutonomously": "Das MDB flasht jetzt selbstständig das DBC.", - "watchLightsForProgress": "Beobachte die Rollerbeleuchtung für den Fortschritt:", - "ledFrontRingPulse": "Frontring atmet", - "ledFrontRingPulseMeaning": "DBC wird vorbereitet (Bootloader, Verbindung)", - "ledFrontRingSolid": "Frontring leuchtet kurz", - "ledFrontRingSolidMeaning": "Flash abgeschlossen. Erfolg!", - "disconnectCbbImage": "[Foto: CBB-Stecker im Fußraum]", - "ledBlinkerProgress": "Blinker leuchten reihum auf", - "ledBlinkerProgressMeaning": "Gesamt-Fortschritt: Vorbereitung → Flash → Neustart → Karten", - "blinkerPosFL": "vorne links", - "blinkerPosFR": "vorne rechts", - "blinkerPosBR": "hinten rechts", - "blinkerPosBL": "hinten links", - "blinkerStepPrep": "DBC vorbereiten", - "blinkerStepFlash": "DBC flashen", - "blinkerStepRestart": "MDB & DBC neu starten", - "blinkerStepMaps": "Karten laden", - "ledBootGreen": "Tacho-LED blinkt grün", - "ledBootGreenMeaning": "Erfolgreich. Laptop wieder verbinden", - "ledRearLightSolid": "Alle vier Blinker (Warnblinker) blinken", - "ledRearLightSolidMeaning": "Fehler. Laptop verbinden für Log; Blinker und LED gehen aus, sobald du verbunden bist", - "bootLedGreenReconnect": "LED blinkt grün", - "rearLightCheckError": "LED blinkt rot, Warnblinker an", "verifyingDbcInstallation": "DBC-Installation wird geprüft", "reconnectUsbToLaptop": "USB wieder mit Laptop verbinden...", "waitingForRndisDevice": "Warte auf RNDIS-Gerät...", @@ -258,88 +223,22 @@ "closeSeatboxAndFootwellDesc": "Klipse zuerst die Metallbügel wieder ein, setze dann die Fußraumabdeckung auf und schraube sie fest.", "unlockScooter": "Roller entsperren", "unlockScooterDesc": "Nutze eine der angelernten Schlüsselkarten oder entsperre über Bluetooth.", - "deleteCachedDownloads": "Heruntergeladene Dateien löschen ({sizeMb} MB)", "deletedCache": "{sizeMb} MB gelöscht", "downloads": "Downloads", "downloadsFinished": "Downloads abgeschlossen", "downloadsFinishedHint": "Du kannst jetzt offline weitermachen.", - "downloadMdbFirmware": "MDB-Firmware", - "downloadDbcFirmware": "DBC-Firmware", - "downloadMapTiles": "Kartenkacheln", - "downloadRoutingTiles": "Routing-Kacheln", - "homeAppTitle": "Librescoot Installer", - "notElevated": "Keine Adminrechte", - "selectFirmwareStep": "Firmware wählen", - "connectDeviceStep": "Gerät verbinden", - "configureNetworkStep": "Netzwerk", - "prepareDeviceStep": "Vorbereiten", - "flashFirmwareStep": "Flashen", - "completeStep": "Fertig", - "selectFirmwareImage": "Firmware-Image auswählen", - "selectFirmwareHint": "Wähle eine .sdimg.gz-, .sdimg-, .wic.gz-, .wic- oder .img-Firmware-Datei", - "selectFile": "Datei auswählen", - "changeFile": "Andere Datei", - "deviceConnected": "Gerät verbunden", - "connectYourDevice": "Gerät verbinden", - "connectMdbViaUsb": "Verbinde das MDB per USB und warte auf die Erkennung", "backButton": "Zurück", - "configuringNetworkHeading": "Netzwerk wird konfiguriert", - "settingUpNetwork": "Netzwerkschnittstelle wird eingerichtet...", - "readyToConfigureNetwork": "Bereit, das Netzwerk für die Gerätekommunikation zu konfigurieren", - "configureNetworkButton": "Netzwerk konfigurieren", - "preparingDevice": "Gerät wird vorbereitet", - "readyToPrepare": "Bereit zur Vorbereitung", - "prepareForFlashing": "Für Flashen vorbereiten", - "flashingFirmware": "Firmware wird geflasht", - "startFlashing": "Flashen starten", - "installationComplete": "Installation abgeschlossen!", - "installationCompleteDesc": "Dein Gerät wurde erfolgreich geflasht.\nEs startet automatisch neu.", - "flashAnotherDevice": "Weiteres Gerät flashen", - "flashDryRun": "Flash-Probelauf", "safetyCheckFailed": "Sicherheitsprüfung fehlgeschlagen", "cannotFlashSafety": "Dieses Gerät kann aus Sicherheitsgründen nicht geflasht werden:", - "okButton": "OK", - "confirmFlashOperation": "Flash-Vorgang bestätigen", - "aboutToWriteFirmware": "Du schreibst gleich Firmware auf:", - "deviceLabel": "Gerät", - "pathLabel": "Pfad", - "sizeLabel": "Größe", - "firmwareLabel": "Firmware:", - "warningsLabel": "Warnungen:", - "eraseWarning": "Dadurch werden ALLE DATEN auf dem Gerät GELÖSCHT. Diese Aktion kann nicht rückgängig gemacht werden.", "cancelButton": "Abbrechen", - "flashDeviceButton": "Gerät flashen", - "installingUsbDriver": "USB-Treiber wird installiert...", - "usbDriverInstalled": "USB-Treiber erfolgreich installiert", - "driverInstallFailed": "Treiber-Installation fehlgeschlagen: {error}", - "autoLoadedFirmware": "Firmware automatisch aus dem aktuellen Verzeichnis geladen", - "deviceDisconnected": "Gerät getrennt. Neu verbinden oder auf Mass-Storage-Modus warten.", - "waitingForMdbNetwork": "Warte auf MDB-Netzwerkstabilisierung...", - "findingNetworkInterface": "Netzwerkschnittstelle wird gesucht...", - "couldNotFindInterface": "USB-Netzwerkschnittstelle nicht gefunden", - "networkConfigured": "Netzwerk erfolgreich konfiguriert", - "selectFirmwareFileError": "Bitte wähle eine .sdimg.gz-, .sdimg-, .wic.gz-, .wic- oder .img-Datei", - "errorOpeningFilePicker": "Fehler beim Öffnen der Dateiauswahl: {error}", - "configuringBootloader": "Bootloader wird für Mass-Storage-Modus konfiguriert...", - "rebootingDevice": "Gerät wird neu gestartet...", - "waitingForMassStorage": "Warte auf Neustart im Mass-Storage-Modus...", - "deviceReadyForFlashing": "Gerät bereit zum Flashen", - "selectFirmwareDialogTitle": "Firmware-Image auswählen", - "connectedTo": "Verbunden mit: {host}\nFirmware: {firmware}\nSeriennummer: {serial}", - "connectedToFirmware": "Verbunden mit {version}", "unknown": "Unbekannt", - "modeLabel": "Modus: {mode}", "backingUpConfig": "Gerätekonfiguration wird gesichert...", "configBackedUp": "Gerätekonfiguration gesichert", - "noConfigFound": "Keine Gerätekonfiguration zum Sichern gefunden", "restoringConfig": "Gerätekonfiguration wird wiederhergestellt...", "healthCheckFailed": "Statusprüfung fehlgeschlagen: {error}", - "flashError": "Flash-Fehler: {error}", - "flashComplete": "Flash abgeschlossen!", "errorPrefix": "Fehler: {error}", "regionHint": "Für Offline-Karten und Navigationsunterstützung", "skipOfflineMaps": "Offline-Karten überspringen", - "skipOfflineMapsHint": "Karten können später durch erneutes Ausführen des Installers installiert werden", "bluetoothPairingHeading": "Bluetooth-Kopplung", "bluetoothPairingHint": "Koppele dein Handy oder andere Bluetooth-Geräte mit dem Roller.", "bleMacLabel": "BLE-Adresse", @@ -403,7 +302,6 @@ "keycardSimulateMasterTapButton": "[DRY RUN] Master-Tap simulieren", "keycardSimulateRejectedTapButton": "[DRY RUN] Bereits-angelernt-Ablehnung simulieren", - "willAskForElevation": "Installation starten (fragt nach Berechtigung)", "installationContinuesInNewWindow": "Die Installation wird im neuen Fenster fortgesetzt", "youCanCloseThisWindow": "Du kannst dieses Fenster schließen.", "cannotQuitWhileFlashing": "Beenden während des Flashens nicht möglich", @@ -436,21 +334,9 @@ "proceedWithoutCbb": "Ich verstehe die Risiken, trotzdem fortfahren", "checkingCbbAndBattery": "CBB und Akku werden geprüft...", "waitingForUsbDisconnect": "Warte auf USB-Trennung...", - "dbcWillCyclePower": "Das DBC wird während dieses Vorgangs mehrmals ein- und ausgeschaltet. Trenne das USB-Kabel zwischen MDB und DBC nicht.", - "ledBootAmber": "Tacho-LED gelb-orange", - "ledBootAmberMeaning": "Flash läuft", - "ledBootRedError": "Tacho-LED blinkt rot", - "ledBootRedMeaning": "Fehler. Laptop verbinden und Log prüfen; Blinker und LED gehen aus, sobald du verbunden bist", - "flashingTakesAbout10Min": "Erst wenn die Tacho-LED blinkt (grün oder rot), und auch wirklich erst dann, das Laptop-USB-Kabel wieder anschließen.", - "dbcFlashDurationHeadline": "Das DBC-Flashen kann 10–20 Minuten dauern.", - "dbcFlashDurationDetail": "Das DBC wird dabei mehrmals ein- und ausgeschaltet — das ist normal. Nichts abziehen, bis die Tacho-LED grün oder rot blinkt.", "finishRebootingTitle": "Roller startet neu…", "finishRebootingBody": "Warte auf die USB-Trennung, dann ist der Installer fertig.", "networkConfigNeedsPermission": "macOS fragt nach Erlaubnis, die Netzwerk-Einstellungen zu ändern. Im Systemdialog auf 'Erlauben' klicken, dann 'Erneut versuchen' drücken.", - "waitingForMdbToReconnect": "Warte auf MDB-Wiederverbindung...", - "ledIsGreen": "LED blinkt grün", - "ledIsRed": "LED blinkt rot", - "ledAmberWaitNotice": "Wichtig: USB und Strom NICHT trennen, solange das hier läuft. Solange die Tacho-LED gelb-orange leuchtet, läuft der Flash noch. Finger weg, nichts anklicken. Wenn der Flash durch ist, fängt die LED an zu blinken: grün = Erfolg, rot = Fehler. Erst weitermachen, wenn sie blinkt.", "dbcWalkAwayHeadline": "Umstecken erledigt. Du kannst den Laptop jetzt abziehen.", "dbcWalkAwayBody": "Lass den Roller ein paar Minuten in Ruhe, während er das DBC selbstständig flasht. Das kann 10 bis 20 Minuten dauern.", "dbcWalkAwayDashboardLit": "Wenn der Tacho angeht, ist die Installation fertig: das DBC-Kabel festschrauben, alles wieder zumachen und den Roller entriegeln.", @@ -469,7 +355,6 @@ "waitingForDevicePath": "Warte auf Gerätepfad...", "noDevicePathFound": "Kein Gerätepfad gefunden. USB-Verbindung prüfen und erneut versuchen.", "mdbDisconnectedFlashingDbc": "MDB getrennt. DBC wird autonom geflasht...", - "mdbReconnectedVerifying": "MDB wieder verbunden! Überprüfung läuft...", "logDebugShell": "Log & Debug-Shell", "copyToClipboard": "In Zwischenablage kopieren", "debugCommandHint": "Befehl im Installer-Kontext ausführen...", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 365261e..5dc250e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,10 +1,6 @@ { "@@locale": "en", - "appTitle": "Librescoot Installer", - - "elevationWarning": "Running without administrator privileges. Some operations may fail.", - "phaseWelcomeTitle": "Welcome", "phaseWelcomeDescription": "Prerequisites and firmware selection", "phaseNoticesTitle": "Notices", @@ -69,7 +65,6 @@ "elevationRequiredBody": "Librescoot Installer needs administrator privileges to write to the scooter's storage and configure the network interface. The elevation prompt was declined or could not be shown.\n\nClick Continue to dismiss this dialog and try again. If you keep declining the prompt, the installer cannot proceed.", "elevationNoticeWelcome": "When you click Start Installation, your system will ask you to allow administrator access. The installer needs it to write to the scooter's storage and configure networking.", "requestingAdminPrivileges": "Requesting administrator privileges...", - "quitButton": "Quit", "firmwareChannel": "Firmware Channel", "channelStable": "Stable", "channelTesting": "Testing", @@ -89,10 +84,8 @@ "physicalPrepSubheading": "Prepare your scooter for USB connection.", "removeFootwellCover": "Remove footwell cover", "removeFootwellCoverDesc": "Four screws to remove. PH2 Phillips from factory, H4 hex or Torx if serviced by a good shop.", - "removeFootwellCoverImage": "[Photo: footwell cover with screw locations highlighted]", "unscrewUsbCable": "Unscrew USB cable from MDB", "unscrewUsbCableDesc": "Disconnect the internal DBC USB cable from the MDB board. Use a flat head or PH1 screwdriver.", - "unscrewUsbCableImage": "[Photo: USB Mini-B connector on MDB, close-up]", "connectLaptopUsb": "Connect laptop USB cable", "connectLaptopUsbDesc": "Plug your USB cable into the MDB port and connect the other end to your laptop.", "doneDetectDevice": "Done. Detect Device", @@ -101,7 +94,6 @@ "waitingForUsbDevice": "Waiting for USB device...", "waitingForRndis": "Waiting for USB device... Make sure your laptop is connected to the MDB via USB.", "checkingRndisDriver": "Checking RNDIS driver...", - "installingRndisDriver": "Installing RNDIS driver...", "configuringNetwork": "Configuring network...", "connectingSsh": "Connecting via SSH...", "waitingForUnlock": "Unlock the scooter to continue...", @@ -110,7 +102,6 @@ "resumeFoundHeading": "Interrupted installation found", "resumeFoundBody": "A previous installation on this scooter did not finish. The unlock step has been skipped and disabled services were re-enabled. Continuing will run the installation again from the beginning.", "resumeFoundLastError": "Last recorded error:", - "unlockTimeout": "Timed out waiting for scooter to be unlocked. Unlock and retry.", "awaitingUnlockHeading": "Unlock your scooter", "awaitingUnlockDetail": "Please unlock your scooter to continue. Use your keycard or paired phone.", "awaitingParkHeading": "Park your scooter", @@ -189,7 +180,6 @@ "flashingMdb": "Flashing MDB", "flashingMdbSubheading": "Two-phase write: partitions first, boot sector last.", "waitingForMdbFirmware": "Waiting for MDB firmware download...", - "noDevicePath": "Error: no device path available", "mdbFlashComplete": "MDB flash complete!", "flashProgressMb": "{mb} MB written", @@ -201,14 +191,12 @@ "flashProgressBootSector": "Boot sector: {mb} MB written", "@flashProgressBootSector": { "placeholders": { "mb": { "type": "String" } } }, - "scooterPrepHeading": "Scooter Preparation", "scooterPrepSubheading": "MDB firmware has been written. Now prepare for reboot.", "disconnectCbb": "Disconnect the CBB", "disconnectCbbDesc": "The main battery must already be removed before disconnecting CBB. Failure to follow this order risks electrical damage.", "disconnectAuxPole": "Disconnect one AUX pole", "disconnectAuxPoleDesc": "Remove ONLY the positive pole (outermost, the red cable and pole) to avoid risk of inverting polarity. This will remove power from the MDB; the USB connection will disappear.", - "disconnectAuxPoleImage": "[Photo: AUX battery poles, positive (red/outermost) highlighted]", "auxDisconnectWarning": "The USB connection will be lost when you disconnect AUX. This is expected. The installer will wait for the MDB to reboot.", "doneCbbAuxDisconnected": "Done. I disconnected CBB and AUX", @@ -235,12 +223,9 @@ }, "reconnectCbbHeading": "Reconnect CBB & Battery", - "reconnectCbb": "Reinstall the main battery and reconnect the CBB", - "reconnectCbbDesc": "Put the main battery back in the seatbox and plug the CBB cable back in. The scooter needs full power for the DBC flash.", "verifyCbbConnection": "Verify CBB Connection", "verifyBatteryPresence": "Verify battery", "checkingCbb": "Checking CBB...", - "cbbConnected": "CBB connected!", "waitingForCbb": "Waiting for CBB... ({attempts})", "@waitingForCbb": { "placeholders": { @@ -269,29 +254,6 @@ "disconnectUsbFromLaptopDesc": "Unplug the laptop USB cable from the MDB to free the port for the DBC cable.", "reconnectDbcUsbToMdb": "Reconnect DBC USB cable to MDB", "reconnectDbcUsbToMdbDesc": "Plug the internal DBC USB cable into the MDB port. Don't screw it in yet.", - "mdbFlashingDbcAutonomously": "The MDB is now flashing the DBC autonomously.", - "watchLightsForProgress": "Watch the scooter lights for progress:", - "ledFrontRingPulse": "Front ring breathing", - "ledFrontRingPulseMeaning": "Preparing DBC (configuring bootloader, waiting for connection)", - "ledFrontRingSolid": "Front ring glows briefly", - "ledFrontRingSolidMeaning": "Flash complete. Success!", - "disconnectCbbImage": "[Photo: CBB connector location in footwell]", - "ledBlinkerProgress": "Blinkers light up in turn", - "ledBlinkerProgressMeaning": "Overall progress: Prep → Flash → Reboot → Maps", - "blinkerPosFL": "front left", - "blinkerPosFR": "front right", - "blinkerPosBR": "rear right", - "blinkerPosBL": "rear left", - "blinkerStepPrep": "Prepare DBC", - "blinkerStepFlash": "Flash DBC", - "blinkerStepRestart": "Restart MDB & DBC", - "blinkerStepMaps": "Upload maps", - "ledBootGreen": "Dashboard LED blinking green", - "ledBootGreenMeaning": "Success. Reconnect laptop", - "ledRearLightSolid": "All four blinkers (hazards) flashing", - "ledRearLightSolidMeaning": "Error. Reconnect laptop to see log; the indicators stop once you reconnect", - "bootLedGreenReconnect": "LED blinking green", - "rearLightCheckError": "LED blinking red, hazards flashing", "verifyingDbcInstallation": "Verifying DBC Installation", "reconnectUsbToLaptop": "Reconnect USB to laptop...", @@ -324,12 +286,6 @@ "closeSeatboxAndFootwellDesc": "Clip the metal bars back in first, then fit the footwell cover and screw it down.", "unlockScooter": "Unlock your scooter", "unlockScooterDesc": "Use one of the keycards you registered, or unlock via Bluetooth.", - "deleteCachedDownloads": "Delete cached downloads ({sizeMb} MB)", - "@deleteCachedDownloads": { - "placeholders": { - "sizeMb": { "type": "String" } - } - }, "deletedCache": "Deleted {sizeMb} MB", "@deletedCache": { "placeholders": { @@ -340,103 +296,14 @@ "downloads": "Downloads", "downloadsFinished": "Downloads finished", "downloadsFinishedHint": "You can continue offline.", - "downloadMdbFirmware": "MDB Firmware", - "downloadDbcFirmware": "DBC Firmware", - "downloadMapTiles": "Map Tiles", - "downloadRoutingTiles": "Routing Tiles", - - "homeAppTitle": "Librescoot Installer", - "notElevated": "Not elevated", - "selectFirmwareStep": "Select Firmware", - "connectDeviceStep": "Connect Device", - "configureNetworkStep": "Configure Network", - "prepareDeviceStep": "Prepare Device", - "flashFirmwareStep": "Flash Firmware", - "completeStep": "Complete", - "selectFirmwareImage": "Select Firmware Image", - "selectFirmwareHint": "Choose a .sdimg.gz, .sdimg, .wic.gz, .wic, or .img firmware file to flash", - "selectFile": "Select File", - "changeFile": "Change File", - "deviceConnected": "Device Connected", - "connectYourDevice": "Connect Your Device", - "connectMdbViaUsb": "Connect the MDB via USB and wait for detection", + "backButton": "Back", - "configuringNetworkHeading": "Configuring Network", - "settingUpNetwork": "Setting up network interface...", - "readyToConfigureNetwork": "Ready to configure network for device communication", - "configureNetworkButton": "Configure Network", - "preparingDevice": "Preparing Device", - "readyToPrepare": "Ready to Prepare", - "prepareForFlashing": "Prepare for Flashing", - "flashingFirmware": "Flashing Firmware", - "startFlashing": "Start Flashing", - "installationComplete": "Installation Complete!", - "installationCompleteDesc": "Your device has been successfully flashed.\nIt will reboot automatically.", - "flashAnotherDevice": "Flash Another Device", - "flashDryRun": "Flash Dry Run", "safetyCheckFailed": "Safety Check Failed", "cannotFlashSafety": "Cannot flash this device due to safety concerns:", - "okButton": "OK", - "confirmFlashOperation": "Confirm Flash Operation", - "aboutToWriteFirmware": "You are about to write firmware to:", - "deviceLabel": "Device", - "pathLabel": "Path", - "sizeLabel": "Size", - "firmwareLabel": "Firmware:", - "warningsLabel": "Warnings:", - "eraseWarning": "This will ERASE ALL DATA on the device. This action cannot be undone.", "cancelButton": "Cancel", - "flashDeviceButton": "Flash Device", - "installingUsbDriver": "Installing USB driver...", - "usbDriverInstalled": "USB driver installed successfully", - "driverInstallFailed": "Driver install failed: {error}", - "@driverInstallFailed": { - "placeholders": { - "error": { "type": "String" } - } - }, - "autoLoadedFirmware": "Auto-loaded firmware from current directory", - "deviceDisconnected": "Device disconnected. Reconnect/wait for mass storage mode.", - "waitingForMdbNetwork": "Waiting for MDB network to settle...", - "findingNetworkInterface": "Finding network interface...", - "couldNotFindInterface": "Could not find USB network interface", - "networkConfigured": "Network configured successfully", - "selectFirmwareFileError": "Please select a .sdimg.gz, .sdimg, .wic.gz, .wic, or .img file", - "errorOpeningFilePicker": "Error opening file picker: {error}", - "@errorOpeningFilePicker": { - "placeholders": { - "error": { "type": "String" } - } - }, - "configuringBootloader": "Configuring bootloader for mass storage mode...", - "rebootingDevice": "Rebooting device...", - "waitingForMassStorage": "Waiting for device to reboot in mass storage mode...", - "deviceReadyForFlashing": "Device ready for flashing", - "selectFirmwareDialogTitle": "Select Firmware Image", - "connectedTo": "Connected to: {host}\nFirmware: {firmware}\nSerial: {serial}", - "@connectedTo": { - "placeholders": { - "host": { "type": "String" }, - "firmware": { "type": "String" }, - "serial": { "type": "String" } - } - }, - "connectedToFirmware": "Connected to {version}", - "@connectedToFirmware": { - "placeholders": { - "version": { "type": "String" } - } - }, "unknown": "Unknown", - "modeLabel": "Mode: {mode}", - "@modeLabel": { - "placeholders": { - "mode": { "type": "String" } - } - }, "backingUpConfig": "Backing up device configuration...", "configBackedUp": "Device configuration backed up", - "noConfigFound": "No device configuration found to back up", "restoringConfig": "Restoring device configuration...", "healthCheckFailed": "Health check failed: {error}", "@healthCheckFailed": { @@ -444,13 +311,6 @@ "error": { "type": "String" } } }, - "flashError": "Flash error: {error}", - "@flashError": { - "placeholders": { - "error": { "type": "String" } - } - }, - "flashComplete": "Flash complete!", "errorPrefix": "Error: {error}", "@errorPrefix": { "placeholders": { @@ -460,7 +320,6 @@ "regionHint": "For offline maps and navigation support", "skipOfflineMaps": "Skip offline maps", - "skipOfflineMapsHint": "You can install maps later by re-running the installer", "bluetoothPairingHeading": "Bluetooth Pairing", "bluetoothPairingHint": "Pair your phone or other Bluetooth devices with the scooter.", "bleMacLabel": "BLE address", @@ -523,7 +382,6 @@ "keycardSimulateTapButton": "[DRY RUN] Simulate tap", "keycardSimulateMasterTapButton": "[DRY RUN] Simulate master tap", "keycardSimulateRejectedTapButton": "[DRY RUN] Simulate already-authorized rejection", - "willAskForElevation": "Start Installation (will ask for elevation)", "installationContinuesInNewWindow": "Installation continues in the new window", "youCanCloseThisWindow": "You can close this window.", @@ -567,21 +425,9 @@ "checkingCbbAndBattery": "Checking CBB and battery...", "waitingForUsbDisconnect": "Waiting for USB disconnect...", - "dbcWillCyclePower": "The DBC will turn on and off multiple times during this process. Do not disconnect the USB cable between MDB and DBC.", - "ledBootAmber": "Dashboard LED amber", - "ledBootAmberMeaning": "Flashing in progress", - "ledBootRedError": "Dashboard LED blinking red", - "ledBootRedMeaning": "Error. Reconnect laptop to check log; the indicators stop once you reconnect", - "flashingTakesAbout10Min": "Once the dashboard LED is blinking (green or red), and only then, reconnect the laptop USB cable.", - "dbcFlashDurationHeadline": "The DBC flash can take 10–20 minutes.", - "dbcFlashDurationDetail": "The DBC will turn on and off several times during this process — that is normal. Do not unplug anything until the dashboard LED is blinking green or red.", "finishRebootingTitle": "Rebooting scooter…", "finishRebootingBody": "Waiting for the MDB to drop the USB link before completing the install.", "networkConfigNeedsPermission": "macOS is asking for permission to change network settings. Click Allow in the system dialog, then hit Retry.", - "waitingForMdbToReconnect": "Waiting for MDB to reconnect...", - "ledIsGreen": "LED blinking green", - "ledIsRed": "LED blinking red", - "ledAmberWaitNotice": "Most important: do NOT disconnect USB or power while this is running. While the dashboard LED is amber/orange, flashing is still in progress. Hands off, don't click anything. The LED will start blinking once it's done: green = success, red = error. Only continue once it's blinking.", "dbcWalkAwayHeadline": "Swap done. You can unplug the laptop now.", "dbcWalkAwayBody": "Leave the scooter alone for a few minutes while it flashes the dashboard on its own. This can take 10 to 20 minutes.", "dbcWalkAwayDashboardLit": "When the dashboard lights up, the install is finished: screw the DBC cable down, close everything up, and unlock the scooter.", @@ -602,7 +448,6 @@ "waitingForDevicePath": "Waiting for device path...", "noDevicePathFound": "No device path found. Check USB connection and retry.", "mdbDisconnectedFlashingDbc": "MDB disconnected. Flashing DBC autonomously...", - "mdbReconnectedVerifying": "MDB reconnected! Verifying...", "logDebugShell": "Log & Debug Shell", "copyToClipboard": "Copy to clipboard", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index a6b7b29..8f51148 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -98,18 +98,6 @@ abstract class AppLocalizations { Locale('en'), ]; - /// No description provided for @appTitle. - /// - /// In en, this message translates to: - /// **'Librescoot Installer'** - String get appTitle; - - /// No description provided for @elevationWarning. - /// - /// In en, this message translates to: - /// **'Running without administrator privileges. Some operations may fail.'** - String get elevationWarning; - /// No description provided for @phaseWelcomeTitle. /// /// In en, this message translates to: @@ -482,12 +470,6 @@ abstract class AppLocalizations { /// **'Requesting administrator privileges...'** String get requestingAdminPrivileges; - /// No description provided for @quitButton. - /// - /// In en, this message translates to: - /// **'Quit'** - String get quitButton; - /// No description provided for @firmwareChannel. /// /// In en, this message translates to: @@ -596,12 +578,6 @@ abstract class AppLocalizations { /// **'Four screws to remove. PH2 Phillips from factory, H4 hex or Torx if serviced by a good shop.'** String get removeFootwellCoverDesc; - /// No description provided for @removeFootwellCoverImage. - /// - /// In en, this message translates to: - /// **'[Photo: footwell cover with screw locations highlighted]'** - String get removeFootwellCoverImage; - /// No description provided for @unscrewUsbCable. /// /// In en, this message translates to: @@ -614,12 +590,6 @@ abstract class AppLocalizations { /// **'Disconnect the internal DBC USB cable from the MDB board. Use a flat head or PH1 screwdriver.'** String get unscrewUsbCableDesc; - /// No description provided for @unscrewUsbCableImage. - /// - /// In en, this message translates to: - /// **'[Photo: USB Mini-B connector on MDB, close-up]'** - String get unscrewUsbCableImage; - /// No description provided for @connectLaptopUsb. /// /// In en, this message translates to: @@ -662,12 +632,6 @@ abstract class AppLocalizations { /// **'Checking RNDIS driver...'** String get checkingRndisDriver; - /// No description provided for @installingRndisDriver. - /// - /// In en, this message translates to: - /// **'Installing RNDIS driver...'** - String get installingRndisDriver; - /// No description provided for @configuringNetwork. /// /// In en, this message translates to: @@ -716,12 +680,6 @@ abstract class AppLocalizations { /// **'Last recorded error:'** String get resumeFoundLastError; - /// No description provided for @unlockTimeout. - /// - /// In en, this message translates to: - /// **'Timed out waiting for scooter to be unlocked. Unlock and retry.'** - String get unlockTimeout; - /// No description provided for @awaitingUnlockHeading. /// /// In en, this message translates to: @@ -1040,12 +998,6 @@ abstract class AppLocalizations { /// **'Waiting for MDB firmware download...'** String get waitingForMdbFirmware; - /// No description provided for @noDevicePath. - /// - /// In en, this message translates to: - /// **'Error: no device path available'** - String get noDevicePath; - /// No description provided for @mdbFlashComplete. /// /// In en, this message translates to: @@ -1112,12 +1064,6 @@ abstract class AppLocalizations { /// **'Remove ONLY the positive pole (outermost, the red cable and pole) to avoid risk of inverting polarity. This will remove power from the MDB; the USB connection will disappear.'** String get disconnectAuxPoleDesc; - /// No description provided for @disconnectAuxPoleImage. - /// - /// In en, this message translates to: - /// **'[Photo: AUX battery poles, positive (red/outermost) highlighted]'** - String get disconnectAuxPoleImage; - /// No description provided for @auxDisconnectWarning. /// /// In en, this message translates to: @@ -1202,18 +1148,6 @@ abstract class AppLocalizations { /// **'Reconnect CBB & Battery'** String get reconnectCbbHeading; - /// No description provided for @reconnectCbb. - /// - /// In en, this message translates to: - /// **'Reinstall the main battery and reconnect the CBB'** - String get reconnectCbb; - - /// No description provided for @reconnectCbbDesc. - /// - /// In en, this message translates to: - /// **'Put the main battery back in the seatbox and plug the CBB cable back in. The scooter needs full power for the DBC flash.'** - String get reconnectCbbDesc; - /// No description provided for @verifyCbbConnection. /// /// In en, this message translates to: @@ -1232,12 +1166,6 @@ abstract class AppLocalizations { /// **'Checking CBB...'** String get checkingCbb; - /// No description provided for @cbbConnected. - /// - /// In en, this message translates to: - /// **'CBB connected!'** - String get cbbConnected; - /// No description provided for @waitingForCbb. /// /// In en, this message translates to: @@ -1328,144 +1256,6 @@ abstract class AppLocalizations { /// **'Plug the internal DBC USB cable into the MDB port. Don\'t screw it in yet.'** String get reconnectDbcUsbToMdbDesc; - /// No description provided for @mdbFlashingDbcAutonomously. - /// - /// In en, this message translates to: - /// **'The MDB is now flashing the DBC autonomously.'** - String get mdbFlashingDbcAutonomously; - - /// No description provided for @watchLightsForProgress. - /// - /// In en, this message translates to: - /// **'Watch the scooter lights for progress:'** - String get watchLightsForProgress; - - /// No description provided for @ledFrontRingPulse. - /// - /// In en, this message translates to: - /// **'Front ring breathing'** - String get ledFrontRingPulse; - - /// No description provided for @ledFrontRingPulseMeaning. - /// - /// In en, this message translates to: - /// **'Preparing DBC (configuring bootloader, waiting for connection)'** - String get ledFrontRingPulseMeaning; - - /// No description provided for @ledFrontRingSolid. - /// - /// In en, this message translates to: - /// **'Front ring glows briefly'** - String get ledFrontRingSolid; - - /// No description provided for @ledFrontRingSolidMeaning. - /// - /// In en, this message translates to: - /// **'Flash complete. Success!'** - String get ledFrontRingSolidMeaning; - - /// No description provided for @disconnectCbbImage. - /// - /// In en, this message translates to: - /// **'[Photo: CBB connector location in footwell]'** - String get disconnectCbbImage; - - /// No description provided for @ledBlinkerProgress. - /// - /// In en, this message translates to: - /// **'Blinkers light up in turn'** - String get ledBlinkerProgress; - - /// No description provided for @ledBlinkerProgressMeaning. - /// - /// In en, this message translates to: - /// **'Overall progress: Prep → Flash → Reboot → Maps'** - String get ledBlinkerProgressMeaning; - - /// No description provided for @blinkerPosFL. - /// - /// In en, this message translates to: - /// **'front left'** - String get blinkerPosFL; - - /// No description provided for @blinkerPosFR. - /// - /// In en, this message translates to: - /// **'front right'** - String get blinkerPosFR; - - /// No description provided for @blinkerPosBR. - /// - /// In en, this message translates to: - /// **'rear right'** - String get blinkerPosBR; - - /// No description provided for @blinkerPosBL. - /// - /// In en, this message translates to: - /// **'rear left'** - String get blinkerPosBL; - - /// No description provided for @blinkerStepPrep. - /// - /// In en, this message translates to: - /// **'Prepare DBC'** - String get blinkerStepPrep; - - /// No description provided for @blinkerStepFlash. - /// - /// In en, this message translates to: - /// **'Flash DBC'** - String get blinkerStepFlash; - - /// No description provided for @blinkerStepRestart. - /// - /// In en, this message translates to: - /// **'Restart MDB & DBC'** - String get blinkerStepRestart; - - /// No description provided for @blinkerStepMaps. - /// - /// In en, this message translates to: - /// **'Upload maps'** - String get blinkerStepMaps; - - /// No description provided for @ledBootGreen. - /// - /// In en, this message translates to: - /// **'Dashboard LED blinking green'** - String get ledBootGreen; - - /// No description provided for @ledBootGreenMeaning. - /// - /// In en, this message translates to: - /// **'Success. Reconnect laptop'** - String get ledBootGreenMeaning; - - /// No description provided for @ledRearLightSolid. - /// - /// In en, this message translates to: - /// **'All four blinkers (hazards) flashing'** - String get ledRearLightSolid; - - /// No description provided for @ledRearLightSolidMeaning. - /// - /// In en, this message translates to: - /// **'Error. Reconnect laptop to see log; the indicators stop once you reconnect'** - String get ledRearLightSolidMeaning; - - /// No description provided for @bootLedGreenReconnect. - /// - /// In en, this message translates to: - /// **'LED blinking green'** - String get bootLedGreenReconnect; - - /// No description provided for @rearLightCheckError. - /// - /// In en, this message translates to: - /// **'LED blinking red, hazards flashing'** - String get rearLightCheckError; - /// No description provided for @verifyingDbcInstallation. /// /// In en, this message translates to: @@ -1586,12 +1376,6 @@ abstract class AppLocalizations { /// **'Use one of the keycards you registered, or unlock via Bluetooth.'** String get unlockScooterDesc; - /// No description provided for @deleteCachedDownloads. - /// - /// In en, this message translates to: - /// **'Delete cached downloads ({sizeMb} MB)'** - String deleteCachedDownloads(String sizeMb); - /// No description provided for @deletedCache. /// /// In en, this message translates to: @@ -1616,198 +1400,6 @@ abstract class AppLocalizations { /// **'You can continue offline.'** String get downloadsFinishedHint; - /// No description provided for @downloadMdbFirmware. - /// - /// In en, this message translates to: - /// **'MDB Firmware'** - String get downloadMdbFirmware; - - /// No description provided for @downloadDbcFirmware. - /// - /// In en, this message translates to: - /// **'DBC Firmware'** - String get downloadDbcFirmware; - - /// No description provided for @downloadMapTiles. - /// - /// In en, this message translates to: - /// **'Map Tiles'** - String get downloadMapTiles; - - /// No description provided for @downloadRoutingTiles. - /// - /// In en, this message translates to: - /// **'Routing Tiles'** - String get downloadRoutingTiles; - - /// No description provided for @homeAppTitle. - /// - /// In en, this message translates to: - /// **'Librescoot Installer'** - String get homeAppTitle; - - /// No description provided for @notElevated. - /// - /// In en, this message translates to: - /// **'Not elevated'** - String get notElevated; - - /// No description provided for @selectFirmwareStep. - /// - /// In en, this message translates to: - /// **'Select Firmware'** - String get selectFirmwareStep; - - /// No description provided for @connectDeviceStep. - /// - /// In en, this message translates to: - /// **'Connect Device'** - String get connectDeviceStep; - - /// No description provided for @configureNetworkStep. - /// - /// In en, this message translates to: - /// **'Configure Network'** - String get configureNetworkStep; - - /// No description provided for @prepareDeviceStep. - /// - /// In en, this message translates to: - /// **'Prepare Device'** - String get prepareDeviceStep; - - /// No description provided for @flashFirmwareStep. - /// - /// In en, this message translates to: - /// **'Flash Firmware'** - String get flashFirmwareStep; - - /// No description provided for @completeStep. - /// - /// In en, this message translates to: - /// **'Complete'** - String get completeStep; - - /// No description provided for @selectFirmwareImage. - /// - /// In en, this message translates to: - /// **'Select Firmware Image'** - String get selectFirmwareImage; - - /// No description provided for @selectFirmwareHint. - /// - /// In en, this message translates to: - /// **'Choose a .sdimg.gz, .sdimg, .wic.gz, .wic, or .img firmware file to flash'** - String get selectFirmwareHint; - - /// No description provided for @selectFile. - /// - /// In en, this message translates to: - /// **'Select File'** - String get selectFile; - - /// No description provided for @changeFile. - /// - /// In en, this message translates to: - /// **'Change File'** - String get changeFile; - - /// No description provided for @deviceConnected. - /// - /// In en, this message translates to: - /// **'Device Connected'** - String get deviceConnected; - - /// No description provided for @connectYourDevice. - /// - /// In en, this message translates to: - /// **'Connect Your Device'** - String get connectYourDevice; - - /// No description provided for @connectMdbViaUsb. - /// - /// In en, this message translates to: - /// **'Connect the MDB via USB and wait for detection'** - String get connectMdbViaUsb; - - /// No description provided for @configuringNetworkHeading. - /// - /// In en, this message translates to: - /// **'Configuring Network'** - String get configuringNetworkHeading; - - /// No description provided for @settingUpNetwork. - /// - /// In en, this message translates to: - /// **'Setting up network interface...'** - String get settingUpNetwork; - - /// No description provided for @readyToConfigureNetwork. - /// - /// In en, this message translates to: - /// **'Ready to configure network for device communication'** - String get readyToConfigureNetwork; - - /// No description provided for @configureNetworkButton. - /// - /// In en, this message translates to: - /// **'Configure Network'** - String get configureNetworkButton; - - /// No description provided for @preparingDevice. - /// - /// In en, this message translates to: - /// **'Preparing Device'** - String get preparingDevice; - - /// No description provided for @readyToPrepare. - /// - /// In en, this message translates to: - /// **'Ready to Prepare'** - String get readyToPrepare; - - /// No description provided for @prepareForFlashing. - /// - /// In en, this message translates to: - /// **'Prepare for Flashing'** - String get prepareForFlashing; - - /// No description provided for @flashingFirmware. - /// - /// In en, this message translates to: - /// **'Flashing Firmware'** - String get flashingFirmware; - - /// No description provided for @startFlashing. - /// - /// In en, this message translates to: - /// **'Start Flashing'** - String get startFlashing; - - /// No description provided for @installationComplete. - /// - /// In en, this message translates to: - /// **'Installation Complete!'** - String get installationComplete; - - /// No description provided for @installationCompleteDesc. - /// - /// In en, this message translates to: - /// **'Your device has been successfully flashed.\nIt will reboot automatically.'** - String get installationCompleteDesc; - - /// No description provided for @flashAnotherDevice. - /// - /// In en, this message translates to: - /// **'Flash Another Device'** - String get flashAnotherDevice; - - /// No description provided for @flashDryRun. - /// - /// In en, this message translates to: - /// **'Flash Dry Run'** - String get flashDryRun; - /// No description provided for @safetyCheckFailed. /// /// In en, this message translates to: @@ -1820,192 +1412,18 @@ abstract class AppLocalizations { /// **'Cannot flash this device due to safety concerns:'** String get cannotFlashSafety; - /// No description provided for @okButton. - /// - /// In en, this message translates to: - /// **'OK'** - String get okButton; - - /// No description provided for @confirmFlashOperation. - /// - /// In en, this message translates to: - /// **'Confirm Flash Operation'** - String get confirmFlashOperation; - - /// No description provided for @aboutToWriteFirmware. - /// - /// In en, this message translates to: - /// **'You are about to write firmware to:'** - String get aboutToWriteFirmware; - - /// No description provided for @deviceLabel. - /// - /// In en, this message translates to: - /// **'Device'** - String get deviceLabel; - - /// No description provided for @pathLabel. - /// - /// In en, this message translates to: - /// **'Path'** - String get pathLabel; - - /// No description provided for @sizeLabel. - /// - /// In en, this message translates to: - /// **'Size'** - String get sizeLabel; - - /// No description provided for @firmwareLabel. - /// - /// In en, this message translates to: - /// **'Firmware:'** - String get firmwareLabel; - - /// No description provided for @warningsLabel. - /// - /// In en, this message translates to: - /// **'Warnings:'** - String get warningsLabel; - - /// No description provided for @eraseWarning. - /// - /// In en, this message translates to: - /// **'This will ERASE ALL DATA on the device. This action cannot be undone.'** - String get eraseWarning; - /// No description provided for @cancelButton. /// /// In en, this message translates to: /// **'Cancel'** String get cancelButton; - /// No description provided for @flashDeviceButton. - /// - /// In en, this message translates to: - /// **'Flash Device'** - String get flashDeviceButton; - - /// No description provided for @installingUsbDriver. - /// - /// In en, this message translates to: - /// **'Installing USB driver...'** - String get installingUsbDriver; - - /// No description provided for @usbDriverInstalled. - /// - /// In en, this message translates to: - /// **'USB driver installed successfully'** - String get usbDriverInstalled; - - /// No description provided for @driverInstallFailed. - /// - /// In en, this message translates to: - /// **'Driver install failed: {error}'** - String driverInstallFailed(String error); - - /// No description provided for @autoLoadedFirmware. - /// - /// In en, this message translates to: - /// **'Auto-loaded firmware from current directory'** - String get autoLoadedFirmware; - - /// No description provided for @deviceDisconnected. - /// - /// In en, this message translates to: - /// **'Device disconnected. Reconnect/wait for mass storage mode.'** - String get deviceDisconnected; - - /// No description provided for @waitingForMdbNetwork. - /// - /// In en, this message translates to: - /// **'Waiting for MDB network to settle...'** - String get waitingForMdbNetwork; - - /// No description provided for @findingNetworkInterface. - /// - /// In en, this message translates to: - /// **'Finding network interface...'** - String get findingNetworkInterface; - - /// No description provided for @couldNotFindInterface. - /// - /// In en, this message translates to: - /// **'Could not find USB network interface'** - String get couldNotFindInterface; - - /// No description provided for @networkConfigured. - /// - /// In en, this message translates to: - /// **'Network configured successfully'** - String get networkConfigured; - - /// No description provided for @selectFirmwareFileError. - /// - /// In en, this message translates to: - /// **'Please select a .sdimg.gz, .sdimg, .wic.gz, .wic, or .img file'** - String get selectFirmwareFileError; - - /// No description provided for @errorOpeningFilePicker. - /// - /// In en, this message translates to: - /// **'Error opening file picker: {error}'** - String errorOpeningFilePicker(String error); - - /// No description provided for @configuringBootloader. - /// - /// In en, this message translates to: - /// **'Configuring bootloader for mass storage mode...'** - String get configuringBootloader; - - /// No description provided for @rebootingDevice. - /// - /// In en, this message translates to: - /// **'Rebooting device...'** - String get rebootingDevice; - - /// No description provided for @waitingForMassStorage. - /// - /// In en, this message translates to: - /// **'Waiting for device to reboot in mass storage mode...'** - String get waitingForMassStorage; - - /// No description provided for @deviceReadyForFlashing. - /// - /// In en, this message translates to: - /// **'Device ready for flashing'** - String get deviceReadyForFlashing; - - /// No description provided for @selectFirmwareDialogTitle. - /// - /// In en, this message translates to: - /// **'Select Firmware Image'** - String get selectFirmwareDialogTitle; - - /// No description provided for @connectedTo. - /// - /// In en, this message translates to: - /// **'Connected to: {host}\nFirmware: {firmware}\nSerial: {serial}'** - String connectedTo(String host, String firmware, String serial); - - /// No description provided for @connectedToFirmware. - /// - /// In en, this message translates to: - /// **'Connected to {version}'** - String connectedToFirmware(String version); - /// No description provided for @unknown. /// /// In en, this message translates to: /// **'Unknown'** String get unknown; - /// No description provided for @modeLabel. - /// - /// In en, this message translates to: - /// **'Mode: {mode}'** - String modeLabel(String mode); - /// No description provided for @backingUpConfig. /// /// In en, this message translates to: @@ -2018,12 +1436,6 @@ abstract class AppLocalizations { /// **'Device configuration backed up'** String get configBackedUp; - /// No description provided for @noConfigFound. - /// - /// In en, this message translates to: - /// **'No device configuration found to back up'** - String get noConfigFound; - /// No description provided for @restoringConfig. /// /// In en, this message translates to: @@ -2036,18 +1448,6 @@ abstract class AppLocalizations { /// **'Health check failed: {error}'** String healthCheckFailed(String error); - /// No description provided for @flashError. - /// - /// In en, this message translates to: - /// **'Flash error: {error}'** - String flashError(String error); - - /// No description provided for @flashComplete. - /// - /// In en, this message translates to: - /// **'Flash complete!'** - String get flashComplete; - /// No description provided for @errorPrefix. /// /// In en, this message translates to: @@ -2066,12 +1466,6 @@ abstract class AppLocalizations { /// **'Skip offline maps'** String get skipOfflineMaps; - /// No description provided for @skipOfflineMapsHint. - /// - /// In en, this message translates to: - /// **'You can install maps later by re-running the installer'** - String get skipOfflineMapsHint; - /// No description provided for @bluetoothPairingHeading. /// /// In en, this message translates to: @@ -2330,12 +1724,6 @@ abstract class AppLocalizations { /// **'[DRY RUN] Simulate already-authorized rejection'** String get keycardSimulateRejectedTapButton; - /// No description provided for @willAskForElevation. - /// - /// In en, this message translates to: - /// **'Start Installation (will ask for elevation)'** - String get willAskForElevation; - /// No description provided for @installationContinuesInNewWindow. /// /// In en, this message translates to: @@ -2528,54 +1916,6 @@ abstract class AppLocalizations { /// **'Waiting for USB disconnect...'** String get waitingForUsbDisconnect; - /// No description provided for @dbcWillCyclePower. - /// - /// In en, this message translates to: - /// **'The DBC will turn on and off multiple times during this process. Do not disconnect the USB cable between MDB and DBC.'** - String get dbcWillCyclePower; - - /// No description provided for @ledBootAmber. - /// - /// In en, this message translates to: - /// **'Dashboard LED amber'** - String get ledBootAmber; - - /// No description provided for @ledBootAmberMeaning. - /// - /// In en, this message translates to: - /// **'Flashing in progress'** - String get ledBootAmberMeaning; - - /// No description provided for @ledBootRedError. - /// - /// In en, this message translates to: - /// **'Dashboard LED blinking red'** - String get ledBootRedError; - - /// No description provided for @ledBootRedMeaning. - /// - /// In en, this message translates to: - /// **'Error. Reconnect laptop to check log; the indicators stop once you reconnect'** - String get ledBootRedMeaning; - - /// No description provided for @flashingTakesAbout10Min. - /// - /// In en, this message translates to: - /// **'Once the dashboard LED is blinking (green or red), and only then, reconnect the laptop USB cable.'** - String get flashingTakesAbout10Min; - - /// No description provided for @dbcFlashDurationHeadline. - /// - /// In en, this message translates to: - /// **'The DBC flash can take 10–20 minutes.'** - String get dbcFlashDurationHeadline; - - /// No description provided for @dbcFlashDurationDetail. - /// - /// In en, this message translates to: - /// **'The DBC will turn on and off several times during this process — that is normal. Do not unplug anything until the dashboard LED is blinking green or red.'** - String get dbcFlashDurationDetail; - /// No description provided for @finishRebootingTitle. /// /// In en, this message translates to: @@ -2594,30 +1934,6 @@ abstract class AppLocalizations { /// **'macOS is asking for permission to change network settings. Click Allow in the system dialog, then hit Retry.'** String get networkConfigNeedsPermission; - /// No description provided for @waitingForMdbToReconnect. - /// - /// In en, this message translates to: - /// **'Waiting for MDB to reconnect...'** - String get waitingForMdbToReconnect; - - /// No description provided for @ledIsGreen. - /// - /// In en, this message translates to: - /// **'LED blinking green'** - String get ledIsGreen; - - /// No description provided for @ledIsRed. - /// - /// In en, this message translates to: - /// **'LED blinking red'** - String get ledIsRed; - - /// No description provided for @ledAmberWaitNotice. - /// - /// In en, this message translates to: - /// **'Most important: do NOT disconnect USB or power while this is running. While the dashboard LED is amber/orange, flashing is still in progress. Hands off, don\'t click anything. The LED will start blinking once it\'s done: green = success, red = error. Only continue once it\'s blinking.'** - String get ledAmberWaitNotice; - /// No description provided for @dbcWalkAwayHeadline. /// /// In en, this message translates to: @@ -2726,12 +2042,6 @@ abstract class AppLocalizations { /// **'MDB disconnected. Flashing DBC autonomously...'** String get mdbDisconnectedFlashingDbc; - /// No description provided for @mdbReconnectedVerifying. - /// - /// In en, this message translates to: - /// **'MDB reconnected! Verifying...'** - String get mdbReconnectedVerifying; - /// No description provided for @logDebugShell. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index b84c6cd..cc2fdb4 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -8,13 +8,6 @@ import 'app_localizations.dart'; class AppLocalizationsDe extends AppLocalizations { AppLocalizationsDe([String locale = 'de']) : super(locale); - @override - String get appTitle => 'Librescoot Installer'; - - @override - String get elevationWarning => - 'Ohne Administratorrechte gestartet. Einige Funktionen sind ggf. Eingeschränkt.'; - @override String get phaseWelcomeTitle => 'Willkommen'; @@ -220,9 +213,6 @@ class AppLocalizationsDe extends AppLocalizations { String get requestingAdminPrivileges => 'Administratorrechte werden angefragt...'; - @override - String get quitButton => 'Beenden'; - @override String get firmwareChannel => 'Firmware-Kanal'; @@ -280,10 +270,6 @@ class AppLocalizationsDe extends AppLocalizations { String get removeFootwellCoverDesc => 'Vier Schrauben lösen. Ab Werk PH2 Kreuzschrauben, bei guten Werkstätten H4 Innensechskant oder Torx.'; - @override - String get removeFootwellCoverImage => - '[Foto: Fußraumabdeckung mit markierten Schraubenpositionen]'; - @override String get unscrewUsbCable => 'USB-Kabel vom MDB lösen'; @@ -291,10 +277,6 @@ class AppLocalizationsDe extends AppLocalizations { String get unscrewUsbCableDesc => 'Trenne das interne DBC-USB-Kabel vom MDB-Board. Verwende einen Schlitz- oder PH1-Schraubendreher.'; - @override - String get unscrewUsbCableImage => - '[Foto: USB-Mini-B-Anschluss am MDB, Nahaufnahme]'; - @override String get connectLaptopUsb => 'Laptop-USB-Kabel anschließen'; @@ -318,9 +300,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get checkingRndisDriver => 'RNDIS-Treiber wird geprüft...'; - @override - String get installingRndisDriver => 'RNDIS-Treiber wird installiert...'; - @override String get configuringNetwork => 'Netzwerk wird konfiguriert...'; @@ -347,10 +326,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get resumeFoundLastError => 'Letzter aufgezeichneter Fehler:'; - @override - String get unlockTimeout => - 'Zeitlimit beim Warten auf Entsperrung. Roller entsperren und erneut versuchen.'; - @override String get awaitingUnlockHeading => 'Roller entsperren'; @@ -529,9 +504,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get waitingForMdbFirmware => 'Warte auf MDB-Firmware-Download...'; - @override - String get noDevicePath => 'Fehler: kein Gerätepfad verfügbar'; - @override String get mdbFlashComplete => 'MDB-Flash abgeschlossen!'; @@ -576,10 +548,6 @@ class AppLocalizationsDe extends AppLocalizations { String get disconnectAuxPoleDesc => 'Entferne NUR den Pluspol (außen, rotes Kabel und Pol), um eine Verpolung zu vermeiden. Dadurch wird das MDB stromlos; die USB-Verbindung geht verloren.'; - @override - String get disconnectAuxPoleImage => - '[Foto: AUX-Batteriepole, Pluspol (rot/außen) markiert]'; - @override String get auxDisconnectWarning => 'Die USB-Verbindung geht verloren, wenn du AUX trennst. Das ist normal. Der Installer wartet auf den Neustart des MDB.'; @@ -632,14 +600,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get reconnectCbbHeading => 'CBB & Batterie wieder anschließen'; - @override - String get reconnectCbb => - 'Hauptbatterie einsetzen und CBB wieder anschließen'; - - @override - String get reconnectCbbDesc => - 'Setze die Hauptbatterie wieder in die Sitzbank ein und stecke das CBB-Kabel wieder ein. Der Roller braucht volle Leistung für den DBC-Flash.'; - @override String get verifyCbbConnection => 'CBB-Verbindung prüfen'; @@ -649,9 +609,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get checkingCbb => 'CBB wird geprüft...'; - @override - String get cbbConnected => 'CBB verbunden!'; - @override String waitingForCbb(int attempts) { return 'Warte auf CBB... ($attempts)'; @@ -705,80 +662,6 @@ class AppLocalizationsDe extends AppLocalizations { String get reconnectDbcUsbToMdbDesc => 'Stecke das interne DBC-USB-Kabel in den MDB-Port. Noch nicht festschrauben.'; - @override - String get mdbFlashingDbcAutonomously => - 'Das MDB flasht jetzt selbstständig das DBC.'; - - @override - String get watchLightsForProgress => - 'Beobachte die Rollerbeleuchtung für den Fortschritt:'; - - @override - String get ledFrontRingPulse => 'Frontring atmet'; - - @override - String get ledFrontRingPulseMeaning => - 'DBC wird vorbereitet (Bootloader, Verbindung)'; - - @override - String get ledFrontRingSolid => 'Frontring leuchtet kurz'; - - @override - String get ledFrontRingSolidMeaning => 'Flash abgeschlossen. Erfolg!'; - - @override - String get disconnectCbbImage => '[Foto: CBB-Stecker im Fußraum]'; - - @override - String get ledBlinkerProgress => 'Blinker leuchten reihum auf'; - - @override - String get ledBlinkerProgressMeaning => - 'Gesamt-Fortschritt: Vorbereitung → Flash → Neustart → Karten'; - - @override - String get blinkerPosFL => 'vorne links'; - - @override - String get blinkerPosFR => 'vorne rechts'; - - @override - String get blinkerPosBR => 'hinten rechts'; - - @override - String get blinkerPosBL => 'hinten links'; - - @override - String get blinkerStepPrep => 'DBC vorbereiten'; - - @override - String get blinkerStepFlash => 'DBC flashen'; - - @override - String get blinkerStepRestart => 'MDB & DBC neu starten'; - - @override - String get blinkerStepMaps => 'Karten laden'; - - @override - String get ledBootGreen => 'Tacho-LED blinkt grün'; - - @override - String get ledBootGreenMeaning => 'Erfolgreich. Laptop wieder verbinden'; - - @override - String get ledRearLightSolid => 'Alle vier Blinker (Warnblinker) blinken'; - - @override - String get ledRearLightSolidMeaning => - 'Fehler. Laptop verbinden für Log; Blinker und LED gehen aus, sobald du verbunden bist'; - - @override - String get bootLedGreenReconnect => 'LED blinkt grün'; - - @override - String get rearLightCheckError => 'LED blinkt rot, Warnblinker an'; - @override String get verifyingDbcInstallation => 'DBC-Installation wird geprüft'; @@ -849,11 +732,6 @@ class AppLocalizationsDe extends AppLocalizations { String get unlockScooterDesc => 'Nutze eine der angelernten Schlüsselkarten oder entsperre über Bluetooth.'; - @override - String deleteCachedDownloads(String sizeMb) { - return 'Heruntergeladene Dateien löschen ($sizeMb MB)'; - } - @override String deletedCache(String sizeMb) { return '$sizeMb MB gelöscht'; @@ -868,106 +746,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get downloadsFinishedHint => 'Du kannst jetzt offline weitermachen.'; - @override - String get downloadMdbFirmware => 'MDB-Firmware'; - - @override - String get downloadDbcFirmware => 'DBC-Firmware'; - - @override - String get downloadMapTiles => 'Kartenkacheln'; - - @override - String get downloadRoutingTiles => 'Routing-Kacheln'; - - @override - String get homeAppTitle => 'Librescoot Installer'; - - @override - String get notElevated => 'Keine Adminrechte'; - - @override - String get selectFirmwareStep => 'Firmware wählen'; - - @override - String get connectDeviceStep => 'Gerät verbinden'; - - @override - String get configureNetworkStep => 'Netzwerk'; - - @override - String get prepareDeviceStep => 'Vorbereiten'; - - @override - String get flashFirmwareStep => 'Flashen'; - - @override - String get completeStep => 'Fertig'; - - @override - String get selectFirmwareImage => 'Firmware-Image auswählen'; - - @override - String get selectFirmwareHint => - 'Wähle eine .sdimg.gz-, .sdimg-, .wic.gz-, .wic- oder .img-Firmware-Datei'; - - @override - String get selectFile => 'Datei auswählen'; - - @override - String get changeFile => 'Andere Datei'; - - @override - String get deviceConnected => 'Gerät verbunden'; - - @override - String get connectYourDevice => 'Gerät verbinden'; - - @override - String get connectMdbViaUsb => - 'Verbinde das MDB per USB und warte auf die Erkennung'; - - @override - String get configuringNetworkHeading => 'Netzwerk wird konfiguriert'; - - @override - String get settingUpNetwork => 'Netzwerkschnittstelle wird eingerichtet...'; - - @override - String get readyToConfigureNetwork => - 'Bereit, das Netzwerk für die Gerätekommunikation zu konfigurieren'; - - @override - String get configureNetworkButton => 'Netzwerk konfigurieren'; - - @override - String get preparingDevice => 'Gerät wird vorbereitet'; - - @override - String get readyToPrepare => 'Bereit zur Vorbereitung'; - - @override - String get prepareForFlashing => 'Für Flashen vorbereiten'; - - @override - String get flashingFirmware => 'Firmware wird geflasht'; - - @override - String get startFlashing => 'Flashen starten'; - - @override - String get installationComplete => 'Installation abgeschlossen!'; - - @override - String get installationCompleteDesc => - 'Dein Gerät wurde erfolgreich geflasht.\nEs startet automatisch neu.'; - - @override - String get flashAnotherDevice => 'Weiteres Gerät flashen'; - - @override - String get flashDryRun => 'Flash-Probelauf'; - @override String get safetyCheckFailed => 'Sicherheitsprüfung fehlgeschlagen'; @@ -975,125 +753,18 @@ class AppLocalizationsDe extends AppLocalizations { String get cannotFlashSafety => 'Dieses Gerät kann aus Sicherheitsgründen nicht geflasht werden:'; - @override - String get okButton => 'OK'; - - @override - String get confirmFlashOperation => 'Flash-Vorgang bestätigen'; - - @override - String get aboutToWriteFirmware => 'Du schreibst gleich Firmware auf:'; - - @override - String get deviceLabel => 'Gerät'; - - @override - String get pathLabel => 'Pfad'; - - @override - String get sizeLabel => 'Größe'; - - @override - String get firmwareLabel => 'Firmware:'; - - @override - String get warningsLabel => 'Warnungen:'; - - @override - String get eraseWarning => - 'Dadurch werden ALLE DATEN auf dem Gerät GELÖSCHT. Diese Aktion kann nicht rückgängig gemacht werden.'; - @override String get cancelButton => 'Abbrechen'; - @override - String get flashDeviceButton => 'Gerät flashen'; - - @override - String get installingUsbDriver => 'USB-Treiber wird installiert...'; - - @override - String get usbDriverInstalled => 'USB-Treiber erfolgreich installiert'; - - @override - String driverInstallFailed(String error) { - return 'Treiber-Installation fehlgeschlagen: $error'; - } - - @override - String get autoLoadedFirmware => - 'Firmware automatisch aus dem aktuellen Verzeichnis geladen'; - - @override - String get deviceDisconnected => - 'Gerät getrennt. Neu verbinden oder auf Mass-Storage-Modus warten.'; - - @override - String get waitingForMdbNetwork => 'Warte auf MDB-Netzwerkstabilisierung...'; - - @override - String get findingNetworkInterface => 'Netzwerkschnittstelle wird gesucht...'; - - @override - String get couldNotFindInterface => - 'USB-Netzwerkschnittstelle nicht gefunden'; - - @override - String get networkConfigured => 'Netzwerk erfolgreich konfiguriert'; - - @override - String get selectFirmwareFileError => - 'Bitte wähle eine .sdimg.gz-, .sdimg-, .wic.gz-, .wic- oder .img-Datei'; - - @override - String errorOpeningFilePicker(String error) { - return 'Fehler beim Öffnen der Dateiauswahl: $error'; - } - - @override - String get configuringBootloader => - 'Bootloader wird für Mass-Storage-Modus konfiguriert...'; - - @override - String get rebootingDevice => 'Gerät wird neu gestartet...'; - - @override - String get waitingForMassStorage => - 'Warte auf Neustart im Mass-Storage-Modus...'; - - @override - String get deviceReadyForFlashing => 'Gerät bereit zum Flashen'; - - @override - String get selectFirmwareDialogTitle => 'Firmware-Image auswählen'; - - @override - String connectedTo(String host, String firmware, String serial) { - return 'Verbunden mit: $host\nFirmware: $firmware\nSeriennummer: $serial'; - } - - @override - String connectedToFirmware(String version) { - return 'Verbunden mit $version'; - } - @override String get unknown => 'Unbekannt'; - @override - String modeLabel(String mode) { - return 'Modus: $mode'; - } - @override String get backingUpConfig => 'Gerätekonfiguration wird gesichert...'; @override String get configBackedUp => 'Gerätekonfiguration gesichert'; - @override - String get noConfigFound => 'Keine Gerätekonfiguration zum Sichern gefunden'; - @override String get restoringConfig => 'Gerätekonfiguration wird wiederhergestellt...'; @@ -1102,14 +773,6 @@ class AppLocalizationsDe extends AppLocalizations { return 'Statusprüfung fehlgeschlagen: $error'; } - @override - String flashError(String error) { - return 'Flash-Fehler: $error'; - } - - @override - String get flashComplete => 'Flash abgeschlossen!'; - @override String errorPrefix(String error) { return 'Fehler: $error'; @@ -1121,10 +784,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get skipOfflineMaps => 'Offline-Karten überspringen'; - @override - String get skipOfflineMapsHint => - 'Karten können später durch erneutes Ausführen des Installers installiert werden'; - @override String get bluetoothPairingHeading => 'Bluetooth-Kopplung'; @@ -1304,10 +963,6 @@ class AppLocalizationsDe extends AppLocalizations { String get keycardSimulateRejectedTapButton => '[DRY RUN] Bereits-angelernt-Ablehnung simulieren'; - @override - String get willAskForElevation => - 'Installation starten (fragt nach Berechtigung)'; - @override String get installationContinuesInNewWindow => 'Die Installation wird im neuen Fenster fortgesetzt'; @@ -1411,35 +1066,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get waitingForUsbDisconnect => 'Warte auf USB-Trennung...'; - @override - String get dbcWillCyclePower => - 'Das DBC wird während dieses Vorgangs mehrmals ein- und ausgeschaltet. Trenne das USB-Kabel zwischen MDB und DBC nicht.'; - - @override - String get ledBootAmber => 'Tacho-LED gelb-orange'; - - @override - String get ledBootAmberMeaning => 'Flash läuft'; - - @override - String get ledBootRedError => 'Tacho-LED blinkt rot'; - - @override - String get ledBootRedMeaning => - 'Fehler. Laptop verbinden und Log prüfen; Blinker und LED gehen aus, sobald du verbunden bist'; - - @override - String get flashingTakesAbout10Min => - 'Erst wenn die Tacho-LED blinkt (grün oder rot), und auch wirklich erst dann, das Laptop-USB-Kabel wieder anschließen.'; - - @override - String get dbcFlashDurationHeadline => - 'Das DBC-Flashen kann 10–20 Minuten dauern.'; - - @override - String get dbcFlashDurationDetail => - 'Das DBC wird dabei mehrmals ein- und ausgeschaltet — das ist normal. Nichts abziehen, bis die Tacho-LED grün oder rot blinkt.'; - @override String get finishRebootingTitle => 'Roller startet neu…'; @@ -1451,19 +1077,6 @@ class AppLocalizationsDe extends AppLocalizations { String get networkConfigNeedsPermission => 'macOS fragt nach Erlaubnis, die Netzwerk-Einstellungen zu ändern. Im Systemdialog auf \'Erlauben\' klicken, dann \'Erneut versuchen\' drücken.'; - @override - String get waitingForMdbToReconnect => 'Warte auf MDB-Wiederverbindung...'; - - @override - String get ledIsGreen => 'LED blinkt grün'; - - @override - String get ledIsRed => 'LED blinkt rot'; - - @override - String get ledAmberWaitNotice => - 'Wichtig: USB und Strom NICHT trennen, solange das hier läuft. Solange die Tacho-LED gelb-orange leuchtet, läuft der Flash noch. Finger weg, nichts anklicken. Wenn der Flash durch ist, fängt die LED an zu blinken: grün = Erfolg, rot = Fehler. Erst weitermachen, wenn sie blinkt.'; - @override String get dbcWalkAwayHeadline => 'Umstecken erledigt. Du kannst den Laptop jetzt abziehen.'; @@ -1528,10 +1141,6 @@ class AppLocalizationsDe extends AppLocalizations { String get mdbDisconnectedFlashingDbc => 'MDB getrennt. DBC wird autonom geflasht...'; - @override - String get mdbReconnectedVerifying => - 'MDB wieder verbunden! Überprüfung läuft...'; - @override String get logDebugShell => 'Log & Debug-Shell'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6c1be34..367df4a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -8,13 +8,6 @@ import 'app_localizations.dart'; class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); - @override - String get appTitle => 'Librescoot Installer'; - - @override - String get elevationWarning => - 'Running without administrator privileges. Some operations may fail.'; - @override String get phaseWelcomeTitle => 'Welcome'; @@ -216,9 +209,6 @@ class AppLocalizationsEn extends AppLocalizations { String get requestingAdminPrivileges => 'Requesting administrator privileges...'; - @override - String get quitButton => 'Quit'; - @override String get firmwareChannel => 'Firmware Channel'; @@ -275,10 +265,6 @@ class AppLocalizationsEn extends AppLocalizations { String get removeFootwellCoverDesc => 'Four screws to remove. PH2 Phillips from factory, H4 hex or Torx if serviced by a good shop.'; - @override - String get removeFootwellCoverImage => - '[Photo: footwell cover with screw locations highlighted]'; - @override String get unscrewUsbCable => 'Unscrew USB cable from MDB'; @@ -286,10 +272,6 @@ class AppLocalizationsEn extends AppLocalizations { String get unscrewUsbCableDesc => 'Disconnect the internal DBC USB cable from the MDB board. Use a flat head or PH1 screwdriver.'; - @override - String get unscrewUsbCableImage => - '[Photo: USB Mini-B connector on MDB, close-up]'; - @override String get connectLaptopUsb => 'Connect laptop USB cable'; @@ -313,9 +295,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get checkingRndisDriver => 'Checking RNDIS driver...'; - @override - String get installingRndisDriver => 'Installing RNDIS driver...'; - @override String get configuringNetwork => 'Configuring network...'; @@ -342,10 +321,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get resumeFoundLastError => 'Last recorded error:'; - @override - String get unlockTimeout => - 'Timed out waiting for scooter to be unlocked. Unlock and retry.'; - @override String get awaitingUnlockHeading => 'Unlock your scooter'; @@ -523,9 +498,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get waitingForMdbFirmware => 'Waiting for MDB firmware download...'; - @override - String get noDevicePath => 'Error: no device path available'; - @override String get mdbFlashComplete => 'MDB flash complete!'; @@ -570,10 +542,6 @@ class AppLocalizationsEn extends AppLocalizations { String get disconnectAuxPoleDesc => 'Remove ONLY the positive pole (outermost, the red cable and pole) to avoid risk of inverting polarity. This will remove power from the MDB; the USB connection will disappear.'; - @override - String get disconnectAuxPoleImage => - '[Photo: AUX battery poles, positive (red/outermost) highlighted]'; - @override String get auxDisconnectWarning => 'The USB connection will be lost when you disconnect AUX. This is expected. The installer will wait for the MDB to reboot.'; @@ -626,13 +594,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get reconnectCbbHeading => 'Reconnect CBB & Battery'; - @override - String get reconnectCbb => 'Reinstall the main battery and reconnect the CBB'; - - @override - String get reconnectCbbDesc => - 'Put the main battery back in the seatbox and plug the CBB cable back in. The scooter needs full power for the DBC flash.'; - @override String get verifyCbbConnection => 'Verify CBB Connection'; @@ -642,9 +603,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get checkingCbb => 'Checking CBB...'; - @override - String get cbbConnected => 'CBB connected!'; - @override String waitingForCbb(int attempts) { return 'Waiting for CBB... ($attempts)'; @@ -699,80 +657,6 @@ class AppLocalizationsEn extends AppLocalizations { String get reconnectDbcUsbToMdbDesc => 'Plug the internal DBC USB cable into the MDB port. Don\'t screw it in yet.'; - @override - String get mdbFlashingDbcAutonomously => - 'The MDB is now flashing the DBC autonomously.'; - - @override - String get watchLightsForProgress => 'Watch the scooter lights for progress:'; - - @override - String get ledFrontRingPulse => 'Front ring breathing'; - - @override - String get ledFrontRingPulseMeaning => - 'Preparing DBC (configuring bootloader, waiting for connection)'; - - @override - String get ledFrontRingSolid => 'Front ring glows briefly'; - - @override - String get ledFrontRingSolidMeaning => 'Flash complete. Success!'; - - @override - String get disconnectCbbImage => - '[Photo: CBB connector location in footwell]'; - - @override - String get ledBlinkerProgress => 'Blinkers light up in turn'; - - @override - String get ledBlinkerProgressMeaning => - 'Overall progress: Prep → Flash → Reboot → Maps'; - - @override - String get blinkerPosFL => 'front left'; - - @override - String get blinkerPosFR => 'front right'; - - @override - String get blinkerPosBR => 'rear right'; - - @override - String get blinkerPosBL => 'rear left'; - - @override - String get blinkerStepPrep => 'Prepare DBC'; - - @override - String get blinkerStepFlash => 'Flash DBC'; - - @override - String get blinkerStepRestart => 'Restart MDB & DBC'; - - @override - String get blinkerStepMaps => 'Upload maps'; - - @override - String get ledBootGreen => 'Dashboard LED blinking green'; - - @override - String get ledBootGreenMeaning => 'Success. Reconnect laptop'; - - @override - String get ledRearLightSolid => 'All four blinkers (hazards) flashing'; - - @override - String get ledRearLightSolidMeaning => - 'Error. Reconnect laptop to see log; the indicators stop once you reconnect'; - - @override - String get bootLedGreenReconnect => 'LED blinking green'; - - @override - String get rearLightCheckError => 'LED blinking red, hazards flashing'; - @override String get verifyingDbcInstallation => 'Verifying DBC Installation'; @@ -843,11 +727,6 @@ class AppLocalizationsEn extends AppLocalizations { String get unlockScooterDesc => 'Use one of the keycards you registered, or unlock via Bluetooth.'; - @override - String deleteCachedDownloads(String sizeMb) { - return 'Delete cached downloads ($sizeMb MB)'; - } - @override String deletedCache(String sizeMb) { return 'Deleted $sizeMb MB'; @@ -862,106 +741,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get downloadsFinishedHint => 'You can continue offline.'; - @override - String get downloadMdbFirmware => 'MDB Firmware'; - - @override - String get downloadDbcFirmware => 'DBC Firmware'; - - @override - String get downloadMapTiles => 'Map Tiles'; - - @override - String get downloadRoutingTiles => 'Routing Tiles'; - - @override - String get homeAppTitle => 'Librescoot Installer'; - - @override - String get notElevated => 'Not elevated'; - - @override - String get selectFirmwareStep => 'Select Firmware'; - - @override - String get connectDeviceStep => 'Connect Device'; - - @override - String get configureNetworkStep => 'Configure Network'; - - @override - String get prepareDeviceStep => 'Prepare Device'; - - @override - String get flashFirmwareStep => 'Flash Firmware'; - - @override - String get completeStep => 'Complete'; - - @override - String get selectFirmwareImage => 'Select Firmware Image'; - - @override - String get selectFirmwareHint => - 'Choose a .sdimg.gz, .sdimg, .wic.gz, .wic, or .img firmware file to flash'; - - @override - String get selectFile => 'Select File'; - - @override - String get changeFile => 'Change File'; - - @override - String get deviceConnected => 'Device Connected'; - - @override - String get connectYourDevice => 'Connect Your Device'; - - @override - String get connectMdbViaUsb => - 'Connect the MDB via USB and wait for detection'; - - @override - String get configuringNetworkHeading => 'Configuring Network'; - - @override - String get settingUpNetwork => 'Setting up network interface...'; - - @override - String get readyToConfigureNetwork => - 'Ready to configure network for device communication'; - - @override - String get configureNetworkButton => 'Configure Network'; - - @override - String get preparingDevice => 'Preparing Device'; - - @override - String get readyToPrepare => 'Ready to Prepare'; - - @override - String get prepareForFlashing => 'Prepare for Flashing'; - - @override - String get flashingFirmware => 'Flashing Firmware'; - - @override - String get startFlashing => 'Start Flashing'; - - @override - String get installationComplete => 'Installation Complete!'; - - @override - String get installationCompleteDesc => - 'Your device has been successfully flashed.\nIt will reboot automatically.'; - - @override - String get flashAnotherDevice => 'Flash Another Device'; - - @override - String get flashDryRun => 'Flash Dry Run'; - @override String get safetyCheckFailed => 'Safety Check Failed'; @@ -969,124 +748,18 @@ class AppLocalizationsEn extends AppLocalizations { String get cannotFlashSafety => 'Cannot flash this device due to safety concerns:'; - @override - String get okButton => 'OK'; - - @override - String get confirmFlashOperation => 'Confirm Flash Operation'; - - @override - String get aboutToWriteFirmware => 'You are about to write firmware to:'; - - @override - String get deviceLabel => 'Device'; - - @override - String get pathLabel => 'Path'; - - @override - String get sizeLabel => 'Size'; - - @override - String get firmwareLabel => 'Firmware:'; - - @override - String get warningsLabel => 'Warnings:'; - - @override - String get eraseWarning => - 'This will ERASE ALL DATA on the device. This action cannot be undone.'; - @override String get cancelButton => 'Cancel'; - @override - String get flashDeviceButton => 'Flash Device'; - - @override - String get installingUsbDriver => 'Installing USB driver...'; - - @override - String get usbDriverInstalled => 'USB driver installed successfully'; - - @override - String driverInstallFailed(String error) { - return 'Driver install failed: $error'; - } - - @override - String get autoLoadedFirmware => - 'Auto-loaded firmware from current directory'; - - @override - String get deviceDisconnected => - 'Device disconnected. Reconnect/wait for mass storage mode.'; - - @override - String get waitingForMdbNetwork => 'Waiting for MDB network to settle...'; - - @override - String get findingNetworkInterface => 'Finding network interface...'; - - @override - String get couldNotFindInterface => 'Could not find USB network interface'; - - @override - String get networkConfigured => 'Network configured successfully'; - - @override - String get selectFirmwareFileError => - 'Please select a .sdimg.gz, .sdimg, .wic.gz, .wic, or .img file'; - - @override - String errorOpeningFilePicker(String error) { - return 'Error opening file picker: $error'; - } - - @override - String get configuringBootloader => - 'Configuring bootloader for mass storage mode...'; - - @override - String get rebootingDevice => 'Rebooting device...'; - - @override - String get waitingForMassStorage => - 'Waiting for device to reboot in mass storage mode...'; - - @override - String get deviceReadyForFlashing => 'Device ready for flashing'; - - @override - String get selectFirmwareDialogTitle => 'Select Firmware Image'; - - @override - String connectedTo(String host, String firmware, String serial) { - return 'Connected to: $host\nFirmware: $firmware\nSerial: $serial'; - } - - @override - String connectedToFirmware(String version) { - return 'Connected to $version'; - } - @override String get unknown => 'Unknown'; - @override - String modeLabel(String mode) { - return 'Mode: $mode'; - } - @override String get backingUpConfig => 'Backing up device configuration...'; @override String get configBackedUp => 'Device configuration backed up'; - @override - String get noConfigFound => 'No device configuration found to back up'; - @override String get restoringConfig => 'Restoring device configuration...'; @@ -1095,14 +768,6 @@ class AppLocalizationsEn extends AppLocalizations { return 'Health check failed: $error'; } - @override - String flashError(String error) { - return 'Flash error: $error'; - } - - @override - String get flashComplete => 'Flash complete!'; - @override String errorPrefix(String error) { return 'Error: $error'; @@ -1114,10 +779,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get skipOfflineMaps => 'Skip offline maps'; - @override - String get skipOfflineMapsHint => - 'You can install maps later by re-running the installer'; - @override String get bluetoothPairingHeading => 'Bluetooth Pairing'; @@ -1294,10 +955,6 @@ class AppLocalizationsEn extends AppLocalizations { String get keycardSimulateRejectedTapButton => '[DRY RUN] Simulate already-authorized rejection'; - @override - String get willAskForElevation => - 'Start Installation (will ask for elevation)'; - @override String get installationContinuesInNewWindow => 'Installation continues in the new window'; @@ -1400,35 +1057,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get waitingForUsbDisconnect => 'Waiting for USB disconnect...'; - @override - String get dbcWillCyclePower => - 'The DBC will turn on and off multiple times during this process. Do not disconnect the USB cable between MDB and DBC.'; - - @override - String get ledBootAmber => 'Dashboard LED amber'; - - @override - String get ledBootAmberMeaning => 'Flashing in progress'; - - @override - String get ledBootRedError => 'Dashboard LED blinking red'; - - @override - String get ledBootRedMeaning => - 'Error. Reconnect laptop to check log; the indicators stop once you reconnect'; - - @override - String get flashingTakesAbout10Min => - 'Once the dashboard LED is blinking (green or red), and only then, reconnect the laptop USB cable.'; - - @override - String get dbcFlashDurationHeadline => - 'The DBC flash can take 10–20 minutes.'; - - @override - String get dbcFlashDurationDetail => - 'The DBC will turn on and off several times during this process — that is normal. Do not unplug anything until the dashboard LED is blinking green or red.'; - @override String get finishRebootingTitle => 'Rebooting scooter…'; @@ -1440,19 +1068,6 @@ class AppLocalizationsEn extends AppLocalizations { String get networkConfigNeedsPermission => 'macOS is asking for permission to change network settings. Click Allow in the system dialog, then hit Retry.'; - @override - String get waitingForMdbToReconnect => 'Waiting for MDB to reconnect...'; - - @override - String get ledIsGreen => 'LED blinking green'; - - @override - String get ledIsRed => 'LED blinking red'; - - @override - String get ledAmberWaitNotice => - 'Most important: do NOT disconnect USB or power while this is running. While the dashboard LED is amber/orange, flashing is still in progress. Hands off, don\'t click anything. The LED will start blinking once it\'s done: green = success, red = error. Only continue once it\'s blinking.'; - @override String get dbcWalkAwayHeadline => 'Swap done. You can unplug the laptop now.'; @@ -1514,9 +1129,6 @@ class AppLocalizationsEn extends AppLocalizations { String get mdbDisconnectedFlashingDbc => 'MDB disconnected. Flashing DBC autonomously...'; - @override - String get mdbReconnectedVerifying => 'MDB reconnected! Verifying...'; - @override String get logDebugShell => 'Log & Debug Shell'; From 1a9b343ac27fcc7a285891b20351031d21447ee5 Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Mon, 13 Jul 2026 22:17:24 +0200 Subject: [PATCH 28/43] fix(installer): hide the merged bluetooth/keycard phases from the sidebar They were folded into dashboardPrep and are never entered on their own, so mark them hiddenUnlessActive (they belonged to no MajorStep and left the sidebar invariant broken). Update the phase-model tests to the merged design and drop the l10n keys orphaned by removing the dead reconnect helper. --- lib/l10n/app_de.arb | 3 --- lib/l10n/app_en.arb | 3 --- lib/l10n/app_localizations.dart | 18 ------------------ lib/l10n/app_localizations_de.dart | 9 --------- lib/l10n/app_localizations_en.dart | 9 --------- lib/models/installer_phase.dart | 4 ++++ test/models/installer_phase_test.dart | 11 +++++++---- 7 files changed, 11 insertions(+), 46 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index dbce389..8db9858 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -347,9 +347,6 @@ "phaseKeycardSetupDescription": "Schlüsselkarten anlernen", "usingLocalFirmwareImages": "Lokale Firmware-Images werden verwendet", "mdbDetectedUmsSkipping": "MDB im UMS-Modus erkannt. Direkt zum Flashen.", - "waitingForMdbToReboot": "Warte auf MDB-Neustart...", - "mdbDetectedWaitingForSsh": "MDB erkannt, warte auf SSH...", - "reconnectedToMdb": "MDB wieder verbunden", "verifyingBootloaderConfig": "Bootloader-Konfiguration wird überprüft...", "umsNotDetectedTimeout": "UMS-Gerät nicht innerhalb von 60 s erkannt. MDB ist möglicherweise wieder in Linux gebootet.", "waitingForDevicePath": "Warte auf Gerätepfad...", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5dc250e..bbe080a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -440,9 +440,6 @@ "usingLocalFirmwareImages": "Using local firmware images", "mdbDetectedUmsSkipping": "MDB detected in UMS mode. Skipping to flash.", - "waitingForMdbToReboot": "Waiting for MDB to reboot...", - "mdbDetectedWaitingForSsh": "MDB detected, waiting for SSH...", - "reconnectedToMdb": "Reconnected to MDB", "verifyingBootloaderConfig": "Verifying bootloader config...", "umsNotDetectedTimeout": "UMS device not detected within 60s. MDB may have booted back into Linux.", "waitingForDevicePath": "Waiting for device path...", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 8f51148..d275f62 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1994,24 +1994,6 @@ abstract class AppLocalizations { /// **'MDB detected in UMS mode. Skipping to flash.'** String get mdbDetectedUmsSkipping; - /// No description provided for @waitingForMdbToReboot. - /// - /// In en, this message translates to: - /// **'Waiting for MDB to reboot...'** - String get waitingForMdbToReboot; - - /// No description provided for @mdbDetectedWaitingForSsh. - /// - /// In en, this message translates to: - /// **'MDB detected, waiting for SSH...'** - String get mdbDetectedWaitingForSsh; - - /// No description provided for @reconnectedToMdb. - /// - /// In en, this message translates to: - /// **'Reconnected to MDB'** - String get reconnectedToMdb; - /// No description provided for @verifyingBootloaderConfig. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index cc2fdb4..5aa4950 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1113,15 +1113,6 @@ class AppLocalizationsDe extends AppLocalizations { String get mdbDetectedUmsSkipping => 'MDB im UMS-Modus erkannt. Direkt zum Flashen.'; - @override - String get waitingForMdbToReboot => 'Warte auf MDB-Neustart...'; - - @override - String get mdbDetectedWaitingForSsh => 'MDB erkannt, warte auf SSH...'; - - @override - String get reconnectedToMdb => 'MDB wieder verbunden'; - @override String get verifyingBootloaderConfig => 'Bootloader-Konfiguration wird überprüft...'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 367df4a..48662b9 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1102,15 +1102,6 @@ class AppLocalizationsEn extends AppLocalizations { String get mdbDetectedUmsSkipping => 'MDB detected in UMS mode. Skipping to flash.'; - @override - String get waitingForMdbToReboot => 'Waiting for MDB to reboot...'; - - @override - String get mdbDetectedWaitingForSsh => 'MDB detected, waiting for SSH...'; - - @override - String get reconnectedToMdb => 'Reconnected to MDB'; - @override String get verifyingBootloaderConfig => 'Verifying bootloader config...'; diff --git a/lib/models/installer_phase.dart b/lib/models/installer_phase.dart index d5b3ae4..e3ae4c4 100644 --- a/lib/models/installer_phase.dart +++ b/lib/models/installer_phase.dart @@ -65,15 +65,19 @@ enum InstallerPhase { description: 'Pair, enroll keycards, stage DBC image', isManual: false, ), + // Merged into dashboardPrep; kept as enum values for switch exhaustiveness + // and resume compatibility, but never entered as standalone phases. bluetoothPairing( title: 'Bluetooth', description: 'Pair phone or other devices', isManual: true, + hiddenUnlessActive: true, ), keycardSetup( title: 'Keycard Setup', description: 'Register master and user keycards', isManual: true, + hiddenUnlessActive: true, ), dbcSwapAndFlash( title: 'DBC Flash', diff --git a/test/models/installer_phase_test.dart b/test/models/installer_phase_test.dart index 119894e..8fd3eb5 100644 --- a/test/models/installer_phase_test.dart +++ b/test/models/installer_phase_test.dart @@ -6,13 +6,16 @@ void main() { test('dashboardPrep is in the MDB-prep major step', () { expect(MajorStep.forPhase(InstallerPhase.dashboardPrep), MajorStep.mdbPrep); }); - test('bluetooth and keycard are Stage 1, not Finish', () { - expect(MajorStep.forPhase(InstallerPhase.bluetoothPairing), MajorStep.mdbPrep); - expect(MajorStep.forPhase(InstallerPhase.keycardSetup), MajorStep.mdbPrep); + test('bluetooth and keycard are merged into dashboardPrep and hidden', () { + expect(InstallerPhase.bluetoothPairing.hiddenUnlessActive, isTrue); + expect(InstallerPhase.keycardSetup.hiddenUnlessActive, isTrue); }); test('dbcSwapAndFlash is the only happy-path DBC phase', () { expect(MajorStep.dbc.phases, contains(InstallerPhase.dbcSwapAndFlash)); - expect(MajorStep.dbc.phases, isNot(contains(InstallerPhase.reconnect))); + expect(InstallerPhase.dbcSwapAndFlash.hiddenUnlessActive, isFalse); + // reconnect belongs to the DBC step so the sidebar shows it active (not + // completed) during recovery, but it stays hidden on the happy path. + expect(InstallerPhase.reconnect.hiddenUnlessActive, isTrue); }); test('finish major step only holds finish', () { expect(MajorStep.finish.phases, [InstallerPhase.finish]); From 55354f58c61d068b86e14316fff38b2e94587a8d Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Mon, 13 Jul 2026 22:17:29 +0200 Subject: [PATCH 29/43] chore(installer): remove dead code and clear analyzer warnings - drop the orphaned dd-phase flash helpers, the unused _reconnectToMdb and _batteryRemovalStarted, and the unused LaunchArgs import - debugPrint instead of print in network_service; escape angle brackets in two doc comments --- lib/main.dart | 2 +- lib/screens/installer_screen.dart | 45 +---- lib/services/flash_service.dart | 263 ------------------------------ lib/services/network_service.dart | 4 +- lib/services/usb_detector.dart | 2 +- 5 files changed, 6 insertions(+), 310 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 6d862c3..5c399c2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -148,7 +148,7 @@ late final LaunchArgs launchArgs; /// `--lang=xx` overrides the default at startup. final ValueNotifier appLocale = ValueNotifier(const Locale('de')); -/// Installer version. Injected by CI via --dart-define=APP_VERSION=; +/// Installer version. Injected by CI via `--dart-define=APP_VERSION=`; /// falls back to 'dev' for local unflagged builds. const String appVersion = String.fromEnvironment('APP_VERSION', defaultValue: 'dev'); diff --git a/lib/screens/installer_screen.dart b/lib/screens/installer_screen.dart index 49a0140..6d86a45 100644 --- a/lib/screens/installer_screen.dart +++ b/lib/screens/installer_screen.dart @@ -6,7 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:url_launcher/url_launcher.dart'; -import '../main.dart' show LaunchArgs, installerLog, launchArgs, showElevationRequiredDialog; +import '../main.dart' show installerLog, launchArgs, showElevationRequiredDialog; import '../l10n/app_localizations.dart'; import '../models/download_state.dart'; import '../models/install_state.dart'; @@ -76,7 +76,7 @@ class _InstallerScreenState extends State { // Which interactive sub-step the dashboardPrep screen is showing. _DashboardPrepStep _dashboardPrepStep = _DashboardPrepStep.bluetooth; bool _reconnectStarted = false; - bool _showElevatedHandoff = false; + final bool _showElevatedHandoff = false; bool _dbcFlashSimulateError = false; DeviceInfo? _mdbInfo; bool _skipMdbFlash = false; @@ -1869,45 +1869,6 @@ class _InstallerScreenState extends State { bool get _isDryRun => launchArgs.dryRun; - /// Wait for MDB to reboot into RNDIS, reconfigure network, reconnect SSH. - Future _reconnectToMdb() async { - final l10n = AppLocalizations.of(context)!; - try { - _setStatus(l10n.waitingForMdbToReboot); - final found = await _waitForDevice(DeviceMode.ethernet, timeout: const Duration(seconds: 60)); - if (!found) return false; - - // MDB needs time to fully boot after RNDIS appears - _setStatus(l10n.mdbDetectedWaitingForSsh); - await Future.delayed(const Duration(seconds: 10)); - - final iface = await NetworkService().findLibrescootInterface(); - if (iface != null) { - try { - await NetworkService().configureInterface(iface); - } on NetworkPrivilegeException catch (e) { - _setStatus(l10n.errorPrefix(e.toString())); - return false; - } - } - - // Retry SSH connection a few times (MDB may still be starting sshd) - for (var i = 0; i < 5; i++) { - try { - await _sshService.loadDeviceConfig('assets'); - await _sshService.connectToMdb(); - _setStatus(l10n.reconnectedToMdb); - return true; - } catch (_) { - await Future.delayed(const Duration(seconds: 5)); - } - } - return false; - } catch (_) { - return false; - } - } - Future _waitForDevice(DeviceMode mode, {Duration timeout = const Duration(seconds: 120)}) async { if (_isDryRun) { await Future.delayed(const Duration(seconds: 1)); @@ -2244,8 +2205,6 @@ class _InstallerScreenState extends State { } } - bool _batteryRemovalStarted = false; - Widget _buildBatteryRemoval(AppLocalizations l10n) { return Center( child: Column( diff --git a/lib/services/flash_service.dart b/lib/services/flash_service.dart index 8f1beb1..394a288 100644 --- a/lib/services/flash_service.dart +++ b/lib/services/flash_service.dart @@ -619,171 +619,6 @@ class FlashService { } } - Future _runDdPhase({ - required String imagePath, - required String devicePath, - required bool isCompressed, - int? skip, - int? seek, - int? count, - void Function(double progress, String message)? onProgress, - }) async { - if (Platform.isWindows) { - await _runDdPhaseWindows(imagePath, devicePath, isCompressed, skip: skip, seek: seek, count: count, onProgress: onProgress); - } else { - await _runDdPhaseUnix(imagePath, devicePath, isCompressed, skip: skip, seek: seek, count: count, onProgress: onProgress); - } - } - - Future _runDdPhaseUnix( - String imagePath, - String devicePath, - bool isCompressed, { - int? skip, - int? seek, - int? count, - void Function(double progress, String message)? onProgress, - }) async { - if (Platform.isMacOS) { - return _runDdPhaseMacOS(imagePath, devicePath, isCompressed, - skip: skip, seek: seek, count: count, onProgress: onProgress); - } - return _runDdPhaseLinux(imagePath, devicePath, isCompressed, - skip: skip, seek: seek, count: count, onProgress: onProgress); - } - - /// macOS: use the diskwriter helper binary to get authorized raw disk access - /// via AuthorizationCreate + authopen fd-passing. - Future _runDdPhaseMacOS( - String imagePath, - String devicePath, - bool isCompressed, { - int? skip, - int? seek, - int? count, - void Function(double progress, String message)? onProgress, - }) async { - final rawDevice = !devicePath.contains('rdisk') - ? devicePath.replaceFirst('/dev/disk', '/dev/rdisk') - : devicePath; - final diskName = rawDevice.replaceFirst('/dev/rdisk', '/dev/disk'); - - // Unmount the disk first (macOS auto-mounts; force kicks Finder/DA off - // even when the "Initialize / Erase / Ignore" dialog is holding the disk). - for (var attempt = 1; attempt <= 3; attempt++) { - debugPrint('Flash(dd): unmounting $diskName (attempt $attempt/3, force)'); - final r = await Process.run('diskutil', ['unmountDisk', 'force', diskName]); - debugPrint('Flash(dd): unmount exit=${r.exitCode} stdout=${(r.stdout as String).trim()} stderr=${(r.stderr as String).trim()}'); - if (r.exitCode == 0) break; - if (attempt < 3) await Future.delayed(const Duration(milliseconds: 500)); - } - - // Locate the diskwriter binary bundled in the app - final diskwriterPath = await _getDiskwriterPath(); - if (diskwriterPath == null) { - throw Exception('diskwriter binary not found in app bundle'); - } - - final dwArgs = [ - if (skip != null) '--skip=$skip', - if (seek != null) '--seek=$seek', - if (count != null) '--count=$count', - rawDevice, - ]; - - // Build the pipeline: decompress (if needed) | diskwriter - final String command; - if (isCompressed) { - command = 'gunzip -c "$imagePath" | "$diskwriterPath" ${dwArgs.join(' ')}'; - } else { - command = 'cat "$imagePath" | "$diskwriterPath" ${dwArgs.join(' ')}'; - } - - debugPrint('Flash: running: $command'); - final process = await Process.start('/bin/sh', ['-c', command]); - - final stderrBuf = StringBuffer(); - process.stdout.listen((_) {}); // drain stdout - - await for (final chunk in process.stderr.transform(utf8.decoder)) { - stderrBuf.write(chunk); - // Parse progress lines: "PROGRESS:" - for (final line in chunk.split('\n')) { - final progressMatch = RegExp(r'PROGRESS:(\d+)').firstMatch(line); - if (progressMatch != null) { - final bytes = int.tryParse(progressMatch.group(1)!); - if (bytes != null) { - final mb = bytes / (1024 * 1024); - final mbStr = mb.toStringAsFixed(1); - onProgress?.call(0.5, l10n?.flashProgressMb(mbStr) ?? '$mbStr MB written'); - } - } - } - } - - final exitCode = await process.exitCode; - debugPrint('Flash: diskwriter exit code: $exitCode'); - if (exitCode != 0) { - debugPrint('Flash: diskwriter output: $stderrBuf'); - throw Exception('diskwriter failed with exit code $exitCode: $stderrBuf'); - } - } - - /// Linux: use dd, elevating via pkexec if not already root. - Future _runDdPhaseLinux( - String imagePath, - String devicePath, - bool isCompressed, { - int? skip, - int? seek, - int? count, - void Function(double progress, String message)? onProgress, - }) async { - final isRoot = Platform.environment['USER'] == 'root' || - (await Process.run('id', ['-u'])).stdout.toString().trim() == '0'; - - final ddParams = [ - 'bs=4M', - if (skip != null) 'skip=$skip', - if (seek != null) 'seek=$seek', - if (count != null) 'count=$count', - 'oflag=direct', - 'status=progress', - ]; - - // When not root, wrap dd in pkexec for a one-time auth prompt - final ddPrefix = isRoot ? 'dd' : 'pkexec dd'; - - final String command; - if (isCompressed) { - command = 'gunzip -c "$imagePath" | $ddPrefix of=$devicePath iflag=fullblock ${ddParams.join(' ')} 2>&1'; - } else { - command = '$ddPrefix if="$imagePath" of=$devicePath ${ddParams.join(' ')} 2>&1'; - } - - debugPrint('Flash: running: $command'); - final process = await Process.start('/bin/sh', ['-c', command]); - - final output = StringBuffer(); - await for (final line in process.stdout.transform(utf8.decoder)) { - output.write(line); - final bytesMatch = RegExp(r'(\d+)\s+bytes').firstMatch(line); - if (bytesMatch != null) { - final bytes = int.tryParse(bytesMatch.group(1)!); - if (bytes != null) { - final mbStr = (bytes / 1024 / 1024).toStringAsFixed(1); - onProgress?.call(0.5, l10n?.flashProgressMb(mbStr) ?? '$mbStr MB written'); - } - } - } - final exitCode = await process.exitCode; - debugPrint('Flash: dd exit code: $exitCode'); - if (exitCode != 0) { - debugPrint('Flash: dd output: $output'); - throw Exception('dd failed with exit code $exitCode'); - } - } - /// Locate the diskwriter binary in the macOS app bundle Future _getDiskwriterPath() async { // When running from Xcode / flutter run, the binary is in the app's Resources @@ -807,24 +642,6 @@ class FlashService { return null; } - /// Parse bmap XML to get total mapped bytes - Future _estimateBmapBytes(String bmapPath) async { - try { - final content = await File(bmapPath).readAsString(); - // Parse MappedBlocksCount and BlockSize from XML - final mappedMatch = RegExp(r'\s*(\d+)\s*').firstMatch(content); - final blockSizeMatch = RegExp(r'\s*(\d+)\s*').firstMatch(content); - if (mappedMatch != null) { - final mapped = int.parse(mappedMatch.group(1)!); - final bs = blockSizeMatch != null ? int.parse(blockSizeMatch.group(1)!) : 4096; - return mapped * bs; - } - } catch (e) { - debugPrint('Flash: failed to parse bmap: $e'); - } - return null; - } - /// Locate the Go flasher binary for the current host platform. /// /// On macOS and Linux the binary is installed with +x by the build system @@ -1291,86 +1108,6 @@ echo "VERIFY:OK" onProgress?.call(1.0, 'Boot sector verified'); } - /// Verify the boot sector written to disk matches the source image. - /// Compares md5sum of the first bootAreaBlocks (24 MB) from image vs device. - Future _verifyBootSector( - String imagePath, - String devicePath, - bool isCompressed, - ) async { - // Hash the first bootAreaBlocks from the source image - final String sourceCmd; - if (isCompressed) { - sourceCmd = 'gunzip -c "$imagePath" | dd bs=4M count=$bootAreaBlocks iflag=fullblock 2>/dev/null | md5sum'; - } else { - sourceCmd = 'dd if="$imagePath" bs=4M count=$bootAreaBlocks 2>/dev/null | md5sum'; - } - - // Hash the first bootAreaBlocks from the device - final deviceCmd = 'dd if="$devicePath" bs=4M count=$bootAreaBlocks iflag=direct 2>/dev/null | md5sum'; - - debugPrint('Flash: verifying boot sector...'); - final results = await Future.wait([ - Process.run('sh', ['-c', sourceCmd]), - Process.run('sh', ['-c', deviceCmd]), - ]); - - final sourceHash = results[0].stdout.toString().split(' ').first.trim(); - final deviceHash = results[1].stdout.toString().split(' ').first.trim(); - - debugPrint('Flash: VERIFY source=$sourceHash device=$deviceHash'); - - if (sourceHash.isEmpty || deviceHash.isEmpty) { - throw Exception('Boot sector verification failed: could not compute checksums'); - } - if (sourceHash != deviceHash) { - throw Exception( - 'Boot sector verification FAILED: checksum mismatch!\n' - 'Expected: $sourceHash\n' - 'Got: $deviceHash', - ); - } - debugPrint('Flash: boot sector verified OK'); - } - - Future _runDdPhaseWindows( - String imagePath, - String devicePath, - bool isCompressed, { - int? skip, - int? seek, - int? count, - void Function(double progress, String message)? onProgress, - }) async { - final ddExePath = await _getDdPath() ?? '${Directory.current.path}/assets/tools/dd.exe'; - - final ddArgs = [ - 'bs=4M', - 'of=$devicePath', - if (skip != null) 'skip=$skip', - if (seek != null) 'seek=$seek', - if (count != null) 'count=$count', - ]; - - if (isCompressed) { - final psScript = ''' -\$input = [System.IO.File]::OpenRead('$imagePath') -\$gzip = New-Object System.IO.Compression.GZipStream(\$input, [System.IO.Compression.CompressionMode]::Decompress) -\$output = [System.Console]::OpenStandardOutput() -\$gzip.CopyTo(\$output) -\$gzip.Close() -\$input.Close() -'''; - final command = 'powershell -Command "$psScript" | "$ddExePath" ${ddArgs.join(' ')}'; - final result = await Process.run('cmd', ['/c', command]); - if (result.exitCode != 0) throw Exception('dd.exe failed: ${result.stderr}'); - } else { - ddArgs.add('if=$imagePath'); - final result = await Process.run(ddExePath, ddArgs); - if (result.exitCode != 0) throw Exception('dd.exe failed: ${result.stderr}'); - } - } - /// Verify the written image by reading and checksumming Future verifyImage(String devicePath, int sizeBytes) async { try { diff --git a/lib/services/network_service.dart b/lib/services/network_service.dart index 5ab1ae6..ff225ea 100644 --- a/lib/services/network_service.dart +++ b/lib/services/network_service.dart @@ -315,14 +315,14 @@ if ($dev) { "$($dev.Name)`t$($dev.NetConnectionID)`t$($dev.NetEnabled)" } ); if (result.exitCode != 0) { - print('ifconfig failed: ${result.stderr}'); + debugPrint('ifconfig failed: ${result.stderr}'); return false; } await Future.delayed(const Duration(seconds: 2)); return await isMdbReachable(); } catch (e) { - print('Failed to configure macOS interface: $e'); + debugPrint('Failed to configure macOS interface: $e'); return false; } } diff --git a/lib/services/usb_detector.dart b/lib/services/usb_detector.dart index 417f022..c2a81f5 100644 --- a/lib/services/usb_detector.dart +++ b/lib/services/usb_detector.dart @@ -901,7 +901,7 @@ if ($dev) { "$($dev.Name)`t$($dev.PNPDeviceID)" } return null; } - /// True if /sys/block/ sits under the MDB USB gadget (idVendor 0525, + /// True if `/sys/block/` sits under the MDB USB gadget (idVendor 0525, /// idProduct a4a5). Resolves the sysfs symlink and walks up to the first /// ancestor exposing idVendor/idProduct (the USB device node). bool _linuxBlockIsMdb(String name) { From 281596dc60dc8592e85698b4e5b2d78e86f51f67 Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Mon, 13 Jul 2026 22:31:40 +0200 Subject: [PATCH 30/43] feat(installer): deactivate the main battery instead of removing it Signal battery-service that the seatbox is open (stop vehicle-service so it can't re-assert the latch, then HSET vehicle seatbox:lock open and PUBLISH vehicle seatbox:lock) so it opens the main pack's contactors. Wait for battery:0 to leave the active state, with a 30s fallback into the UMS reboot which deactivates the pack regardless. Drops the physical-removal step and its now-dead openSeatbox helper. --- lib/l10n/app_de.arb | 16 +++----- lib/l10n/app_en.arb | 16 +++----- lib/l10n/app_localizations.dart | 60 +++++++++--------------------- lib/l10n/app_localizations_de.dart | 25 ++++--------- lib/l10n/app_localizations_en.dart | 26 ++++--------- lib/screens/installer_screen.dart | 43 ++++++++++----------- lib/services/ssh_service.dart | 35 ++++++++++++++--- 7 files changed, 96 insertions(+), 125 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 8db9858..1f7349e 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -132,16 +132,12 @@ "riskCbbSoh": "Schlechter CBB-Zustand kann zu unzuverlässiger Stromversorgung während des Flashens führen.", "riskCbbCharge": "Niedriger CBB-Ladezustand erhöht das Risiko eines Stromausfalls beim DBC-Flash. Sitzbank mit eingesetztem Fahrakku schließen und warten, bis die CBB geladen ist.", "riskNoBattery": "Ohne den Fahrakku entlädt sich die 12V-Hilfsbatterie schneller. Der Roller könnte bei längeren Vorgängen herunterfahren.", - "batteryRemovalHeading": "Akku entfernen", - "seatboxOpening": "Sitzbank wird geöffnet...", - "seatboxOpeningDesc": "Die Sitzbank öffnet sich automatisch.", - "removeMainBattery": "Fahrakku entnehmen", - "removeMainBatteryDesc": "Hebe den Fahrakku aus der Sitzbank.", - "openSeatbox": "Sitzbank öffnen", - "mainBatteryAlreadyRemoved": "Fahrakku bereits entnommen", - "openingSeatbox": "Sitzbank wird geöffnet...", - "waitingForBatteryRemoval": "Warte auf Akku-Entnahme...", - "batteryRemoved": "Akku entnommen!", + "deactivateMainBatteryHeading": "Fahrakku", + "deactivateMainBattery": "Fahrakku deaktivieren", + "deactivateMainBatteryStep": "Der Roller schaltet den Fahrakku ab. Du musst ihn nicht aus der Sitzbank nehmen.", + "deactivatingMainBattery": "Fahrakku wird abgeschaltet...", + "mainBatteryDeactivated": "Fahrakku abgeschaltet", + "mainBatteryAlreadyOff": "Fahrakku ist bereits abgeschaltet", "configuringMdbBootloader": "MDB-Bootloader wird konfiguriert", "preparing": "Vorbereitung...", "uploadingBootloaderTools": "Bootloader-Tools werden hochgeladen...", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bbe080a..d6e0097 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -157,16 +157,12 @@ "riskCbbCharge": "Low CBB charge increases the risk of power loss during the DBC flash. Close the seatbox with the main battery inserted and wait for the CBB to charge.", "riskNoBattery": "Without the main battery, the 12V auxiliary battery will drain faster. The scooter may shut down during extended operations.", - "batteryRemovalHeading": "Battery Removal", - "seatboxOpening": "Seatbox is opening...", - "seatboxOpeningDesc": "The seatbox will open automatically.", - "removeMainBattery": "Remove the main battery", - "removeMainBatteryDesc": "Lift the main battery (Fahrakku) out of the seatbox.", - "openSeatbox": "Open Seatbox", - "mainBatteryAlreadyRemoved": "Main battery already removed", - "openingSeatbox": "Opening seatbox...", - "waitingForBatteryRemoval": "Waiting for battery removal...", - "batteryRemoved": "Battery removed!", + "deactivateMainBatteryHeading": "Main Battery", + "deactivateMainBattery": "Deactivate main battery", + "deactivateMainBatteryStep": "The scooter will switch off the main battery. You do not need to take it out of the seatbox.", + "deactivatingMainBattery": "Switching off the main battery...", + "mainBatteryDeactivated": "Main battery switched off", + "mainBatteryAlreadyOff": "Main battery is already off", "configuringMdbBootloader": "Configuring MDB Bootloader", "preparing": "Preparing...", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d275f62..ed33f58 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -872,65 +872,41 @@ abstract class AppLocalizations { /// **'Without the main battery, the 12V auxiliary battery will drain faster. The scooter may shut down during extended operations.'** String get riskNoBattery; - /// No description provided for @batteryRemovalHeading. + /// No description provided for @deactivateMainBatteryHeading. /// /// In en, this message translates to: - /// **'Battery Removal'** - String get batteryRemovalHeading; + /// **'Main Battery'** + String get deactivateMainBatteryHeading; - /// No description provided for @seatboxOpening. + /// No description provided for @deactivateMainBattery. /// /// In en, this message translates to: - /// **'Seatbox is opening...'** - String get seatboxOpening; + /// **'Deactivate main battery'** + String get deactivateMainBattery; - /// No description provided for @seatboxOpeningDesc. + /// No description provided for @deactivateMainBatteryStep. /// /// In en, this message translates to: - /// **'The seatbox will open automatically.'** - String get seatboxOpeningDesc; + /// **'The scooter will switch off the main battery. You do not need to take it out of the seatbox.'** + String get deactivateMainBatteryStep; - /// No description provided for @removeMainBattery. + /// No description provided for @deactivatingMainBattery. /// /// In en, this message translates to: - /// **'Remove the main battery'** - String get removeMainBattery; + /// **'Switching off the main battery...'** + String get deactivatingMainBattery; - /// No description provided for @removeMainBatteryDesc. + /// No description provided for @mainBatteryDeactivated. /// /// In en, this message translates to: - /// **'Lift the main battery (Fahrakku) out of the seatbox.'** - String get removeMainBatteryDesc; + /// **'Main battery switched off'** + String get mainBatteryDeactivated; - /// No description provided for @openSeatbox. + /// No description provided for @mainBatteryAlreadyOff. /// /// In en, this message translates to: - /// **'Open Seatbox'** - String get openSeatbox; - - /// No description provided for @mainBatteryAlreadyRemoved. - /// - /// In en, this message translates to: - /// **'Main battery already removed'** - String get mainBatteryAlreadyRemoved; - - /// No description provided for @openingSeatbox. - /// - /// In en, this message translates to: - /// **'Opening seatbox...'** - String get openingSeatbox; - - /// No description provided for @waitingForBatteryRemoval. - /// - /// In en, this message translates to: - /// **'Waiting for battery removal...'** - String get waitingForBatteryRemoval; - - /// No description provided for @batteryRemoved. - /// - /// In en, this message translates to: - /// **'Battery removed!'** - String get batteryRemoved; + /// **'Main battery is already off'** + String get mainBatteryAlreadyOff; /// No description provided for @configuringMdbBootloader. /// diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 5aa4950..32b6c8e 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -438,34 +438,23 @@ class AppLocalizationsDe extends AppLocalizations { 'Ohne den Fahrakku entlädt sich die 12V-Hilfsbatterie schneller. Der Roller könnte bei längeren Vorgängen herunterfahren.'; @override - String get batteryRemovalHeading => 'Akku entfernen'; + String get deactivateMainBatteryHeading => 'Fahrakku'; @override - String get seatboxOpening => 'Sitzbank wird geöffnet...'; + String get deactivateMainBattery => 'Fahrakku deaktivieren'; @override - String get seatboxOpeningDesc => 'Die Sitzbank öffnet sich automatisch.'; + String get deactivateMainBatteryStep => + 'Der Roller schaltet den Fahrakku ab. Du musst ihn nicht aus der Sitzbank nehmen.'; @override - String get removeMainBattery => 'Fahrakku entnehmen'; + String get deactivatingMainBattery => 'Fahrakku wird abgeschaltet...'; @override - String get removeMainBatteryDesc => 'Hebe den Fahrakku aus der Sitzbank.'; + String get mainBatteryDeactivated => 'Fahrakku abgeschaltet'; @override - String get openSeatbox => 'Sitzbank öffnen'; - - @override - String get mainBatteryAlreadyRemoved => 'Fahrakku bereits entnommen'; - - @override - String get openingSeatbox => 'Sitzbank wird geöffnet...'; - - @override - String get waitingForBatteryRemoval => 'Warte auf Akku-Entnahme...'; - - @override - String get batteryRemoved => 'Akku entnommen!'; + String get mainBatteryAlreadyOff => 'Fahrakku ist bereits abgeschaltet'; @override String get configuringMdbBootloader => 'MDB-Bootloader wird konfiguriert'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 48662b9..a525048 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -433,35 +433,23 @@ class AppLocalizationsEn extends AppLocalizations { 'Without the main battery, the 12V auxiliary battery will drain faster. The scooter may shut down during extended operations.'; @override - String get batteryRemovalHeading => 'Battery Removal'; + String get deactivateMainBatteryHeading => 'Main Battery'; @override - String get seatboxOpening => 'Seatbox is opening...'; + String get deactivateMainBattery => 'Deactivate main battery'; @override - String get seatboxOpeningDesc => 'The seatbox will open automatically.'; + String get deactivateMainBatteryStep => + 'The scooter will switch off the main battery. You do not need to take it out of the seatbox.'; @override - String get removeMainBattery => 'Remove the main battery'; + String get deactivatingMainBattery => 'Switching off the main battery...'; @override - String get removeMainBatteryDesc => - 'Lift the main battery (Fahrakku) out of the seatbox.'; + String get mainBatteryDeactivated => 'Main battery switched off'; @override - String get openSeatbox => 'Open Seatbox'; - - @override - String get mainBatteryAlreadyRemoved => 'Main battery already removed'; - - @override - String get openingSeatbox => 'Opening seatbox...'; - - @override - String get waitingForBatteryRemoval => 'Waiting for battery removal...'; - - @override - String get batteryRemoved => 'Battery removed!'; + String get mainBatteryAlreadyOff => 'Main battery is already off'; @override String get configuringMdbBootloader => 'Configuring MDB Bootloader'; diff --git a/lib/screens/installer_screen.dart b/lib/screens/installer_screen.dart index 6d86a45..dac683a 100644 --- a/lib/screens/installer_screen.dart +++ b/lib/screens/installer_screen.dart @@ -2210,25 +2210,20 @@ class _InstallerScreenState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text(l10n.batteryRemovalHeading, + Text(l10n.deactivateMainBatteryHeading, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), const SizedBox(height: 24), if (_scooterHealth?.batteryPresent == true) ...[ InstructionStep( number: 1, - title: l10n.seatboxOpening, - description: l10n.seatboxOpeningDesc, - ), - InstructionStep( - number: 2, - title: l10n.removeMainBattery, - description: l10n.removeMainBatteryDesc, + title: l10n.deactivateMainBattery, + description: l10n.deactivateMainBatteryStep, ), const SizedBox(height: 16), if (!_isProcessing) FilledButton( - onPressed: _openSeatboxAndWaitForBattery, - child: Text(l10n.openSeatbox), + onPressed: _deactivateMainBatteryAndWait, + child: Text(l10n.deactivateMainBattery), ), if (_isProcessing) ...[ const CircularProgressIndicator(), @@ -2238,7 +2233,7 @@ class _InstallerScreenState extends State { ] else ...[ const Icon(Icons.check_circle, size: 48, color: kAccent), const SizedBox(height: 16), - Text(l10n.mainBatteryAlreadyRemoved), + Text(l10n.mainBatteryAlreadyOff), const SizedBox(height: 16), FilledButton.icon( onPressed: () => _setPhase(InstallerPhase.mdbToUms), @@ -2251,29 +2246,35 @@ class _InstallerScreenState extends State { ); } - Future _openSeatboxAndWaitForBattery() async { + Future _deactivateMainBatteryAndWait() async { final l10n = AppLocalizations.of(context)!; setState(() => _isProcessing = true); if (_isDryRun) { - _setStatus('[DRY RUN] Simulating battery removal...'); + _setStatus('[DRY RUN] Simulating main battery deactivation...'); await Future.delayed(const Duration(seconds: 1)); setState(() { _scooterHealth?.batteryPresent = false; _isProcessing = false; }); _setPhase(InstallerPhase.mdbToUms); return; } - _setStatus(l10n.openingSeatbox); - await _sshService.openSeatbox(); + _setStatus(l10n.deactivatingMainBattery); + await _sshService.deactivateMainBattery(); - _setStatus(l10n.waitingForBatteryRemoval); - debugPrint('Battery: waiting for depart on battery:0'); - while (await _sshService.isBatteryPresent()) { + debugPrint('Battery: waiting for battery:0 state to leave active'); + final deadline = DateTime.now().add(const Duration(seconds: 30)); + var stillActive = await _sshService.isMainBatteryActive(); + while (stillActive && DateTime.now().isBefore(deadline)) { await Future.delayed(const Duration(seconds: 2)); if (!mounted) return; + stillActive = await _sshService.isMainBatteryActive(); + } + if (stillActive) { + debugPrint('Battery: still active after 30s, proceeding anyway (UMS reboot deactivates it regardless)'); + } else { + debugPrint('Battery: main battery deactivated'); } - debugPrint('Battery: depart detected on battery:0'); - await _sshService.logScooterStats('battery-removed'); + await _sshService.logScooterStats('main-battery-deactivated'); - _setStatus(l10n.batteryRemoved); + _setStatus(l10n.mainBatteryDeactivated); setState(() { _scooterHealth?.batteryPresent = false; _isProcessing = false; diff --git a/lib/services/ssh_service.dart b/lib/services/ssh_service.dart index f2425b1..581fd95 100644 --- a/lib/services/ssh_service.dart +++ b/lib/services/ssh_service.dart @@ -865,6 +865,36 @@ class SshService { await runCommand('redis-cli LPUSH $key $value'); } + /// Run a Redis HSET command on the MDB. + Future redisHset(String hash, String field, String value) async { + await runCommand('redis-cli HSET ${_shellEscape(hash)} ${_shellEscape(field)} ${_shellEscape(value)}'); + } + + /// Run a Redis PUBLISH command on the MDB. + Future redisPublish(String channel, String message) async { + await runCommand('redis-cli PUBLISH ${_shellEscape(channel)} ${_shellEscape(message)}'); + } + + /// Signal battery-service that the seatbox is open so it deactivates the + /// main pack (opens the BMS contactors) without physically opening the + /// seatbox. Electrically equivalent to removing the battery for the flash. + Future deactivateMainBattery() async { + // Stop vehicle-service first so it can't re-assert the physical seatbox + // latch back to "closed" and re-enable the main battery. Safe because + // the MDB is about to be rebooted into UMS mode and reflashed anyway. + // The unit may be stock (vehicle-service) or Librescoot + // (librescoot-vehicle) at this point in the install, so stop both. + await runCommand('systemctl stop vehicle-service librescoot-vehicle 2>/dev/null; true'); + await redisHset('vehicle', 'seatbox:lock', 'open'); + await redisPublish('vehicle', 'seatbox:lock'); + } + + /// Check whether the main battery is still active (powered on). + Future isMainBatteryActive() async { + final state = await redisHget('battery:0', 'state'); + return state == 'active'; + } + /// Subscribe to a Redis pub/sub [channel] over a long-running SSH session. /// Yields one event per published message (the payload only). Caller must /// invoke [stop] on the returned subscription to terminate the session and @@ -1060,11 +1090,6 @@ class SshService { return health; } - /// Open the seatbox. - Future openSeatbox() async { - await redisLpush('scooter:seatbox', 'open'); - } - /// Check if CBB is connected. Future isCbbPresent() async { final present = await redisHget('cb-battery', 'present'); From ca2069861c7588b429ca12252b99aead3f099073 Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Mon, 13 Jul 2026 22:50:34 +0200 Subject: [PATCH 31/43] feat(installer): reconnect CBB before AUX so it lands on a dead bus Since the main battery is now deactivated in place rather than removed, it can be live after the reflash, and reconnecting the CBB onto a powered HV bus is unsafe. Move the CBB reconnect ahead of the AUX reconnect (both in the boot step, with a warning) so the CBB is connected while the scooter is fully depowered. The CBB-reconnect screen becomes verification-only: confirm CBB and main battery are detected, drop the physical reconnect and insert-battery instructions and the now-stale insert-battery step. --- lib/l10n/app_de.arb | 9 +- lib/l10n/app_en.arb | 9 +- lib/l10n/app_localizations.dart | 44 +++------ lib/l10n/app_localizations_de.dart | 28 ++---- lib/l10n/app_localizations_en.dart | 28 ++---- lib/screens/installer_screen.dart | 140 ++++++++++++----------------- 6 files changed, 92 insertions(+), 166 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 1f7349e..4b2d0a1 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -167,6 +167,8 @@ "waitingForMdbBoot": "Warte auf MDB-Boot", "reconnectAuxPole": "AUX-Pol wieder anschließen", "reconnectAuxPoleDesc": "Schließe den positiven AUX-Pol wieder an. Das MDB startet und bootet Librescoot.", + "reconnectCbbFirstDesc": "Schließe zuerst die CBB wieder an, solange der AUX noch getrennt ist, damit sie an einen stromlosen Roller kommt.", + "cbbBeforeAuxWarning": "Schließe die CBB vor dem AUX an. Wird die CBB bei eingeschaltetem Roller verbunden, kann der Fahrakku unter Spannung stehen.", "dbcLedHint": "DBC-LED: orange = startet, grün = bootet, aus = läuft", "mdbStillUms": "MDB immer noch im UMS-Modus. Flash war möglicherweise nicht erfolgreich. Neuer Versuch...", "mdbDetectedNetwork": "MDB im Netzwerkmodus erkannt. Warte auf stabile Verbindung...", @@ -175,13 +177,12 @@ "stableConnectionStallHint": "Verbindung noch instabil. Die USB-Netzwerkschnittstelle hat eventuell ihre IP verloren. Auf Linux: NetworkManager stört möglicherweise (IPv6 deaktivieren kann helfen). Details im Log.", "reconnectingSsh": "SSH wird neu verbunden...", "sshReconnectionFailed": "SSH-Neuverbindung fehlgeschlagen: {error}", - "reconnectCbbHeading": "CBB & Batterie wieder anschließen", + "reconnectCbbHeading": "CBB und Fahrakku prüfen", "verifyCbbConnection": "CBB-Verbindung prüfen", "verifyBatteryPresence": "Akku prüfen", "checkingCbb": "CBB wird geprüft...", "waitingForCbb": "Warte auf CBB... ({attempts})", "cbbNotDetected": "CBB nicht erkannt. Bitte Verbindung prüfen.", - "cbbDetectionMayTakeMinutes": "Das kann mehrere Minuten dauern, bitte etwas Geduld.", "preparingDbcFlash": "DBC-Flash wird vorbereitet", "waitingForDownloads": "Warte auf Abschluss der Downloads...", "finishStepsAboveToContinue": "Schließe die Schritte oben ab, um fortzufahren.", @@ -320,11 +321,7 @@ "skipDbcFlashOption": "DBC-Flash überspringen", "onlyFlashMdbSkipDbc": "Nur MDB flashen, DBC überspringen", "firmwareVersionDisplay": "Firmware: {version}", - "openSeatboxButton": "Sitzbank öffnen", "reconnectCbbStep": "CBB wieder anschließen", - "reconnectCbbStepDesc": "Stecke das CBB-Kabel wieder in den Anschluss im Fußraum. Ohne CBB könnte das MDB während des Flashens herunterfahren.", - "insertMainBatteryStep": "Fahrakku einsetzen", - "insertMainBatteryStepDesc": "Setze den Fahrakku wieder in die Sitzbank ein. Ohne ihn könnte die CBB oder die 12V-Hilfsbatterie während des Flashens leer werden, was MDB oder DBC zum Absturz bringen kann.", "cbbDetected": "CBB erkannt", "batteryDetected": "Akku erkannt", "proceedWithoutCbb": "Ich verstehe die Risiken, trotzdem fortfahren", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d6e0097..121a342 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -199,6 +199,8 @@ "waitingForMdbBoot": "Waiting for MDB Boot", "reconnectAuxPole": "Reconnect the AUX pole", "reconnectAuxPoleDesc": "Reconnect the positive AUX pole. The MDB will power on and boot into Librescoot.", + "reconnectCbbFirstDesc": "Reconnect the CBB first, while the AUX is still disconnected, so it connects to a powered-down scooter.", + "cbbBeforeAuxWarning": "Reconnect the CBB before the AUX. Connecting the CBB while the scooter is powered risks the main battery being live.", "dbcLedHint": "DBC LED: orange = starting, green = booting, off = running", "mdbStillUms": "MDB still in UMS mode. Flash may not have taken. Retrying...", "mdbDetectedNetwork": "MDB detected in network mode. Waiting for stable connection...", @@ -218,7 +220,7 @@ } }, - "reconnectCbbHeading": "Reconnect CBB & Battery", + "reconnectCbbHeading": "Verify CBB and main battery", "verifyCbbConnection": "Verify CBB Connection", "verifyBatteryPresence": "Verify battery", "checkingCbb": "Checking CBB...", @@ -229,7 +231,6 @@ } }, "cbbNotDetected": "CBB not detected. Please check the connection.", - "cbbDetectionMayTakeMinutes": "This can take several minutes, please be patient.", "preparingDbcFlash": "Preparing DBC Flash", "waitingForDownloads": "Waiting for downloads to complete...", @@ -409,11 +410,7 @@ } }, - "openSeatboxButton": "Open seatbox", "reconnectCbbStep": "Reconnect the CBB", - "reconnectCbbStepDesc": "Plug the CBB cable back into the connector in the footwell. Without the CBB, the MDB could shut down during flashing.", - "insertMainBatteryStep": "Insert the main battery", - "insertMainBatteryStepDesc": "Put the main battery back in the seatbox. Without it, the CBB or 12V auxiliary battery could run empty during flashing, which may cause the MDB or DBC to shut down.", "cbbDetected": "CBB detected", "batteryDetected": "Battery detected", "proceedWithoutCbb": "I understand the risks, proceed anyway", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ed33f58..f59386a 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1070,6 +1070,18 @@ abstract class AppLocalizations { /// **'Reconnect the positive AUX pole. The MDB will power on and boot into Librescoot.'** String get reconnectAuxPoleDesc; + /// No description provided for @reconnectCbbFirstDesc. + /// + /// In en, this message translates to: + /// **'Reconnect the CBB first, while the AUX is still disconnected, so it connects to a powered-down scooter.'** + String get reconnectCbbFirstDesc; + + /// No description provided for @cbbBeforeAuxWarning. + /// + /// In en, this message translates to: + /// **'Reconnect the CBB before the AUX. Connecting the CBB while the scooter is powered risks the main battery being live.'** + String get cbbBeforeAuxWarning; + /// No description provided for @dbcLedHint. /// /// In en, this message translates to: @@ -1121,7 +1133,7 @@ abstract class AppLocalizations { /// No description provided for @reconnectCbbHeading. /// /// In en, this message translates to: - /// **'Reconnect CBB & Battery'** + /// **'Verify CBB and main battery'** String get reconnectCbbHeading; /// No description provided for @verifyCbbConnection. @@ -1154,12 +1166,6 @@ abstract class AppLocalizations { /// **'CBB not detected. Please check the connection.'** String get cbbNotDetected; - /// No description provided for @cbbDetectionMayTakeMinutes. - /// - /// In en, this message translates to: - /// **'This can take several minutes, please be patient.'** - String get cbbDetectionMayTakeMinutes; - /// No description provided for @preparingDbcFlash. /// /// In en, this message translates to: @@ -1832,36 +1838,12 @@ abstract class AppLocalizations { /// **'Firmware: {version}'** String firmwareVersionDisplay(String version); - /// No description provided for @openSeatboxButton. - /// - /// In en, this message translates to: - /// **'Open seatbox'** - String get openSeatboxButton; - /// No description provided for @reconnectCbbStep. /// /// In en, this message translates to: /// **'Reconnect the CBB'** String get reconnectCbbStep; - /// No description provided for @reconnectCbbStepDesc. - /// - /// In en, this message translates to: - /// **'Plug the CBB cable back into the connector in the footwell. Without the CBB, the MDB could shut down during flashing.'** - String get reconnectCbbStepDesc; - - /// No description provided for @insertMainBatteryStep. - /// - /// In en, this message translates to: - /// **'Insert the main battery'** - String get insertMainBatteryStep; - - /// No description provided for @insertMainBatteryStepDesc. - /// - /// In en, this message translates to: - /// **'Put the main battery back in the seatbox. Without it, the CBB or 12V auxiliary battery could run empty during flashing, which may cause the MDB or DBC to shut down.'** - String get insertMainBatteryStepDesc; - /// No description provided for @cbbDetected. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 32b6c8e..eeac239 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -554,6 +554,14 @@ class AppLocalizationsDe extends AppLocalizations { String get reconnectAuxPoleDesc => 'Schließe den positiven AUX-Pol wieder an. Das MDB startet und bootet Librescoot.'; + @override + String get reconnectCbbFirstDesc => + 'Schließe zuerst die CBB wieder an, solange der AUX noch getrennt ist, damit sie an einen stromlosen Roller kommt.'; + + @override + String get cbbBeforeAuxWarning => + 'Schließe die CBB vor dem AUX an. Wird die CBB bei eingeschaltetem Roller verbunden, kann der Fahrakku unter Spannung stehen.'; + @override String get dbcLedHint => 'DBC-LED: orange = startet, grün = bootet, aus = läuft'; @@ -587,7 +595,7 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get reconnectCbbHeading => 'CBB & Batterie wieder anschließen'; + String get reconnectCbbHeading => 'CBB und Fahrakku prüfen'; @override String get verifyCbbConnection => 'CBB-Verbindung prüfen'; @@ -606,10 +614,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get cbbNotDetected => 'CBB nicht erkannt. Bitte Verbindung prüfen.'; - @override - String get cbbDetectionMayTakeMinutes => - 'Das kann mehrere Minuten dauern, bitte etwas Geduld.'; - @override String get preparingDbcFlash => 'DBC-Flash wird vorbereitet'; @@ -1022,23 +1026,9 @@ class AppLocalizationsDe extends AppLocalizations { return 'Firmware: $version'; } - @override - String get openSeatboxButton => 'Sitzbank öffnen'; - @override String get reconnectCbbStep => 'CBB wieder anschließen'; - @override - String get reconnectCbbStepDesc => - 'Stecke das CBB-Kabel wieder in den Anschluss im Fußraum. Ohne CBB könnte das MDB während des Flashens herunterfahren.'; - - @override - String get insertMainBatteryStep => 'Fahrakku einsetzen'; - - @override - String get insertMainBatteryStepDesc => - 'Setze den Fahrakku wieder in die Sitzbank ein. Ohne ihn könnte die CBB oder die 12V-Hilfsbatterie während des Flashens leer werden, was MDB oder DBC zum Absturz bringen kann.'; - @override String get cbbDetected => 'CBB erkannt'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a525048..a758e9b 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -547,6 +547,14 @@ class AppLocalizationsEn extends AppLocalizations { String get reconnectAuxPoleDesc => 'Reconnect the positive AUX pole. The MDB will power on and boot into Librescoot.'; + @override + String get reconnectCbbFirstDesc => + 'Reconnect the CBB first, while the AUX is still disconnected, so it connects to a powered-down scooter.'; + + @override + String get cbbBeforeAuxWarning => + 'Reconnect the CBB before the AUX. Connecting the CBB while the scooter is powered risks the main battery being live.'; + @override String get dbcLedHint => 'DBC LED: orange = starting, green = booting, off = running'; @@ -580,7 +588,7 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String get reconnectCbbHeading => 'Reconnect CBB & Battery'; + String get reconnectCbbHeading => 'Verify CBB and main battery'; @override String get verifyCbbConnection => 'Verify CBB Connection'; @@ -599,10 +607,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get cbbNotDetected => 'CBB not detected. Please check the connection.'; - @override - String get cbbDetectionMayTakeMinutes => - 'This can take several minutes, please be patient.'; - @override String get preparingDbcFlash => 'Preparing DBC Flash'; @@ -1013,23 +1017,9 @@ class AppLocalizationsEn extends AppLocalizations { return 'Firmware: $version'; } - @override - String get openSeatboxButton => 'Open seatbox'; - @override String get reconnectCbbStep => 'Reconnect the CBB'; - @override - String get reconnectCbbStepDesc => - 'Plug the CBB cable back into the connector in the footwell. Without the CBB, the MDB could shut down during flashing.'; - - @override - String get insertMainBatteryStep => 'Insert the main battery'; - - @override - String get insertMainBatteryStepDesc => - 'Put the main battery back in the seatbox. Without it, the CBB or 12V auxiliary battery could run empty during flashing, which may cause the MDB or DBC to shut down.'; - @override String get cbbDetected => 'CBB detected'; diff --git a/lib/screens/installer_screen.dart b/lib/screens/installer_screen.dart index dac683a..90da796 100644 --- a/lib/screens/installer_screen.dart +++ b/lib/screens/installer_screen.dart @@ -2668,10 +2668,37 @@ class _InstallerScreenState extends State { const SizedBox(height: 16), InstructionStep( number: 1, + title: l10n.reconnectCbbStep, + description: l10n.reconnectCbbFirstDesc, + imageAsset: 'assets/images/lsi-unu_scooter_cbb_connected.jpg', + ), + InstructionStep( + number: 2, title: l10n.reconnectAuxPole, description: l10n.reconnectAuxPoleDesc, imageAsset: 'assets/images/lsi-unu_scooter_aux_connected.jpg', ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.orange.shade900.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.orange.shade700), + ), + child: Row( + children: [ + const Icon(Icons.warning, color: Colors.orange), + const SizedBox(width: 12), + Expanded( + child: Text( + l10n.cbbBeforeAuxWarning, + style: const TextStyle(color: Colors.orange, fontSize: 13), + ), + ), + ], + ), + ), const SizedBox(height: 16), Text(l10n.dbcLedHint, style: TextStyle(color: Colors.grey.shade500, fontSize: 12)), @@ -2859,12 +2886,9 @@ class _InstallerScreenState extends State { bool _cbbAutoCheckStarted = false; bool _cbbDetected = false; bool _batteryDetected = false; - bool _cbbWaitNoticeShown = false; - // Poll for CBB presence. Up to 3 minutes (90 × 2s); flips _cbbWaitNoticeShown - // after 30s so the "be patient" notice appears. + // Poll for CBB presence, up to 3 minutes (90 x 2s). static const int _cbbPollIterations = 90; - static const int _cbbNoticeAfterIterations = 15; Future _pollForCbb(AppLocalizations l10n) async { if (_isDryRun) { @@ -2889,9 +2913,6 @@ class _InstallerScreenState extends State { return true; } if (!mounted) return false; - if (i + 1 == _cbbNoticeAfterIterations && !_cbbWaitNoticeShown) { - setState(() => _cbbWaitNoticeShown = true); - } _setStatus(l10n.waitingForCbb(i + 1)); await Future.delayed(const Duration(seconds: 2)); } @@ -2935,13 +2956,6 @@ class _InstallerScreenState extends State { style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), const SizedBox(height: 24), - // Step 1: Reconnect CBB - InstructionStep( - number: 1, - title: l10n.reconnectCbbStep, - description: l10n.reconnectCbbStepDesc, - imageAsset: 'assets/images/lsi-unu_scooter_cbb_connected.jpg', - ), if (_cbbDetected) Row( mainAxisSize: MainAxisSize.min, @@ -2950,77 +2964,18 @@ class _InstallerScreenState extends State { const SizedBox(width: 8), Text(l10n.cbbDetected, style: const TextStyle(color: kAccent, fontSize: 13)), ], - ) - else ...[ - if (_cbbWaitNoticeShown) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 4), - child: Text( - l10n.cbbDetectionMayTakeMinutes, - textAlign: TextAlign.center, - style: TextStyle(color: Colors.grey.shade400, fontSize: 12, fontStyle: FontStyle.italic), - ), - ), - if (!_isProcessing) - FilledButton( - onPressed: () async { - setState(() => _isProcessing = true); - _setStatus(l10n.checkingCbb); - final detected = await _pollForCbb(l10n); - if (!mounted) return; - if (detected) { - setState(() { _cbbDetected = true; _isProcessing = false; }); - _setStatus(''); - } else { - _setStatus(l10n.cbbNotDetected); - setState(() { _isProcessing = false; _cbbDetected = false; }); - } - }, - child: Text(l10n.verifyCbbConnection), - ), - ], - - const SizedBox(height: 16), - - // Step 2: Insert battery (greyed out until CBB connected) - Opacity( - opacity: _cbbDetected ? 1.0 : 0.4, - child: Column( + ), + if (_batteryDetected) ...[ + const SizedBox(height: 8), + Row( + mainAxisSize: MainAxisSize.min, children: [ - InstructionStep( - number: 2, - title: l10n.insertMainBatteryStep, - description: l10n.insertMainBatteryStepDesc, - ), - if (_cbbDetected) ...[ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - OutlinedButton.icon( - onPressed: _sshService.isConnected ? () async { - try { await _sshService.runCommand('lsc open'); } catch (_) {} - } : null, - icon: const Icon(Icons.lock_open, size: 18), - label: Text(l10n.openSeatboxButton), - ), - ], - ), - if (_batteryDetected) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.check_circle, size: 16, color: kAccent), - const SizedBox(width: 8), - Text(l10n.batteryDetected, style: const TextStyle(color: kAccent, fontSize: 13)), - ], - ), - ), - ], + const Icon(Icons.check_circle, size: 16, color: kAccent), + const SizedBox(width: 8), + Text(l10n.batteryDetected, style: const TextStyle(color: kAccent, fontSize: 13)), ], ), - ), + ], const SizedBox(height: 16), if (_isProcessing) ...[ @@ -3053,10 +3008,25 @@ class _InstallerScreenState extends State { style: TextStyle(color: Colors.grey.shade600, fontSize: 12)), ), ] else ...[ - TextButton( - onPressed: () { - setState(() => _cbbDetected = true); + FilledButton( + onPressed: () async { + setState(() => _isProcessing = true); + _setStatus(l10n.checkingCbb); + final detected = await _pollForCbb(l10n); + if (!mounted) return; + if (detected) { + setState(() { _cbbDetected = true; _isProcessing = false; }); + _setStatus(''); + } else { + _setStatus(l10n.cbbNotDetected); + setState(() { _isProcessing = false; _cbbDetected = false; }); + } }, + child: Text(l10n.verifyCbbConnection), + ), + const SizedBox(height: 12), + TextButton( + onPressed: () => setState(() => _cbbDetected = true), child: Text(l10n.proceedWithoutCbb, style: TextStyle(color: Colors.grey.shade600, fontSize: 12)), ), From d9f0f27ea4a6f760b56f74466067ed0ec70fa9a4 Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Mon, 13 Jul 2026 23:13:19 +0200 Subject: [PATCH 32/43] fix(installer): launch the trampoline in a shell pkill cannot kill pkill -f matches the full command line of every process. The [t] bracket keeps the pkill token itself from matching, but the nohup clause on the same line contained the bare /data/installer/trampoline.sh path, so pkill killed its own launching shell before nohup ran. The trampoline never started, no log was written, and state.json still said trampoline-armed. Split the kill and the launch into separate SSH commands, and clear a stale trampoline-status when arming so an old result can't be read as the new run's outcome. --- lib/services/trampoline_service.dart | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/lib/services/trampoline_service.dart b/lib/services/trampoline_service.dart index 38b2016..e75f6aa 100644 --- a/lib/services/trampoline_service.dart +++ b/lib/services/trampoline_service.dart @@ -495,7 +495,12 @@ http.server.HTTPServer(('0.0.0.0', 8080), H).serve_forever() /// Start the trampoline script on MDB in background. Kills any instance /// still running from an earlier arm (retry after error, resume) first, so /// two trampolines never race each other over the USB role and DBC power. - /// The [t] bracket keeps pkill's regex from matching this command line. + /// + /// The pkill and the nohup launch MUST be separate SSH commands. pkill -f + /// matches the full command line of every process; the [t] bracket keeps + /// the pkill token itself from matching, but if the nohup clause shares + /// the shell's command line, its bare /data/installer/trampoline.sh path + /// matches and pkill kills its own launching shell before nohup runs. /// /// After launching, verifies the trampoline process is actually running: /// a silent launch failure (or a stale SSH connection) would otherwise @@ -507,22 +512,33 @@ http.server.HTTPServer(('0.0.0.0', 8080), H).serve_forever() /// Begin button enabled for retry. Future start() async { await _ssh.runCommand( - "pkill -f 'installer/[t]rampoline.sh' 2>/dev/null; " + "pkill -f 'installer/[t]rampoline.sh' 2>/dev/null; true", + ); + // A leftover trampoline-status from an earlier run would be read as the + // fresh run's result; arming a new trampoline invalidates it. + await _ssh.runCommand( + 'rm -f /data/installer/trampoline-status; ' 'nohup /data/installer/trampoline.sh > /data/installer/trampoline-stdout.log 2>&1 &', ); for (var attempt = 0; attempt < 5; attempt++) { await Future.delayed(const Duration(milliseconds: 500)); - final pid = (await _ssh.runCommand( - "pgrep -f 'installer/[t]rampoline.sh' 2>/dev/null", - )).trim(); - if (pid.isNotEmpty) { + if (await isRunning()) { return; } } throw Exception('Trampoline did not start on the MDB'); } + /// Whether a trampoline process is currently running on the MDB. The + /// bracketed pattern keeps pgrep from matching its own command line. + Future isRunning() async { + final pid = (await _ssh.runCommand( + "pgrep -f 'installer/[t]rampoline.sh' 2>/dev/null; true", + )).trim(); + return pid.isNotEmpty; + } + /// Read trampoline status (call after reconnecting to MDB). Future readStatus() async { return _ssh.readTrampolineStatus(); From 16bc9bcaefc273ae533681d92488a8f3b0c281af Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Mon, 13 Jul 2026 23:13:36 +0200 Subject: [PATCH 33/43] fix(installer): re-arm dead trampoline on resume, fix walk-away flow - Resuming into dbcSwapAndFlash now checks whether the trampoline process is alive and re-arms it when it is gone (launch failure, MDB reboot, crash) instead of sending the user to the swap-cables screen with nothing listening for the cable swap. - Walk-away screen: the success signal is the keycard LED blinking green, not the dashboard turning on. The dashboard cycles several times mid-install, and the fallback path finishes with the DBC off. - The headline no longer asks to unplug a laptop that is already unplugged when the screen appears. - Finish screen: skip the cable-swap steps and the MDB-side settings reset and reboot when arriving from the walk-away path. There is no MDB connection on that path; the stale SSH transport stacked multi-minute TCP timeouts on every step. - Resume text no longer claims the install restarts from the beginning. - Point the unknown-status hint at /data/installer/trampoline.log. --- lib/l10n/app_de.arb | 16 ++++--- lib/l10n/app_en.arb | 16 ++++--- lib/l10n/app_localizations.dart | 26 ++++++++--- lib/l10n/app_localizations_de.dart | 21 ++++++--- lib/l10n/app_localizations_en.dart | 22 ++++++--- lib/screens/installer_screen.dart | 73 ++++++++++++++++++++++-------- 6 files changed, 121 insertions(+), 53 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 4b2d0a1..04ff6c9 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -95,7 +95,7 @@ "unfinishedInstallDetected": "Unvollständige Installation erkannt, Entsperren wird übersprungen...", "waitingForBatteryData": "Warte auf AUX/CBB-Batteriedaten...", "resumeFoundHeading": "Unterbrochene Installation gefunden", - "resumeFoundBody": "Eine frühere Installation auf diesem Roller wurde nicht abgeschlossen. Der Entsperr-Schritt wurde übersprungen und deaktivierte Dienste wurden wieder aktiviert. Die Installation beginnt anschließend von vorn.", + "resumeFoundBody": "Eine frühere Installation auf diesem Roller wurde nicht abgeschlossen. Der Entsperr-Schritt wurde übersprungen und deaktivierte Dienste wurden wieder aktiviert. Die Installation wird an der passenden Stelle fortgesetzt.", "resumeFoundLastError": "Letzter aufgezeichneter Fehler:", "awaitingUnlockHeading": "Roller entsperren", "awaitingUnlockDetail": "Bitte entsperre deinen Roller, um fortzufahren. Halte deine Schlüsselkarte an den Leser oder benutze ein gekoppeltes Handy.", @@ -209,13 +209,15 @@ "dbcFlashFailed": "DBC-Flash fehlgeschlagen: {message}", "dbcFlashError": "DBC-Flash-Fehler", "closeButton": "Schließen", - "trampolineStatusUnknown": "Trampoline-Status unbekannt. Prüfe /data/trampoline.log auf dem MDB.", + "trampolineStatusUnknown": "Trampoline-Status unbekannt. Prüfe /data/installer/trampoline.log auf dem MDB.", "welcomeToLibrescoot": "Willkommen bei Librescoot!", "finalSteps": "Letzte Schritte:", "disconnectUsbFromLaptopFinal": "Laptop-USB-Kabel vom MDB abziehen", "disconnectUsbFromLaptopFinalDesc": "Ziehe das Laptop-USB-Kabel vom MDB ab. Dort kommt gleich das DBC-Kabel wieder hinein.", "reconnectDbcUsbCable": "DBC-USB-Kabel anschließen", "reconnectDbcUsbCableDesc": "Stecke das interne DBC-USB-Kabel wieder in den MDB-Port und schraube es jetzt vorsichtig fest.", + "screwDbcUsbCable": "DBC-USB-Kabel festschrauben", + "screwDbcUsbCableDesc": "Das DBC-Kabel steckt bereits im MDB-Port; schraube es jetzt vorsichtig fest.", "closeSeatboxAndFootwell": "Fußraumabdeckung wieder anbringen", "closeSeatboxAndFootwellDesc": "Klipse zuerst die Metallbügel wieder ein, setze dann die Fußraumabdeckung auf und schraube sie fest.", "unlockScooter": "Roller entsperren", @@ -330,11 +332,11 @@ "finishRebootingTitle": "Roller startet neu…", "finishRebootingBody": "Warte auf die USB-Trennung, dann ist der Installer fertig.", "networkConfigNeedsPermission": "macOS fragt nach Erlaubnis, die Netzwerk-Einstellungen zu ändern. Im Systemdialog auf 'Erlauben' klicken, dann 'Erneut versuchen' drücken.", - "dbcWalkAwayHeadline": "Umstecken erledigt. Du kannst den Laptop jetzt abziehen.", - "dbcWalkAwayBody": "Lass den Roller ein paar Minuten in Ruhe, während er das DBC selbstständig flasht. Das kann 10 bis 20 Minuten dauern.", - "dbcWalkAwayDashboardLit": "Wenn der Tacho angeht, ist die Installation fertig: das DBC-Kabel festschrauben, alles wieder zumachen und den Roller entriegeln.", - "dbcWalkAwayFailure": "Wenn der Roller stattdessen mit dem Warnblinker blinkt oder du ein rotes Licht siehst, ist etwas schiefgelaufen: den Laptop wieder anschließen.", - "dbcWalkAwayDashboardLitButton": "Der Tacho ist angegangen", + "dbcWalkAwayHeadline": "Umstecken erledigt. Die Installation läuft jetzt von selbst.", + "dbcWalkAwayBody": "Lass den Roller in Ruhe, während er das DBC flasht. Das kann 10 bis 20 Minuten dauern.", + "dbcWalkAwayDashboardLit": "Der Tacho geht während der Installation mehrmals an und aus, das ist normal. Fertig ist die Installation erst, wenn die Keycard-LED am Tacho grün blinkt: dann das DBC-Kabel festschrauben, alles wieder zumachen und den Roller entriegeln.", + "dbcWalkAwayFailure": "Wenn der Roller mit dem Warnblinker blinkt oder die Keycard-LED rot blinkt, ist etwas schiefgelaufen: den Laptop wieder ans MDB anschließen.", + "dbcWalkAwayDashboardLitButton": "Die LED blinkt grün", "dbcWalkAwayWentWrongButton": "Etwas ist schiefgelaufen", "phaseKeycardSetupTitle": "Schlüsselkarten einrichten", "phaseKeycardSetupDescription": "Schlüsselkarten anlernen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 121a342..29b84ce 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -100,7 +100,7 @@ "unfinishedInstallDetected": "Unfinished installation detected, continuing without unlock...", "waitingForBatteryData": "Waiting for AUX/CBB battery data...", "resumeFoundHeading": "Interrupted installation found", - "resumeFoundBody": "A previous installation on this scooter did not finish. The unlock step has been skipped and disabled services were re-enabled. Continuing will run the installation again from the beginning.", + "resumeFoundBody": "A previous installation on this scooter did not finish. The unlock step has been skipped and disabled services were re-enabled. Continuing will pick the installation up where it left off.", "resumeFoundLastError": "Last recorded error:", "awaitingUnlockHeading": "Unlock your scooter", "awaitingUnlockDetail": "Please unlock your scooter to continue. Use your keycard or paired phone.", @@ -271,7 +271,7 @@ }, "dbcFlashError": "DBC Flash Error", "closeButton": "Close", - "trampolineStatusUnknown": "Trampoline status unknown. Check /data/trampoline.log on MDB.", + "trampolineStatusUnknown": "Trampoline status unknown. Check /data/installer/trampoline.log on the MDB.", "welcomeToLibrescoot": "Welcome to Librescoot!", "finalSteps": "Final steps:", @@ -279,6 +279,8 @@ "disconnectUsbFromLaptopFinalDesc": "Unplug the laptop USB cable from the MDB. The DBC cable goes back into that port next.", "reconnectDbcUsbCable": "Reconnect DBC USB cable", "reconnectDbcUsbCableDesc": "Plug the internal DBC USB cable back into the MDB port, then gently screw it in to secure it.", + "screwDbcUsbCable": "Screw the DBC USB cable down", + "screwDbcUsbCableDesc": "The DBC cable is already plugged into the MDB port; gently screw it in to secure it.", "closeSeatboxAndFootwell": "Replace the footwell cover", "closeSeatboxAndFootwellDesc": "Clip the metal bars back in first, then fit the footwell cover and screw it down.", "unlockScooter": "Unlock your scooter", @@ -421,11 +423,11 @@ "finishRebootingTitle": "Rebooting scooter…", "finishRebootingBody": "Waiting for the MDB to drop the USB link before completing the install.", "networkConfigNeedsPermission": "macOS is asking for permission to change network settings. Click Allow in the system dialog, then hit Retry.", - "dbcWalkAwayHeadline": "Swap done. You can unplug the laptop now.", - "dbcWalkAwayBody": "Leave the scooter alone for a few minutes while it flashes the dashboard on its own. This can take 10 to 20 minutes.", - "dbcWalkAwayDashboardLit": "When the dashboard lights up, the install is finished: screw the DBC cable down, close everything up, and unlock the scooter.", - "dbcWalkAwayFailure": "If the scooter flashes its hazard lights or you see a red light instead, something went wrong: plug the laptop back in.", - "dbcWalkAwayDashboardLitButton": "The dashboard lit up", + "dbcWalkAwayHeadline": "Swap done. The install is now running on its own.", + "dbcWalkAwayBody": "Leave the scooter alone while it flashes the dashboard. This can take 10 to 20 minutes.", + "dbcWalkAwayDashboardLit": "The dashboard will turn on and off several times during the install; that's normal. The install is only finished when the keycard LED on the dashboard blinks green: then screw the DBC cable down, close everything up, and unlock the scooter.", + "dbcWalkAwayFailure": "If the scooter flashes its hazard lights or the keycard LED blinks red instead, something went wrong: plug the laptop back into the MDB.", + "dbcWalkAwayDashboardLitButton": "The LED is blinking green", "dbcWalkAwayWentWrongButton": "Something went wrong", "phaseKeycardSetupTitle": "Keycard Setup", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index f59386a..59332d7 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -671,7 +671,7 @@ abstract class AppLocalizations { /// No description provided for @resumeFoundBody. /// /// In en, this message translates to: - /// **'A previous installation on this scooter did not finish. The unlock step has been skipped and disabled services were re-enabled. Continuing will run the installation again from the beginning.'** + /// **'A previous installation on this scooter did not finish. The unlock step has been skipped and disabled services were re-enabled. Continuing will pick the installation up where it left off.'** String get resumeFoundBody; /// No description provided for @resumeFoundLastError. @@ -1295,7 +1295,7 @@ abstract class AppLocalizations { /// No description provided for @trampolineStatusUnknown. /// /// In en, this message translates to: - /// **'Trampoline status unknown. Check /data/trampoline.log on MDB.'** + /// **'Trampoline status unknown. Check /data/installer/trampoline.log on the MDB.'** String get trampolineStatusUnknown; /// No description provided for @welcomeToLibrescoot. @@ -1334,6 +1334,18 @@ abstract class AppLocalizations { /// **'Plug the internal DBC USB cable back into the MDB port, then gently screw it in to secure it.'** String get reconnectDbcUsbCableDesc; + /// No description provided for @screwDbcUsbCable. + /// + /// In en, this message translates to: + /// **'Screw the DBC USB cable down'** + String get screwDbcUsbCable; + + /// No description provided for @screwDbcUsbCableDesc. + /// + /// In en, this message translates to: + /// **'The DBC cable is already plugged into the MDB port; gently screw it in to secure it.'** + String get screwDbcUsbCableDesc; + /// No description provided for @closeSeatboxAndFootwell. /// /// In en, this message translates to: @@ -1895,31 +1907,31 @@ abstract class AppLocalizations { /// No description provided for @dbcWalkAwayHeadline. /// /// In en, this message translates to: - /// **'Swap done. You can unplug the laptop now.'** + /// **'Swap done. The install is now running on its own.'** String get dbcWalkAwayHeadline; /// No description provided for @dbcWalkAwayBody. /// /// In en, this message translates to: - /// **'Leave the scooter alone for a few minutes while it flashes the dashboard on its own. This can take 10 to 20 minutes.'** + /// **'Leave the scooter alone while it flashes the dashboard. This can take 10 to 20 minutes.'** String get dbcWalkAwayBody; /// No description provided for @dbcWalkAwayDashboardLit. /// /// In en, this message translates to: - /// **'When the dashboard lights up, the install is finished: screw the DBC cable down, close everything up, and unlock the scooter.'** + /// **'The dashboard will turn on and off several times during the install; that\'s normal. The install is only finished when the keycard LED on the dashboard blinks green: then screw the DBC cable down, close everything up, and unlock the scooter.'** String get dbcWalkAwayDashboardLit; /// No description provided for @dbcWalkAwayFailure. /// /// In en, this message translates to: - /// **'If the scooter flashes its hazard lights or you see a red light instead, something went wrong: plug the laptop back in.'** + /// **'If the scooter flashes its hazard lights or the keycard LED blinks red instead, something went wrong: plug the laptop back into the MDB.'** String get dbcWalkAwayFailure; /// No description provided for @dbcWalkAwayDashboardLitButton. /// /// In en, this message translates to: - /// **'The dashboard lit up'** + /// **'The LED is blinking green'** String get dbcWalkAwayDashboardLitButton; /// No description provided for @dbcWalkAwayWentWrongButton. diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index eeac239..d8082d6 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -321,7 +321,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get resumeFoundBody => - 'Eine frühere Installation auf diesem Roller wurde nicht abgeschlossen. Der Entsperr-Schritt wurde übersprungen und deaktivierte Dienste wurden wieder aktiviert. Die Installation beginnt anschließend von vorn.'; + 'Eine frühere Installation auf diesem Roller wurde nicht abgeschlossen. Der Entsperr-Schritt wurde übersprungen und deaktivierte Dienste wurden wieder aktiviert. Die Installation wird an der passenden Stelle fortgesetzt.'; @override String get resumeFoundLastError => 'Letzter aufgezeichneter Fehler:'; @@ -688,7 +688,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get trampolineStatusUnknown => - 'Trampoline-Status unbekannt. Prüfe /data/trampoline.log auf dem MDB.'; + 'Trampoline-Status unbekannt. Prüfe /data/installer/trampoline.log auf dem MDB.'; @override String get welcomeToLibrescoot => 'Willkommen bei Librescoot!'; @@ -711,6 +711,13 @@ class AppLocalizationsDe extends AppLocalizations { String get reconnectDbcUsbCableDesc => 'Stecke das interne DBC-USB-Kabel wieder in den MDB-Port und schraube es jetzt vorsichtig fest.'; + @override + String get screwDbcUsbCable => 'DBC-USB-Kabel festschrauben'; + + @override + String get screwDbcUsbCableDesc => + 'Das DBC-Kabel steckt bereits im MDB-Port; schraube es jetzt vorsichtig fest.'; + @override String get closeSeatboxAndFootwell => 'Fußraumabdeckung wieder anbringen'; @@ -1058,22 +1065,22 @@ class AppLocalizationsDe extends AppLocalizations { @override String get dbcWalkAwayHeadline => - 'Umstecken erledigt. Du kannst den Laptop jetzt abziehen.'; + 'Umstecken erledigt. Die Installation läuft jetzt von selbst.'; @override String get dbcWalkAwayBody => - 'Lass den Roller ein paar Minuten in Ruhe, während er das DBC selbstständig flasht. Das kann 10 bis 20 Minuten dauern.'; + 'Lass den Roller in Ruhe, während er das DBC flasht. Das kann 10 bis 20 Minuten dauern.'; @override String get dbcWalkAwayDashboardLit => - 'Wenn der Tacho angeht, ist die Installation fertig: das DBC-Kabel festschrauben, alles wieder zumachen und den Roller entriegeln.'; + 'Der Tacho geht während der Installation mehrmals an und aus, das ist normal. Fertig ist die Installation erst, wenn die Keycard-LED am Tacho grün blinkt: dann das DBC-Kabel festschrauben, alles wieder zumachen und den Roller entriegeln.'; @override String get dbcWalkAwayFailure => - 'Wenn der Roller stattdessen mit dem Warnblinker blinkt oder du ein rotes Licht siehst, ist etwas schiefgelaufen: den Laptop wieder anschließen.'; + 'Wenn der Roller mit dem Warnblinker blinkt oder die Keycard-LED rot blinkt, ist etwas schiefgelaufen: den Laptop wieder ans MDB anschließen.'; @override - String get dbcWalkAwayDashboardLitButton => 'Der Tacho ist angegangen'; + String get dbcWalkAwayDashboardLitButton => 'Die LED blinkt grün'; @override String get dbcWalkAwayWentWrongButton => 'Etwas ist schiefgelaufen'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a758e9b..b16e5d9 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -316,7 +316,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get resumeFoundBody => - 'A previous installation on this scooter did not finish. The unlock step has been skipped and disabled services were re-enabled. Continuing will run the installation again from the beginning.'; + 'A previous installation on this scooter did not finish. The unlock step has been skipped and disabled services were re-enabled. Continuing will pick the installation up where it left off.'; @override String get resumeFoundLastError => 'Last recorded error:'; @@ -682,7 +682,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get trampolineStatusUnknown => - 'Trampoline status unknown. Check /data/trampoline.log on MDB.'; + 'Trampoline status unknown. Check /data/installer/trampoline.log on the MDB.'; @override String get welcomeToLibrescoot => 'Welcome to Librescoot!'; @@ -705,6 +705,13 @@ class AppLocalizationsEn extends AppLocalizations { String get reconnectDbcUsbCableDesc => 'Plug the internal DBC USB cable back into the MDB port, then gently screw it in to secure it.'; + @override + String get screwDbcUsbCable => 'Screw the DBC USB cable down'; + + @override + String get screwDbcUsbCableDesc => + 'The DBC cable is already plugged into the MDB port; gently screw it in to secure it.'; + @override String get closeSeatboxAndFootwell => 'Replace the footwell cover'; @@ -1047,22 +1054,23 @@ class AppLocalizationsEn extends AppLocalizations { 'macOS is asking for permission to change network settings. Click Allow in the system dialog, then hit Retry.'; @override - String get dbcWalkAwayHeadline => 'Swap done. You can unplug the laptop now.'; + String get dbcWalkAwayHeadline => + 'Swap done. The install is now running on its own.'; @override String get dbcWalkAwayBody => - 'Leave the scooter alone for a few minutes while it flashes the dashboard on its own. This can take 10 to 20 minutes.'; + 'Leave the scooter alone while it flashes the dashboard. This can take 10 to 20 minutes.'; @override String get dbcWalkAwayDashboardLit => - 'When the dashboard lights up, the install is finished: screw the DBC cable down, close everything up, and unlock the scooter.'; + 'The dashboard will turn on and off several times during the install; that\'s normal. The install is only finished when the keycard LED on the dashboard blinks green: then screw the DBC cable down, close everything up, and unlock the scooter.'; @override String get dbcWalkAwayFailure => - 'If the scooter flashes its hazard lights or you see a red light instead, something went wrong: plug the laptop back in.'; + 'If the scooter flashes its hazard lights or the keycard LED blinks red instead, something went wrong: plug the laptop back into the MDB.'; @override - String get dbcWalkAwayDashboardLitButton => 'The dashboard lit up'; + String get dbcWalkAwayDashboardLitButton => 'The LED is blinking green'; @override String get dbcWalkAwayWentWrongButton => 'Something went wrong'; diff --git a/lib/screens/installer_screen.dart b/lib/screens/installer_screen.dart index 90da796..e692707 100644 --- a/lib/screens/installer_screen.dart +++ b/lib/screens/installer_screen.dart @@ -454,9 +454,13 @@ class _InstallerScreenState extends State { /// updates pull from the same track they just installed). Both are /// best-effort — failure is harmless, the user can fix from the dashboard. Future _onEnterFinish() async { - if (_isDryRun || !_sshService.isConnected) { - // Dry-run / no SSH: nothing to reboot, render the success screen - // immediately. + // On the walk-away happy path the laptop is not plugged into the MDB. + // The SSH object may still claim to be connected (the transport only + // notices on the next command), so every MDB-side step below would + // stack multi-minute TCP timeouts while the user stares at the + // waiting-for-reboot screen. There is no MDB to talk to: skip it all. + if (_isDryRun || _finishFromWalkAway || !_sshService.isConnected) { + // Nothing to reboot, render the success screen immediately. if (mounted) setState(() => _awaitingFinishReboot = false); return; } @@ -1806,6 +1810,18 @@ class _InstallerScreenState extends State { } }); try { + // state.json saying trampoline-armed only proves an earlier session + // launched the trampoline; the process may be gone (launch failure, + // MDB reboot, crash). Resuming to the swap-cables screen with no + // trampoline listening would wait forever, so re-arm a dead one here. + if (decision?.phase == InstallerPhase.dbcSwapAndFlash && !_isDryRun) { + final trampoline = TrampolineService(_sshService); + if (!await trampoline.isRunning()) { + debugPrint('Resume: trampoline not running, re-arming'); + _setStatus(l10n.startingTrampoline); + await trampoline.start(); + } + } await _completeConnectionSetup( l10n, nextPhase: decision?.phase ?? InstallerPhase.healthCheck, @@ -3359,6 +3375,10 @@ class _InstallerScreenState extends State { bool _dbcFlashWatchStarted = false; bool _dbcUsbDisconnected = false; + // Whether the finish screen was reached from the walk-away happy path + // (green LED confirmed, laptop never reconnected, DBC cable already in + // the MDB port) rather than via the laptop-reconnect verify path. + bool _finishFromWalkAway = false; List _dbcPrepSubsteps = const []; List _reconnectSubsteps = const []; DateTime? _reconnectRndisWaitStart; @@ -3532,10 +3552,16 @@ class _InstallerScreenState extends State { runSpacing: 8, alignment: WrapAlignment.center, children: [ - // Happy path: the dashboard powered on. No MDB reconnect, no - // verify; we trust the lit display and finish. + // Happy path: the keycard LED blinks green. No MDB reconnect, + // no verify; we trust the success signal and finish. The + // laptop is not plugged into the MDB on this path and the DBC + // cable already is, so the finish screen must not ask to swap + // them again. FilledButton.icon( - onPressed: () => _setPhase(InstallerPhase.finish), + onPressed: () { + _finishFromWalkAway = true; + _setPhase(InstallerPhase.finish); + }, icon: const Icon(Icons.check_circle, color: Colors.green), label: Text(l10n.dbcWalkAwayDashboardLitButton), ), @@ -5036,23 +5062,34 @@ class _InstallerScreenState extends State { const SizedBox(height: 24), Text(l10n.finalSteps, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), const SizedBox(height: 16), + // On the walk-away happy path the laptop is not plugged into the + // MDB and the DBC cable already is (just not screwed down), so + // the cable-swap steps don't apply. + if (_finishFromWalkAway) + InstructionStep( + number: 1, + title: l10n.screwDbcUsbCable, + description: l10n.screwDbcUsbCableDesc, + ) + else ...[ + InstructionStep( + number: 1, + title: l10n.disconnectUsbFromLaptopFinal, + description: l10n.disconnectUsbFromLaptopFinalDesc, + ), + InstructionStep( + number: 2, + title: l10n.reconnectDbcUsbCable, + description: l10n.reconnectDbcUsbCableDesc, + ), + ], InstructionStep( - number: 1, - title: l10n.disconnectUsbFromLaptopFinal, - description: l10n.disconnectUsbFromLaptopFinalDesc, - ), - InstructionStep( - number: 2, - title: l10n.reconnectDbcUsbCable, - description: l10n.reconnectDbcUsbCableDesc, - ), - InstructionStep( - number: 3, + number: _finishFromWalkAway ? 2 : 3, title: l10n.closeSeatboxAndFootwell, description: l10n.closeSeatboxAndFootwellDesc, ), InstructionStep( - number: 4, + number: _finishFromWalkAway ? 3 : 4, title: l10n.unlockScooter, description: l10n.unlockScooterDesc, ), From 5f569d9e9f7033f9ec066949216c88823334f55b Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Mon, 13 Jul 2026 23:13:36 +0200 Subject: [PATCH 34/43] fix(installer): switch geoip back to ip-api.com ipwho.is places Berlin consumer IPs in Hessen; ip-api.com resolves the same IP correctly. Its region field is the ISO 3166-2 subdivision code, so the existing geo map works unchanged. The free tier is HTTP-only and non-commercial, which is fine here: worst case a spoofed answer preselects a dropdown the user can change. --- lib/models/region.dart | 20 ++++++++++++-------- test/services/download_service_test.dart | 24 ++++++++++++------------ 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/lib/models/region.dart b/lib/models/region.dart index 62823e7..6eec689 100644 --- a/lib/models/region.dart +++ b/lib/models/region.dart @@ -45,9 +45,9 @@ class Region { 'italy-nord-ovest': (name: 'Italien (Nordwest)', country: 'Italien'), }; - /// Map ipwho.is country_code -> region_code -> our slug. Keyed on the ISO - /// 3166-2 subdivision code rather than the English region name, which is - /// stable across the provider's localisation. Both Berlin (BE) and + /// Map geo-provider countryCode -> ISO 3166-2 subdivision code -> our slug. + /// Keyed on the subdivision code rather than the English region name, which + /// is stable across the provider's localisation. Both Berlin (BE) and /// Brandenburg (BB) collapse to the combined berlin_brandenburg region. /// France and Italy only have partial coverage, so only the subdivisions /// whose tiles we publish map to a slug: @@ -106,7 +106,11 @@ class Region { .map((w) => '${w[0].toUpperCase()}${w.substring(1)}') .join(' '); - /// Try to detect the user's region from their IP via ipwho.is (HTTPS). + /// Try to detect the user's region from their IP via ip-api.com. The free + /// tier is HTTP-only (non-commercial use), but it resolves German consumer + /// IPs to the right state far more reliably than the HTTPS providers we + /// tried (ipwho.is placed Berlin DSL lines in Hessen), and a spoofed + /// answer can at worst preselect a dropdown the user can change. /// Returns the region slug, or null if detection fails or the location is /// not a region we map. The caller matches the slug against the regions it /// actually offers before preselecting. @@ -115,14 +119,14 @@ class Region { final c = client ?? http.Client(); final response = await c .get(Uri.parse( - 'https://ipwho.is/?fields=success,country_code,region_code')) + 'http://ip-api.com/json/?fields=status,countryCode,region')) .timeout(const Duration(seconds: 5)); if (client == null) c.close(); if (response.statusCode != 200) return null; final data = jsonDecode(response.body) as Map; - if (data['success'] != true) return null; - final countryCode = data['country_code'] as String?; - final regionCode = data['region_code'] as String?; + if (data['status'] != 'success') return null; + final countryCode = data['countryCode'] as String?; + final regionCode = data['region'] as String?; if (countryCode == null) return null; return _geoMap[countryCode]?[regionCode] ?? _countryDefault[countryCode]; } catch (_) { diff --git a/test/services/download_service_test.dart b/test/services/download_service_test.dart index 9eba744..7a92ea4 100644 --- a/test/services/download_service_test.dart +++ b/test/services/download_service_test.dart @@ -161,14 +161,14 @@ void main() { }); group('Region.detectSlugFromIp', () { - test('maps a German region_code to its slug', () async { + test('maps a German subdivision code to its slug', () async { final client = http_testing.MockClient((request) async { - expect(request.url.host, 'ipwho.is'); + expect(request.url.host, 'ip-api.com'); return http.Response( jsonEncode({ - 'success': true, - 'country_code': 'DE', - 'region_code': 'BY', + 'status': 'success', + 'countryCode': 'DE', + 'region': 'BY', }), 200); }); @@ -179,9 +179,9 @@ void main() { for (final code in ['BE', 'BB']) { final client = http_testing.MockClient((request) async => http.Response( jsonEncode({ - 'success': true, - 'country_code': 'DE', - 'region_code': code, + 'status': 'success', + 'countryCode': 'DE', + 'region': code, }), 200)); expect(await Region.detectSlugFromIp(client: client), @@ -192,9 +192,9 @@ void main() { Future detect(String country, String? region) { final client = http_testing.MockClient((request) async => http.Response( jsonEncode({ - 'success': true, - 'country_code': country, - if (region != null) 'region_code': region, + 'status': 'success', + 'countryCode': country, + if (region != null) 'region': region, }), 200)); return Region.detectSlugFromIp(client: client); @@ -221,7 +221,7 @@ void main() { test('returns null when the lookup is unsuccessful', () async { final client = http_testing.MockClient((request) async => - http.Response(jsonEncode({'success': false}), 200)); + http.Response(jsonEncode({'status': 'fail'}), 200)); expect(await Region.detectSlugFromIp(client: client), isNull); }); }); From cc84ce969bbce16f9807e3d5953a2f275e7add57 Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Mon, 13 Jul 2026 23:32:59 +0200 Subject: [PATCH 35/43] feat(installer): LED progress pulses, restore normal state on success The boot LED guard now doubles as a progress display: amber pulse groups, one pulse per started quarter, fed from step boundaries and the block-device sector counter during the long image write. Green and red stay reserved for the terminal states. The success exits that skip the MDB reboot (V3 UMS tile copy, DBC already at target) left the scooter degraded: keycard/ums/pm stopped or masked and the installer-only overrides (usb0-policy=always-on, auto-standby=0, alarm off) persisted, so a walk-away user ended up with a dead keycard reader and no alarm. All success paths now restore the services and wipe the override settings. Starting keycard is safe in the MDB-first flow: cards are enrolled before the DBC is staged, so a master exists and auto-master-learn stays off. --- assets/trampoline.sh.template | 106 +++++++++++++++++++++++++++++----- 1 file changed, 92 insertions(+), 14 deletions(-) diff --git a/assets/trampoline.sh.template b/assets/trampoline.sh.template index bcbbe39..d73edaf 100644 --- a/assets/trampoline.sh.template +++ b/assets/trampoline.sh.template @@ -130,21 +130,58 @@ led_off() { led_duty $1 0; led_deactivate $1; } # the LP5562. Spawned via systemd-run so it survives this script exiting # (KillMode=control-group on librescoot-onboot.service would otherwise # clip it). Stopped via `systemctl stop`. +# +# The guard doubles as the progress display: amber pulse groups, one pulse +# per started quarter (1 = 0-24%, 2 = 25-49%, 3 = 50-74%, 4 = 75-99%), +# read from /data/installer/progress each cycle. Any stray LED write is +# still overwritten within a pulse. Green and red stay reserved for the +# terminal states. BOOTLED_GUARD_UNIT="librescoot-bootled-guard" bootled_guard_start() { systemctl stop "${BOOTLED_GUARD_UNIT}.service" 2>/dev/null systemd-run --unit="$BOOTLED_GUARD_UNIT" --collect --quiet --slice=system.slice /bin/sh -c ' - while true; do - i2cset -f -y 2 0x30 0x02 0xFF 2>/dev/null + amber() { + i2cset -f -y 2 0x30 0x02 "$1" 2>/dev/null i2cset -f -y 2 0x30 0x03 0x00 2>/dev/null i2cset -f -y 2 0x30 0x04 0x00 2>/dev/null - sleep 2 + } + while true; do + P=$(cat /data/installer/progress 2>/dev/null) + case "$P" in ""|*[!0-9]*) P=0;; esac + N=$((P / 25 + 1)); [ "$N" -gt 4 ] && N=4 + i=0 + while [ "$i" -lt "$N" ]; do + amber 0xFF; sleep 0.2 + amber 0x00; sleep 0.3 + i=$((i+1)) + done + sleep 1.4 done ' } bootled_guard_stop() { systemctl stop "${BOOTLED_GUARD_UNIT}.service" 2>/dev/null } +set_progress() { echo "$1" > "$INSTALLER_DIR/progress"; } + +# Put the scooter back into normal operation after a successful install. +# Both success exits that skip the MDB reboot need this: nothing else +# restarts what the trampoline stopped pre-flash, and the walk-away user +# never reconnects the installer. Wiping /data/settings.toml drops the +# installer-only overrides the app persisted before the flash +# (usb0-policy=always-on, auto-standby=0, alarm.enabled=false). Starting +# keycard is safe in the MDB-first flow: cards are enrolled before the +# DBC is staged, so a master exists and auto-master-learn stays off. +restore_normal_operation() { + log " restoring services + settings for normal operation..." + rm -f /data/settings.toml + systemctl restart librescoot-settings 2>/dev/null + systemctl unmask librescoot-keycard keycard-service librescoot-bluetooth librescoot-ums 2>/dev/null + systemctl start librescoot-bluetooth 2>/dev/null + systemctl start librescoot-ums 2>/dev/null + systemctl start librescoot-pm 2>/dev/null + systemctl start librescoot-keycard 2>/dev/null +} # Hazards (all four blinkers — FL=3, FR=4, RL=6, RR=7) flashing in unison # is the universal "vehicle in trouble" signal — much more visible than @@ -508,6 +545,7 @@ FLASH_MON_PID="" flash_progress_monitor_start() { local name="$(basename "$1")" ( + base="$(awk '{print $7}' "/sys/block/$name/stat" 2>/dev/null)" last="" while :; do sleep 60 @@ -517,6 +555,14 @@ flash_progress_monitor_start() { log " flash progress: $cur sectors written to $name" last="$cur" fi + # Map written bytes onto the 20-70 band of the overall install so the + # LED pulse groups advance during the longest step. + if [ -n "$base" ] && [ "${FLASH_TOTAL:-0}" -gt 0 ]; then + pct=$((20 + (cur - base) * 512 * 50 / FLASH_TOTAL)) + [ "$pct" -gt 70 ] && pct=70 + [ "$pct" -lt 20 ] && pct=20 + set_progress "$pct" + fi done ) & FLASH_MON_PID=$! @@ -839,6 +885,7 @@ systemctl stop librescoot-ums 2>/dev/null || true systemctl mask librescoot-ums 2>/dev/null || true systemctl stop librescoot-pm 2>/dev/null || true +set_progress 0 bootled_init bootled amber # vehicle-service also drives the LP5562 (for blinker brightness) and we @@ -879,6 +926,7 @@ while [ "$GONE" -lt "$GONE_NEEDED" ]; do sleep 1 done log "Laptop disconnected (debounced)" +set_progress 5 step_end # Step 2: Re-init g_ether for DBC @@ -908,6 +956,7 @@ if [ "$DBC_ALREADY_UMS" != "yes" ]; then probe_dbc_or_fail fi fi +set_progress 10 step_end if [ "$DBC_ALREADY_UMS" != "yes" ]; then @@ -939,6 +988,7 @@ if [ "$DBC_ALREADY_UMS" != "yes" ]; then fi trampoline_watchdog_stop [ -n "$JOURNAL_PID" ] && kill $JOURNAL_PID 2>/dev/null + restore_normal_operation echo "success" > "$STATUS_FILE" bootled_guard_stop bootled_blink_green @@ -1020,6 +1070,7 @@ if [ "$DBC_ALREADY_UMS" != "yes" ]; then break done [ "$STEP4_OK" != "yes" ] && fail "Failed to configure DBC after 3 attempts" + set_progress 15 step_end fi # end of DBC_ALREADY_UMS != yes @@ -1150,6 +1201,7 @@ do_dbc_flash_round() { return 1 fi log "DBC device: $DBC_DEV ($(cat /sys/block/$(basename $DBC_DEV)/size 2>/dev/null || echo '?') sectors)" + set_progress 20 step_end # Step 7: Flash DBC @@ -1243,6 +1295,7 @@ do_dbc_flash_round() { return 1 fi log "DBC flash complete" + set_progress 72 step_end return 0 } @@ -1333,8 +1386,10 @@ fi # ANY reason (tools missing, partition not found, grow/mount/copy failed) we # fall through to the proven onboot.sh + reboot path below, unchanged. step_begin "Step 8a: grow DBC data + copy tiles over UMS (V3)" +set_progress 75 if grow_and_populate_dbc "$DBC_DEV"; then log "DBC complete via UMS (V3); no MDB reboot, no onboot tile push needed" + set_progress 90 step_end # Power the DBC on so its dashboard comes up = done. The DBC's own first-boot # grow now no-ops (already grown). Mirror the clean-success LED sequence used @@ -1347,6 +1402,7 @@ if grow_and_populate_dbc "$DBC_DEV"; then trampoline_watchdog_stop log " powering DBC on..." lsc --redis-addr localhost:6379 dbc on 2>/dev/null || true + restore_normal_operation echo "success" > "$STATUS_FILE" cat "$LOG_FILE" >> "$STATUS_FILE" bootled_guard_stop @@ -1361,6 +1417,7 @@ step_end # Step 8: Sync, power off DBC, write onboot script, reboot MDB step_begin "Step 8: post-flash cleanup" +set_progress 75 # Power off the DBC cleanly. Historically we did off-wait → on-wait → # off-wait here to "let the DBC do its first boot / partition resize" @@ -1499,23 +1556,37 @@ hazards_30s() { } # Re-arm the boot-LED guard. vehicle-service can stomp the LP5562 when -# it drives blinker brightness; the guard re-asserts amber every 2s. +# it drives blinker brightness; the guard re-asserts the LED state. # Stopped explicitly before bootled_blink_{green,red} at the end. +# Same amber pulse-group progress display as the trampoline: one pulse +# per started quarter, read from /data/installer/progress. BOOTLED_GUARD_UNIT="librescoot-bootled-guard" bootled_guard_start() { systemctl stop "${BOOTLED_GUARD_UNIT}.service" 2>/dev/null systemd-run --unit="$BOOTLED_GUARD_UNIT" --collect --quiet --slice=system.slice /bin/sh -c ' - while true; do - i2cset -f -y 2 0x30 0x02 0xFF 2>/dev/null + amber() { + i2cset -f -y 2 0x30 0x02 "$1" 2>/dev/null i2cset -f -y 2 0x30 0x03 0x00 2>/dev/null i2cset -f -y 2 0x30 0x04 0x00 2>/dev/null - sleep 2 + } + while true; do + P=$(cat /data/installer/progress 2>/dev/null) + case "$P" in ""|*[!0-9]*) P=0;; esac + N=$((P / 25 + 1)); [ "$N" -gt 4 ] && N=4 + i=0 + while [ "$i" -lt "$N" ]; do + amber 0xFF; sleep 0.2 + amber 0x00; sleep 0.3 + i=$((i+1)) + done + sleep 1.4 done ' } bootled_guard_stop() { systemctl stop "${BOOTLED_GUARD_UNIT}.service" 2>/dev/null } +set_progress() { echo "$1" > "$INSTALLER_DIR/progress"; } # Image write succeeded but we're not done — tile uploads to the DBC still # need to happen over the same USB link. Stay amber so the user knows the @@ -1526,6 +1597,7 @@ bootled_guard_stop() { i2cset -f -y 2 0x30 0x02 0xFF 2>/dev/null i2cset -f -y 2 0x30 0x03 0x00 2>/dev/null i2cset -f -y 2 0x30 0x04 0x00 2>/dev/null +set_progress 80 bootled_guard_start # Restore original onboot.sh if backed up, otherwise delete ourselves @@ -1702,6 +1774,7 @@ TILES fi cat >> /data/onboot.sh << 'ONBOOT_END' +set_progress 90 # Turn off DBC lsc --redis-addr localhost:6379 dbc off 2>/dev/null || true @@ -1729,16 +1802,21 @@ else # off). Now and only now signal the user it's safe to swap the MDB's # single USB port back to the laptop. # Stop the LED guard before starting the green blink, otherwise the - # guard re-asserts amber every 2s and we get an ugly amber/green flicker. + # guard re-asserts amber and we get an ugly amber/green flicker. bootled_guard_stop bootled_blink_green - # Unmask keycard-service. The installer's keycardSetup phase will start - # it explicitly. We deliberately do NOT start it now: a freshly flashed - # device with no master would auto-enter master-learning and silently - # teach in whatever card the user taps before they reach explicit setup. + # Put the scooter back into normal operation. The walk-away user never + # reconnects the installer, so this is the last chance to drop the + # installer-only overrides (usb0-policy=always-on, auto-standby=0, + # alarm.enabled=false) and restart the stopped services. Starting + # keycard is safe in the MDB-first flow: cards were enrolled before + # the DBC was staged, so a master exists and auto-master-learn stays + # off. Started after the green blink is armed so its LP5562 init loses + # to the blink loop within a cycle. + rm -f /data/settings.toml + systemctl restart librescoot-settings 2>/dev/null || true systemctl unmask librescoot-keycard keycard-service 2>/dev/null || true - # Unmask + start bluetooth — the installer's bluetoothPairing phase - # needs it running. No equivalent "wait for explicit start" concern. + systemctl start librescoot-keycard 2>/dev/null || true systemctl unmask librescoot-bluetooth 2>/dev/null || true systemctl start librescoot-bluetooth 2>/dev/null || true # Re-enable UMS now that all USB-host work on the OTG UDC is done. From ab25e3e67a359ef7b483157f7b7a3e5b4b55e102 Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Mon, 13 Jul 2026 23:33:12 +0200 Subject: [PATCH 36/43] fix(installer): resume keycard start and flow-text continuity Resuming into a post-flash phase now starts the keycard service when state.json says cards are enrolled; the resolver carries the bt_paired/keycard_enrolled flags through the trampoline phases so the resume handler can tell. Previously a resume that landed on the finish screen left the reader dead until the next reboot. Text continuity fixes from a full flow audit: - disconnectCbbDesc claimed the battery must be removed; the previous step only switches it off in place (safety-instruction mismatch) - the DBC prep strip showed "DBC flash successful!" once the upload to the MDB finished, before the flash ever ran; new dbcPrepComplete key for the staged state - sidebar step said "Remove Battery" for the deactivate-in-place phase - the disabled Begin button hinted "waiting for downloads" during the upload stage - unlockScooterDesc assumed keycards and Bluetooth were set up; both are skippable - reconnectUsbToLaptop now says to unplug the DBC cable first (the MDB port is occupied at that point) - finish-screen cable steps are phrased state-safely for the resume path, which cannot know whether the swap already happened --- lib/l10n/app_de.arb | 17 +++++++++-------- lib/l10n/app_en.arb | 17 +++++++++-------- lib/l10n/app_localizations.dart | 22 ++++++++++++++-------- lib/l10n/app_localizations_de.dart | 22 +++++++++++++--------- lib/l10n/app_localizations_en.dart | 22 +++++++++++++--------- lib/screens/installer_screen.dart | 15 ++++++++++++--- lib/services/resume_resolver.dart | 18 ++++++++++++++++-- 7 files changed, 86 insertions(+), 47 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 04ff6c9..444c9b7 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -12,7 +12,7 @@ "phaseResumeDetectedDescription": "Unterbrochene Installation gefunden", "phaseHealthCheckTitle": "Statusprüfung", "phaseHealthCheckDescription": "Roller-Bereitschaft prüfen", - "phaseBatteryRemovalTitle": "Akku entfernen", + "phaseBatteryRemovalTitle": "Akku abschalten", "phaseBatteryRemovalDescription": "Sitzbank öffnen, Fahrakku entnehmen", "phaseMdbToUmsTitle": "MDB → UMS", "phaseMdbToUmsDescription": "Bootloader für Flashen konfigurieren", @@ -159,7 +159,7 @@ "scooterPrepHeading": "Roller vorbereiten", "scooterPrepSubheading": "MDB-Firmware wurde geschrieben. Jetzt für den Neustart vorbereiten.", "disconnectCbb": "CBB trennen", - "disconnectCbbDesc": "Der Fahrakku muss bereits entnommen sein, bevor du die CBB trennst. Bei falscher Reihenfolge droht ein elektrischer Schaden.", + "disconnectCbbDesc": "Der Fahrakku muss bereits abgeschaltet sein (vorheriger Schritt), bevor du die CBB trennst. Bei falscher Reihenfolge droht ein elektrischer Schaden.", "disconnectAuxPole": "Einen AUX-Pol trennen", "disconnectAuxPoleDesc": "Entferne NUR den Pluspol (außen, rotes Kabel und Pol), um eine Verpolung zu vermeiden. Dadurch wird das MDB stromlos; die USB-Verbindung geht verloren.", "auxDisconnectWarning": "Die USB-Verbindung geht verloren, wenn du AUX trennst. Das ist normal. Der Installer wartet auf den Neustart des MDB.", @@ -196,7 +196,7 @@ "reconnectDbcUsbToMdb": "DBC-USB-Kabel mit MDB verbinden", "reconnectDbcUsbToMdbDesc": "Stecke das interne DBC-USB-Kabel in den MDB-Port. Noch nicht festschrauben.", "verifyingDbcInstallation": "DBC-Installation wird geprüft", - "reconnectUsbToLaptop": "USB wieder mit Laptop verbinden...", + "reconnectUsbToLaptop": "DBC-Kabel vom MDB abziehen und den Laptop wieder anschließen...", "waitingForRndisDevice": "Warte auf RNDIS-Gerät...", "readingTrampolineStatus": "Trampoline-Status wird gelesen...", "readingTrampolineStatusElapsed": "Trampoline-Status wird gelesen… ({elapsed}s)", @@ -206,22 +206,23 @@ } }, "dbcFlashSuccessful": "DBC-Flash erfolgreich!", + "dbcPrepComplete": "DBC-Image bereit zum Flashen", "dbcFlashFailed": "DBC-Flash fehlgeschlagen: {message}", "dbcFlashError": "DBC-Flash-Fehler", "closeButton": "Schließen", "trampolineStatusUnknown": "Trampoline-Status unbekannt. Prüfe /data/installer/trampoline.log auf dem MDB.", "welcomeToLibrescoot": "Willkommen bei Librescoot!", "finalSteps": "Letzte Schritte:", - "disconnectUsbFromLaptopFinal": "Laptop-USB-Kabel vom MDB abziehen", - "disconnectUsbFromLaptopFinalDesc": "Ziehe das Laptop-USB-Kabel vom MDB ab. Dort kommt gleich das DBC-Kabel wieder hinein.", - "reconnectDbcUsbCable": "DBC-USB-Kabel anschließen", - "reconnectDbcUsbCableDesc": "Stecke das interne DBC-USB-Kabel wieder in den MDB-Port und schraube es jetzt vorsichtig fest.", + "disconnectUsbFromLaptopFinal": "Laptop vom MDB abziehen", + "disconnectUsbFromLaptopFinalDesc": "Falls der Laptop noch am MDB-Port steckt, zieh ihn jetzt ab. Dort kommt das DBC-Kabel wieder hinein.", + "reconnectDbcUsbCable": "DBC-USB-Kabel anschließen und festschrauben", + "reconnectDbcUsbCableDesc": "Stecke das interne DBC-USB-Kabel wieder in den MDB-Port, falls nicht schon geschehen, und schraube es vorsichtig fest.", "screwDbcUsbCable": "DBC-USB-Kabel festschrauben", "screwDbcUsbCableDesc": "Das DBC-Kabel steckt bereits im MDB-Port; schraube es jetzt vorsichtig fest.", "closeSeatboxAndFootwell": "Fußraumabdeckung wieder anbringen", "closeSeatboxAndFootwellDesc": "Klipse zuerst die Metallbügel wieder ein, setze dann die Fußraumabdeckung auf und schraube sie fest.", "unlockScooter": "Roller entsperren", - "unlockScooterDesc": "Nutze eine der angelernten Schlüsselkarten oder entsperre über Bluetooth.", + "unlockScooterDesc": "Nutze eine eingerichtete Schlüsselkarte, ein gekoppeltes Handy oder den Button in der App.", "deletedCache": "{sizeMb} MB gelöscht", "downloads": "Downloads", "downloadsFinished": "Downloads abgeschlossen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 29b84ce..aba1f40 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -13,7 +13,7 @@ "phaseResumeDetectedDescription": "Interrupted installation found", "phaseHealthCheckTitle": "Health Check", "phaseHealthCheckDescription": "Verify scooter readiness", - "phaseBatteryRemovalTitle": "Remove Battery", + "phaseBatteryRemovalTitle": "Switch Off Battery", "phaseBatteryRemovalDescription": "Open seatbox, remove main battery", "phaseMdbToUmsTitle": "Prepare for Flashing", "phaseMdbToUmsDescription": "Configure bootloader for flashing", @@ -190,7 +190,7 @@ "scooterPrepHeading": "Scooter Preparation", "scooterPrepSubheading": "MDB firmware has been written. Now prepare for reboot.", "disconnectCbb": "Disconnect the CBB", - "disconnectCbbDesc": "The main battery must already be removed before disconnecting CBB. Failure to follow this order risks electrical damage.", + "disconnectCbbDesc": "The main battery must already be switched off (the previous step) before disconnecting the CBB. Failure to follow this order risks electrical damage.", "disconnectAuxPole": "Disconnect one AUX pole", "disconnectAuxPoleDesc": "Remove ONLY the positive pole (outermost, the red cable and pole) to avoid risk of inverting polarity. This will remove power from the MDB; the USB connection will disappear.", "auxDisconnectWarning": "The USB connection will be lost when you disconnect AUX. This is expected. The installer will wait for the MDB to reboot.", @@ -253,7 +253,7 @@ "reconnectDbcUsbToMdbDesc": "Plug the internal DBC USB cable into the MDB port. Don't screw it in yet.", "verifyingDbcInstallation": "Verifying DBC Installation", - "reconnectUsbToLaptop": "Reconnect USB to laptop...", + "reconnectUsbToLaptop": "Unplug the DBC cable from the MDB and plug the laptop back in...", "waitingForRndisDevice": "Waiting for RNDIS device...", "readingTrampolineStatus": "Reading trampoline status...", "readingTrampolineStatusElapsed": "Reading trampoline status… ({elapsed}s)", @@ -263,6 +263,7 @@ } }, "dbcFlashSuccessful": "DBC flash successful!", + "dbcPrepComplete": "DBC image ready to flash", "dbcFlashFailed": "DBC flash failed: {message}", "@dbcFlashFailed": { "placeholders": { @@ -275,16 +276,16 @@ "welcomeToLibrescoot": "Welcome to Librescoot!", "finalSteps": "Final steps:", - "disconnectUsbFromLaptopFinal": "Unplug the laptop USB cable from the MDB", - "disconnectUsbFromLaptopFinalDesc": "Unplug the laptop USB cable from the MDB. The DBC cable goes back into that port next.", - "reconnectDbcUsbCable": "Reconnect DBC USB cable", - "reconnectDbcUsbCableDesc": "Plug the internal DBC USB cable back into the MDB port, then gently screw it in to secure it.", + "disconnectUsbFromLaptopFinal": "Unplug the laptop from the MDB", + "disconnectUsbFromLaptopFinalDesc": "If the laptop is still plugged into the MDB port, unplug it now. The DBC cable goes back into that port.", + "reconnectDbcUsbCable": "Reconnect and screw down the DBC USB cable", + "reconnectDbcUsbCableDesc": "Plug the internal DBC USB cable back into the MDB port if it isn't already, then gently screw it in to secure it.", "screwDbcUsbCable": "Screw the DBC USB cable down", "screwDbcUsbCableDesc": "The DBC cable is already plugged into the MDB port; gently screw it in to secure it.", "closeSeatboxAndFootwell": "Replace the footwell cover", "closeSeatboxAndFootwellDesc": "Clip the metal bars back in first, then fit the footwell cover and screw it down.", "unlockScooter": "Unlock your scooter", - "unlockScooterDesc": "Use one of the keycards you registered, or unlock via Bluetooth.", + "unlockScooterDesc": "Use a keycard or paired phone if you set one up, or the button in the app.", "deletedCache": "Deleted {sizeMb} MB", "@deletedCache": { "placeholders": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 59332d7..d2540bc 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -173,7 +173,7 @@ abstract class AppLocalizations { /// No description provided for @phaseBatteryRemovalTitle. /// /// In en, this message translates to: - /// **'Remove Battery'** + /// **'Switch Off Battery'** String get phaseBatteryRemovalTitle; /// No description provided for @phaseBatteryRemovalDescription. @@ -1025,7 +1025,7 @@ abstract class AppLocalizations { /// No description provided for @disconnectCbbDesc. /// /// In en, this message translates to: - /// **'The main battery must already be removed before disconnecting CBB. Failure to follow this order risks electrical damage.'** + /// **'The main battery must already be switched off (the previous step) before disconnecting the CBB. Failure to follow this order risks electrical damage.'** String get disconnectCbbDesc; /// No description provided for @disconnectAuxPole. @@ -1247,7 +1247,7 @@ abstract class AppLocalizations { /// No description provided for @reconnectUsbToLaptop. /// /// In en, this message translates to: - /// **'Reconnect USB to laptop...'** + /// **'Unplug the DBC cable from the MDB and plug the laptop back in...'** String get reconnectUsbToLaptop; /// No description provided for @waitingForRndisDevice. @@ -1274,6 +1274,12 @@ abstract class AppLocalizations { /// **'DBC flash successful!'** String get dbcFlashSuccessful; + /// No description provided for @dbcPrepComplete. + /// + /// In en, this message translates to: + /// **'DBC image ready to flash'** + String get dbcPrepComplete; + /// No description provided for @dbcFlashFailed. /// /// In en, this message translates to: @@ -1313,25 +1319,25 @@ abstract class AppLocalizations { /// No description provided for @disconnectUsbFromLaptopFinal. /// /// In en, this message translates to: - /// **'Unplug the laptop USB cable from the MDB'** + /// **'Unplug the laptop from the MDB'** String get disconnectUsbFromLaptopFinal; /// No description provided for @disconnectUsbFromLaptopFinalDesc. /// /// In en, this message translates to: - /// **'Unplug the laptop USB cable from the MDB. The DBC cable goes back into that port next.'** + /// **'If the laptop is still plugged into the MDB port, unplug it now. The DBC cable goes back into that port.'** String get disconnectUsbFromLaptopFinalDesc; /// No description provided for @reconnectDbcUsbCable. /// /// In en, this message translates to: - /// **'Reconnect DBC USB cable'** + /// **'Reconnect and screw down the DBC USB cable'** String get reconnectDbcUsbCable; /// No description provided for @reconnectDbcUsbCableDesc. /// /// In en, this message translates to: - /// **'Plug the internal DBC USB cable back into the MDB port, then gently screw it in to secure it.'** + /// **'Plug the internal DBC USB cable back into the MDB port if it isn\'t already, then gently screw it in to secure it.'** String get reconnectDbcUsbCableDesc; /// No description provided for @screwDbcUsbCable. @@ -1367,7 +1373,7 @@ abstract class AppLocalizations { /// No description provided for @unlockScooterDesc. /// /// In en, this message translates to: - /// **'Use one of the keycards you registered, or unlock via Bluetooth.'** + /// **'Use a keycard or paired phone if you set one up, or the button in the app.'** String get unlockScooterDesc; /// No description provided for @deletedCache. diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index d8082d6..13f7635 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -46,7 +46,7 @@ class AppLocalizationsDe extends AppLocalizations { String get phaseHealthCheckDescription => 'Roller-Bereitschaft prüfen'; @override - String get phaseBatteryRemovalTitle => 'Akku entfernen'; + String get phaseBatteryRemovalTitle => 'Akku abschalten'; @override String get phaseBatteryRemovalDescription => @@ -528,7 +528,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get disconnectCbbDesc => - 'Der Fahrakku muss bereits entnommen sein, bevor du die CBB trennst. Bei falscher Reihenfolge droht ein elektrischer Schaden.'; + 'Der Fahrakku muss bereits abgeschaltet sein (vorheriger Schritt), bevor du die CBB trennst. Bei falscher Reihenfolge droht ein elektrischer Schaden.'; @override String get disconnectAuxPole => 'Einen AUX-Pol trennen'; @@ -659,7 +659,8 @@ class AppLocalizationsDe extends AppLocalizations { String get verifyingDbcInstallation => 'DBC-Installation wird geprüft'; @override - String get reconnectUsbToLaptop => 'USB wieder mit Laptop verbinden...'; + String get reconnectUsbToLaptop => + 'DBC-Kabel vom MDB abziehen und den Laptop wieder anschließen...'; @override String get waitingForRndisDevice => 'Warte auf RNDIS-Gerät...'; @@ -675,6 +676,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get dbcFlashSuccessful => 'DBC-Flash erfolgreich!'; + @override + String get dbcPrepComplete => 'DBC-Image bereit zum Flashen'; + @override String dbcFlashFailed(String message) { return 'DBC-Flash fehlgeschlagen: $message'; @@ -697,19 +701,19 @@ class AppLocalizationsDe extends AppLocalizations { String get finalSteps => 'Letzte Schritte:'; @override - String get disconnectUsbFromLaptopFinal => - 'Laptop-USB-Kabel vom MDB abziehen'; + String get disconnectUsbFromLaptopFinal => 'Laptop vom MDB abziehen'; @override String get disconnectUsbFromLaptopFinalDesc => - 'Ziehe das Laptop-USB-Kabel vom MDB ab. Dort kommt gleich das DBC-Kabel wieder hinein.'; + 'Falls der Laptop noch am MDB-Port steckt, zieh ihn jetzt ab. Dort kommt das DBC-Kabel wieder hinein.'; @override - String get reconnectDbcUsbCable => 'DBC-USB-Kabel anschließen'; + String get reconnectDbcUsbCable => + 'DBC-USB-Kabel anschließen und festschrauben'; @override String get reconnectDbcUsbCableDesc => - 'Stecke das interne DBC-USB-Kabel wieder in den MDB-Port und schraube es jetzt vorsichtig fest.'; + 'Stecke das interne DBC-USB-Kabel wieder in den MDB-Port, falls nicht schon geschehen, und schraube es vorsichtig fest.'; @override String get screwDbcUsbCable => 'DBC-USB-Kabel festschrauben'; @@ -730,7 +734,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get unlockScooterDesc => - 'Nutze eine der angelernten Schlüsselkarten oder entsperre über Bluetooth.'; + 'Nutze eine eingerichtete Schlüsselkarte, ein gekoppeltes Handy oder den Button in der App.'; @override String deletedCache(String sizeMb) { diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index b16e5d9..c6d8753 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -45,7 +45,7 @@ class AppLocalizationsEn extends AppLocalizations { String get phaseHealthCheckDescription => 'Verify scooter readiness'; @override - String get phaseBatteryRemovalTitle => 'Remove Battery'; + String get phaseBatteryRemovalTitle => 'Switch Off Battery'; @override String get phaseBatteryRemovalDescription => @@ -521,7 +521,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get disconnectCbbDesc => - 'The main battery must already be removed before disconnecting CBB. Failure to follow this order risks electrical damage.'; + 'The main battery must already be switched off (the previous step) before disconnecting the CBB. Failure to follow this order risks electrical damage.'; @override String get disconnectAuxPole => 'Disconnect one AUX pole'; @@ -653,7 +653,8 @@ class AppLocalizationsEn extends AppLocalizations { String get verifyingDbcInstallation => 'Verifying DBC Installation'; @override - String get reconnectUsbToLaptop => 'Reconnect USB to laptop...'; + String get reconnectUsbToLaptop => + 'Unplug the DBC cable from the MDB and plug the laptop back in...'; @override String get waitingForRndisDevice => 'Waiting for RNDIS device...'; @@ -669,6 +670,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get dbcFlashSuccessful => 'DBC flash successful!'; + @override + String get dbcPrepComplete => 'DBC image ready to flash'; + @override String dbcFlashFailed(String message) { return 'DBC flash failed: $message'; @@ -691,19 +695,19 @@ class AppLocalizationsEn extends AppLocalizations { String get finalSteps => 'Final steps:'; @override - String get disconnectUsbFromLaptopFinal => - 'Unplug the laptop USB cable from the MDB'; + String get disconnectUsbFromLaptopFinal => 'Unplug the laptop from the MDB'; @override String get disconnectUsbFromLaptopFinalDesc => - 'Unplug the laptop USB cable from the MDB. The DBC cable goes back into that port next.'; + 'If the laptop is still plugged into the MDB port, unplug it now. The DBC cable goes back into that port.'; @override - String get reconnectDbcUsbCable => 'Reconnect DBC USB cable'; + String get reconnectDbcUsbCable => + 'Reconnect and screw down the DBC USB cable'; @override String get reconnectDbcUsbCableDesc => - 'Plug the internal DBC USB cable back into the MDB port, then gently screw it in to secure it.'; + 'Plug the internal DBC USB cable back into the MDB port if it isn\'t already, then gently screw it in to secure it.'; @override String get screwDbcUsbCable => 'Screw the DBC USB cable down'; @@ -724,7 +728,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get unlockScooterDesc => - 'Use one of the keycards you registered, or unlock via Bluetooth.'; + 'Use a keycard or paired phone if you set one up, or the button in the app.'; @override String deletedCache(String sizeMb) { diff --git a/lib/screens/installer_screen.dart b/lib/screens/installer_screen.dart index e692707..105666c 100644 --- a/lib/screens/installer_screen.dart +++ b/lib/screens/installer_screen.dart @@ -1711,6 +1711,15 @@ class _InstallerScreenState extends State { 'librescoot-bluetooth librescoot-ums 2>/dev/null; ' 'systemctl start librescoot-bluetooth librescoot-ums 2>/dev/null; true', ); + // With cards already enrolled a master exists, so starting the + // keycard service cannot trigger auto-master-learn. Without this, + // a resume that lands on the finish screen leaves the reader dead + // until the next reboot. + if (decision.keycardDone) { + await _sshService.runCommand( + 'systemctl start librescoot-keycard 2>/dev/null; true', + ); + } } catch (e) { debugPrint('SSH: service unmask on resume failed (ok): $e'); } @@ -3094,7 +3103,7 @@ class _InstallerScreenState extends State { padding: const EdgeInsets.only(top: 8), child: Center( child: Text( - !_dbcUploadReady ? l10n.waitingForDownloads : l10n.finishStepsAboveToContinue, + !_dbcUploadReady ? l10n.preparingDbcFlash : l10n.finishStepsAboveToContinue, style: TextStyle(color: Colors.grey.shade500, fontSize: 12), textAlign: TextAlign.center, ), @@ -3166,7 +3175,7 @@ class _InstallerScreenState extends State { const SizedBox(width: 8), Expanded( child: Text( - _dbcUploadReady ? l10n.dbcFlashSuccessful : l10n.preparingDbcFlash, + _dbcUploadReady ? l10n.dbcPrepComplete : l10n.preparingDbcFlash, style: TextStyle(fontSize: 12, color: Colors.grey.shade400), overflow: TextOverflow.ellipsis, ), @@ -3209,7 +3218,7 @@ class _InstallerScreenState extends State { const SizedBox(width: 8), Expanded( child: Text( - _dbcUploadReady ? l10n.dbcFlashSuccessful : l10n.preparingDbcFlash, + _dbcUploadReady ? l10n.dbcPrepComplete : l10n.preparingDbcFlash, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14), ), ), diff --git a/lib/services/resume_resolver.dart b/lib/services/resume_resolver.dart index b0dde41..d81d909 100644 --- a/lib/services/resume_resolver.dart +++ b/lib/services/resume_resolver.dart @@ -67,20 +67,34 @@ ResumeDecision resolveResume({ ); case InstallPhase.trampolineArmed: if (status?.result == TrampolineResult.success) { - return ResumeDecision(phase: InstallerPhase.finish, skipUnlockGate: true); + return ResumeDecision( + phase: InstallerPhase.finish, + skipUnlockGate: true, + bluetoothDone: state.btPaired, + keycardDone: state.keycardEnrolled, + ); } return ResumeDecision( phase: InstallerPhase.dbcSwapAndFlash, skipUnlockGate: true, + bluetoothDone: state.btPaired, + keycardDone: state.keycardEnrolled, previousError: err, ); case InstallPhase.trampolineOk: case InstallPhase.finished: - return ResumeDecision(phase: InstallerPhase.finish, skipUnlockGate: true); + return ResumeDecision( + phase: InstallerPhase.finish, + skipUnlockGate: true, + bluetoothDone: state.btPaired, + keycardDone: state.keycardEnrolled, + ); case InstallPhase.trampolineErr: return ResumeDecision( phase: InstallerPhase.dbcSwapAndFlash, skipUnlockGate: true, + bluetoothDone: state.btPaired, + keycardDone: state.keycardEnrolled, previousError: err, ); case InstallPhase.unknown: From 365d3854e15f31765db85a39ec963612430e88da Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Tue, 14 Jul 2026 10:13:52 +0200 Subject: [PATCH 37/43] fix(installer): clear resume state on finish in debug builds Non-release builds skipped the MDB cleanup entirely, so state.json stayed at trampoline-armed and every reconnect after a successful finish resumed to the finish screen as an unfinished install. Keep the images and logs for postmortem but remove the resume triggers. Also reword the reboot-wait screen: the old text read like an instruction to unplug USB, but the app is just waiting for the MDB reboot to drop the link on its own. --- lib/l10n/app_de.arb | 2 +- lib/l10n/app_en.arb | 2 +- lib/l10n/app_localizations.dart | 2 +- lib/l10n/app_localizations_de.dart | 2 +- lib/l10n/app_localizations_en.dart | 2 +- lib/screens/installer_screen.dart | 16 +++++++++++++--- 6 files changed, 18 insertions(+), 8 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 444c9b7..263d2c7 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -331,7 +331,7 @@ "checkingCbbAndBattery": "CBB und Akku werden geprüft...", "waitingForUsbDisconnect": "Warte auf USB-Trennung...", "finishRebootingTitle": "Roller startet neu…", - "finishRebootingBody": "Warte auf die USB-Trennung, dann ist der Installer fertig.", + "finishRebootingBody": "Das MDB startet gerade neu; die USB-Verbindung bricht dabei von selbst ab. Du musst noch nichts umstecken.", "networkConfigNeedsPermission": "macOS fragt nach Erlaubnis, die Netzwerk-Einstellungen zu ändern. Im Systemdialog auf 'Erlauben' klicken, dann 'Erneut versuchen' drücken.", "dbcWalkAwayHeadline": "Umstecken erledigt. Die Installation läuft jetzt von selbst.", "dbcWalkAwayBody": "Lass den Roller in Ruhe, während er das DBC flasht. Das kann 10 bis 20 Minuten dauern.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index aba1f40..0e77cc2 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -422,7 +422,7 @@ "waitingForUsbDisconnect": "Waiting for USB disconnect...", "finishRebootingTitle": "Rebooting scooter…", - "finishRebootingBody": "Waiting for the MDB to drop the USB link before completing the install.", + "finishRebootingBody": "The MDB is rebooting; the USB connection will drop by itself. No need to touch the cable yet.", "networkConfigNeedsPermission": "macOS is asking for permission to change network settings. Click Allow in the system dialog, then hit Retry.", "dbcWalkAwayHeadline": "Swap done. The install is now running on its own.", "dbcWalkAwayBody": "Leave the scooter alone while it flashes the dashboard. This can take 10 to 20 minutes.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d2540bc..b36dfc9 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1901,7 +1901,7 @@ abstract class AppLocalizations { /// No description provided for @finishRebootingBody. /// /// In en, this message translates to: - /// **'Waiting for the MDB to drop the USB link before completing the install.'** + /// **'The MDB is rebooting; the USB connection will drop by itself. No need to touch the cable yet.'** String get finishRebootingBody; /// No description provided for @networkConfigNeedsPermission. diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 13f7635..37c89b2 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1061,7 +1061,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get finishRebootingBody => - 'Warte auf die USB-Trennung, dann ist der Installer fertig.'; + 'Das MDB startet gerade neu; die USB-Verbindung bricht dabei von selbst ab. Du musst noch nichts umstecken.'; @override String get networkConfigNeedsPermission => diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index c6d8753..326b0c6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1051,7 +1051,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get finishRebootingBody => - 'Waiting for the MDB to drop the USB link before completing the install.'; + 'The MDB is rebooting; the USB connection will drop by itself. No need to touch the cable yet.'; @override String get networkConfigNeedsPermission => diff --git a/lib/screens/installer_screen.dart b/lib/screens/installer_screen.dart index 105666c..a8b1c4f 100644 --- a/lib/screens/installer_screen.dart +++ b/lib/screens/installer_screen.dart @@ -499,12 +499,22 @@ class _InstallerScreenState extends State { // Wipe installer staging from /data before we kick off the reboot, so // the user doesn't carry a few hundred MB of leftover image/tile files - // around forever. Skipped in non-release builds so devs can poke at - // the trampoline state after a failed run. + // around forever. Non-release builds keep the images and logs so devs + // can poke at the trampoline state, but must still clear the resume + // triggers: leaving state.json at trampoline-armed makes every later + // connect resume to the finish screen as an unfinished install. if (kReleaseMode) { await _cleanupMdb(); } else { - debugPrint('UI: skipping MDB cleanup (non-release build)'); + debugPrint('UI: non-release build, clearing resume state only'); + try { + await _sshService.runCommand( + 'rm -f /data/installer/state.json /data/installer/trampoline-status ' + '/data/installer/trampoline.sh; true', + ); + } catch (e) { + debugPrint('UI: failed to clear resume state (ok): $e'); + } } // Reboot the MDB. The install path leaves several services stopped From 6a6e5c51265a4b916da01fda1494a46bf28a05ba Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Tue, 14 Jul 2026 10:16:19 +0200 Subject: [PATCH 38/43] feat(installer): add --force-dbc-reflash launch flag The trampoline skips the DBC flash when the target version is already installed. That makes full end-to-end testing impossible on an up-to-date scooter; this flag threads through to FORCE_DBC_REFLASH in the trampoline template and flashes unconditionally. Carried across the self-elevation relaunch like --dry-run. --- lib/main.dart | 8 ++++++++ lib/screens/installer_screen.dart | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/main.dart b/lib/main.dart index 5c399c2..63cff5e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -80,6 +80,9 @@ class LaunchArgs { /// region selection). final bool noOfflineMaps; final bool dryRun; + /// Skip the DBC already-at-target-version check in the trampoline and + /// flash unconditionally. For testing the full flash path. + final bool forceDbcReflash; LaunchArgs({ this.channel, @@ -90,6 +93,7 @@ class LaunchArgs { this.autoStart = false, this.noOfflineMaps = false, this.dryRun = false, + this.forceDbcReflash = false, }); factory LaunchArgs.fromArgs(List args) { @@ -97,6 +101,7 @@ class LaunchArgs { var autoStart = false; var noOfflineMaps = false; var dryRun = false; + var forceDbcReflash = false; for (final arg in args) { if (arg.startsWith('--channel=')) channel = arg.split('=')[1]; if (arg.startsWith('--region=')) region = arg.split('=')[1]; @@ -106,6 +111,7 @@ class LaunchArgs { if (arg == '--auto-start') autoStart = true; if (arg == '--no-offline-maps') noOfflineMaps = true; if (arg == '--dry-run') dryRun = true; + if (arg == '--force-dbc-reflash') forceDbcReflash = true; } return LaunchArgs( channel: channel, @@ -116,6 +122,7 @@ class LaunchArgs { autoStart: autoStart, noOfflineMaps: noOfflineMaps, dryRun: dryRun, + forceDbcReflash: forceDbcReflash, ); } @@ -138,6 +145,7 @@ class LaunchArgs { if (dbcImage != null) '--dbc-image=$dbcImage', if (!wantsOfflineMaps) '--no-offline-maps', if (dryRun) '--dry-run', + if (forceDbcReflash) '--force-dbc-reflash', '--auto-start', ]; } diff --git a/lib/screens/installer_screen.dart b/lib/screens/installer_screen.dart index a8b1c4f..542b239 100644 --- a/lib/screens/installer_screen.dart +++ b/lib/screens/installer_screen.dart @@ -3318,7 +3318,7 @@ class _InstallerScreenState extends State { // flash if they match. releaseTag is the version/tag of the release // being installed; an empty tag simply never triggers the skip. targetDbcVersion: _downloadState.releaseTag ?? '', - forceDbcReflash: false, + forceDbcReflash: launchArgs.forceDbcReflash, onProgress: (status, progress) { _setStatus(status, progress: progress); }, From 71292f27adffdd5fd53ec8a4448c6c16a5dc3b67 Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Tue, 14 Jul 2026 10:19:35 +0200 Subject: [PATCH 39/43] feat(installer): ask before re-flashing an up-to-date DBC When version-service data in Redis (version:dbc, written the last time the DBC booted Librescoot) shows the DBC already at the exact target version, ask the user: flash again or skip. Skipping reuses the existing skip-DBC path straight to finish, avoiding the pointless cable swap the trampoline's silent at-target exit required. Re-flash answers force FORCE_DBC_REFLASH into the generated trampoline, same as --force-dbc-reflash. With no version:dbc key (stock DBC, or no DBC boot since the last MDB reboot) nothing changes: the trampoline's own SSH check still handles the silent skip. --- lib/l10n/app_de.arb | 4 +++ lib/l10n/app_en.arb | 9 +++++ lib/l10n/app_localizations.dart | 24 +++++++++++++ lib/l10n/app_localizations_de.dart | 14 ++++++++ lib/l10n/app_localizations_en.dart | 14 ++++++++ lib/screens/installer_screen.dart | 55 ++++++++++++++++++++++++++++-- 6 files changed, 118 insertions(+), 2 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 263d2c7..bc53749 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -206,6 +206,10 @@ } }, "dbcFlashSuccessful": "DBC-Flash erfolgreich!", + "dbcAlreadyCurrentTitle": "Dashboard ist bereits aktuell", + "dbcAlreadyCurrentBody": "Auf dem Dashboard (DBC) läuft bereits Librescoot {version}. Trotzdem neu flashen?", + "dbcAlreadyCurrentReflash": "Neu flashen", + "dbcAlreadyCurrentSkip": "DBC-Flash überspringen", "dbcPrepComplete": "DBC-Image bereit zum Flashen", "dbcFlashFailed": "DBC-Flash fehlgeschlagen: {message}", "dbcFlashError": "DBC-Flash-Fehler", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0e77cc2..c75b1e7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -263,6 +263,15 @@ } }, "dbcFlashSuccessful": "DBC flash successful!", + "dbcAlreadyCurrentTitle": "Dashboard already up to date", + "dbcAlreadyCurrentBody": "The dashboard (DBC) already runs Librescoot {version}. Flash it again anyway?", + "@dbcAlreadyCurrentBody": { + "placeholders": { + "version": { "type": "String" } + } + }, + "dbcAlreadyCurrentReflash": "Flash again", + "dbcAlreadyCurrentSkip": "Skip DBC flash", "dbcPrepComplete": "DBC image ready to flash", "dbcFlashFailed": "DBC flash failed: {message}", "@dbcFlashFailed": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index b36dfc9..ee72f63 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1274,6 +1274,30 @@ abstract class AppLocalizations { /// **'DBC flash successful!'** String get dbcFlashSuccessful; + /// No description provided for @dbcAlreadyCurrentTitle. + /// + /// In en, this message translates to: + /// **'Dashboard already up to date'** + String get dbcAlreadyCurrentTitle; + + /// No description provided for @dbcAlreadyCurrentBody. + /// + /// In en, this message translates to: + /// **'The dashboard (DBC) already runs Librescoot {version}. Flash it again anyway?'** + String dbcAlreadyCurrentBody(String version); + + /// No description provided for @dbcAlreadyCurrentReflash. + /// + /// In en, this message translates to: + /// **'Flash again'** + String get dbcAlreadyCurrentReflash; + + /// No description provided for @dbcAlreadyCurrentSkip. + /// + /// In en, this message translates to: + /// **'Skip DBC flash'** + String get dbcAlreadyCurrentSkip; + /// No description provided for @dbcPrepComplete. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 37c89b2..6e91f2b 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -676,6 +676,20 @@ class AppLocalizationsDe extends AppLocalizations { @override String get dbcFlashSuccessful => 'DBC-Flash erfolgreich!'; + @override + String get dbcAlreadyCurrentTitle => 'Dashboard ist bereits aktuell'; + + @override + String dbcAlreadyCurrentBody(String version) { + return 'Auf dem Dashboard (DBC) läuft bereits Librescoot $version. Trotzdem neu flashen?'; + } + + @override + String get dbcAlreadyCurrentReflash => 'Neu flashen'; + + @override + String get dbcAlreadyCurrentSkip => 'DBC-Flash überspringen'; + @override String get dbcPrepComplete => 'DBC-Image bereit zum Flashen'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 326b0c6..e29b71d 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -670,6 +670,20 @@ class AppLocalizationsEn extends AppLocalizations { @override String get dbcFlashSuccessful => 'DBC flash successful!'; + @override + String get dbcAlreadyCurrentTitle => 'Dashboard already up to date'; + + @override + String dbcAlreadyCurrentBody(String version) { + return 'The dashboard (DBC) already runs Librescoot $version. Flash it again anyway?'; + } + + @override + String get dbcAlreadyCurrentReflash => 'Flash again'; + + @override + String get dbcAlreadyCurrentSkip => 'Skip DBC flash'; + @override String get dbcPrepComplete => 'DBC image ready to flash'; diff --git a/lib/screens/installer_screen.dart b/lib/screens/installer_screen.dart index 542b239..ac040b0 100644 --- a/lib/screens/installer_screen.dart +++ b/lib/screens/installer_screen.dart @@ -3299,6 +3299,57 @@ class _InstallerScreenState extends State { } } + // If the DBC already runs the exact target Librescoot version (per + // version-service data in Redis, written when the DBC last booted), + // ask the user instead of silently deciding: re-flash or skip. An + // absent version:dbc key (stock DBC, or no DBC boot since the last + // MDB reboot) means we can't know here; the trampoline's own SSH + // check still covers the silent at-target skip in that case. + var forceReflash = launchArgs.forceDbcReflash; + final targetVersion = _downloadState.releaseTag ?? ''; + if (!forceReflash && targetVersion.isNotEmpty) { + String? dbcId, dbcVersion; + try { + dbcId = await _sshService.redisHget('version:dbc', 'id'); + dbcVersion = await _sshService.redisHget('version:dbc', 'version_id'); + } catch (e) { + debugPrint('UI: version:dbc lookup failed (ok): $e'); + } + if ((dbcId ?? '').startsWith('librescoot') && dbcVersion == targetVersion) { + if (!mounted) return; + final reflash = await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + title: Text(l10n.dbcAlreadyCurrentTitle), + content: Text(l10n.dbcAlreadyCurrentBody(targetVersion)), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: Text(l10n.dbcAlreadyCurrentSkip), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: Text(l10n.dbcAlreadyCurrentReflash), + ), + ], + ), + ); + if (reflash != true) { + _setCritical(false); + if (mounted) { + setState(() { + _skipDbcFlash = true; + _dbcUploadReady = true; + _isProcessing = false; + }); + } + return; + } + forceReflash = true; + } + } + try { final trampolineService = TrampolineService(_sshService); final dbcItem = _downloadState.itemOfType(DownloadItemType.dbcFirmware); @@ -3317,8 +3368,8 @@ class _InstallerScreenState extends State { // the DBC's os-release VERSION_ID over SSH and skips the destructive // flash if they match. releaseTag is the version/tag of the release // being installed; an empty tag simply never triggers the skip. - targetDbcVersion: _downloadState.releaseTag ?? '', - forceDbcReflash: launchArgs.forceDbcReflash, + targetDbcVersion: targetVersion, + forceDbcReflash: forceReflash, onProgress: (status, progress) { _setStatus(status, progress: progress); }, From 94e3eea3f4288d4b105d125b128c6705b2996605 Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Tue, 14 Jul 2026 11:02:29 +0200 Subject: [PATCH 40/43] feat(installer): quiet success, LED off instead of green blink Success used to arm a persistent green blink that ran until someone power-cycled the scooter or the installer reconnected. With the success paths now restoring normal operation, the scooter is immediately usable, so signal completion by silence: the amber progress pulses stop and the LED goes dark. Red blink and hazards stay as the error signal. --- assets/trampoline.sh.template | 48 +++++++++-------------------------- 1 file changed, 12 insertions(+), 36 deletions(-) diff --git a/assets/trampoline.sh.template b/assets/trampoline.sh.template index d73edaf..05f30dd 100644 --- a/assets/trampoline.sh.template +++ b/assets/trampoline.sh.template @@ -78,21 +78,6 @@ bootled_blink_red() { done ' } -bootled_blink_green() { - bootled_blink_stop - systemd-run --unit="$BOOTLED_BLINK_UNIT" --collect --quiet --slice=system.slice /bin/sh -c ' - while true; do - i2cset -f -y 2 0x30 0x02 0x00 2>/dev/null - i2cset -f -y 2 0x30 0x03 0xFF 2>/dev/null - i2cset -f -y 2 0x30 0x04 0x00 2>/dev/null - sleep 0.4 - i2cset -f -y 2 0x30 0x02 0x00 2>/dev/null - i2cset -f -y 2 0x30 0x03 0x00 2>/dev/null - i2cset -f -y 2 0x30 0x04 0x00 2>/dev/null - sleep 0.4 - done - ' -} bootled_blink_stop() { systemctl stop "${BOOTLED_BLINK_UNIT}.service" 2>/dev/null # Best-effort cleanup of any leftover PID-file blink loop from older @@ -991,7 +976,7 @@ if [ "$DBC_ALREADY_UMS" != "yes" ]; then restore_normal_operation echo "success" > "$STATUS_FILE" bootled_guard_stop - bootled_blink_green + bootled off exit 0 fi @@ -1406,7 +1391,9 @@ if grow_and_populate_dbc "$DBC_DEV"; then echo "success" > "$STATUS_FILE" cat "$LOG_FILE" >> "$STATUS_FILE" bootled_guard_stop - bootled_blink_green + # Success is deliberately quiet: pulses stop and the LED goes dark, the + # scooter is back in normal operation and immediately usable. + bootled off # Stop the background journal capture, otherwise it leaks past this # script exiting (reparented to init) and keeps appending forever. [ -n "$JOURNAL_PID" ] && kill $JOURNAL_PID 2>/dev/null @@ -1499,21 +1486,6 @@ bootled_blink_stop() { rm -f /data/bootled-blink.pid fi } -bootled_blink_green() { - bootled_blink_stop - systemd-run --unit="$BOOTLED_BLINK_UNIT" --collect --quiet --slice=system.slice /bin/sh -c ' - while true; do - i2cset -f -y 2 0x30 0x02 0x00 2>/dev/null - i2cset -f -y 2 0x30 0x03 0xFF 2>/dev/null - i2cset -f -y 2 0x30 0x04 0x00 2>/dev/null - sleep 0.4 - i2cset -f -y 2 0x30 0x02 0x00 2>/dev/null - i2cset -f -y 2 0x30 0x03 0x00 2>/dev/null - i2cset -f -y 2 0x30 0x04 0x00 2>/dev/null - sleep 0.4 - done - ' -} bootled_blink_red() { bootled_blink_stop systemd-run --unit="$BOOTLED_BLINK_UNIT" --collect --quiet --slice=system.slice /bin/sh -c ' @@ -1678,7 +1650,9 @@ if [ $ELAPSED -ge 300 ]; then cat "$LOG" >> "$STATUS_FILE" lsc --redis-addr localhost:6379 dbc off 2>/dev/null || true bootled_guard_stop - bootled_blink_green + i2cset -f -y 2 0x30 0x02 0x00 2>/dev/null + i2cset -f -y 2 0x30 0x03 0x00 2>/dev/null + i2cset -f -y 2 0x30 0x04 0x00 2>/dev/null systemctl unmask librescoot-keycard keycard-service 2>/dev/null || true systemctl unmask librescoot-bluetooth 2>/dev/null || true systemctl start librescoot-bluetooth 2>/dev/null || true @@ -1801,10 +1775,12 @@ else # All MDB<->DBC work is done (image flashed, tiles uploaded, DBC powered # off). Now and only now signal the user it's safe to swap the MDB's # single USB port back to the laptop. - # Stop the LED guard before starting the green blink, otherwise the - # guard re-asserts amber and we get an ugly amber/green flicker. + # Success is deliberately quiet: stop the pulse guard and turn the LED + # off. The scooter is back in normal operation and immediately usable. bootled_guard_stop - bootled_blink_green + i2cset -f -y 2 0x30 0x02 0x00 2>/dev/null + i2cset -f -y 2 0x30 0x03 0x00 2>/dev/null + i2cset -f -y 2 0x30 0x04 0x00 2>/dev/null # Put the scooter back into normal operation. The walk-away user never # reconnects the installer, so this is the last chance to drop the # installer-only overrides (usb0-policy=always-on, auto-standby=0, From a46418d13c1f1a921b661b1935cbc484c4238997 Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Tue, 14 Jul 2026 11:02:29 +0200 Subject: [PATCH 41/43] feat(installer): walk-away screen explains LED pulses; pair BT without unlock The DBC-flash walk-away screen now carries the full story: the install runs on its own for 10-20 minutes, progress reads as amber pulse groups on the keycard LED (one pulse after start, up to four near the end), done means the LED stops pulsing and stays off, and the getting-started/handbook block moves up so there is something to read in the meantime. The installer is done at this point; the primary button just continues to the finish screen. Bluetooth pairing no longer unlocks the vehicle. The nRF52 accepts bonding whenever it advertises without whitelist (its gate is the advertising mode, not the lock state) and the passkey reaches the installer via the ble hash, so pairing now pushes advertising-restart-no-whitelisting instead of scooter:state unlock. Unlocking also powered on the DBC, which at that point may still run the stock firmware. Done/skip restore whitelist advertising instead of re-locking. --- lib/l10n/app_de.arb | 11 +++---- lib/l10n/app_en.arb | 11 +++---- lib/l10n/app_localizations.dart | 24 +++++++++------ lib/l10n/app_localizations_de.dart | 16 ++++++---- lib/l10n/app_localizations_en.dart | 16 ++++++---- lib/screens/installer_screen.dart | 48 ++++++++++++++++++++---------- 6 files changed, 80 insertions(+), 46 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index bc53749..080b5e2 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -246,9 +246,9 @@ "bluetoothPairingHeading": "Bluetooth-Kopplung", "bluetoothPairingHint": "Koppele dein Handy oder andere Bluetooth-Geräte mit dem Roller.", "bleMacLabel": "BLE-Adresse", - "startPairing": "Entsperren und Kopplung starten", + "startPairing": "Kopplung starten", "skipPairing": "Überspringen", - "pairingActive": "Roller entsperrt", + "pairingActive": "Kopplungsmodus aktiv", "pairingActiveHint": "Suche den Roller in den Bluetooth-Einstellungen deines Handys und koppele ihn. Drücke Fertig wenn du fertig bist.", "pairingDone": "Fertig", "blePinHint": "Gib diese PIN auf deinem Gerät ein, um die Kopplung abzuschließen.", @@ -338,10 +338,11 @@ "finishRebootingBody": "Das MDB startet gerade neu; die USB-Verbindung bricht dabei von selbst ab. Du musst noch nichts umstecken.", "networkConfigNeedsPermission": "macOS fragt nach Erlaubnis, die Netzwerk-Einstellungen zu ändern. Im Systemdialog auf 'Erlauben' klicken, dann 'Erneut versuchen' drücken.", "dbcWalkAwayHeadline": "Umstecken erledigt. Die Installation läuft jetzt von selbst.", - "dbcWalkAwayBody": "Lass den Roller in Ruhe, während er das DBC flasht. Das kann 10 bis 20 Minuten dauern.", - "dbcWalkAwayDashboardLit": "Der Tacho geht während der Installation mehrmals an und aus, das ist normal. Fertig ist die Installation erst, wenn die Keycard-LED am Tacho grün blinkt: dann das DBC-Kabel festschrauben, alles wieder zumachen und den Roller entriegeln.", + "dbcWalkAwayBody": "Der Roller flasht das Dashboard jetzt selbstständig. Das dauert 10 bis 20 Minuten. Hier am Laptop bist du fertig; du kannst den Installer schließen.", + "dbcWalkAwayLedProgress": "Fortschritt: Die Keycard-LED am Tacho pulst gelb in Gruppen. Ein Puls kurz nach dem Start, bis zu vier Pulse kurz vor Schluss. Der Tacho selbst geht dabei mehrmals an und aus, das ist normal.", + "dbcWalkAwayDone": "Fertig: Die LED hört auf zu pulsieren und bleibt aus. Dann das DBC-Kabel festschrauben, alles wieder zumachen, Roller entriegeln und losfahren.", "dbcWalkAwayFailure": "Wenn der Roller mit dem Warnblinker blinkt oder die Keycard-LED rot blinkt, ist etwas schiefgelaufen: den Laptop wieder ans MDB anschließen.", - "dbcWalkAwayDashboardLitButton": "Die LED blinkt grün", + "dbcWalkAwayDoneButton": "Weiter zum Abschluss", "dbcWalkAwayWentWrongButton": "Etwas ist schiefgelaufen", "phaseKeycardSetupTitle": "Schlüsselkarten einrichten", "phaseKeycardSetupDescription": "Schlüsselkarten anlernen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c75b1e7..774cc13 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -332,9 +332,9 @@ "bluetoothPairingHeading": "Bluetooth Pairing", "bluetoothPairingHint": "Pair your phone or other Bluetooth devices with the scooter.", "bleMacLabel": "BLE address", - "startPairing": "Unlock and start pairing", + "startPairing": "Start pairing", "skipPairing": "Skip", - "pairingActive": "Scooter unlocked", + "pairingActive": "Pairing mode active", "pairingActiveHint": "Search for the scooter in your phone's Bluetooth settings and pair it. Press Done when finished.", "pairingDone": "Done", "blePinHint": "Enter this PIN on your device to complete pairing.", @@ -434,10 +434,11 @@ "finishRebootingBody": "The MDB is rebooting; the USB connection will drop by itself. No need to touch the cable yet.", "networkConfigNeedsPermission": "macOS is asking for permission to change network settings. Click Allow in the system dialog, then hit Retry.", "dbcWalkAwayHeadline": "Swap done. The install is now running on its own.", - "dbcWalkAwayBody": "Leave the scooter alone while it flashes the dashboard. This can take 10 to 20 minutes.", - "dbcWalkAwayDashboardLit": "The dashboard will turn on and off several times during the install; that's normal. The install is only finished when the keycard LED on the dashboard blinks green: then screw the DBC cable down, close everything up, and unlock the scooter.", + "dbcWalkAwayBody": "The scooter now flashes the dashboard on its own. This takes 10 to 20 minutes. You are done here on the laptop; you can close the installer.", + "dbcWalkAwayLedProgress": "Progress: the keycard LED on the dashboard pulses amber in groups. One pulse shortly after the start, up to four pulses near the end. The dashboard itself may turn on and off several times; that's normal.", + "dbcWalkAwayDone": "Done: the LED stops pulsing and stays off. Then screw the DBC cable down, close everything up, unlock the scooter, and ride.", "dbcWalkAwayFailure": "If the scooter flashes its hazard lights or the keycard LED blinks red instead, something went wrong: plug the laptop back into the MDB.", - "dbcWalkAwayDashboardLitButton": "The LED is blinking green", + "dbcWalkAwayDoneButton": "Continue to finish", "dbcWalkAwayWentWrongButton": "Something went wrong", "phaseKeycardSetupTitle": "Keycard Setup", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ee72f63..c8bf154 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1511,7 +1511,7 @@ abstract class AppLocalizations { /// No description provided for @startPairing. /// /// In en, this message translates to: - /// **'Unlock and start pairing'** + /// **'Start pairing'** String get startPairing; /// No description provided for @skipPairing. @@ -1523,7 +1523,7 @@ abstract class AppLocalizations { /// No description provided for @pairingActive. /// /// In en, this message translates to: - /// **'Scooter unlocked'** + /// **'Pairing mode active'** String get pairingActive; /// No description provided for @pairingActiveHint. @@ -1943,14 +1943,20 @@ abstract class AppLocalizations { /// No description provided for @dbcWalkAwayBody. /// /// In en, this message translates to: - /// **'Leave the scooter alone while it flashes the dashboard. This can take 10 to 20 minutes.'** + /// **'The scooter now flashes the dashboard on its own. This takes 10 to 20 minutes. You are done here on the laptop; you can close the installer.'** String get dbcWalkAwayBody; - /// No description provided for @dbcWalkAwayDashboardLit. + /// No description provided for @dbcWalkAwayLedProgress. /// /// In en, this message translates to: - /// **'The dashboard will turn on and off several times during the install; that\'s normal. The install is only finished when the keycard LED on the dashboard blinks green: then screw the DBC cable down, close everything up, and unlock the scooter.'** - String get dbcWalkAwayDashboardLit; + /// **'Progress: the keycard LED on the dashboard pulses amber in groups. One pulse shortly after the start, up to four pulses near the end. The dashboard itself may turn on and off several times; that\'s normal.'** + String get dbcWalkAwayLedProgress; + + /// No description provided for @dbcWalkAwayDone. + /// + /// In en, this message translates to: + /// **'Done: the LED stops pulsing and stays off. Then screw the DBC cable down, close everything up, unlock the scooter, and ride.'** + String get dbcWalkAwayDone; /// No description provided for @dbcWalkAwayFailure. /// @@ -1958,11 +1964,11 @@ abstract class AppLocalizations { /// **'If the scooter flashes its hazard lights or the keycard LED blinks red instead, something went wrong: plug the laptop back into the MDB.'** String get dbcWalkAwayFailure; - /// No description provided for @dbcWalkAwayDashboardLitButton. + /// No description provided for @dbcWalkAwayDoneButton. /// /// In en, this message translates to: - /// **'The LED is blinking green'** - String get dbcWalkAwayDashboardLitButton; + /// **'Continue to finish'** + String get dbcWalkAwayDoneButton; /// No description provided for @dbcWalkAwayWentWrongButton. /// diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 6e91f2b..f6fb07c 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -813,13 +813,13 @@ class AppLocalizationsDe extends AppLocalizations { String get bleMacLabel => 'BLE-Adresse'; @override - String get startPairing => 'Entsperren und Kopplung starten'; + String get startPairing => 'Kopplung starten'; @override String get skipPairing => 'Überspringen'; @override - String get pairingActive => 'Roller entsperrt'; + String get pairingActive => 'Kopplungsmodus aktiv'; @override String get pairingActiveHint => @@ -1087,18 +1087,22 @@ class AppLocalizationsDe extends AppLocalizations { @override String get dbcWalkAwayBody => - 'Lass den Roller in Ruhe, während er das DBC flasht. Das kann 10 bis 20 Minuten dauern.'; + 'Der Roller flasht das Dashboard jetzt selbstständig. Das dauert 10 bis 20 Minuten. Hier am Laptop bist du fertig; du kannst den Installer schließen.'; @override - String get dbcWalkAwayDashboardLit => - 'Der Tacho geht während der Installation mehrmals an und aus, das ist normal. Fertig ist die Installation erst, wenn die Keycard-LED am Tacho grün blinkt: dann das DBC-Kabel festschrauben, alles wieder zumachen und den Roller entriegeln.'; + String get dbcWalkAwayLedProgress => + 'Fortschritt: Die Keycard-LED am Tacho pulst gelb in Gruppen. Ein Puls kurz nach dem Start, bis zu vier Pulse kurz vor Schluss. Der Tacho selbst geht dabei mehrmals an und aus, das ist normal.'; + + @override + String get dbcWalkAwayDone => + 'Fertig: Die LED hört auf zu pulsieren und bleibt aus. Dann das DBC-Kabel festschrauben, alles wieder zumachen, Roller entriegeln und losfahren.'; @override String get dbcWalkAwayFailure => 'Wenn der Roller mit dem Warnblinker blinkt oder die Keycard-LED rot blinkt, ist etwas schiefgelaufen: den Laptop wieder ans MDB anschließen.'; @override - String get dbcWalkAwayDashboardLitButton => 'Die LED blinkt grün'; + String get dbcWalkAwayDoneButton => 'Weiter zum Abschluss'; @override String get dbcWalkAwayWentWrongButton => 'Etwas ist schiefgelaufen'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index e29b71d..4ca2217 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -807,13 +807,13 @@ class AppLocalizationsEn extends AppLocalizations { String get bleMacLabel => 'BLE address'; @override - String get startPairing => 'Unlock and start pairing'; + String get startPairing => 'Start pairing'; @override String get skipPairing => 'Skip'; @override - String get pairingActive => 'Scooter unlocked'; + String get pairingActive => 'Pairing mode active'; @override String get pairingActiveHint => @@ -1077,18 +1077,22 @@ class AppLocalizationsEn extends AppLocalizations { @override String get dbcWalkAwayBody => - 'Leave the scooter alone while it flashes the dashboard. This can take 10 to 20 minutes.'; + 'The scooter now flashes the dashboard on its own. This takes 10 to 20 minutes. You are done here on the laptop; you can close the installer.'; @override - String get dbcWalkAwayDashboardLit => - 'The dashboard will turn on and off several times during the install; that\'s normal. The install is only finished when the keycard LED on the dashboard blinks green: then screw the DBC cable down, close everything up, and unlock the scooter.'; + String get dbcWalkAwayLedProgress => + 'Progress: the keycard LED on the dashboard pulses amber in groups. One pulse shortly after the start, up to four pulses near the end. The dashboard itself may turn on and off several times; that\'s normal.'; + + @override + String get dbcWalkAwayDone => + 'Done: the LED stops pulsing and stays off. Then screw the DBC cable down, close everything up, unlock the scooter, and ride.'; @override String get dbcWalkAwayFailure => 'If the scooter flashes its hazard lights or the keycard LED blinks red instead, something went wrong: plug the laptop back into the MDB.'; @override - String get dbcWalkAwayDashboardLitButton => 'The LED is blinking green'; + String get dbcWalkAwayDoneButton => 'Continue to finish'; @override String get dbcWalkAwayWentWrongButton => 'Something went wrong'; diff --git a/lib/screens/installer_screen.dart b/lib/screens/installer_screen.dart index ac040b0..950dd4e 100644 --- a/lib/screens/installer_screen.dart +++ b/lib/screens/installer_screen.dart @@ -3602,10 +3602,16 @@ class _InstallerScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + _walkAwayOutcome( + Icons.wb_incandescent, + Colors.amber, + l10n.dbcWalkAwayLedProgress, + ), + const SizedBox(height: 10), _walkAwayOutcome( Icons.check_circle, Colors.green, - l10n.dbcWalkAwayDashboardLit, + l10n.dbcWalkAwayDone, ), const SizedBox(height: 10), _walkAwayOutcome( @@ -3617,23 +3623,25 @@ class _InstallerScreenState extends State { ), ), const SizedBox(height: 16), + _buildGettingStarted(l10n), + const SizedBox(height: 16), Wrap( spacing: 12, runSpacing: 8, alignment: WrapAlignment.center, children: [ - // Happy path: the keycard LED blinks green. No MDB reconnect, - // no verify; we trust the success signal and finish. The - // laptop is not plugged into the MDB on this path and the DBC - // cable already is, so the finish screen must not ask to swap - // them again. + // The install runs autonomously from here; the installer is + // effectively done. No MDB reconnect, no verify. The laptop + // is not plugged into the MDB on this path and the DBC cable + // already is, so the finish screen must not ask to swap them + // again. FilledButton.icon( onPressed: () { _finishFromWalkAway = true; _setPhase(InstallerPhase.finish); }, icon: const Icon(Icons.check_circle, color: Colors.green), - label: Text(l10n.dbcWalkAwayDashboardLitButton), + label: Text(l10n.dbcWalkAwayDoneButton), ), // Failure path: reconnect the laptop and run the verify logic // to surface the trampoline error log. @@ -4187,16 +4195,22 @@ class _InstallerScreenState extends State { Future _startBluetoothPairing() async { try { - await _sshService.redisLpush('scooter:state', 'unlock'); - debugPrint('UI: scooter unlocked for BT pairing'); + // Pairing does not need the vehicle unlocked: the nRF52 accepts + // bonding whenever it advertises without whitelist (its pairing gate + // is the advertising mode, not the lock state), and the passkey + // reaches us via the ble hash. Unlocking here used to power on the + // DBC, which at this point may still run the stock firmware. + await _sshService.redisLpush( + 'scooter:bluetooth', 'advertising-restart-no-whitelisting'); + debugPrint('UI: BT advertising without whitelist for pairing'); setState(() { _btPairingActive = true; _blePinCode = null; }); _startBlePinPolling(); } catch (e) { - debugPrint('UI: failed to unlock scooter: $e'); - _setStatus('Failed to unlock scooter: $e'); + debugPrint('UI: failed to start BT pairing: $e'); + _setStatus('Failed to start BT pairing: $e'); } } @@ -4230,11 +4244,14 @@ class _InstallerScreenState extends State { Future _stopBluetoothPairing() async { _blePinPollTimer?.cancel(); _blePinPollTimer = null; + // No unlock happens at pairing start anymore, so there is nothing to + // lock back. Restart advertising with whitelist so only bonded devices + // reconnect from here on. try { - await _sshService.redisLpush('scooter:state', 'lock'); - debugPrint('UI: scooter locked after BT pairing'); + await _sshService.redisLpush( + 'scooter:bluetooth', 'advertising-start-with-whitelisting'); } catch (e) { - debugPrint('UI: failed to lock scooter: $e'); + debugPrint('UI: failed to restore whitelist advertising (ok): $e'); } setState(() { _btPairingActive = false; @@ -4250,7 +4267,8 @@ class _InstallerScreenState extends State { _blePinPollTimer?.cancel(); _blePinPollTimer = null; try { - await _sshService.redisLpush('scooter:state', 'lock'); + await _sshService.redisLpush( + 'scooter:bluetooth', 'advertising-start-with-whitelisting'); } catch (_) {} setState(() { _btPairingActive = false; From 763727103d9f823871f89b9286e6bbb95433e7b7 Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Wed, 15 Jul 2026 09:07:58 +0200 Subject: [PATCH 42/43] ci: publish hyphenated tags as prereleases, skip site rebuilds for them --- .github/workflows/build-desktop.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 2433e4f..3a3e633 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -307,8 +307,13 @@ jobs: with: files: dist/* body_path: release-notes.md + # Hyphenated tags (v1.2.3-beta.1) are prereleases: hidden from + # /releases/latest, so the downloads site and installer never + # pick them up as the current version. + prerelease: ${{ contains(github.ref_name, '-') }} - name: Generate app token for site rebuilds + if: ${{ !contains(github.ref_name, '-') }} id: app-token uses: actions/create-github-app-token@v3 with: @@ -318,6 +323,7 @@ jobs: repositories: downloads.librescoot.org,librescoot.github.io - name: Trigger downloads site rebuild + if: ${{ !contains(github.ref_name, '-') }} uses: peter-evans/repository-dispatch@v4 with: token: ${{ steps.app-token.outputs.token }} @@ -325,6 +331,7 @@ jobs: event-type: releases-changed - name: Trigger main site rebuild + if: ${{ !contains(github.ref_name, '-') }} uses: peter-evans/repository-dispatch@v4 with: token: ${{ steps.app-token.outputs.token }} From eb20cc326483abd22a686638665ed55fb34e1c6e Mon Sep 17 00:00:00 2001 From: Teal Bauer Date: Thu, 16 Jul 2026 07:00:18 +0200 Subject: [PATCH 43/43] fix(installer): point walk-away body at first steps, not app exit Waiting users should land in the getting-started tips and handbook link right below, not be told to close the installer. --- lib/l10n/app_de.arb | 2 +- lib/l10n/app_en.arb | 2 +- lib/l10n/app_localizations.dart | 2 +- lib/l10n/app_localizations_de.dart | 2 +- lib/l10n/app_localizations_en.dart | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 080b5e2..8d780a1 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -338,7 +338,7 @@ "finishRebootingBody": "Das MDB startet gerade neu; die USB-Verbindung bricht dabei von selbst ab. Du musst noch nichts umstecken.", "networkConfigNeedsPermission": "macOS fragt nach Erlaubnis, die Netzwerk-Einstellungen zu ändern. Im Systemdialog auf 'Erlauben' klicken, dann 'Erneut versuchen' drücken.", "dbcWalkAwayHeadline": "Umstecken erledigt. Die Installation läuft jetzt von selbst.", - "dbcWalkAwayBody": "Der Roller flasht das Dashboard jetzt selbstständig. Das dauert 10 bis 20 Minuten. Hier am Laptop bist du fertig; du kannst den Installer schließen.", + "dbcWalkAwayBody": "Der Roller flasht das Dashboard jetzt selbstständig. Das dauert 10 bis 20 Minuten. Schau dir in der Zwischenzeit die ersten Schritte unten an und wirf einen Blick ins Handbuch.", "dbcWalkAwayLedProgress": "Fortschritt: Die Keycard-LED am Tacho pulst gelb in Gruppen. Ein Puls kurz nach dem Start, bis zu vier Pulse kurz vor Schluss. Der Tacho selbst geht dabei mehrmals an und aus, das ist normal.", "dbcWalkAwayDone": "Fertig: Die LED hört auf zu pulsieren und bleibt aus. Dann das DBC-Kabel festschrauben, alles wieder zumachen, Roller entriegeln und losfahren.", "dbcWalkAwayFailure": "Wenn der Roller mit dem Warnblinker blinkt oder die Keycard-LED rot blinkt, ist etwas schiefgelaufen: den Laptop wieder ans MDB anschließen.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 774cc13..98e5f50 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -434,7 +434,7 @@ "finishRebootingBody": "The MDB is rebooting; the USB connection will drop by itself. No need to touch the cable yet.", "networkConfigNeedsPermission": "macOS is asking for permission to change network settings. Click Allow in the system dialog, then hit Retry.", "dbcWalkAwayHeadline": "Swap done. The install is now running on its own.", - "dbcWalkAwayBody": "The scooter now flashes the dashboard on its own. This takes 10 to 20 minutes. You are done here on the laptop; you can close the installer.", + "dbcWalkAwayBody": "The scooter now flashes the dashboard on its own. This takes 10 to 20 minutes. In the meantime, have a look at the first steps below and the handbook.", "dbcWalkAwayLedProgress": "Progress: the keycard LED on the dashboard pulses amber in groups. One pulse shortly after the start, up to four pulses near the end. The dashboard itself may turn on and off several times; that's normal.", "dbcWalkAwayDone": "Done: the LED stops pulsing and stays off. Then screw the DBC cable down, close everything up, unlock the scooter, and ride.", "dbcWalkAwayFailure": "If the scooter flashes its hazard lights or the keycard LED blinks red instead, something went wrong: plug the laptop back into the MDB.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c8bf154..b52a989 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1943,7 +1943,7 @@ abstract class AppLocalizations { /// No description provided for @dbcWalkAwayBody. /// /// In en, this message translates to: - /// **'The scooter now flashes the dashboard on its own. This takes 10 to 20 minutes. You are done here on the laptop; you can close the installer.'** + /// **'The scooter now flashes the dashboard on its own. This takes 10 to 20 minutes. In the meantime, have a look at the first steps below and the handbook.'** String get dbcWalkAwayBody; /// No description provided for @dbcWalkAwayLedProgress. diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index f6fb07c..7f88dc9 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1087,7 +1087,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get dbcWalkAwayBody => - 'Der Roller flasht das Dashboard jetzt selbstständig. Das dauert 10 bis 20 Minuten. Hier am Laptop bist du fertig; du kannst den Installer schließen.'; + 'Der Roller flasht das Dashboard jetzt selbstständig. Das dauert 10 bis 20 Minuten. Schau dir in der Zwischenzeit die ersten Schritte unten an und wirf einen Blick ins Handbuch.'; @override String get dbcWalkAwayLedProgress => diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 4ca2217..93c9ab4 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1077,7 +1077,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get dbcWalkAwayBody => - 'The scooter now flashes the dashboard on its own. This takes 10 to 20 minutes. You are done here on the laptop; you can close the installer.'; + 'The scooter now flashes the dashboard on its own. This takes 10 to 20 minutes. In the meantime, have a look at the first steps below and the handbook.'; @override String get dbcWalkAwayLedProgress =>