Find a Phone with Claude and Bluetooth

Build a Swift tool to find an owned Bluetooth device using RSSI.

Introduction

30 Second Summary

A missing phone can turn a familiar room into a frustrating search within minutes. If Find My is unavailable, the usual map and alert controls cannot guide you.

In this project, you will build a Mac command-line finder using Claude Code and Swift. The finder turns nearby Bluetooth Low Energy signal strength, called RSSI, into a hotter-and-colder guide with proximity clicks.

What You'll Build

You will watch one large negative number climb toward zero while accelerating clicks guide you to your hidden device.

By the end of this project, you'll have:

  • A live signal survey in Terminal that shows changing RSSI readings for nearby Bluetooth devices.
  • A focused hunt mode with a smoothed RSSI display for one owned device. Proximity clicks speed up as you move closer.
  • A privacy-safe hunt recording that hides unrelated names and identifiers. Your notes preserve the elapsed time and strongest reading.
  • Secret Mission: Measure three locations or obstacles to create a personal signal map for your home.

Are there any prerequisites?

You need a Mac with Bluetooth running macOS 13 or later plus one Bluetooth device you own. You also need Claude Code access through an eligible paid Claude plan or billed API account.

Before We Start

This first checkpoint defines why you are building the finder and sets a clear safety boundary before any hands-on work. You will use nearby Bluetooth signals only to recover a device you explicitly own in a safe home location.

Set Up the Mac and Claude Code

The finder depends on your Mac's Bluetooth radio. It also needs Apple's development tools before Claude can build the app.

An unsupported version of macOS can block the frameworks this project needs. A missing toolchain can turn the next build into confusing errors.

In this step, you'll prepare the Swift toolchain. You'll also start Claude Code inside an empty project folder.

In this step, get ready to:
  • Confirm that the Mac is ready for Bluetooth scanning.
  • Install the Apple development tools with Swift 5.9 or later.
  • Install Claude Code inside an empty findphone folder.
Check macOS and Bluetooth

The macOS version determines whether the Apple frameworks used later are available. Terminal provides the version directly from the operating system.

  • Press Command-Space to open Spotlight.
  • Type Terminal into the search field.
  • Press Return to open Terminal.
  • Check the installed macOS version by running this command:
sw_vers

What does this command show?

The ProductVersion line identifies the installed macOS version. The first number is the major version used for this requirement.

  • Find the ProductVersion line in the output.
  • Compare its first number with the required major version of 13.

✔️ The macOS requirement is met

Your Mac is running macOS 13 or later. You can continue with the project.

ⓧ The macOS version is too old

This project requires macOS 13 or later because the finished package targets that version.

  • Update the Mac to macOS 13 or later.
  • Repeat the version check before continuing.

The scanner also needs the Mac's Bluetooth radio to receive nearby advertisements. Confirming the radio now removes another source of silent scan failures.

  • Click the Apple menu in the top-left corner.
  • Select System Settings.
  • Select Bluetooth in the sidebar.
  • Turn Bluetooth on if it is currently off.

Good progress. Your operating system now meets the requirement. Your Bluetooth radio is ready for the scanner you build next.

Verify the Swift toolchain

The Xcode Command Line Tools provide Apple's compiler. They also include Swift Package Manager for building the command-line app.

Why native Swift?

Swift fits this Mac-only project because it works directly with Apple's system frameworks. This keeps the project free from third-party package dependencies.

The installation command may open an Apple installer dialog. Complete that installer before checking the toolchain.

  • Request the latest compatible Command Line Tools by running this command:
xcode-select --install

What does this command do?

This command asks macOS to install the latest compatible Command Line Tools. A Mac that already has the tools may report that no new installation is needed.

  • Complete the Apple installer if a dialog appears.

Before you check, what two results would prove that the developer directory exists and Swift meets the project requirement?

  • Verify the active developer directory plus the Swift version by running these commands:
xcode-select --print-path
swift --version

What do these checks prove?

  • The first command prints the active Apple developer directory. The standalone tools commonly use /Library/Developer/CommandLineTools.
  • The second command prints the installed Swift toolchain version. This project needs Swift 5.9 or later.

✔️ Swift meets the requirement

That's a major setup hurdle cleared. Your developer directory is active. Your Swift toolchain meets the 5.9 minimum.

ⓧ I see an older Swift version

The current toolchain is below Swift 5.9. It needs an update before the package can use the required manifest version.

ⓧ Swift is unavailable

The installer may not have completed. Request it again from Terminal.

  • Restart the Command Line Tools installer by running this command:
xcode-select --install

What happens next?

macOS opens the Apple installer when the tools are missing. Completing it adds the compiler plus the Swift command to your Mac.

  • Complete the installer dialog.
  • Repeat both verification checks from the main instructions.

Command Line Tools still unavailable?

Restart Terminal after the installer finishes. A fresh session can detect the newly installed commands.

If Swift remains unavailable, help me diagnose my Swift toolchain setup. You can also share the non-sensitive output in the NextWork community.

Start Claude Code in the project folder

Claude Code requires access through an eligible paid Claude plan or a Console API account. API-key use can incur API charges, so use the access route you already intend to fund.

Checking the command first separates Macs that already have Claude Code from Macs that still need the installer.

  • Check whether Claude Code is already installed by running this command:
claude --version

What does this check prove?

A version response confirms that the Claude Code command is installed. It also confirms that Terminal can find the command through your current shell.

✔️ I see a Claude Code version

Claude Code is installed. You can move on to the empty project folder.

ⓧ The command is unavailable

Claude Code still needs its official native installer. The next command installs it for macOS before checking the version again.

  • Install Claude Code plus verify the installation by running these commands:
curl -fsSL https://claude.ai/install.sh | bash
claude --version

What do these commands do?

  • The first command downloads the official Claude Code native installer.
  • The second command confirms that Terminal can find the completed installation.

Claude Code installation failed?

Check that the Mac has an internet connection. Restart Terminal after the installer completes if the command remains unavailable.

If the problem continues, help me troubleshoot the Claude Code installation. You can also share the non-sensitive installer output in the NextWork community.

Claude Code uses the current folder as its project context. Starting from an empty folder lets Claude confirm the safety constraints before it creates any package files.

  • Click Finder in the Dock.
  • Select Desktop in the Finder sidebar.
  • Click File in the menu bar.
  • Select New Folder.
  • Enter findphone as the folder name.
  • Press Return to create the folder.

Why start with an empty folder?

An empty folder gives Claude a clean boundary for this project. It also makes the final confirmation meaningful because no existing files can be mistaken for generated work.

  • Click View in the Finder menu bar.
  • Select Show Path Bar.
  • Control-click the findphone folder in the path bar.
  • Select Open in Terminal.

You should see a Terminal window open inside the empty findphone folder. That folder is now the boundary for Claude's work.

The first Claude Code launch may open an authentication flow. Starting the command also keeps this Terminal session occupied while Claude is running.

Before you start Claude, what should it discover when it inspects the new project folder?

  • Start Claude Code from the current findphone folder by running this command:
claude

What does this command do?

The command starts Claude Code with the current folder as its project context. Keep this Terminal session open while Claude is running.

  • Complete the authentication flow if Claude Code requests access.
  • Paste this first message into the Claude Code prompt:
We are building a beginner macOS command-line tool named findphone. It must only scan for and connect to Bluetooth devices the user explicitly confirms they own. Use Swift Package Manager, target macOS 13 or later, use only Apple system frameworks, and explain every command before running it. Do not pair with any device. First inspect this empty folder and confirm you are ready without creating files yet.

What does this message establish?

  • It limits the project to devices that you explicitly confirm you own.
  • It requires Swift Package Manager plus Apple system frameworks.
  • It forbids Bluetooth pairing.
  • It tells Claude to inspect the empty folder without creating files.
  • Press Return to send the message.

You should see Claude confirm that the folder is empty. It should also confirm the ownership rules plus the instruction to avoid pairing without creating package files.

Claude created files too early?

Ask Claude to stop before approving any file changes. Confirm that your first message includes the instruction to inspect the folder without creating files.

If Claude does not follow the constraint, help me reset this empty Claude Code session safely.

Your Mac now has the operating system support, Bluetooth access, Swift toolchain, and Claude Code session this project needs. Next, you'll use that clean session to make nearby Bluetooth signals visible.

Make Nearby Bluetooth Signals Visible

Your Mac can now build Swift packages. Claude Code is already running inside the empty findphone folder in Terminal.

This step uses Swift Package Manager to turn nearby Bluetooth advertisements into a live survey. Seeing changing RSSI values proves that your Mac can receive the signals that later guide the hunt.

In this step, get ready to:
  • Build the first survey scanner with Claude Code.
  • Run the scanner with Bluetooth access enabled.
  • Move an owned device to expose noisy RSSI readings.
Build the first survey scanner

Core Bluetooth lets a Mac discover nearby Bluetooth Low Energy devices through Apple’s system framework. Duplicate discoveries provide repeated readings from the same device as its signal changes.

Why Core Bluetooth for this scanner?

Core Bluetooth already ships with macOS. A Python scanner using Bleak would add a third-party package plus a separate asynchronous runtime.

Native Swift keeps this Mac-only project focused on the radio signal. You can reach the first visible result without another dependency layer.

  • Review each requested file if Claude Code asks for edit permission.
  • Send this build request to Claude Code:
Build the first working version now. Create a Swift Package Manager executable named findphone targeting macOS 13. Use CoreBluetooth and CBCentralManagerDelegate to scan for all nearby BLE peripherals. Enable duplicate discoveries because this project needs changing RSSI for proximity. Show a live terminal survey with each device's advertised local name, or its CoreBluetooth name, or "Unnamed device". Include its CoreBluetooth identifier and its latest RSSI in dBm. Keep the process alive with the main RunLoop. Add clear messages for Bluetooth off, unsupported, and unauthorized states. Use Package.swift plus Sources/findphone/main.swift and Sources/findphone/Finder.swift. Build it and fix any compiler errors, but do not add hunt mode, sound, paired-device fallback, or redaction yet.

What does this request create?

  • The Package.swift manifest defines a macOS executable named findphone.
  • The Finder.swift file owns Bluetooth scanning plus the live survey.
  • Duplicate discoveries let the same peripheral report new signal readings.
  • The main run loop keeps the command-line process ready for asynchronous Bluetooth callbacks.
  • Approve the three proposed file edits.
  • Approve the Swift build command if Claude Code requests permission.

Claude Code should finish with a successful build. That result confirms the package structure compiles before Bluetooth enters the picture.

Build not completing?

  • Tell Claude Code to inspect the complete compiler output before changing any file.
  • Confirm that Package.swift points to Sources/findphone.
  • Confirm that both Swift source files exist inside Sources/findphone.

Help me diagnose why this Swift survey package does not build.

✔️ Awesome, I've got everything!

Your successful build confirms that all three survey files compile together.

ⓧ I'd like to double check the full code

Your survey version should match these three files.

// swift-tools-version:5.9
import PackageDescription

let package = Package(
    name: "findphone",
    platforms: [.macOS(.v13)],
    targets: [
        .executableTarget(name: "findphone", path: "Sources/findphone")
    ]
)
import CoreBluetooth
import Foundation

private struct NearbyDevice {
    let identifier: UUID
    var name: String
    var smoothedRSSI: Double
    var peakRSSI: Int
    var lastSeen: Date
}

final class Finder: NSObject, CBCentralManagerDelegate {
    private var central: CBCentralManager!
    private var nearby: [UUID: NearbyDevice] = [:]
    private var radioMessage = "Waiting for Bluetooth..."
    private var renderTimer: Timer?

    func start() {
        print("Only track Bluetooth devices you own.")
        print("Never pair with or deliberately follow someone else's device.")

        central = CBCentralManager(delegate: self, queue: nil)
        renderTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) {
            [weak self] _ in self?.renderSurvey()
        }
    }

    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        switch central.state {
        case .poweredOn:
            radioMessage = "Scanning"
            central.scanForPeripherals(
                withServices: nil,
                options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
            )
        case .poweredOff:
            radioMessage = "Bluetooth is off. Turn it on in System Settings."
        case .unauthorized:
            radioMessage = "Bluetooth access denied. Enable Terminal in Privacy & Security > Bluetooth."
        case .unsupported:
            radioMessage = "This Mac does not support Bluetooth Low Energy."
        case .resetting:
            radioMessage = "Bluetooth is resetting."
        case .unknown:
            radioMessage = "Bluetooth state is unknown."
        @unknown default:
            radioMessage = "Bluetooth is unavailable."
        }
    }

    func centralManager(
        _ central: CBCentralManager,
        didDiscover peripheral: CBPeripheral,
        advertisementData: [String: Any],
        rssi RSSI: NSNumber
    ) {
        let rssi = RSSI.intValue
        guard isValid(rssi) else { return }

        let name = (advertisementData[CBAdvertisementDataLocalNameKey] as? String)
            ?? peripheral.name
            ?? "Unnamed device"
        let now = Date()

        var device = nearby[peripheral.identifier] ?? NearbyDevice(
            identifier: peripheral.identifier,
            name: name,
            smoothedRSSI: Double(rssi),
            peakRSSI: rssi,
            lastSeen: now
        )
        if device.name == "Unnamed device" && name != "Unnamed device" {
            device.name = name
        }
        device.smoothedRSSI = device.smoothedRSSI * 0.7 + Double(rssi) * 0.3
        device.peakRSSI = max(device.peakRSSI, rssi)
        device.lastSeen = now
        nearby[peripheral.identifier] = device
    }

    private func isValid(_ rssi: Int) -> Bool {
        rssi < 0 && rssi > -127
    }

    private func renderSurvey() {
        let now = Date()
        let visible = nearby.values
            .filter { now.timeIntervalSince($0.lastSeen) < 10 }
            .sorted { $0.smoothedRSSI > $1.smoothedRSSI }

        print("\u{1B}[2J\u{1B}[H", terminator: "")
        print("Nearby Bluetooth devices | \(radioMessage)")
        print("Move an owned device and watch its RSSI change. Control-C stops.\n")

        if visible.isEmpty {
            print("No advertisements yet. Wait a few seconds.")
            return
        }

        for device in visible.prefix(15) {
            let rssi = Int(device.smoothedRSSI.rounded())
            print("\(device.name) | \(device.identifier.uuidString) | \(rssi) dBm")
        }
    }
}
import Foundation

let finder = Finder()
finder.start()
RunLoop.main.run()
Run the live Bluetooth survey

The scanner receives Bluetooth discoveries through callbacks while the process stays alive. The first run may trigger a macOS permission request because Terminal is the app launching the executable.

Before you ask Claude to run it, do you think the survey will immediately show device names for every signal?

  • Ask Claude Code to launch the survey by sending this message:
Run the survey version with swift run findphone. Tell me exactly what Bluetooth permission prompt or terminal output I should see. Do not connect to any device.

What happens when Claude runs it?

  • The Swift run command builds the executable before starting it.
  • The scanner requests all nearby BLE advertisements without connecting to a device.
  • The main run loop keeps listening after the first discovery callback.
  • Grant Terminal Bluetooth access if macOS requests it.
  • Wait a few seconds for the survey rows to populate.

You should see rows containing a device name or Unnamed device. Each row also shows an identifier plus a negative reading in dBm.

No Bluetooth rows?

  • Wait a few more seconds for a nearby device to advertise.
  • Wake the Bluetooth device you own.
  • Open the Apple menu if Terminal was previously denied access.
  • Select System Settings.
  • Select Privacy & Security.
  • Select Bluetooth.
  • Enable Terminal.

Help me troubleshoot why my Swift Core Bluetooth survey shows no nearby rows.

Test the raw signal

A live row proves the scanner can receive radio data. Moving one owned device now reveals whether raw RSSI behaves like a steady distance meter.

Before you move the device, do you expect its RSSI to rise in a smooth line or jump between readings?

  • Choose one Bluetooth device you own from the survey.
  • Start a 15-second observation.
  • Move the owned device toward your Mac.
  • Move the owned device away from your Mac.
  • Watch the device’s RSSI row throughout the movement.

The reading jumps even when your movement is smooth. The device may also appear as Unnamed device because Bluetooth advertisements do not always include a name.

That shortfall is intentional. You have proved that the signal is visible while exposing the noise that makes the survey difficult to steer by.

  • Press Control-C after you have seen at least one RSSI value change.

The survey stops and returns control to Claude Code. Your first Bluetooth scanner has now captured changing proximity data from a device you own.

Your Mac can now see nearby Bluetooth signal strength in real time. Next, you’ll turn the noisy survey into a focused hotter-and-colder guide.

Turn the Scanner into Hunt Mode

Your Bluetooth Low Energy survey proved that your Mac can receive changing RSSI readings from nearby devices. It also exposed the scanner's biggest weakness.

The raw values jump between scans. Some owned devices also omit their names.

This step turns that noisy survey into a focused hunt for one owned device. Claude Code will add exact targeting, median smoothing, connected readings, and a paired-device fallback.

In this step, get ready to:
  • Add a hunt mode that follows one confirmed owned device.
  • Find the exact name of your owned target without sharing the unredacted list.
  • Test the smoothed signal while moving toward the target.
Build exact targeting and smoothing

A seven-reading median reduces the effect of individual RSSI spikes. Connected readings and the paired-device fallback give the hunt more ways to receive a current signal.

Why use native Bluetooth sources?

The app uses Core Bluetooth for advertisements and connected readings. It uses the Mac's paired-device report as a fallback when an owned device advertises its name inconsistently.

This keeps the project inside Apple's system frameworks. The hunt can combine multiple signal sources without adding a third-party package.

  • Switch back to the Claude Code session running in your findphone folder.
  • Ask Claude Code to add hunt mode by pasting this prompt:
Add hunt mode without removing survey mode. A single non-flag argument is the exact target name. Require the --owned flag before hunt mode starts, and print a safety error if it is missing. Match BLE names case-insensitively but exactly, then connect only to that confirmed owned peripheral and call readRSSI every 0.3 seconds. Smooth the displayed value with the median of the seven newest valid readings, reject RSSI values outside -126 through -1, refresh the terminal every 0.33 seconds, and draw the number as large ASCII digits. Show the strongest reading, elapsed time, and whether the source is advertisement, connected link, or paired fallback. Add Sources/findphone/Classic.swift using /usr/sbin/system_profiler with SPBluetoothDataType and -json as a fallback for an already-paired owned device. Add --list to show paired devices so the learner can copy the exact name. Never initiate pairing and never connect in survey or --list mode. Build and fix compiler errors.

What does this prompt add?

  • The exact target name keeps hunt mode focused on the owned device you choose.
  • The --owned requirement makes you confirm ownership before the app connects.
  • The newest seven readings produce the displayed median instead of exposing every jump.
  • The paired fallback checks an already-paired device without initiating new pairing.
  • Approve each proposed file edit in Claude Code.
  • Approve the build command when Claude Code requests permission.
  • Confirm that Claude Code reports a successful build.

Build not succeeding?

Check that Classic.swift sits inside Sources/findphone. Confirm that the generated target still points to that folder.

Ask Claude Code to compare each compiler error with the generated file before changing any Bluetooth behavior. Help me diagnose the hunt-mode Swift build without adding sound or redaction.

The generated files should now match the hunt-mode structure below. Use the reference only to compare Claude Code's work.

✔️ Awesome, I've got everything!

Your hunt-mode files are built. Keep Claude Code open for the exact-name check.

ⓧ I'd like to double check the full code

Check Package.swift
// swift-tools-version:5.9
import PackageDescription

let package = Package(
    name: "findphone",
    platforms: [.macOS(.v13)],
    targets: [
        .executableTarget(name: "findphone", path: "Sources/findphone")
    ]
)
Check Sources/findphone/main.swift
import Foundation

let usage = """
findphone: locate an owned Bluetooth device by signal strength

Usage:
  swift run findphone
  swift run findphone --list
  swift run findphone "Exact Device Name" --owned

Flags:
  --owned   confirm the hunt target is your property
  --list    list paired devices without connecting
  --help    show this message
"""

let knownFlags: Set<String> = ["--owned", "--list", "--help"]
let arguments = Array(CommandLine.arguments.dropFirst())

func usageError(_ message: String) -> Never {
    FileHandle.standardError.write(Data("findphone: \(message)\n\n\(usage)\n".utf8))
    exit(2)
}

if arguments.contains("--help") {
    print(usage)
    exit(0)
}

if let unknown = arguments.first(where: { $0.hasPrefix("-") && !knownFlags.contains($0) }) {
    usageError("unknown option \(unknown)")
}

let names = arguments.filter { !$0.hasPrefix("-") }
if names.count > 1 {
    usageError("provide only one exact device name")
}

if arguments.contains("--list") {
    let devices = Classic.devicesByStrength()
    if devices.isEmpty {
        print("No paired Bluetooth devices are currently available.")
    } else {
        print("NAME\tADDRESS\tRSSI\tSTATE")
        for device in devices {
            let rssi = device.rssi.map(String.init) ?? "-"
            let state = device.connected ? "connected" : "not connected"
            print("\(device.name)\t\(device.address)\t\(rssi)\t\(state)")
        }
    }
    exit(0)
}

let targetName = names.first
if targetName != nil && !arguments.contains("--owned") {
    usageError("hunt mode requires --owned; only track a device you own")
}

let finder = Finder(
    targetName: targetName,
    soundEnabled: false,
    redact: false
)
finder.start()
RunLoop.main.run()
Check Sources/findphone/Classic.swift
import Foundation

struct ClassicDevice {
    let name: String
    let address: String
    let rssi: Int?
    let connected: Bool
}

enum Classic {
    static func devices() -> [ClassicDevice] {
        let process = Process()
        process.executableURL = URL(fileURLWithPath: "/usr/sbin/system_profiler")
        process.arguments = ["SPBluetoothDataType", "-json"]

        let pipe = Pipe()
        process.standardOutput = pipe
        process.standardError = FileHandle.nullDevice

        guard (try? process.run()) != nil else { return [] }
        let data = pipe.fileHandleForReading.readDataToEndOfFile()
        process.waitUntilExit()

        guard
            let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
            let bluetooth = (root["SPBluetoothDataType"] as? [[String: Any]])?.first
        else {
            return []
        }

        return bluetooth.flatMap { section, value -> [ClassicDevice] in
            guard let items = value as? [[String: Any]] else { return [] }
            return items.flatMap { item in
                item.compactMap { name, raw -> ClassicDevice? in
                    guard
                        let info = raw as? [String: Any],
                        let address = info["device_address"] as? String
                    else {
                        return nil
                    }

                    let rssi = (info["device_rssi"] as? String).flatMap(Int.init)
                        ?? info["device_rssi"] as? Int

                    return ClassicDevice(
                        name: name,
                        address: address,
                        rssi: rssi,
                        connected: section == "device_connected"
                    )
                }
            }
        }
    }

    static func devicesByStrength() -> [ClassicDevice] {
        devices().sorted { ($0.rssi ?? .min) > ($1.rssi ?? .min) }
    }

    static func find(name: String) -> ClassicDevice? {
        devices().first {
            $0.name.localizedCaseInsensitiveCompare(name) == .orderedSame
        }
    }
}
Check Sources/findphone/Finder.swift
import CoreBluetooth
import Foundation

private struct Reading {
    let rssi: Int
    let at: Date
    let source: String
}

private struct NearbyDevice {
    let identifier: UUID
    var name: String
    var smoothedRSSI: Double
    var peakRSSI: Int
    var lastSeen: Date
}

final class Finder: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
    private let targetName: String?
    private let startedAt = Date()

    private var central: CBCentralManager!
    private var nearby: [UUID: NearbyDevice] = [:]
    private var targetPeripheral: CBPeripheral?
    private var targetAddress: String?
    private var readings: [Reading] = []
    private var strongestRSSI: Int?
    private var lastReadingAt: Date?
    private var lastClassicRSSI: Int?
    private var radioMessage = "Waiting for Bluetooth..."

    private var renderTimer: Timer?
    private var readTimer: Timer?
    private var classicTimer: Timer?

    init(targetName: String?, soundEnabled: Bool, redact: Bool) {
        self.targetName = targetName
        super.init()
    }

    func start() {
        print("Only track Bluetooth devices you own.")
        print("Never pair with or deliberately follow someone else's device.")

        central = CBCentralManager(delegate: self, queue: nil)

        let refresh = targetName == nil ? 1.0 : 0.33
        renderTimer = Timer.scheduledTimer(withTimeInterval: refresh, repeats: true) {
            [weak self] _ in self?.render()
        }

        if targetName != nil {
            pollClassic()
            classicTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) {
                [weak self] _ in self?.pollClassic()
            }
        }
    }

    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        switch central.state {
        case .poweredOn:
            radioMessage = "Scanning"
            central.scanForPeripherals(
                withServices: nil,
                options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
            )
        case .poweredOff:
            radioMessage = "Bluetooth is off. Turn it on in System Settings."
        case .unauthorized:
            radioMessage = "Bluetooth access denied. Enable Terminal in Privacy & Security > Bluetooth."
        case .unsupported:
            radioMessage = "This Mac does not support Bluetooth Low Energy."
        case .resetting:
            radioMessage = "Bluetooth is resetting."
        case .unknown:
            radioMessage = "Bluetooth state is unknown."
        @unknown default:
            radioMessage = "Bluetooth is unavailable."
        }
    }

    func centralManager(
        _ central: CBCentralManager,
        didDiscover peripheral: CBPeripheral,
        advertisementData: [String: Any],
        rssi RSSI: NSNumber
    ) {
        let rssi = RSSI.intValue
        guard isValid(rssi) else { return }

        let name = (advertisementData[CBAdvertisementDataLocalNameKey] as? String)
            ?? peripheral.name
            ?? "Unnamed device"
        let now = Date()

        var device = nearby[peripheral.identifier] ?? NearbyDevice(
            identifier: peripheral.identifier,
            name: name,
            smoothedRSSI: Double(rssi),
            peakRSSI: rssi,
            lastSeen: now
        )
        if device.name == "Unnamed device" && name != "Unnamed device" {
            device.name = name
        }
        device.smoothedRSSI = device.smoothedRSSI * 0.7 + Double(rssi) * 0.3
        device.peakRSSI = max(device.peakRSSI, rssi)
        device.lastSeen = now
        nearby[peripheral.identifier] = device

        guard
            let wanted = targetName,
            name.localizedCaseInsensitiveCompare(wanted) == .orderedSame
        else {
            return
        }

        record(rssi, source: "advertisement")

        if targetPeripheral?.identifier != peripheral.identifier {
            targetPeripheral = peripheral
            peripheral.delegate = self
        }

        if peripheral.state == .disconnected {
            central.connect(peripheral, options: nil)
        }
    }

    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        targetPeripheral = peripheral
        peripheral.delegate = self
        readTimer?.invalidate()
        peripheral.readRSSI()
        readTimer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { _ in
            peripheral.readRSSI()
        }
    }

    func centralManager(
        _ central: CBCentralManager,
        didFailToConnect peripheral: CBPeripheral,
        error: Error?
    ) {
        readTimer?.invalidate()
        readTimer = nil
    }

    func centralManager(
        _ central: CBCentralManager,
        didDisconnectPeripheral peripheral: CBPeripheral,
        error: Error?
    ) {
        readTimer?.invalidate()
        readTimer = nil
    }

    func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) {
        guard error == nil else { return }
        record(RSSI.intValue, source: "connected link")
    }

    private func pollClassic() {
        guard let targetName else { return }

        DispatchQueue.global(qos: .utility).async { [weak self] in
            let found = Classic.find(name: targetName)
            DispatchQueue.main.async {
                guard let self else { return }
                self.targetAddress = found?.address ?? self.targetAddress
                guard
                    let rssi = found?.rssi,
                    rssi != self.lastClassicRSSI
                else {
                    return
                }
                self.lastClassicRSSI = rssi
                self.record(rssi, source: "paired fallback")
            }
        }
    }

    private func isValid(_ rssi: Int) -> Bool {
        rssi < 0 && rssi > -127
    }

    private func record(_ rssi: Int, source: String) {
        guard isValid(rssi) else { return }
        let now = Date()
        readings.append(Reading(rssi: rssi, at: now, source: source))
        if readings.count > 120 {
            readings.removeFirst(readings.count - 120)
        }
        lastReadingAt = now
        strongestRSSI = max(strongestRSSI ?? rssi, rssi)
    }

    private var smoothedRSSI: Int? {
        let values = readings.suffix(7).map(\.rssi).sorted()
        guard !values.isEmpty else { return nil }
        return values[values.count / 2]
    }

    private var freshRSSI: Int? {
        guard
            let lastReadingAt,
            Date().timeIntervalSince(lastReadingAt) < 10
        else {
            return nil
        }
        return smoothedRSSI
    }

    private func render() {
        if targetName == nil {
            renderSurvey()
        } else {
            renderHunt()
        }
    }

    private func renderSurvey() {
        let now = Date()
        let visible = nearby.values
            .filter { now.timeIntervalSince($0.lastSeen) < 10 }
            .sorted { $0.smoothedRSSI > $1.smoothedRSSI }

        print("\u{1B}[2J\u{1B}[H", terminator: "")
        print("Nearby Bluetooth devices | \(radioMessage)")
        print("Move an owned device and watch its RSSI change. Control-C stops.\n")

        if visible.isEmpty {
            print("No advertisements yet. Wait a few seconds.")
            return
        }

        for device in visible.prefix(15) {
            let rssi = Int(device.smoothedRSSI.rounded())
            print("\(device.name) | \(device.identifier.uuidString) | \(rssi) dBm")
        }
    }

    private func renderHunt() {
        print("\u{1B}[2J\u{1B}[H", terminator: "")
        let displayName = targetName ?? "Owned device"
        let address = targetAddress ?? "not available"
        let elapsed = Int(Date().timeIntervalSince(startedAt))

        print("HUNTING OWNED DEVICE: \(displayName)")
        print("Address: \(address) | \(radioMessage)\n")

        guard let rssi = freshRSSI else {
            print("NO SIGNAL")
            print("\nMove slowly and wait for a fresh reading.")
            print("Elapsed: \(elapsed)s | Strongest: \(strongestRSSI.map(String.init) ?? "-") dBm")
            return
        }

        print(bigNumber(rssi))
        print("\nRSSI: \(rssi) dBm")
        print("Closer means less negative and nearer to zero.")
        print("Elapsed: \(elapsed)s | Strongest: \(strongestRSSI ?? rssi) dBm")
        print("Source: \(readings.last?.source ?? "unknown")")
    }
}

private let largeDigits: [Character: [String]] = [
    "-": ["     ", "     ", "#####", "     ", "     "],
    "0": [" ### ", "#   #", "#   #", "#   #", " ### "],
    "1": ["  #  ", " ##  ", "  #  ", "  #  ", " ### "],
    "2": [" ### ", "#   #", "   # ", "  #  ", "#####"],
    "3": ["#### ", "    #", " ### ", "    #", "#### "],
    "4": ["#  # ", "#  # ", "#####", "   # ", "   # "],
    "5": ["#####", "#    ", "#### ", "    #", "#### "],
    "6": [" ### ", "#    ", "#### ", "#   #", " ### "],
    "7": ["#####", "    #", "   # ", "  #  ", "  #  "],
    "8": [" ### ", "#   #", " ### ", "#   #", " ### "],
    "9": [" ### ", "#   #", " ####", "    #", " ### "]
]

private func bigNumber(_ number: Int) -> String {
    let characters = Array(String(number))
    return (0..<5).map { row in
        characters.map { largeDigits[$0]?[row] ?? "     " }.joined(separator: "  ")
    }.joined(separator: "\n")
}
Find the owned device's exact name

Hunt mode uses an exact case-insensitive name match. The paired-device list helps you copy the spelling that macOS already knows.

This unredacted list can expose nearby device names and Bluetooth addresses. Keep the output out of screenshots or recordings.

  • Ask Claude Code to run the paired-device list by pasting this prompt:
Run swift run findphone --list. Explain which exact name I should copy for my owned test device, and remind me not to share this unredacted list.

What does this check do?

The --list mode reads paired-device information from macOS. It sorts available readings by signal strength without connecting to a device.

The output gives you the exact name required by hunt mode. Keep that list private because redaction arrives in a later step.

  • Approve the run command in Claude Code.
  • Locate the exact name of the test device you own.
  • Copy that exact name for the hunt command.

Owned device missing from the list?

  • Use the exact advertised name you observed in survey mode.
  • Wake the owned device if it is sleeping.
  • Move the owned device closer to the Mac before checking again.

Do not change pairing settings to make an unknown device appear. Help me identify why my owned Bluetooth device is absent without pairing with another device.

Test the hotter-and-colder reading

Hunt mode combines recent valid readings into one steadier number. A reading that becomes less negative shows a stronger signal trend.

  • Replace Exact Device Name in the command below with the exact owned-device name you copied.

Before you run this, predict whether seven-reading median smoothing produces instant jumps or a steadier trend.

swift run findphone "Exact Device Name" --owned

What does this command do?

  • The quoted name becomes the app's single exact target.
  • The --owned flag confirms that the target is your property.
  • The running app scans for matching advertisements before using connected or paired fallback readings.

You'll see one large negative number refresh about three times per second. The elapsed time, strongest reading, and current source appear below it.

  • Walk a few steps away from the owned device.
  • Watch the large number settle into a more negative range.
  • Walk back toward the owned device.
  • Confirm that the smoothed number generally moves closer to zero.
  • Press Control-C after you have observed the trend in both directions.

No hunt reading appearing?

Check that the target name matches the paired or advertised name exactly. Keep the quotation marks when the name contains spaces.

Wake the owned device before trying again. Help me diagnose why hunt mode cannot find my exact owned-device name.

Your scanner can now focus on one owned device and turn noisy radio readings into a steadier hotter-and-colder display. Next, you'll add clicks that speed up as the signal strengthens.

Add Parking-Sensor Sound

Your hunt mode now turns noisy Bluetooth readings into one stable RSSI number for an owned device. Walking toward the target makes that number easier to follow.

Watching a laptop screen while searching under furniture is awkward. Signal-driven clicks let you keep your eyes on the room.

This step uses AudioToolbox to accelerate the clicks as signal strength rises. The clicker goes silent when its reading becomes stale.

In this step, get ready to:
  • Add an AudioToolbox clicker to owned-device hunt mode.
  • Map smoothed signal strength to faster or slower clicks.
  • Confirm stale readings stop the sound after ten seconds.
Add sound with Claude Code

Claude Code can coordinate the new sound file with the existing hunt display. It also builds the package after making the edits.

  • Ask Claude Code from earlier to add the sound system by pasting this request:
Add a --sound flag for hunt mode only. Create Sources/findphone/Sound.swift using AudioToolbox. Load /System/Library/Sounds/Tink.aiff with AudioServicesCreateSystemSoundID and play it with AudioServicesPlaySystemSound. Schedule clicks independently from the screen refresh. Map RSSI linearly from one click per second at -95 dBm to a fastest interval of 0.07 seconds at -50 dBm, clamped at both ends. Feed the same smoothed RSSI shown on screen into the clicker. Pass nil after ten seconds without a valid target reading so silence means no current signal. Dispose the sound ID in deinit. Build and fix any compiler errors.

How will the clicker work?

  • The new Sound.swift file keeps audio playback separate from Bluetooth scanning.
  • The Clicker class loads /System/Library/Sounds/Tink.aiff through AudioToolbox.
  • One-shot timers keep click scheduling independent from the screen refresh.
  • A reading of -95 dBm produces one click per second.
  • A reading of -50 dBm produces the fastest interval of 0.07 seconds.
  • Passing nil stops playback when the current signal is stale.
  • Approve the requested file changes if Claude Code asks for permission.
  • Allow Claude Code to run the build command if it asks for permission.

Claude Code should report that the package builds successfully. Good progress. Your finder now has an audio layer driven by the same smoothed reading as the screen.

Build not succeeding?

  • Ask Claude Code to fix each compiler error before continuing.
  • Check that Sound.swift sits inside Sources/findphone.
  • Check that the file imports AudioToolbox before using the sound APIs.

Help me diagnose the sound build failure.

Start the audible hunt

The --sound flag belongs to hunt mode because its cadence follows one confirmed owned target. Survey mode remains visual and never connects.

Before you start, predict whether the clicks and the large number will react to the same movement.

  • Ask Claude Code to start the audible hunt by pasting this request:
Run swift run findphone "Exact Device Name" --owned --sound, substituting the owned device name I used in the previous step. Tell me when the app is ready for me to walk, but do not change any Bluetooth pairing settings.

What does this request do?

  • Claude Code reuses the exact owned-device name from your previous hunt.
  • The --owned flag confirms that the selected target belongs to you.
  • The --sound flag starts the clicker alongside the visual hunt.
  • Your existing Bluetooth pairing settings remain unchanged.
  • Approve the run command if Claude Code asks for permission.
  • Wait for Claude Code to confirm that the app is ready for you to walk.

You will see the large negative reading from hunt mode. You will also hear a repeating click while the target signal remains current.

Sweep one room and test stale silence

A useful audio guide must react to proximity without relying on the screen. Its silence must also tell you when there is no current reading.

  • Walk slowly through one room toward your owned device.

You will see the RSSI become less negative as the signal strengthens. You will hear the gaps between clicks shrink at the same time.

Before you interrupt the signal, predict what the clicker will do after the last valid reading becomes stale.

  • Increase the distance until the current target readings stop.
  • Wait at least ten seconds.

The clicks stop after the stale timeout. The display changes to NO SIGNAL because the app no longer has a current proximity reading.

  • Press Control-C after confirming that movement changes both the number and the click cadence.

That is the hands-free loop working. Your finder now turns one smoothed signal into matching visual and audible feedback.

Clicks not changing or stopping?

  • Confirm that --sound appears in the hunt command.
  • Raise your Mac volume if the screen responds but the clicks are inaudible.
  • Move more slowly if the cadence jumps before the seven-reading median can settle.
  • Increase the distance further if valid readings continue arriving during the stale-signal test.

Help me troubleshoot the audible hunt.

Your finder can now guide you by sight or sound. Next, you will protect nearby device information before recording a real hunt.

Redact, Record, and Find the Hidden Device

Your Bluetooth finder now turns movement into a large signal reading and matching clicks. You can use those cues to recover an owned device without watching the screen.

A real hunt can expose nearby names and stable device addresses in Terminal. In this step, you will use privacy redaction to hide those details before creating evidence with Claude Code.

In this step, get ready to:
  • Add a redaction mode that hides unrelated device information.
  • Complete a timed hunt for an owned device.
  • Create privacy-safe evidence of the successful hunt.
Add privacy redaction

Redaction replaces identifying values before they reach the screen. Survey mode needs to hide every nearby name and identifier. Hunt mode can keep the target name because you supplied it yourself.

Before Claude runs the survey, do you think any unrelated device detail should remain readable?

  • Switch back to Claude Code in the findphone folder.
  • Request the final privacy controls by pasting this prompt:
Add a --redact privacy flag. In survey mode replace every device name with "Nearby device" and every CoreBluetooth identifier with "hidden". In --list mode replace every name with "Paired device" and every Bluetooth address with "hidden". In hunt mode keep only the target name the user typed, and mask any paired-device address as "hidden". Make sure no unrelated name, UUID, or Bluetooth address is printed anywhere while --redact is active. Update --help, build, and run a short redacted survey so we can verify the output.

What does this prompt change?

  • The new --redact flag controls whether identifying Bluetooth details can reach Terminal.
  • Survey mode replaces every device name with Nearby device.
  • List mode replaces every paired-device name with Paired device.
  • Every identifier or paired-device address becomes hidden.
  • Approve Claude Code's edits to Sources/findphone/main.swift and Sources/findphone/Finder.swift.
  • Approve the build command when Claude Code requests permission.
  • Allow Claude Code to start the short redacted survey.

You should see rows containing Nearby device and hidden. Negative RSSI readings should continue to change.

  • Inspect every visible survey row for an unredacted name or identifier.
  • Stop the survey by pressing Control-C after the privacy check passes.

Still seeing private device details?

Check that the survey was started with --redact. Confirm that Claude Code completed the build after editing both Swift files.

If a real name or identifier remains visible, stop the survey before taking a screenshot. Help me find why redaction is missing from my Swift survey output.

Complete a real hunt

A controlled hide turns the finder into a realistic recovery test. The hidden target must be a device you own. The hiding place must also be safe to access.

  • Record the exact target name here: your exact owned-device name.
  • Give the owned target device to another person.
  • Ask that person to hide the device somewhere safe in your home.
  • Confirm that Bluetooth remains on for the target device.
  • Record the hiding place after the hunt reveals it: your safe hiding place.
  • Start a timer.
  • Return to Terminal in the findphone folder.

Before you start the hunt, do you expect the large number and click cadence to point toward the same area?

  • Start the private audible hunt by running this command:
swift run findphone "[[OWNED_DEVICE_NAME="your exact owned-device name"]]" --owned --sound --redact

What does this command protect?

  • The --owned flag confirms that the named target belongs to you.
  • The --sound flag turns the smoothed signal into proximity clicks.
  • The --redact flag masks the paired-device address.

You should see only your typed target name with an address of hidden. The large negative number should refresh while fresh readings arrive.

No fresh target signal?

Wake the owned device if it has gone idle. Move slowly for several seconds so the scanner has time to collect fresh readings.

Confirm that the target name matches the exact advertised or paired name from the earlier step. Help me diagnose why my owned Bluetooth target has no fresh RSSI.

  • Search by following clicks that become faster.
  • Confirm your direction with a large number that moves closer to zero.
  • Avoid opening any device that you do not own.
  • Avoid moving any device that you do not own.
  • Avoid connecting to any device that you do not own.
  • Stop the timer when you find the owned target.
  • Stop the hunt by pressing Control-C.
  • Record the elapsed result here: your time to find.
  • Record the peak signal here: your strongest RSSI.

You found the owned device using signal trends instead of an exact map. Your elapsed time and strongest reading now capture the outcome of the hunt.

Record and save the result

A privacy-safe recording demonstrates the number climbing without exposing nearby people. The macOS Screenshot tool can record only the redacted Terminal area.

  • Re-hide the owned device for a short demonstration.
  • Press Shift-Command-5 to open Screenshot.
  • Choose Record Selected Portion.
  • Frame only the redacted Terminal window.
  • Click Record.

Before you run the filmed hunt, do you expect the recording to reveal anything beyond your target name and changing signal?

  • Start the filmed redacted hunt by running this command:
swift run findphone "[[OWNED_DEVICE_NAME="your exact owned-device name"]]" --owned --sound --redact

Why use the same flags?

The filmed run keeps the same ownership check and audible guidance as the real hunt. Redaction keeps addresses and unrelated Bluetooth details outside the recording.

  • Walk toward the hidden target until the number clearly moves closer to zero.
  • Stop the hunt by pressing Control-C.
  • Stop the recording by clicking the Stop button in the menu bar.
  • Confirm that Screenshot saved a file ending in .mov.

The recording should show only the redacted Terminal area. Your target name can remain visible because you entered it yourself.

  • Return to Claude Code in the findphone folder.
  • Create your hunt summary by pasting this prompt:
Create HUNT_RESULTS.md with a short title and these facts from my test: owned device name [[OWNED_DEVICE_NAME="your exact owned-device name"]], hiding place [[HIDING_PLACE="your safe hiding place"]], time to find [[TIME_TO_FIND="your time to find"]], strongest RSSI [[STRONGEST_RSSI="your strongest RSSI"]]. Add one sentence saying RSSI showed proximity rather than exact distance or direction. Do not include any other device name, UUID, or Bluetooth address.

What goes into the hunt record?

The file captures the result someone needs to understand your test. The final caution keeps the result honest because RSSI indicates proximity without giving an exact location.

  • Approve the creation of HUNT_RESULTS.md.
  • Review the HUNT_RESULTS.md edit in Claude Code.
  • Confirm that the file contains a short title.
  • Confirm that the file contains your owned target name.
  • Confirm that the file contains the hiding place.
  • Confirm that the file contains the elapsed time.
  • Confirm that the file contains the strongest RSSI.
  • Confirm that the file contains no unrelated device information.

Your package is complete. Use these tabs to compare the final Swift files with the finished version.

✔️ Awesome, I've got everything!

Great. Double-check that every file is saved in the findphone folder.

ⓧ I'd like to double check the full code

// swift-tools-version:5.9
import PackageDescription

let package = Package(
    name: "findphone",
    platforms: [.macOS(.v13)],
    targets: [
        .executableTarget(name: "findphone", path: "Sources/findphone")
    ]
)

What does this file define?

The package manifest targets macOS 13 with one executable named findphone.

import Foundation

let usage = """
findphone: locate an owned Bluetooth device by signal strength

Usage:
  swift run findphone
  swift run findphone --list [--redact]
  swift run findphone "Exact Device Name" --owned [--sound] [--redact]

Flags:
  --owned   confirm the hunt target is your property
  --sound   play faster clicks as RSSI gets stronger
  --redact  hide unrelated names and all identifiers or addresses
  --list    list paired devices without connecting
  --help    show this message
"""

let knownFlags: Set<String> = ["--owned", "--sound", "--redact", "--list", "--help"]
let arguments = Array(CommandLine.arguments.dropFirst())

func usageError(_ message: String) -> Never {
    FileHandle.standardError.write(Data("findphone: \(message)\n\n\(usage)\n".utf8))
    exit(2)
}

if arguments.contains("--help") {
    print(usage)
    exit(0)
}

if let unknown = arguments.first(where: { $0.hasPrefix("-") && !knownFlags.contains($0) }) {
    usageError("unknown option \(unknown)")
}

let names = arguments.filter { !$0.hasPrefix("-") }
if names.count > 1 {
    usageError("provide only one exact device name")
}

let redact = arguments.contains("--redact")

if arguments.contains("--list") {
    let devices = Classic.devicesByStrength()
    if devices.isEmpty {
        print("No paired Bluetooth devices are currently available.")
    } else {
        print("NAME\tADDRESS\tRSSI\tSTATE")
        for device in devices {
            let name = redact ? "Paired device" : device.name
            let address = redact ? "hidden" : device.address
            let rssi = device.rssi.map(String.init) ?? "-"
            let state = device.connected ? "connected" : "not connected"
            print("\(name)\t\(address)\t\(rssi)\t\(state)")
        }
    }
    exit(0)
}

let targetName = names.first
if targetName != nil && !arguments.contains("--owned") {
    usageError("hunt mode requires --owned; only track a device you own")
}

if arguments.contains("--sound") && targetName == nil {
    usageError("--sound requires an owned hunt target")
}

let finder = Finder(
    targetName: targetName,
    soundEnabled: arguments.contains("--sound"),
    redact: redact
)
finder.start()
RunLoop.main.run()

What does the entry point handle?

The entry point validates the supported flags before starting the finder. It also masks paired-device names and addresses in redacted list mode.

import Foundation

struct ClassicDevice {
    let name: String
    let address: String
    let rssi: Int?
    let connected: Bool
}

enum Classic {
    static func devices() -> [ClassicDevice] {
        let process = Process()
        process.executableURL = URL(fileURLWithPath: "/usr/sbin/system_profiler")
        process.arguments = ["SPBluetoothDataType", "-json"]

        let pipe = Pipe()
        process.standardOutput = pipe
        process.standardError = FileHandle.nullDevice

        guard (try? process.run()) != nil else { return [] }
        let data = pipe.fileHandleForReading.readDataToEndOfFile()
        process.waitUntilExit()

        guard
            let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
            let bluetooth = (root["SPBluetoothDataType"] as? [[String: Any]])?.first
        else {
            return []
        }

        return bluetooth.flatMap { section, value -> [ClassicDevice] in
            guard let items = value as? [[String: Any]] else { return [] }
            return items.flatMap { item in
                item.compactMap { name, raw -> ClassicDevice? in
                    guard
                        let info = raw as? [String: Any],
                        let address = info["device_address"] as? String
                    else {
                        return nil
                    }

                    let rssi = (info["device_rssi"] as? String).flatMap(Int.init)
                        ?? info["device_rssi"] as? Int

                    return ClassicDevice(
                        name: name,
                        address: address,
                        rssi: rssi,
                        connected: section == "device_connected"
                    )
                }
            }
        }
    }

    static func devicesByStrength() -> [ClassicDevice] {
        devices().sorted { ($0.rssi ?? .min) > ($1.rssi ?? .min) }
    }

    static func find(name: String) -> ClassicDevice? {
        devices().first {
            $0.name.localizedCaseInsensitiveCompare(name) == .orderedSame
        }
    }
}

What does the paired fallback do?

This file reads paired-device information from the Mac. It can sort those devices by signal strength or find one exact case-insensitive name.

import AudioToolbox
import Foundation

final class Clicker {
    private let touching = -50.0
    private let distant = -95.0
    private let fastest: TimeInterval = 0.07
    private let slowest: TimeInterval = 1.0

    private var sound: SystemSoundID = 0
    private var rssi: Int?

    init?(path: String = "/System/Library/Sounds/Tink.aiff") {
        let url = URL(fileURLWithPath: path)
        guard
            FileManager.default.fileExists(atPath: path),
            AudioServicesCreateSystemSoundID(url as CFURL, &sound) == kAudioServicesNoError
        else {
            return nil
        }
    }

    deinit {
        AudioServicesDisposeSystemSoundID(sound)
    }

    func start() {
        schedule()
    }

    func update(rssi: Int?) {
        self.rssi = rssi
    }

    private func interval(for rssi: Int) -> TimeInterval {
        let ramp = (Double(rssi) - distant) / (touching - distant)
        let clamped = min(1, max(0, ramp))
        return slowest - clamped * (slowest - fastest)
    }

    private func schedule() {
        let wait = rssi.map(interval) ?? slowest
        Timer.scheduledTimer(withTimeInterval: wait, repeats: false) { [weak self] _ in
            guard let self else { return }
            if self.rssi != nil {
                AudioServicesPlaySystemSound(self.sound)
            }
            self.schedule()
        }
    }
}

What does the clicker control?

The clicker maps smoothed signal strength to a clamped delay between sounds. A missing current reading produces silence.

import CoreBluetooth
import Foundation

private struct Reading {
    let rssi: Int
    let at: Date
    let source: String
}

private struct NearbyDevice {
    let identifier: UUID
    var name: String
    var smoothedRSSI: Double
    var peakRSSI: Int
    var lastSeen: Date
}

final class Finder: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
    private let targetName: String?
    private let redact: Bool
    private let startedAt = Date()
    private let clicker: Clicker?

    private var central: CBCentralManager!
    private var nearby: [UUID: NearbyDevice] = [:]
    private var targetPeripheral: CBPeripheral?
    private var targetAddress: String?
    private var readings: [Reading] = []
    private var strongestRSSI: Int?
    private var lastReadingAt: Date?
    private var lastClassicRSSI: Int?
    private var radioMessage = "Waiting for Bluetooth..."

    private var renderTimer: Timer?
    private var readTimer: Timer?
    private var classicTimer: Timer?

    init(targetName: String?, soundEnabled: Bool, redact: Bool) {
        self.targetName = targetName
        self.redact = redact
        self.clicker = soundEnabled ? Clicker() : nil
        super.init()
    }

    func start() {
        print("Only track Bluetooth devices you own.")
        print("Never pair with or deliberately follow someone else's device.")

        clicker?.start()
        central = CBCentralManager(delegate: self, queue: nil)

        let refresh = targetName == nil ? 1.0 : 0.33
        renderTimer = Timer.scheduledTimer(withTimeInterval: refresh, repeats: true) {
            [weak self] _ in self?.render()
        }

        if targetName != nil {
            pollClassic()
            classicTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) {
                [weak self] _ in self?.pollClassic()
            }
        }
    }

    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        switch central.state {
        case .poweredOn:
            radioMessage = "Scanning"
            central.scanForPeripherals(
                withServices: nil,
                options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
            )
        case .poweredOff:
            radioMessage = "Bluetooth is off. Turn it on in System Settings."
        case .unauthorized:
            radioMessage = "Bluetooth access denied. Enable Terminal in Privacy & Security > Bluetooth."
        case .unsupported:
            radioMessage = "This Mac does not support Bluetooth Low Energy."
        case .resetting:
            radioMessage = "Bluetooth is resetting."
        case .unknown:
            radioMessage = "Bluetooth state is unknown."
        @unknown default:
            radioMessage = "Bluetooth is unavailable."
        }
    }

    func centralManager(
        _ central: CBCentralManager,
        didDiscover peripheral: CBPeripheral,
        advertisementData: [String: Any],
        rssi RSSI: NSNumber
    ) {
        let rssi = RSSI.intValue
        guard isValid(rssi) else { return }

        let name = (advertisementData[CBAdvertisementDataLocalNameKey] as? String)
            ?? peripheral.name
            ?? "Unnamed device"
        let now = Date()

        var device = nearby[peripheral.identifier] ?? NearbyDevice(
            identifier: peripheral.identifier,
            name: name,
            smoothedRSSI: Double(rssi),
            peakRSSI: rssi,
            lastSeen: now
        )
        if device.name == "Unnamed device" && name != "Unnamed device" {
            device.name = name
        }
        device.smoothedRSSI = device.smoothedRSSI * 0.7 + Double(rssi) * 0.3
        device.peakRSSI = max(device.peakRSSI, rssi)
        device.lastSeen = now
        nearby[peripheral.identifier] = device

        guard
            let wanted = targetName,
            name.localizedCaseInsensitiveCompare(wanted) == .orderedSame
        else {
            return
        }

        record(rssi, source: "advertisement")

        if targetPeripheral?.identifier != peripheral.identifier {
            targetPeripheral = peripheral
            peripheral.delegate = self
        }

        if peripheral.state == .disconnected {
            central.connect(peripheral, options: nil)
        }
    }

    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        targetPeripheral = peripheral
        peripheral.delegate = self
        readTimer?.invalidate()
        peripheral.readRSSI()
        readTimer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { _ in
            peripheral.readRSSI()
        }
    }

    func centralManager(
        _ central: CBCentralManager,
        didFailToConnect peripheral: CBPeripheral,
        error: Error?
    ) {
        readTimer?.invalidate()
        readTimer = nil
    }

    func centralManager(
        _ central: CBCentralManager,
        didDisconnectPeripheral peripheral: CBPeripheral,
        error: Error?
    ) {
        readTimer?.invalidate()
        readTimer = nil
    }

    func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) {
        guard error == nil else { return }
        record(RSSI.intValue, source: "connected link")
    }

    private func pollClassic() {
        guard let targetName else { return }

        DispatchQueue.global(qos: .utility).async { [weak self] in
            let found = Classic.find(name: targetName)
            DispatchQueue.main.async {
                guard let self else { return }
                self.targetAddress = found?.address ?? self.targetAddress
                guard
                    let rssi = found?.rssi,
                    rssi != self.lastClassicRSSI
                else {
                    return
                }
                self.lastClassicRSSI = rssi
                self.record(rssi, source: "paired fallback")
            }
        }
    }

    private func isValid(_ rssi: Int) -> Bool {
        rssi < 0 && rssi > -127
    }

    private func record(_ rssi: Int, source: String) {
        guard isValid(rssi) else { return }
        let now = Date()
        readings.append(Reading(rssi: rssi, at: now, source: source))
        if readings.count > 120 {
            readings.removeFirst(readings.count - 120)
        }
        lastReadingAt = now
        strongestRSSI = max(strongestRSSI ?? rssi, rssi)
    }

    private var smoothedRSSI: Int? {
        let values = readings.suffix(7).map(\.rssi).sorted()
        guard !values.isEmpty else { return nil }
        return values[values.count / 2]
    }

    private var freshRSSI: Int? {
        guard
            let lastReadingAt,
            Date().timeIntervalSince(lastReadingAt) < 10
        else {
            return nil
        }
        return smoothedRSSI
    }

    private func render() {
        if targetName == nil {
            renderSurvey()
        } else {
            renderHunt()
        }
    }

    private func renderSurvey() {
        let now = Date()
        let visible = nearby.values
            .filter { now.timeIntervalSince($0.lastSeen) < 10 }
            .sorted { $0.smoothedRSSI > $1.smoothedRSSI }

        print("\u{1B}[2J\u{1B}[H", terminator: "")
        print("Nearby Bluetooth devices | \(radioMessage)")
        print("Move an owned device and watch its RSSI change. Control-C stops.\n")

        if visible.isEmpty {
            print("No advertisements yet. Wait a few seconds.")
            return
        }

        for device in visible.prefix(15) {
            let name = redact ? "Nearby device" : device.name
            let identifier = redact ? "hidden" : device.identifier.uuidString
            let rssi = Int(device.smoothedRSSI.rounded())
            print("\(name) | \(identifier) | \(rssi) dBm")
        }
    }

    private func renderHunt() {
        print("\u{1B}[2J\u{1B}[H", terminator: "")
        let displayName = targetName ?? "Owned device"
        let address = redact ? "hidden" : (targetAddress ?? "not available")
        let elapsed = Int(Date().timeIntervalSince(startedAt))

        print("HUNTING OWNED DEVICE: \(displayName)")
        print("Address: \(address) | \(radioMessage)\n")

        guard let rssi = freshRSSI else {
            clicker?.update(rssi: nil)
            print("NO SIGNAL")
            print("\nSilence means no fresh reading. Move slowly and wait.")
            print("Elapsed: \(elapsed)s | Strongest: \(strongestRSSI.map(String.init) ?? "-") dBm")
            return
        }

        clicker?.update(rssi: rssi)
        print(bigNumber(rssi))
        print("\nRSSI: \(rssi) dBm")
        print("Closer means less negative and nearer to zero.")
        print("Elapsed: \(elapsed)s | Strongest: \(strongestRSSI ?? rssi) dBm")
        print("Source: \(readings.last?.source ?? "unknown")")
    }
}

private let largeDigits: [Character: [String]] = [
    "-": ["     ", "     ", "#####", "     ", "     "],
    "0": [" ### ", "#   #", "#   #", "#   #", " ### "],
    "1": ["  #  ", " ##  ", "  #  ", "  #  ", " ### "],
    "2": [" ### ", "#   #", "   # ", "  #  ", "#####"],
    "3": ["#### ", "    #", " ### ", "    #", "#### "],
    "4": ["#  # ", "#  # ", "#####", "   # ", "   # "],
    "5": ["#####", "#    ", "#### ", "    #", "#### "],
    "6": [" ### ", "#    ", "#### ", "#   #", " ### "],
    "7": ["#####", "    #", "   # ", "  #  ", "  #  "],
    "8": [" ### ", "#   #", " ### ", "#   #", " ### "],
    "9": [" ### ", "#   #", " ####", "    #", " ### "]
]

private func bigNumber(_ number: Int) -> String {
    let characters = Array(String(number))
    return (0..<5).map { row in
        characters.map { largeDigits[$0]?[row] ?? "     " }.joined(separator: "  ")
    }.joined(separator: "\n")
}

What does the finder coordinate?

The finder combines live advertisements with connected readings and the paired fallback. It smooths valid readings before updating the display or clicker.

Redaction changes survey names and identifiers before printing them. Hunt mode also masks the paired address.

Your personalized HUNT_RESULTS.md should contain only your safe hunt details. Compare it against the review checklist in the main instructions.

Before the final check, which details should be absent from both your recording and hunt notes?

  • Play the saved .mov recording.
  • Confirm that the recording shows only the redacted Terminal area.
  • Confirm that the visible hunt address says hidden.
  • Review HUNT_RESULTS.md for your elapsed time and strongest RSSI.
  • Confirm that the notes describe RSSI as proximity without claiming exact distance or direction.

That is the full recovery workflow complete. Your finder now supports a privacy-safe hunt with visual feedback, audible guidance, recorded evidence, and honest result notes.

Secret mission

Calibrate Your Home

Measure three locations or obstacles to learn how your home affects the finder. Repeat the weakest condition and turn your evidence into a home-specific signal map for future hunts.

Clean Up Your Resources

Clean Up Your Resources

Your Swift finder runs locally, so idle project files create no ongoing cost. Choose whether to keep the artifacts, end the current Terminal session, or remove the project.

Resources you used:

  • The local findphone folder with the Swift package, HUNT_RESULTS.md, and SIGNAL_MAP.md.
  • The privacy-safe .mov recording created with Screenshot.
  • Terminal's Bluetooth authorization.

Keep everything running

No action is needed. Choose this if you want to repeat hunts or improve your signal map.

  • Leave the findphone folder in its current location.
  • Keep the .mov recording as your privacy-safe demonstration.
  • Leave Terminal's Bluetooth authorization enabled for future hunts.

Future Claude Code use still requires access through an eligible paid Claude plan or billed API account.

Pause - I'll come back to this later

End active local processes to free memory. Your package and recorded results stay available for another hunt.

  • Stop any running finder process by pressing Control-C.
  • Close Terminal to end the current Claude Code session.

The findphone folder, signal map, hunt results, and recording remain on your Mac.

Delete - I don't want to use this again

Removing the findphone folder is permanent. Every file stored only inside it disappears.

Delete the local project folder:

  • Copy any project result you want to keep to another folder.
  • Return to the shell prompt in Terminal from the current Claude Code session.
  • Confirm the shell prompt is inside the findphone folder.
  • Move to the directory above findphone and remove the project by running these commands:
cd ..
rm -rf findphone

What do these commands remove?

The first line moves out of the project folder. The second line removes the entire findphone directory with its source files, build files, hunt results, and signal map.

  • Return to the folder in Finder where findphone was created.
  • Confirm the findphone folder is absent.

Still see the project folder?

Check that the shell prompt was inside the correct findphone folder before the first line ran.

Help me safely remove my local findphone folder without deleting anything else.

Delete the screen recording:

  • Delete the .mov recording from the location where Screenshot saved it if you do not want to keep the demo.
  • Confirm the recording is absent from that location.

Remove Terminal's Bluetooth access:

  • Open the Apple menu.
  • Select System Settings.
  • Select Privacy & Security.
  • Select Bluetooth.
  • Turn off the switch beside Terminal.
  • Confirm Terminal no longer has Bluetooth access.

Nice Work!

Nice Work!

That is a strong finish. Using Claude Code, you built a native Swift finder that turns Bluetooth signal strength into a privacy-safe hunt for an owned device.

What you can now do:

  • Use Core Bluetooth to collect live RSSI in a nearby-device survey. Follow one exact owned target through hunt mode's connected readings or paired-device fallback.
  • Stabilize noisy readings with median smoothing across the seven newest valid readings. Search with large visual feedback plus proximity clicks that accelerate as signal strength rises. Hear silence when the signal becomes stale.
  • Protect nearby people with redacted output that hides unrelated names, UUIDs, and Bluetooth addresses. Save a privacy-safe demo as a .mov recording. Record the hunt's elapsed time plus strongest reading in HUNT_RESULTS.md.
  • Complete the optional Secret Mission by testing three home conditions or obstacles. Turn those measurements into a personal signal map in SIGNAL_MAP.md.

Ready to quiz yourself?