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 }} diff --git a/assets/trampoline.sh.template b/assets/trampoline.sh.template index cfbd165..05f30dd 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" @@ -79,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 @@ -117,81 +101,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 + @@ -206,21 +115,58 @@ signal_progress_off() { # 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 @@ -231,8 +177,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 ' @@ -251,8 +195,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 ' } @@ -260,9 +206,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 } @@ -470,6 +417,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 @@ -580,6 +530,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 @@ -589,6 +540,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=$! @@ -598,6 +557,91 @@ 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 + # 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" + 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 @@ -826,6 +870,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 @@ -841,18 +886,32 @@ 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}')" # 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 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 "configured" "$UDC_STATE" 2>/dev/null; then + GONE=0 + else + GONE=$((GONE + 1)) + fi + sleep 1 +done +log "Laptop disconnected (debounced)" +set_progress 5 step_end # Step 2: Re-init g_ether for DBC @@ -882,6 +941,7 @@ if [ "$DBC_ALREADY_UMS" != "yes" ]; then probe_dbc_or_fail fi fi +set_progress 10 step_end if [ "$DBC_ALREADY_UMS" != "yes" ]; then @@ -890,6 +950,36 @@ 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" + # 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 + restore_normal_operation + echo "success" > "$STATUS_FILE" + bootled_guard_stop + bootled off + 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="" @@ -965,175 +1055,356 @@ 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 -# 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')" +# 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. +# +# 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";; + esac + 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 - # 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" + 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" 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." + if [ -n "$min_total" ] && [ "$bytes" -lt "$min_total" ]; then + fail "Target $dev (${bytes}B) smaller than image (${min_total}B) - refusing" 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 + log "Sanity OK: $dev ${bytes}B" + return 0 +} -# 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 - -# 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" - -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 + + # 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)" + set_progress 20 + 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 7: Flash DBC + step_begin "Step 7: flash DBC" + + # 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 - sync + 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 - # 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 + 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" + set_progress 72 + 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 -# 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)" +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 + # 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 + restore_normal_operation + echo "success" > "$STATUS_FILE" + cat "$LOG_FILE" >> "$STATUS_FILE" + bootled_guard_stop + # 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 + 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" +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" @@ -1164,7 +1435,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"; } @@ -1173,45 +1443,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 @@ -1237,34 +1468,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, leaving the channels at duty 0. -# -# We deliberately do NOT deactivate (activate=0) the channels afterwards. -# In the imx_pwm_led driver the active flag gates the PWM output: SET_ACTIVE 0 -# forces the duty to 0 regardless of what's loaded, and SET_DUTY only takes -# effect while the channel is active. vehicle-service runs throughout the -# install and is never restarted by us; it sets activate=1 exactly once, in its -# own Init, and runBlinker afterwards only loads duties. So deactivating here -# would leave vehicle-service loading duties into a gated-off channel and the -# blinkers would stay dark until vehicle-service re-inits (a reboot, a service -# restart). Duty 0 with activate=1 is the same "off" but keeps the channel in -# the state vehicle-service expects, so blinkers work the moment the user signals. -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 -} - # 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, @@ -1283,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 ' @@ -1331,30 +1519,46 @@ 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 ' } # 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 @@ -1365,6 +1569,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 @@ -1420,12 +1625,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 @@ -1450,13 +1649,10 @@ 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 + 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 @@ -1470,10 +1666,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 @@ -1556,6 +1748,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 @@ -1582,22 +1775,24 @@ 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. + # 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 - # 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 - # teach in whatever card the user taps before they reach explicit setup. + 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, + # 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. @@ -1615,12 +1810,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 diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index d5f9c5b..8d780a1 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", @@ -14,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", @@ -26,12 +24,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 +37,7 @@ "majorStepPrepare": "Vorbereitung", "majorStepConnect": "Verbinden", "majorStepMdbFlash": "MDB flashen", + "majorStepMdbPrep": "Dashboard vorbereiten", "majorStepDbcFlash": "DBC flashen", "majorStepFinish": "Abschluss", "majorStepSkippedSuffix": "übersprungen", @@ -63,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", @@ -82,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", @@ -93,16 +89,14 @@ "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...", "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:", - "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", @@ -138,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...", @@ -159,7 +149,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", @@ -170,15 +159,16 @@ "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.", - "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", "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...", @@ -187,18 +177,15 @@ "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", - "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.", + "reconnectCbbHeading": "CBB und Fahrakku prüfen", "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.", "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", @@ -208,31 +195,8 @@ "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...", + "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)", @@ -242,108 +206,49 @@ } }, "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", "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.", + "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.", - "deleteCachedDownloads": "Heruntergeladene Dateien löschen ({sizeMb} MB)", + "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", "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", - "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.", @@ -401,7 +306,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", @@ -424,44 +328,31 @@ "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", "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.", + "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.", - "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. Die Installation läuft jetzt von selbst.", + "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.", + "dbcWalkAwayDoneButton": "Weiter zum Abschluss", + "dbcWalkAwayWentWrongButton": "Etwas ist schiefgelaufen", "phaseKeycardSetupTitle": "Schlüsselkarten einrichten", "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...", "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...", @@ -507,5 +398,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 216b719..98e5f50 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", @@ -17,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", @@ -29,12 +25,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 +39,7 @@ "majorStepPrepare": "Prepare", "majorStepConnect": "Connect", "majorStepMdbFlash": "Flash MDB", + "majorStepMdbPrep": "Dashboard Prep", "majorStepDbcFlash": "Flash DBC", "majorStepFinish": "Finish", "majorStepSkippedSuffix": "skipped", @@ -68,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", @@ -88,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", @@ -100,16 +94,14 @@ "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...", "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:", - "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", @@ -165,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...", @@ -188,7 +176,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", @@ -200,20 +187,20 @@ "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.", + "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.", - "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", "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...", @@ -233,13 +220,10 @@ } }, - "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.", + "reconnectCbbHeading": "Verify CBB and main battery", "verifyCbbConnection": "Verify CBB Connection", "verifyBatteryPresence": "Verify battery", "checkingCbb": "Checking CBB...", - "cbbConnected": "CBB connected!", "waitingForCbb": "Waiting for CBB... ({attempts})", "@waitingForCbb": { "placeholders": { @@ -247,10 +231,10 @@ } }, "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...", + "finishStepsAboveToContinue": "Finish the steps above to continue.", "startingTrampoline": "Starting trampoline script...", "uploadError": "Upload error: {error}", "@uploadError": { @@ -267,32 +251,9 @@ "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...", + "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)", @@ -302,6 +263,16 @@ } }, "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": { "placeholders": { @@ -310,24 +281,20 @@ }, "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:", - "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.", - "deleteCachedDownloads": "Delete cached downloads ({sizeMb} MB)", - "@deleteCachedDownloads": { - "placeholders": { - "sizeMb": { "type": "String" } - } - }, + "unlockScooterDesc": "Use a keycard or paired phone if you set one up, or the button in the app.", "deletedCache": "Deleted {sizeMb} MB", "@deletedCache": { "placeholders": { @@ -338,103 +305,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": { @@ -442,13 +320,6 @@ "error": { "type": "String" } } }, - "flashError": "Flash error: {error}", - "@flashError": { - "placeholders": { - "error": { "type": "String" } - } - }, - "flashComplete": "Flash complete!", "errorPrefix": "Error: {error}", "@errorPrefix": { "placeholders": { @@ -458,13 +329,12 @@ "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", - "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.", @@ -521,7 +391,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.", @@ -553,11 +422,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", @@ -565,36 +430,27 @@ "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.", + "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.", - "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. The install is now running on its own.", + "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.", + "dbcWalkAwayDoneButton": "Continue to finish", + "dbcWalkAwayWentWrongButton": "Something went wrong", "phaseKeycardSetupTitle": "Keycard Setup", "phaseKeycardSetupDescription": "Register your keycards", "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...", "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", @@ -646,5 +502,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 f676e96..b52a989 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: @@ -185,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. @@ -254,29 +242,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 +275,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 +320,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: @@ -476,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: @@ -590,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: @@ -608,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: @@ -656,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: @@ -701,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. @@ -710,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: @@ -908,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. - /// - /// In en, this message translates to: - /// **'Battery Removal'** - String get batteryRemovalHeading; - - /// No description provided for @seatboxOpening. + /// No description provided for @deactivateMainBatteryHeading. /// /// In en, this message translates to: - /// **'Seatbox is opening...'** - String get seatboxOpening; + /// **'Main Battery'** + String get deactivateMainBatteryHeading; - /// No description provided for @seatboxOpeningDesc. + /// No description provided for @deactivateMainBattery. /// /// In en, this message translates to: - /// **'The seatbox will open automatically.'** - String get seatboxOpeningDesc; + /// **'Deactivate main battery'** + String get deactivateMainBattery; - /// No description provided for @removeMainBattery. + /// No description provided for @deactivateMainBatteryStep. /// /// In en, this message translates to: - /// **'Remove the main battery'** - String get removeMainBattery; + /// **'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 @removeMainBatteryDesc. + /// No description provided for @deactivatingMainBattery. /// /// In en, this message translates to: - /// **'Lift the main battery (Fahrakku) out of the seatbox.'** - String get removeMainBatteryDesc; + /// **'Switching off the main battery...'** + String get deactivatingMainBattery; - /// No description provided for @openSeatbox. + /// No description provided for @mainBatteryDeactivated. /// /// In en, this message translates to: - /// **'Open Seatbox'** - String get openSeatbox; + /// **'Main battery switched off'** + String get mainBatteryDeactivated; - /// No description provided for @mainBatteryAlreadyRemoved. + /// No description provided for @mainBatteryAlreadyOff. /// /// 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. /// @@ -1034,12 +974,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: @@ -1091,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. @@ -1106,12 +1040,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: @@ -1142,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: @@ -1193,21 +1133,9 @@ 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 @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: @@ -1226,12 +1154,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: @@ -1244,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: @@ -1262,6 +1178,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: @@ -1316,144 +1238,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: @@ -1463,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. @@ -1490,6 +1274,36 @@ 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: + /// **'DBC image ready to flash'** + String get dbcPrepComplete; + /// No description provided for @dbcFlashFailed. /// /// In en, this message translates to: @@ -1511,7 +1325,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. @@ -1529,27 +1343,39 @@ 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. + /// + /// 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: @@ -1571,15 +1397,9 @@ 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 @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: @@ -1604,198 +1424,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: @@ -1808,192 +1436,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: @@ -2006,12 +1460,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: @@ -2024,18 +1472,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: @@ -2054,12 +1490,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: @@ -2081,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. @@ -2093,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. @@ -2318,12 +1748,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: @@ -2456,36 +1880,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: @@ -2516,95 +1916,65 @@ 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. + /// No description provided for @finishRebootingTitle. /// /// In en, this message translates to: - /// **'Error. Reconnect laptop to check log; the indicators stop once you reconnect'** - String get ledBootRedMeaning; + /// **'Rebooting scooter…'** + String get finishRebootingTitle; - /// No description provided for @flashingTakesAbout10Min. + /// No description provided for @finishRebootingBody. /// /// 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; + /// **'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 @dbcFlashDurationHeadline. + /// No description provided for @networkConfigNeedsPermission. /// /// In en, this message translates to: - /// **'The DBC flash can take 10–20 minutes.'** - String get dbcFlashDurationHeadline; + /// **'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 @dbcFlashDurationDetail. + /// No description provided for @dbcWalkAwayHeadline. /// /// 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; + /// **'Swap done. The install is now running on its own.'** + String get dbcWalkAwayHeadline; - /// No description provided for @finishRebootingTitle. + /// No description provided for @dbcWalkAwayBody. /// /// In en, this message translates to: - /// **'Rebooting scooter…'** - String get finishRebootingTitle; + /// **'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 @finishRebootingBody. + /// No description provided for @dbcWalkAwayLedProgress. /// /// In en, this message translates to: - /// **'Waiting for the MDB to drop the USB link before completing the install.'** - String get finishRebootingBody; + /// **'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 @networkConfigNeedsPermission. + /// No description provided for @dbcWalkAwayDone. /// /// In en, this message translates to: - /// **'macOS is asking for permission to change network settings. Click Allow in the system dialog, then hit Retry.'** - String get networkConfigNeedsPermission; + /// **'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 @waitingForMdbToReconnect. + /// No description provided for @dbcWalkAwayFailure. /// /// In en, this message translates to: - /// **'Waiting for MDB to reconnect...'** - String get waitingForMdbToReconnect; + /// **'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 @ledIsGreen. + /// No description provided for @dbcWalkAwayDoneButton. /// /// In en, this message translates to: - /// **'LED blinking green'** - String get ledIsGreen; + /// **'Continue to finish'** + String get dbcWalkAwayDoneButton; - /// No description provided for @ledIsRed. + /// No description provided for @dbcWalkAwayWentWrongButton. /// /// 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; + /// **'Something went wrong'** + String get dbcWalkAwayWentWrongButton; /// No description provided for @phaseKeycardSetupTitle. /// @@ -2630,24 +2000,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: @@ -2678,12 +2030,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: @@ -2887,6 +2233,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 0cb1ab7..7f88dc9 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'; @@ -53,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 => @@ -92,22 +85,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 +127,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get majorStepMdbFlash => 'MDB flashen'; + @override + String get majorStepMdbPrep => 'Dashboard vorbereiten'; + @override String get majorStepDbcFlash => 'DBC flashen'; @@ -214,9 +213,6 @@ class AppLocalizationsDe extends AppLocalizations { String get requestingAdminPrivileges => 'Administratorrechte werden angefragt...'; - @override - String get quitButton => 'Beenden'; - @override String get firmwareChannel => 'Firmware-Kanal'; @@ -274,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'; @@ -285,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'; @@ -312,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...'; @@ -336,15 +321,11 @@ 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:'; - @override - String get unlockTimeout => - 'Zeitlimit beim Warten auf Entsperrung. Roller entsperren und erneut versuchen.'; - @override String get awaitingUnlockHeading => 'Roller entsperren'; @@ -457,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'; - - @override - String get seatboxOpening => 'Sitzbank wird geöffnet...'; + String get deactivateMainBatteryHeading => 'Fahrakku'; @override - String get seatboxOpeningDesc => 'Die Sitzbank öffnet sich automatisch.'; + String get deactivateMainBattery => 'Fahrakku deaktivieren'; @override - String get removeMainBattery => 'Fahrakku entnehmen'; + String get deactivateMainBatteryStep => + 'Der Roller schaltet den Fahrakku ab. Du musst ihn nicht aus der Sitzbank nehmen.'; @override - String get removeMainBatteryDesc => 'Hebe den Fahrakku aus der Sitzbank.'; + String get deactivatingMainBattery => 'Fahrakku wird abgeschaltet...'; @override - String get openSeatbox => 'Sitzbank öffnen'; + String get mainBatteryDeactivated => 'Fahrakku abgeschaltet'; @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'; @@ -523,9 +493,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!'; @@ -561,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'; @@ -570,10 +537,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.'; @@ -591,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'; @@ -624,15 +595,7 @@ 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.'; + String get reconnectCbbHeading => 'CBB und Fahrakku prüfen'; @override String get verifyCbbConnection => 'CBB-Verbindung prüfen'; @@ -643,9 +606,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)'; @@ -654,16 +614,16 @@ 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'; @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...'; @@ -696,98 +656,42 @@ class AppLocalizationsDe extends AppLocalizations { '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'; + String get verifyingDbcInstallation => 'DBC-Installation wird geprüft'; @override - String get ledBootGreen => 'Tacho-LED blinkt grün'; + String get reconnectUsbToLaptop => + 'DBC-Kabel vom MDB abziehen und den Laptop wieder anschließen...'; @override - String get ledBootGreenMeaning => 'Erfolgreich. Laptop wieder verbinden'; + String get waitingForRndisDevice => 'Warte auf RNDIS-Gerät...'; @override - String get ledRearLightSolid => 'Alle vier Blinker (Warnblinker) blinken'; + String get readingTrampolineStatus => 'Trampoline-Status wird gelesen...'; @override - String get ledRearLightSolidMeaning => - 'Fehler. Laptop verbinden für Log; Blinker und LED gehen aus, sobald du verbunden bist'; + String readingTrampolineStatusElapsed(int elapsed) { + return 'Trampoline-Status wird gelesen… (${elapsed}s)'; + } @override - String get bootLedGreenReconnect => 'LED blinkt grün'; + String get dbcFlashSuccessful => 'DBC-Flash erfolgreich!'; @override - String get rearLightCheckError => 'LED blinkt rot, Warnblinker an'; + String get dbcAlreadyCurrentTitle => 'Dashboard ist bereits aktuell'; @override - String get verifyingDbcInstallation => 'DBC-Installation wird geprüft'; + String dbcAlreadyCurrentBody(String version) { + return 'Auf dem Dashboard (DBC) läuft bereits Librescoot $version. Trotzdem neu flashen?'; + } @override - String get reconnectUsbToLaptop => 'USB wieder mit Laptop verbinden...'; + String get dbcAlreadyCurrentReflash => 'Neu flashen'; @override - String get waitingForRndisDevice => 'Warte auf RNDIS-Gerät...'; + String get dbcAlreadyCurrentSkip => 'DBC-Flash überspringen'; @override - String get readingTrampolineStatus => 'Trampoline-Status wird gelesen...'; - - @override - String readingTrampolineStatusElapsed(int elapsed) { - return 'Trampoline-Status wird gelesen… (${elapsed}s)'; - } - - @override - String get dbcFlashSuccessful => 'DBC-Flash erfolgreich!'; + String get dbcPrepComplete => 'DBC-Image bereit zum Flashen'; @override String dbcFlashFailed(String message) { @@ -802,7 +706,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!'; @@ -811,19 +715,26 @@ 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'; + + @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'; @@ -837,12 +748,7 @@ class AppLocalizationsDe extends AppLocalizations { @override 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)'; - } + 'Nutze eine eingerichtete Schlüsselkarte, ein gekoppeltes Handy oder den Button in der App.'; @override String deletedCache(String sizeMb) { @@ -858,106 +764,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'; @@ -965,125 +771,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...'; @@ -1092,14 +791,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'; @@ -1111,10 +802,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'; @@ -1126,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 => @@ -1294,10 +981,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'; @@ -1368,23 +1051,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'; @@ -1402,57 +1071,41 @@ class AppLocalizationsDe extends AppLocalizations { 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.'; + String get finishRebootingTitle => 'Roller startet neu…'; @override - String get dbcFlashDurationHeadline => - 'Das DBC-Flashen kann 10–20 Minuten dauern.'; + String get finishRebootingBody => + 'Das MDB startet gerade neu; die USB-Verbindung bricht dabei von selbst ab. Du musst noch nichts umstecken.'; @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.'; + 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 finishRebootingTitle => 'Roller startet neu…'; + String get dbcWalkAwayHeadline => + 'Umstecken erledigt. Die Installation läuft jetzt von selbst.'; @override - String get finishRebootingBody => - 'Warte auf die USB-Trennung, dann ist der Installer fertig.'; + String get 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.'; @override - String get networkConfigNeedsPermission => - 'macOS fragt nach Erlaubnis, die Netzwerk-Einstellungen zu ändern. Im Systemdialog auf \'Erlauben\' klicken, dann \'Erneut versuchen\' drücken.'; + 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 waitingForMdbToReconnect => 'Warte auf MDB-Wiederverbindung...'; + 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 ledIsGreen => 'LED blinkt grün'; + 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 ledIsRed => 'LED blinkt rot'; + String get dbcWalkAwayDoneButton => 'Weiter zum Abschluss'; @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.'; + String get dbcWalkAwayWentWrongButton => 'Etwas ist schiefgelaufen'; @override String get phaseKeycardSetupTitle => 'Schlüsselkarten einrichten'; @@ -1468,15 +1121,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...'; @@ -1496,10 +1140,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'; @@ -1614,4 +1254,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 726addf..93c9ab4 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'; @@ -52,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 => @@ -89,22 +82,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 +123,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get majorStepMdbFlash => 'Flash MDB'; + @override + String get majorStepMdbPrep => 'Dashboard Prep'; + @override String get majorStepDbcFlash => 'Flash DBC'; @@ -210,9 +209,6 @@ class AppLocalizationsEn extends AppLocalizations { String get requestingAdminPrivileges => 'Requesting administrator privileges...'; - @override - String get quitButton => 'Quit'; - @override String get firmwareChannel => 'Firmware Channel'; @@ -269,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'; @@ -280,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'; @@ -307,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...'; @@ -331,15 +316,11 @@ 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:'; - @override - String get unlockTimeout => - 'Timed out waiting for scooter to be unlocked. Unlock and retry.'; - @override String get awaitingUnlockHeading => 'Unlock your scooter'; @@ -452,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'; - - @override - String get seatboxOpening => 'Seatbox is opening...'; + String get deactivateMainBatteryHeading => 'Main Battery'; @override - String get seatboxOpeningDesc => 'The seatbox will open automatically.'; + String get deactivateMainBattery => 'Deactivate main battery'; @override - String get removeMainBattery => 'Remove the main battery'; + 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 removeMainBatteryDesc => - 'Lift the main battery (Fahrakku) out of the seatbox.'; + String get deactivatingMainBattery => 'Switching off the main battery...'; @override - String get openSeatbox => 'Open Seatbox'; + String get mainBatteryDeactivated => 'Main battery switched off'; @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'; @@ -517,9 +486,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!'; @@ -555,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'; @@ -564,10 +530,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.'; @@ -585,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'; @@ -618,14 +588,7 @@ 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.'; + String get reconnectCbbHeading => 'Verify CBB and main battery'; @override String get verifyCbbConnection => 'Verify CBB Connection'; @@ -636,9 +599,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)'; @@ -647,16 +607,16 @@ 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'; @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...'; @@ -690,98 +650,42 @@ class AppLocalizationsEn extends AppLocalizations { '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'; + String get verifyingDbcInstallation => 'Verifying DBC Installation'; @override - String get ledBootGreen => 'Dashboard LED blinking green'; + String get reconnectUsbToLaptop => + 'Unplug the DBC cable from the MDB and plug the laptop back in...'; @override - String get ledBootGreenMeaning => 'Success. Reconnect laptop'; + String get waitingForRndisDevice => 'Waiting for RNDIS device...'; @override - String get ledRearLightSolid => 'All four blinkers (hazards) flashing'; + String get readingTrampolineStatus => 'Reading trampoline status...'; @override - String get ledRearLightSolidMeaning => - 'Error. Reconnect laptop to see log; the indicators stop once you reconnect'; + String readingTrampolineStatusElapsed(int elapsed) { + return 'Reading trampoline status… (${elapsed}s)'; + } @override - String get bootLedGreenReconnect => 'LED blinking green'; + String get dbcFlashSuccessful => 'DBC flash successful!'; @override - String get rearLightCheckError => 'LED blinking red, hazards flashing'; + String get dbcAlreadyCurrentTitle => 'Dashboard already up to date'; @override - String get verifyingDbcInstallation => 'Verifying DBC Installation'; + String dbcAlreadyCurrentBody(String version) { + return 'The dashboard (DBC) already runs Librescoot $version. Flash it again anyway?'; + } @override - String get reconnectUsbToLaptop => 'Reconnect USB to laptop...'; + String get dbcAlreadyCurrentReflash => 'Flash again'; @override - String get waitingForRndisDevice => 'Waiting for RNDIS device...'; + String get dbcAlreadyCurrentSkip => 'Skip DBC flash'; @override - String get readingTrampolineStatus => 'Reading trampoline status...'; - - @override - String readingTrampolineStatusElapsed(int elapsed) { - return 'Reading trampoline status… (${elapsed}s)'; - } - - @override - String get dbcFlashSuccessful => 'DBC flash successful!'; + String get dbcPrepComplete => 'DBC image ready to flash'; @override String dbcFlashFailed(String message) { @@ -796,7 +700,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!'; @@ -805,19 +709,26 @@ 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'; + + @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'; @@ -831,12 +742,7 @@ class AppLocalizationsEn extends AppLocalizations { @override 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)'; - } + 'Use a keycard or paired phone if you set one up, or the button in the app.'; @override String deletedCache(String sizeMb) { @@ -852,106 +758,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'; @@ -959,124 +765,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...'; @@ -1085,14 +785,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'; @@ -1104,10 +796,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'; @@ -1119,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 => @@ -1284,10 +972,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'; @@ -1358,23 +1042,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'; @@ -1391,57 +1061,41 @@ class AppLocalizationsEn extends AppLocalizations { 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.'; + String get finishRebootingTitle => 'Rebooting scooter…'; @override - String get dbcFlashDurationHeadline => - 'The DBC flash can take 10–20 minutes.'; + String get finishRebootingBody => + 'The MDB is rebooting; the USB connection will drop by itself. No need to touch the cable yet.'; @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.'; + String get networkConfigNeedsPermission => + 'macOS is asking for permission to change network settings. Click Allow in the system dialog, then hit Retry.'; @override - String get finishRebootingTitle => 'Rebooting scooter…'; + String get dbcWalkAwayHeadline => + 'Swap done. The install is now running on its own.'; @override - String get finishRebootingBody => - 'Waiting for the MDB to drop the USB link before completing the install.'; + String get 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.'; @override - String get networkConfigNeedsPermission => - 'macOS is asking for permission to change network settings. Click Allow in the system dialog, then hit Retry.'; + 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 waitingForMdbToReconnect => 'Waiting for MDB to reconnect...'; + 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 ledIsGreen => 'LED blinking green'; + 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 ledIsRed => 'LED blinking red'; + String get dbcWalkAwayDoneButton => 'Continue to finish'; @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.'; + String get dbcWalkAwayWentWrongButton => 'Something went wrong'; @override String get phaseKeycardSetupTitle => 'Keycard Setup'; @@ -1456,15 +1110,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...'; @@ -1483,9 +1128,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'; @@ -1599,4 +1241,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'; } 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/main.dart b/lib/main.dart index 4ace75a..63cff5e 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'))); @@ -71,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, @@ -81,6 +93,7 @@ class LaunchArgs { this.autoStart = false, this.noOfflineMaps = false, this.dryRun = false, + this.forceDbcReflash = false, }); factory LaunchArgs.fromArgs(List args) { @@ -88,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]; @@ -97,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, @@ -107,6 +122,7 @@ class LaunchArgs { autoStart: autoStart, noOfflineMaps: noOfflineMaps, dryRun: dryRun, + forceDbcReflash: forceDbcReflash, ); } @@ -129,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', ]; } @@ -139,7 +156,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/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/lib/models/installer_phase.dart b/lib/models/installer_phase.dart index f56352f..e3ae4c4 100644 --- a/lib/models/installer_phase.dart +++ b/lib/models/installer_phase.dart @@ -60,30 +60,35 @@ 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, - ), + // 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', + 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', @@ -112,8 +117,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]), + dbc('Flash DBC', [InstallerPhase.dbcSwapAndFlash, InstallerPhase.reconnect]), + finish('Finish', [InstallerPhase.finish]); const MajorStep(this.title, this.phases); @@ -130,6 +136,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/models/region.dart b/lib/models/region.dart index ba23057..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 (_) { @@ -131,9 +135,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/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/screens/installer_screen.dart b/lib/screens/installer_screen.dart index fd6ffb5..950dd4e 100644 --- a/lib/screens/installer_screen.dart +++ b/lib/screens/installer_screen.dart @@ -6,16 +6,17 @@ 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'; import '../models/installer_phase.dart'; 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'; import '../widgets/instruction_step.dart'; import '../widgets/phase_sidebar.dart'; @@ -63,12 +64,20 @@ 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; + final bool _showElevatedHandoff = false; bool _dbcFlashSimulateError = false; - bool _cbbCheckFailed = false; DeviceInfo? _mdbInfo; bool _skipMdbFlash = false; bool _skipDbcFlash = false; @@ -101,6 +110,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 @@ -328,21 +338,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 { @@ -417,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; } @@ -458,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 @@ -729,24 +780,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, @@ -823,11 +856,13 @@ 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), + // 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), }; } @@ -1643,21 +1678,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( @@ -1678,24 +1721,25 @@ 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'); } - // 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); @@ -1729,8 +1773,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. @@ -1757,15 +1805,46 @@ 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); + // 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, + ); } catch (e) { _setStatus(l10n.sshConnectionFailed(e.toString())); if (mounted) setState(() => _isProcessing = false); @@ -1825,45 +1904,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)); @@ -1991,10 +2031,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); } @@ -2197,32 +2240,25 @@ class _InstallerScreenState extends State { } } - bool _batteryRemovalStarted = false; - Widget _buildBatteryRemoval(AppLocalizations l10n) { return Center( 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(), @@ -2232,7 +2268,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), @@ -2245,29 +2281,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(); } - debugPrint('Battery: depart detected on battery:0'); - await _sshService.logScooterStats('battery-removed'); + if (stillActive) { + debugPrint('Battery: still active after 30s, proceeding anyway (UMS reboot deactivates it regardless)'); + } else { + debugPrint('Battery: main battery deactivated'); + } + await _sshService.logScooterStats('main-battery-deactivated'); - _setStatus(l10n.batteryRemoved); + _setStatus(l10n.mainBatteryDeactivated); setState(() { _scooterHealth?.batteryPresent = false; _isProcessing = false; @@ -2485,6 +2527,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!, @@ -2644,10 +2703,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)), @@ -2748,6 +2834,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 @@ -2781,7 +2876,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); } @@ -2825,12 +2921,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) { @@ -2855,9 +2948,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)); } @@ -2887,7 +2977,7 @@ class _InstallerScreenState extends State { } if (mounted) { setState(() => _batteryDetected = bat); - if (bat) _setPhase(InstallerPhase.dbcPrep); + if (bat) _setPhase(InstallerPhase.dashboardPrep); } } }); @@ -2901,13 +2991,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, @@ -2916,77 +2999,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) ...[ @@ -3004,7 +3028,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); @@ -3014,15 +3038,30 @@ 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)), ), ] 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)), ), @@ -3032,102 +3071,210 @@ 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.dbcPrep); - 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.dbcPrep); - return; + /// 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); } - attempts++; - _setStatus(l10n.waitingForCbb(attempts)); - await Future.delayed(const Duration(seconds: 2)); - if (!mounted) return; } - _setStatus(l10n.cbbNotDetected); - setState(() { - _isProcessing = false; - _cbbCheckFailed = true; - }); - } - Widget _buildDbcPrep(AppLocalizations l10n) { - if (!_dbcPrepStarted && !_isProcessing) { - _dbcPrepStarted = true; - Future.microtask(_uploadDbcFiles); + final btSatisfied = _btDone || _btSkipped; + final keycardSatisfied = _keycardDone || _keycardSkipped; + final prepDone = btSatisfied && keycardSatisfied; + final beginEnabled = + btSatisfied && keycardSatisfied && _dbcUploadReady && !_isProcessing; + + final Widget interactive; + if (_dashboardPrepStep == _DashboardPrepStep.bluetooth) { + interactive = _bluetoothPairingContent(l10n); + } else { + 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.preparingDbcFlash : 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: [ - 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), + 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.dbcPrepComplete : l10n.preparingDbcFlash, + style: TextStyle(fontSize: 12, color: Colors.grey.shade400), + overflow: TextOverflow.ellipsis, + ), + ), + // 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, + ), + ], + ), + ); + } + + 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.dbcPrepComplete : l10n.preparingDbcFlash, + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14), ), - child: SubstepList(substeps: _dbcPrepSubsteps), - ) + ), + ], + ), + 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: 13), - textAlign: TextAlign.center), - if (_dbcUploadReady) ...[ - const SizedBox(height: 20), - Center( - child: FilledButton.icon( - onPressed: _isProcessing ? null : _startTrampoline, - icon: const Icon(Icons.bolt), - label: Text(l10n.dbcReadyButton), - ), - ), - ] else if (!_isProcessing) ...[ - const SizedBox(height: 16), - Center( - child: FilledButton.icon( + 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); @@ -3152,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); @@ -3166,6 +3364,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: targetVersion, + forceDbcReflash: forceReflash, onProgress: (status, progress) { _setStatus(status, progress: progress); }, @@ -3177,28 +3381,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) { @@ -3206,29 +3416,39 @@ 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; }); } } 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; @@ -3236,8 +3456,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(); @@ -3316,10 +3538,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), @@ -3347,7 +3569,7 @@ class _InstallerScreenState extends State { const SizedBox(width: 10), Expanded( child: Text( - l10n.dbcFlashDurationHeadline, + l10n.dbcWalkAwayHeadline, style: TextStyle( fontSize: 17, fontWeight: FontWeight.bold, @@ -3359,7 +3581,7 @@ class _InstallerScreenState extends State { ), const SizedBox(height: 8), Text( - l10n.ledAmberWaitNotice, + l10n.dbcWalkAwayBody, style: TextStyle( fontSize: 13, color: Colors.orange.shade100, @@ -3380,57 +3602,56 @@ 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.wb_incandescent, + Colors.amber, + l10n.dbcWalkAwayLedProgress, + ), + const SizedBox(height: 10), + _walkAwayOutcome( + Icons.check_circle, + Colors.green, + l10n.dbcWalkAwayDone, + ), + 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), - ), - ), - ], - ), + _buildGettingStarted(l10n), const SizedBox(height: 16), Wrap( spacing: 12, runSpacing: 8, alignment: WrapAlignment.center, children: [ + // 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: () { - _dbcFlashSimulateError = false; - _setPhase(InstallerPhase.reconnect); + _finishFromWalkAway = true; + _setPhase(InstallerPhase.finish); }, icon: const Icon(Icons.check_circle, color: Colors.green), - label: Text(l10n.ledIsGreen), + label: Text(l10n.dbcWalkAwayDoneButton), ), + // Failure path: reconnect the laptop and run the verify logic + // to surface the trampoline error log. OutlinedButton.icon( onPressed: () { _dbcFlashSimulateError = true; _setPhase(InstallerPhase.reconnect); }, icon: const Icon(Icons.error, color: Colors.red), - label: Text(l10n.ledIsRed), + label: Text(l10n.dbcWalkAwayWentWrongButton), ), ], ), @@ -3440,6 +3661,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 @@ -3461,107 +3703,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; - } - } - } - - 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), - ], - ), - ); + // 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 _buildReconnect(AppLocalizations l10n) { @@ -3624,20 +3769,32 @@ 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. Only + // send the user back through pairing/enrollment for + // whichever of the two isn't actually satisfied yet. setState(() { - _dbcPrepStarted = false; + _dashboardPrepStarted = false; + if (!(_btDone || _btSkipped)) { + _dashboardPrepStep = _DashboardPrepStep.bluetooth; + } else if (!(_keycardDone || _keycardSkipped)) { + _dashboardPrepStep = _DashboardPrepStep.keycard; + } + _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), ), ], @@ -3773,7 +3930,7 @@ class _InstallerScreenState extends State { return; } _setStatus('[DRY RUN] DBC flash successful!'); - _setPhase(InstallerPhase.bluetoothPairing); + _setPhase(InstallerPhase.finish); return; } @@ -3831,8 +3988,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; @@ -3867,12 +4023,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 @@ -3906,9 +4063,11 @@ class _InstallerScreenState extends State { } } - Widget _buildBluetoothPairing(AppLocalizations l10n) { - return Center( - child: Column( + /// 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), @@ -3951,7 +4110,7 @@ class _InstallerScreenState extends State { ), const SizedBox(height: 12), TextButton( - onPressed: () => _setPhase(InstallerPhase.keycardSetup), + onPressed: _skipBluetoothPairing, child: Text(l10n.skipPairing), ), ], @@ -4031,22 +4190,27 @@ 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'); } } @@ -4080,18 +4244,64 @@ 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; _blePinCode = null; _bleConnected = false; }); - _setPhase(InstallerPhase.keycardSetup); + 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:bluetooth', 'advertising-start-with-whitelisting'); + } catch (_) {} + setState(() { + _btPairingActive = false; + _blePinCode = null; + _bleConnected = false; + }); + } + await _bluetoothComplete(skipped: true); + } + + /// 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(() { + 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(); + } } // Killed on entry to keycardSetup so that any auto-startup master-learning @@ -4446,7 +4656,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); @@ -4479,7 +4689,7 @@ class _InstallerScreenState extends State { await _keycardTearDown(); if (!mounted) return; if (advance) { - _setPhase(InstallerPhase.finish); + await _keycardComplete(skipped: false); } else { setState(() { _keycardStage = _KeycardStage.cardsReview; @@ -4538,12 +4748,37 @@ class _InstallerScreenState extends State { await _stopKeycardLearning(advance: false); } await _keycardTearDown(); - if (mounted) _setPhase(InstallerPhase.finish); + await _keycardComplete(skipped: true); } - Widget _buildKeycardSetup(AppLocalizations l10n) { - return Center( - child: ConstrainedBox( + /// 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(() { + 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'); + } + } + } + } + + /// 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, @@ -4572,8 +4807,7 @@ class _InstallerScreenState extends State { }, ], ), - ), - ); + ); } String _keycardStageHeading(AppLocalizations l10n) { @@ -4599,7 +4833,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), ), @@ -4734,7 +4968,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), ), @@ -4916,23 +5150,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, ), @@ -5191,3 +5436,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/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) { 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/flash_service.dart b/lib/services/flash_service.dart index aa4b212..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 @@ -844,8 +661,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 +789,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 +859,7 @@ class FlashService { } if (line.startsWith('CHECKSUM MISMATCH')) { debugPrint('Flash: $line'); + sawChecksumMismatch = true; } } } @@ -1044,6 +867,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 +983,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 +1006,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(); @@ -1274,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 7140ba4..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; } } @@ -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'); - } } diff --git a/lib/services/resume_resolver.dart b/lib/services/resume_resolver.dart new file mode 100644 index 0000000..d81d909 --- /dev/null +++ b/lib/services/resume_resolver.dart @@ -0,0 +1,103 @@ +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, + 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, + 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: + return ResumeDecision(phase: InstallerPhase.healthCheck); + } +} diff --git a/lib/services/ssh_service.dart b/lib/services/ssh_service.dart index 597a28a..581fd95 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'; @@ -255,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 { @@ -518,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) { @@ -566,6 +578,7 @@ class SshService { socket, username: sshUser, onPasswordRequest: () => pw, + keepAliveInterval: const Duration(seconds: 5), ); try { await client.authenticated; @@ -852,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 @@ -1047,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'); @@ -1079,8 +1117,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 (_) { @@ -1255,6 +1298,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 { diff --git a/lib/services/trampoline_service.dart b/lib/services/trampoline_service.dart index f253030..e75f6aa 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; @@ -20,26 +43,23 @@ 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( - '{{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. @@ -260,6 +280,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 { @@ -268,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; @@ -436,6 +474,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. @@ -452,11 +492,51 @@ 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 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 + /// 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; 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)); + 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). diff --git a/lib/services/usb_detector.dart b/lib/services/usb_detector.dart index 166b5a5..c2a81f5 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; @@ -835,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) { 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), ), ], 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'); + }); + }); +} diff --git a/test/models/installer_phase_test.dart b/test/models/installer_phase_test.dart new file mode 100644 index 0000000..8fd3eb5 --- /dev/null +++ b/test/models/installer_phase_test.dart @@ -0,0 +1,31 @@ +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 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(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]); + }); + 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'); + } + }); + }); +} 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); }); }); 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')); + }); + }); +} 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); + }); + }); +}