#!/bin/bash
# Timeframe clock updater for macOS.
#
# The owner plugs the clock in and double-clicks one file; everything else
# happens here. The Windows kit's twin (tools/windows-kit/scripts/flash.ps1) -
# same steps, same safety rules, same messages - written in the small, boring
# subset of bash 3.2 that every Mac ships, because it runs once, unattended,
# on a machine nobody can debug on. No Homebrew, no Xcode tools, no python.
#
# The clock tells us which processor it has over USB, so one kit serves both
# boards and the owner never has to know which they own:
#
#   VID 2E8A  PID 000A  RP2040  running the clock software   -> pico_w
#   VID 2E8A  PID 0009  RP2350  running the clock software   -> pico2_w
#   VID 2E8A  PID 0003  RP2040  in update mode (BOOTSEL)     -> pico_w
#   VID 2E8A  PID 000F  RP2350  in update mode (BOOTSEL)     -> pico2_w
#   VID 2E8A  PID 0005  the ORIGINAL MicroPython clock software
#   VID 2E8A  PID 0004  debug probe, not a clock
#
# The oldest clocks run MicroPython, which has none of the pico-sdk reset
# machinery. They are still updatable: MicroPython's own REPL is on that same
# USB serial port, and machine.bootloader() is the documented way in. The
# chip is not knowable from PID 0005 (MicroPython reports it for either
# processor), so those clocks are identified once they reach update mode.
#
# Safety: the two images must land in ONE update-mode session, because the
# clock cannot boot with only one of them. So we prove picotool runs AND that
# it can talk to the device BEFORE writing anything; if either fails we stop
# with the clock untouched and still working.
#
# macOS-only hazard, and the reason this is not a straight port: everything
# that comes out of a downloaded ZIP is quarantined, and picotool is ad-hoc
# signed rather than notarised, so Gatekeeper would kill it the moment we
# needed it - which, without the check above, would be after the clock had
# already been sent into update mode. We clear the flag on the kit first and
# then prove picotool runs.

set -u

CHECK_ONLY=0
if [ "${1:-}" = "--check-only" ]; then CHECK_ONLY=1; fi

KIT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PICOTOOL="$KIT_ROOT/tools/picotool"
LOG="$KIT_ROOT/Timeframe-update-log.txt"

# PID -> "board chip mode". The single source of truth for board detection;
# tools/test_mac_kit.py asserts this table against the real hardware ids and
# against the Windows kit's copy of it. "-" stands in for "not knowable yet".
pid_info() {
    case "$1" in
        000a) echo "pico_w RP2040 app" ;;
        0009) echo "pico2_w RP2350 app" ;;
        0003) echo "pico_w RP2040 bootsel" ;;
        000f) echo "pico2_w RP2350 bootsel" ;;
        0005) echo "- MicroPython micropython" ;;
        0004) echo "- - probe" ;;
        *) echo "" ;;
    esac
}

say() { printf '%s\n' "$1"; }
ok() { printf '\033[32m%s\033[0m\n' "$1"; }
warn() { printf '\033[33m%s\033[0m\n' "$1"; }

pause_and_exit() {
    say ""
    say "Press Return to close."
    read -r _ || true
    exit "$1"
}

# Write the serial where the owner can find it without a browser: on screen,
# and in a file next to these scripts they can attach or copy from.
save_serial() {
    local path="$KIT_ROOT/Clock serial number.txt"
    {
        say "Your clock's serial number is:"
        say ""
        say "    $1"
        say ""
        say "Send this to Lachlan and he'll finish setting the clock up remotely."
        say "(Written by the updater on $(date '+%e %B %Y, %H:%M').)"
    } > "$path" 2>/dev/null && say "  Saved to: $path"
}

stop_badly() {
    say ""
    printf '\033[31m%s\033[0m\n' "$1"
    say ""
    say "Nothing more will happen. Please send this file to Lachlan:"
    say "  $LOG"
    pause_and_exit "$2"
}

# The serial a clock reports over USB IS its serial number: the firmware
# builds its USB descriptor from pico_get_unique_board_id_string, the very
# same value it reports to the fleet and shows on its settings page. So we
# read it here and save the owner from hunting for a web page on a phone.
#
# Every USB device attached, one per line: "<vid> <pid> <serial> <name>".
#
# ioreg first, because system_profiler cannot be relied on to answer at all.
# macOS 26 removed SPUSBDataType - `system_profiler -listDataTypes` offers
# only SPUSBHostDataType - and asking for a data type it no longer has
# returns an EMPTY RESULT rather than an error. So the kit reported a bus
# with nothing on it, for every device on the machine, and we spent an
# evening looking at a clock that was working the whole time. IOKit is not
# versioned like this and is not translated; picotool reads the same registry
# through libusb, which is why it could see the clock when we could not.
#
# ioreg repeats a device's properties on its interface nodes, so identical
# triples are emitted once.
ioreg_usb_devices() {
    ioreg -r -c IOUSBHostDevice -l 2>/dev/null | awk '
        function flush(   key) {
            if (vid != "" && pid != "") {
                key = vid "/" pid "/" ser
                if (!(key in seen)) {
                    seen[key] = 1
                    printf "%04x %04x %s %s\n", vid, pid, (ser == "" ? "-" : ser), nm
                }
            }
            vid = ""; pid = ""; ser = ""; nm = ""
        }
        /IOUSBHostDevice/        { flush(); next }
        /"idVendor" *=/          { vid = $NF + 0; next }
        /"idProduct" *=/         { pid = $NF + 0; next }
        /"USB Serial Number" *=/ { ser = $NF; gsub(/"/, "", ser); next }
        /"USB Product Name" *=/  { nm = $0; sub(/^[^=]*= */, "", nm); gsub(/"/, "", nm); next }
        END { flush() }
    '
}

# The fallback, for a Mac where ioreg names the class differently. -json, not
# the human-readable output: system_profiler LOCALISES its field labels, so a
# parser that greps for "Product ID:" finds nothing on a Japanese Mac. The
# JSON keys are machine names and do not translate.
#
# Devices nest (a hub carries its children), so objects are tracked by brace
# depth rather than by field order: a hub's children close before the hub
# does, and no child can inherit its parent's serial number.
profiler_usb_devices() {
    system_profiler -json SPUSBDataType SPUSBHostDataType 2>/dev/null | awk '
        function clean(s,   v) {
            v = s; sub(/^[^:]*:[[:space:]]*/, "", v); gsub(/["|,]/, "", v)
            gsub(/^[[:space:]]+|[[:space:]]+$/, "", v); return v
        }
        function emit(d,   v, p) {
            if (pid[d] != "") {
                # Known vendors come through as "apple:0x05ac"; keep the id.
                v = vid[d]; sub(/^.*:/, "", v); sub(/^0x/, "", v)
                p = pid[d]; sub(/^0x/, "", p)
                printf "%s %s %s %s\n", tolower(v), tolower(p),
                       (ser[d] == "" ? "-" : ser[d]), nm[d]
            }
            pid[d] = ""; vid[d] = ""; ser[d] = ""; nm[d] = ""
        }
        /\{/ { depth++; pid[depth] = ""; vid[depth] = ""; ser[depth] = ""; nm[depth] = ""; next }
        /\}/ { emit(depth); depth--; next }
        /"product_id"/ { pid[depth] = clean($0); next }
        /"vendor_id"/  { vid[depth] = clean($0); next }
        /"serial_num"/ { ser[depth] = clean($0); next }
        /"_name"/      { nm[depth]  = clean($0); next }
    '
}

# The last word, when the operating system will not answer.
#
# picotool reads USB through libusb, which is beholden to no data type name
# and no display language. On Max's Mac it described his clock in detail
# while every OS route we had reported an empty bus. So when enumeration
# comes back with nothing, we ask the tool we are about to flash with
# whether it can see anything - and if it can, that is authoritative.
#
# `info -a` prints the chip, which is all we need: a device answering
# picotool at all is in update mode, and the chip decides the images.
picotool_bootsel_device() {
    local out
    out="$("$PICOTOOL" info -a 2>/dev/null)" || return 1
    case "$out" in
        *RP2350*) echo "000f" ;;
        *RP2040*) echo "0003" ;;
        *) return 1 ;;
    esac
}

# Ask IOKit; fall back to system_profiler only if it had nothing to say.
list_usb_devices() {
    local found
    found="$(ioreg_usb_devices)"
    if [ -n "$found" ]; then
        printf '%s\n' "$found"
        return 0
    fi
    profiler_usb_devices
}

# Just the RP-series ones, as "<pid> <serial>" - what the rest of this script
# reasons about.
list_rp_devices() {
    list_usb_devices | while read -r vid pid serial name; do
        [ "$vid" = "2e8a" ] || continue
        : "$name"
        echo "$pid $serial"
    done
}

# Everything on the USB bus, not just clocks: "2e8a:000a  Board CDC".
#
# When the clock is not found, this is the difference between a shrug and a
# diagnosis. An empty list means the Mac sees no USB devices at all - a dead
# cable or a dead port. A list with other things on it but no clock means the
# cable and the port are fine and the clock is presenting nothing on the bus,
# which is exactly what firmware built with USB serial compiled out does.
list_usb_inventory() {
    list_usb_devices | while read -r vid pid serial name; do
        : "$serial"
        printf '    %s:%s  %s\n' "$vid" "$pid" "$name"
    done
}

# The clock's serial, preferring whatever the running clock software reports.
# A device that supplies no serial shows as "-", and only a real 16-character
# hex serial is accepted - the same rule the Windows kit applies.
clock_serial() {
    local mode pid serial info
    for mode in app bootsel micropython; do
        while read -r pid serial; do
            [ -n "$pid" ] || continue
            info="$(pid_info "$pid")"
            [ -n "$info" ] || continue
            if [ "${info##* }" = "$mode" ] && is_serial "$serial"; then
                echo "$serial" | tr '[:lower:]' '[:upper:]'
                return 0
            fi
        done <<EOF
$(list_rp_devices)
EOF
    done
    echo ""
}

is_serial() {
    case "$1" in
        *[!0-9A-Fa-f]*) return 1 ;;
        ????????????????) return 0 ;;
        *) return 1 ;;
    esac
}

# Is any device in this mode attached? Sets DEV_PID/DEV_BOARD/DEV_CHIP.
device_in_mode() {
    local wanted="$1" pid serial info devmode
    while read -r pid serial; do
        [ -n "$pid" ] || continue
        info="$(pid_info "$pid")"
        [ -n "$info" ] || continue
        devmode="${info##* }"
        if [ "$devmode" = "$wanted" ]; then
            DEV_PID="$pid"
            DEV_BOARD="$(echo "$info" | cut -d' ' -f1)"
            DEV_CHIP="$(echo "$info" | cut -d' ' -f2)"
            return 0
        fi
    done <<EOF
$(list_rp_devices)
EOF
    # Nothing from the operating system. Before believing that, ask picotool -
    # it saw a clock on a Mac where every OS route reported an empty bus.
    if [ "$wanted" = "bootsel" ] && [ -z "$(list_rp_devices)" ]; then
        local pid
        pid="$(picotool_bootsel_device)" || return 1
        info="$(pid_info "$pid")"
        [ -n "$info" ] || return 1
        DEV_PID="$pid"
        DEV_BOARD="$(echo "$info" | cut -d' ' -f1)"
        DEV_CHIP="$(echo "$info" | cut -d' ' -f2)"
        return 0
    fi
    return 1
}

# Wait up to $1 seconds for the clock to appear in update mode.
wait_for_bootsel() {
    local i=0
    while [ "$i" -lt "$1" ]; do
        sleep 1
        if device_in_mode bootsel; then return 0; fi
        i=$((i + 1))
    done
    return 1
}

# The clock's USB serial port. Every Mac names these /dev/cu.usbmodemNNNN.
# If there is more than one we refuse to guess: writing REPL commands into
# somebody else's serial device is not a risk worth taking to save a step.
#
# The prefix is overridable only so that tools/test_mac_kit.py can point it at
# ordinary files and check what actually gets written down the port - the one
# part of this script that types into a device rather than reading from it.
CU_PREFIX="${TIMEFRAME_CU_PREFIX:-/dev/cu.usbmodem}"
clock_port() {
    local found="" p count=0
    for p in "$CU_PREFIX"*; do
        [ -e "$p" ] || continue
        found="$p"; count=$((count + 1))
    done
    if [ "$count" -eq 1 ]; then echo "$found"; fi
}

# The original MicroPython clocks predate every pico-sdk reset mechanism, so
# picotool cannot ask them to restart. What they do have is MicroPython's REPL
# on the same USB serial port: interrupt whatever is running with Ctrl-C, then
# machine.bootloader() - the documented way in, and what MicroPython's own
# tooling uses. The port disappears the instant it takes effect, so write
# errors here are success, not failure.
enter_update_mode_micropython() {
    local port
    port="$(clock_port)"
    if [ -z "$port" ]; then
        warn "  I can see the clock, but there is more than one USB serial device"
        warn "  attached, so I can't tell which is which. Please unplug the others"
        warn "  and run this again."
        return 1
    fi
    say "  This is one of the original clocks; asking it to restart via $port..."
    /bin/stty -f "$port" 115200 >/dev/null 2>&1 || true
    # One subshell holds the port open for the whole exchange: reopening it
    # between lines would drop the REPL back to a fresh state. A failed
    # redirect exits the subshell, which is why this is not `exec 3>` - that
    # form kills a non-interactive shell outright when the port is gone.
    (
        # Two interrupts: the first stops the clock program, the second lands
        # us at a bare prompt even if the first was swallowed mid-import.
        printf '\003'
        sleep 0.3
        printf '\003'
        sleep 0.3
        printf '\r\nimport machine\r\n'
        sleep 0.3
        printf 'machine.bootloader()\r\n'
        sleep 0.5
    ) > "$port" 2>/dev/null || true
    wait_for_bootsel 20
}

# The third door: over the owner's wifi. On RP2350 the clock software's USB
# answers neither picotool nor the serial touch, so the kit asks the clock
# ITSELF to step aside: it POSTs the bootsel trampoline - a tiny signed
# software image whose only job is to restart the clock into update mode -
# to the clock's own update endpoint on the local network. The clock checks
# the signature exactly as it would a real update, swaps it in, and surfaces
# on USB as an update-mode device. Nothing is committed: if this kit is
# interrupted at any point after, unplugging and replugging the clock brings
# the previous software straight back, wifi settings and all.
#
# The serial cross-check is the safety: a household with two clocks must not
# have the wrong one restarted. If both serials are readable they must match;
# if either is unknowable we proceed, because the kit's audience plugs in the
# only clock they own.
enter_update_mode_trampoline() {
    # Which board's image to push: the USB-detected board when there is one,
    # else the caller's guess. A wrong guess is harmless - the clock verifies
    # the image's board stamp itself and refuses a mismatch.
    local board="${1:-$DEV_BOARD}"
    local trampoline="$KIT_ROOT/firmware/$board/bootsel_trampoline.bin"
    if [ ! -f "$trampoline" ]; then return 1; fi

    say "  Trying over your wifi instead..."
    local host="" candidate reply
    for candidate in timeframe.local wordclock.local wordclock; do
        reply="$(/usr/bin/curl -fsS -m 6 "http://$candidate/config/api/bootstrap" 2>/dev/null)"
        if [ -n "$reply" ]; then host="$candidate"; break; fi
    done
    if [ -z "$host" ]; then
        warn "  (couldn't find the clock on this wifi network)"
        return 1
    fi

    # The serial the clock reports over wifi, from its bootstrap JSON. No
    # python on the owner's Mac is assumed, so this stays sed.
    local wifi_serial usb_serial
    wifi_serial="$(printf '%s' "$reply" | /usr/bin/sed -n 's/.*"serial":"\([0-9A-Fa-f]*\)".*/\1/p' | tr '[:lower:]' '[:upper:]')"
    usb_serial="$(clock_serial)"
    if [ -n "$wifi_serial" ] && [ -n "$usb_serial" ] && [ "$wifi_serial" != "$usb_serial" ]; then
        warn "  (the clock on your wifi is a different clock to the one plugged in)"
        return 1
    fi

    say "  Found the clock at $host - asking it to step into update mode..."
    reply="$(/usr/bin/curl -fsS -m 120 -X POST --data-binary "@$trampoline" \
        -H "Content-Type: application/octet-stream" \
        "http://$host/config/api/firmware" 2>&1)"
    case "$reply" in
        *rebooting*) ;;
        *) warn "  (the clock declined: $reply)"; return 1 ;;
    esac
    # Staging, swap and restart take a little while; the clock then appears
    # on USB in update mode.
    wait_for_bootsel 90
}

# Ask the running clock to restart into update mode. Three ways in: picotool's
# reset interface; the 1200-baud touch, which needs no driver at all; and for
# the clocks whose USB answers neither (RP2350), the trampoline over wifi.
# The device drops off the USB bus mid-call by design, so errors here are
# expected and ignored.
enter_update_mode() {
    local port
    say "  Asking the clock to restart into update mode..."
    "$PICOTOOL" reboot -f -u 2>&1 || true
    sleep 2
    if device_in_mode bootsel; then return 0; fi

    port="$(clock_port)"
    if [ -n "$port" ]; then
        say "  Trying the serial route..."
        /bin/stty -f "$port" 1200 >/dev/null 2>&1 || true
    else
        warn "  (skipping the serial route: more than one USB serial device is attached)"
    fi
    if wait_for_bootsel 20; then return 0; fi

    enter_update_mode_trampoline
}

# ---------------------------------------------------------------------------

main() {
    say ""
    say "  Timeframe clock updater"
    say "  ======================="
    say ""

    # macOS quarantines everything unzipped from a download, and picotool is
    # ad-hoc signed rather than notarised, so Gatekeeper would refuse to run
    # it. Clearing the flag on this kit is the same consent the owner already
    # gave by opening this script.
    /usr/bin/xattr -d -r com.apple.quarantine "$KIT_ROOT" >/dev/null 2>&1 || true
    chmod +x "$PICOTOOL" 2>/dev/null || true

    if [ ! -f "$PICOTOOL" ]; then
        stop_badly "Some of the kit is missing (tools/picotool). Please unzip the whole folder and try again." 2
    fi

    # Prove the tool itself runs before anything is asked of the clock. On a
    # Mac this is not a formality: a quarantined or blocked picotool fails
    # here, with the clock untouched, instead of half way through.
    if ! "$PICOTOOL" version >/dev/null 2>&1; then
        stop_badly "macOS is blocking the updater's helper program.

Open System Settings > Privacy & Security, scroll down, and click
\"Open Anyway\" if it offers to. Then run this again. Nothing has been
changed on the clock." 9
    fi

    say "  Looking for your clock..."
    local devices
    devices="$(list_rp_devices)"
    # Never give up on the operating system's word alone: picotool has seen a
    # clock on a Mac where every OS route reported an empty bus.
    if [ -z "$devices" ] && device_in_mode bootsel; then
        warn "  (this Mac isn't reporting its USB devices; asking picotool instead)"
        devices="found"
    fi
    # An RP2350 clock's USB can be entirely mute while the clock software
    # runs - the device shows up nameless or not at all. The clock itself is
    # fine, and reachable over the house wifi. So before concluding there is
    # no clock: ask the wifi. If a clock answers, the trampoline walks it
    # into update mode, where its USB is driven by the chip's boot ROM and
    # always works - and identifies the board for the rest of this script.
    if [ -z "$devices" ]; then
        say "  Nothing recognisable on USB - looking on your wifi instead..."
        if enter_update_mode_trampoline pico2_w && device_in_mode bootsel; then
            devices="found"
        fi
    fi
    if [ -z "$devices" ]; then
        # Say what WAS seen. "I can't see the clock" on its own sends people
        # round the cable loop forever, including when the cable is fine.
        local inventory
        inventory="$(list_usb_inventory)"
        say ""
        if [ -n "$inventory" ]; then
            say "  Everything this Mac can see on its USB ports:"
            say "$inventory"
            stop_badly "I can't see the clock.

The list above is what this computer can see. Your clock isn't in it.

If you have already tried a cable you know carries data - one that
works for copying photos off a phone - then the cable is not the
problem, and there is nothing more to try at this end. Send Lachlan
the log file below: the list above tells him what is going on." 3
        else
            say "  This Mac can't see ANY USB devices at all."
            stop_badly "I can't see the clock - or anything else.

  * Check the USB cable is pushed in at both ends.
  * Try a different cable - many charging cables carry power only
    and cannot carry data. A cable that works for a phone's files
    will work here.
  * Try the other USB socket, or remove any adapter or hub." 3
        fi
    fi

    # A clock in any of its three states wins over anything else attached; a
    # debug probe is the only other thing that answers to 2E8A, and it is not
    # a clock.
    local mode="" candidate
    for candidate in app micropython bootsel; do
        if device_in_mode "$candidate"; then mode="$candidate"; break; fi
    done
    if [ -z "$mode" ]; then
        if device_in_mode probe; then
            stop_badly "That's a debug probe, not a clock. Please unplug it and connect the clock instead." 4
        fi
        # Same maker, unfamiliar product id. Calling that a debug probe would
        # be a guess dressed up as an answer.
        say ""
        say "  Everything this Mac can see on its USB ports:"
        say "$(list_usb_inventory)"
        stop_badly "There is something plugged in that I don't recognise.

It comes from the same maker as the clock, but it isn't one I know how
to update. Nothing has been changed. Send Lachlan the log file below -
the list above tells him exactly what it is." 4
    fi

    if [ "$mode" = "micropython" ]; then
        ok "  Found your clock (it's one of the original ones)."
    else
        ok "  Found your clock ($DEV_CHIP)."
    fi

    local serial
    serial="$(clock_serial)"
    if [ -n "$serial" ]; then say "  Its serial number is $serial."; fi

    if [ "$CHECK_ONLY" -eq 1 ]; then
        say ""
        ok "  This is a check only - nothing has been changed."
        say "  Everything needed to update it is here and working."
        if [ -n "$serial" ]; then save_serial "$serial"; fi
        pause_and_exit 0
    fi

    if [ "$mode" = "app" ] || [ "$mode" = "micropython" ]; then
        local switched=1
        if [ "$mode" = "micropython" ]; then
            enter_update_mode_micropython && switched=0
        else
            enter_update_mode && switched=0
        fi
        if [ "$switched" -ne 0 ]; then
            stop_badly "The clock didn't switch into update mode.

Unplug it, plug it back in, and run this again - it is unchanged
and still working normally." 5
        fi
        if ! device_in_mode bootsel; then
            stop_badly "The clock switched into update mode but then disappeared.

Unplug it, plug it back in, and run this again - it is unchanged
and still working normally." 5
        fi
    fi

    local board="$DEV_BOARD"
    local bootsel_pid="0x$DEV_PID"
    ok "  The clock is in update mode ($DEV_CHIP)."

    # Prove we can talk to it BEFORE writing anything. If this fails the clock
    # is still untouched, and a replug brings the old software straight back.
    say "  Checking I can write to it..."
    if ! "$PICOTOOL" info --vid 0x2e8a --pid "$bootsel_pid" 2>&1; then
        stop_badly "I can reach the clock but not write to it on this computer.

Unplug it and plug it back in - it is unchanged and will start up
normally. Then send Lachlan the log file below and he'll sort it out." 6
    fi

    local bootloader="$KIT_ROOT/firmware/$board/pico_fota_bootloader-$board.uf2"
    local application="$KIT_ROOT/firmware/$board/word_clock-$board.uf2"
    local image
    for image in "$bootloader" "$application"; do
        if [ ! -f "$image" ]; then
            stop_badly "Part of the kit is missing:
  $image

Please unzip the whole folder and try again." 2
        fi
    done

    say ""
    say "  Installing the new software. This takes about a minute."
    say "  Please don't unplug the clock until it says it has finished."
    say ""

    # Both images must land in this one session: the clock cannot start with
    # only one of them. Neither -f nor -x is used, because both would restart
    # the clock between the two writes.
    say "  1 of 2..."
    if ! "$PICOTOOL" load -v "$bootloader" --vid 0x2e8a --pid "$bootsel_pid" 2>&1; then
        stop_badly "The first part didn't write correctly.

IMPORTANT: leave the clock plugged in and switched on, and send
Lachlan the log file below. It is half-updated, which is easy to
finish from here and harder after it loses power." 7
    fi

    say "  2 of 2..."
    if ! "$PICOTOOL" load -v "$application" --vid 0x2e8a --pid "$bootsel_pid" 2>&1; then
        stop_badly "The second part didn't write correctly.

IMPORTANT: leave the clock plugged in and switched on, and send
Lachlan the log file below. It is half-updated, which is easy to
finish from here and harder after it loses power." 8
    fi

    say "  Restarting the clock..."
    "$PICOTOOL" reboot 2>&1 || true

    # Once the new software is running it re-appears on USB reporting its own
    # serial. Prefer that reading - it comes from the software now installed.
    local i=0 fresh
    while [ "$i" -lt 15 ]; do
        sleep 1
        fresh="$(clock_serial)"
        if [ -n "$fresh" ]; then serial="$fresh"; break; fi
        i=$((i + 1))
    done

    say ""
    ok "  Done - the new software is on your clock."
    say ""

    if [ -n "$serial" ]; then
        say "  ============================================================"
        say "   YOUR CLOCK'S SERIAL NUMBER:"
        say ""
        ok "       $serial"
        say ""
        say "   Send this to Lachlan - he needs it to finish setting up"
        say "   your clock, and it is the only thing he needs from you."
        say "  ============================================================"
        save_serial "$serial"
    else
        warn "  I couldn't read the clock's serial number automatically."
        warn "  Once it is on your wifi, open http://timeframe.local/config/"
        warn "  and it is shown on the 'This clock' card."
    fi

    say ""
    say "  You can unplug it from the computer now and hang it back up."
    say ""
    say "  What happens next:"
    say "    * If the clock already knew your wifi, it simply comes back"
    say "      by itself and shows the time - nothing more to do."
    say "    * A brand-new clock shows a slow spiral instead: it is waiting"
    say "      to be told about your wifi. Follow the wifi step in the"
    say "      instructions (join the 'Timeframe-Setup' network on your"
    say "      phone) to finish."
    say ""

    pause_and_exit 0
}

# Sourced by tools/test_mac_kit.py, which exercises the device parsing against
# recorded system_profiler output. Only a direct run does anything.
if [ "${TIMEFRAME_KIT_SOURCED:-0}" != "1" ]; then
    main "$@" 2>&1 | tee "$LOG"
    exit "${PIPESTATUS[0]}"
fi
