Claude 找手機最吸引人的地方,不是 AI 突然學會了 GPS,而是它把「手機不見了」翻成一個可以量測的問題:Mac 能不能持續看見附近的 Bluetooth 廣播?當你移動時,RSSI 是變強還是變弱?
2026 年 8 月,Ben Zhang 表示公司 MDM 關掉了 Find My,Claude 建議改看藍牙訊號,並迅速做出 findphone;原始 X 貼文隨後成為熱門案例。截至 8 月 9 日晚間快照,Hacker News 討論有 278 points、204 comments,repo 約 1,100 stars。真正值得學的,不是照抄一個熱門專案,而是 AI 如何把生活語言變成感測、判讀、錯誤處理與現場校正。
這篇會帶你在 macOS 建立一個可編譯的 Swift BLE 訊號羅盤:先盤點廣播、明確選目標,再用中位數平滑 RSSI、顯示走近/走遠、加入聲音、失聯判斷、逾時與安全停止。你不需要先會 Swift,但需要一台 Mac;完成後也會知道什麼情況下這個方法根本不該用。
Claude 找手機先說結論:這是訊號羅盤,不是定位器
先打電話、先用 Find My;只有它們不可用,而且目標真的發出 Mac 看得到的藍牙訊號時,才輪到 RSSI。Apple 的 Find My能顯示位置或播放聲音,通常比自製工具直接。本文工具無法喚醒一台沒有可觀測廣播的手機,也不能保證從附近耳機、手錶與其他手機中認出你的那一台。
搜尋方向=你移動後的 RSSI 趨勢,不是 RSSI 換算出的公尺數。
- 能回答:在同一組 Mac、目標與環境下,最近幾秒的訊號相對變強、變弱或持平。
- 不能回答:手機精確在 2.3 公尺外、位於哪個方位,或某個 UUID 必然屬於你。
- 適用前提:目標在附近、開機、正在送出 CoreBluetooth 可見的廣播,而且你有足夠線索選中它。
先拆需求:不要只對 Claude 說「幫我找手機」
模糊需求容易得到看似漂亮、實際不可驗收的 demo。把「找回手機」拆成八個可觀測行為:盤點、選擇、連續取樣、平滑、趨勢、失聯、提示、停止。這正是 AI Agent Harness 的精神:模型負責產生候選解法,工具與驗收條件負責把它關進可檢查的流程。

把下面提示詞交給 Claude Code。若你還沒裝好,可先看 Claude Code 官方安裝與啟動說明;想先比較不同 Coding Agent,再讀 Claude Code vs Codex 實測。
你正在 macOS 14.5+、Swift 6 開發環境中。請建立一個 deployment target 為 macOS 13 的 BLEFinder SwiftPM CLI,僅使用 CoreBluetooth、AudioToolbox、Foundation 與 Dispatch,不使用第三方套件。
目標不是把 RSSI 換算成距離,而是做「人移動後的冷熱趨勢」。驗收條件:
1. --survey 只盤點正在廣播的 BLE 來源;--id、--name、--service 三選一追蹤,模糊條件不能混用。
2. scanForPeripherals 開啟 duplicates;遇到 RSSI 127 或無效值要忽略。
3. 最近 4 秒取中位數,和先前 8 秒中位數比較;差 4 dB 才顯示走近或走遠,樣本不足顯示蒐集基準。
4. 8 秒沒新封包就是 stale,畫面明示失聯並停止聲音。
5. --sound 讓 Mac 的 Tink 聲隨訊號變強而加快;音效失敗仍保留文字輸出。
6. 處理 Bluetooth denied、off、unsupported、resetting;支援 timeout、Ctrl-C、SIGTERM;結束必須 stopScan 並釋放 timers。
7. 名稱與 CBPeripheral.identifier 只能稱為選擇線索,不能當身分證明;不要解析原 repo 自行硬編碼的 Apple Continuity type bytes。
8. 把訊號平滑拆成可測試型別,至少測 spike、warmer、colder、steady、learning、stale。
先列計畫,再建立 Package.swift、Sources/blefinder/main.swift、Sources/blefinder/SignalFilter.swift、Tests/BLEFinderTests/SignalFilterTests.swift。完成後執行 swift test 與 swift build -c release;若失敗就修到兩者通過,最後回報實際命令與仍未驗證的硬體假設。
這段提示詞最重要的一句是「最後回報仍未驗證的硬體假設」。Coding Agent 可以跑編譯與單元測試,卻不能在沒有手機、房間與移動路線時,假裝完成走近、走遠、隔牆實驗。
RSSI 能告訴你什麼?為什麼不能直接換算距離?
RSSI 是接收端量到的訊號強度,單位是 dBm。常見讀值是負數,因此 -50 dBm 通常比 -80 dBm 強;但「更強」不等於「一定更近」。Bluetooth 規格允許接收量測存在實作誤差,Bluetooth SIG 的 range 說明也列出牆壁、木材、玻璃、金屬、混凝土、天線與環境都會改變 path loss。人體或包包遮住手機,造成的下降可能比你走兩步還大。
因此程式不相信單一封包,而是取最近 4 秒的中位數,再和前一段 8 秒的中位數比較:
趨勢差 = median(最近 0–4 秒)− median(先前 4–12 秒)
差值 ≥ 4 dB → 走近中
差值 ≤ −4 dB → 走遠中
其他 → 大致持平
樣本不足 → 蒐集基準
4 秒、4 dB 都是這個教學的起始參數,不是藍牙標準。中位數能壓掉偶發尖峰,兩個時間窗能減少畫面每個封包都翻轉;若 8 秒沒新資料,就標成 stale 並靜音,不能把「收不到」硬解讀成「很遠」。Apple 的 didDiscover callback只提供當下 RSSI 線索;真正的角度定位需要不同硬體與多天線設計,單一 Mac 不會憑 RSSI 指出東西南北。
步驟一:在 macOS 建立 Swift BLE 掃描專案
先安裝 Apple Command Line Tools,再建立固定目錄。本文使用 Swift 6,因此建置機需 macOS 14.5 以上與 Swift 6(Xcode 16 或相容的 Command Line Tools;版本對照見 Apple Xcode 支援表);Package.swift 的 macOS 13 代表產物 deployment target,不是說 macOS 13 能安裝 Swift 6 工具鏈來編譯。先用 swift --version 確認,不要把「能執行」和「能建置」混在一起。
xcode-select --install
swift --version
mkdir -p BLEFinder/Sources/blefinder BLEFinder/Tests/BLEFinderTests
cd BLEFinder
claude
讓 Claude 依剛才的驗收契約建立四個檔案。為了讓結果可以重做,以下是本文實際通過測試的完整版本;它是依官方 API 獨立實作。截至 2026-08-09,findphone 的 main commit db7e908 tree 未見 LICENSE,因此本文不大段重製其原碼。
Package.swift
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "BLEFinder",
platforms: [.macOS(.v13)],
targets: [
.executableTarget(name: "blefinder"),
.testTarget(name: "BLEFinderTests", dependencies: ["blefinder"]),
]
)
SignalFilter.swift:把訊號判讀抽成可測試邏輯
import Foundation
struct Sample: Equatable {
let at: TimeInterval
let rssi: Int
}
enum Trend: String, Equatable {
case warmer = "走近中"
case colder = "走遠中"
case steady = "大致持平"
case learning = "蒐集基準"
}
/// A short median rejects one-packet spikes. Comparing two time windows keeps
/// "warmer/colder" from flipping on every ordinary RSSI wobble.
struct SignalFilter {
private(set) var samples: [Sample] = []
let historySeconds: TimeInterval
let recentSeconds: TimeInterval
let thresholdDB: Int
let minimumPerWindow: Int
init(historySeconds: TimeInterval = 12,
recentSeconds: TimeInterval = 4,
thresholdDB: Int = 4,
minimumPerWindow: Int = 3) {
self.historySeconds = historySeconds
self.recentSeconds = recentSeconds
self.thresholdDB = thresholdDB
self.minimumPerWindow = minimumPerWindow
}
mutating func add(rssi: Int, at: TimeInterval) {
guard (-127 ... 20).contains(rssi) else { return }
samples.append(Sample(at: at, rssi: rssi))
let cutoff = at - historySeconds
samples.removeAll { $0.at < cutoff }
}
func smoothed(now: TimeInterval) -> Int? {
median(samples.filter { $0.at >= now - recentSeconds }.map(\.rssi))
}
/// Keeps the last complete short-window median available until freshness
/// expires. This separates "no packet in 4 seconds" from the 8-second
/// stale boundary used by the UI and sound.
func latestSmoothed() -> Int? {
guard let newest = samples.last?.at else { return nil }
return median(samples.filter { $0.at >= newest - recentSeconds }.map(\.rssi))
}
func trend(now: TimeInterval) -> Trend {
let recentStart = now - recentSeconds
let historyStart = now - historySeconds
let recent = samples.filter { $0.at >= recentStart }.map(\.rssi)
let prior = samples.filter { $0.at >= historyStart && $0.at < recentStart }.map(\.rssi)
guard recent.count >= minimumPerWindow,
prior.count >= minimumPerWindow,
let new = median(recent),
let old = median(prior) else { return .learning }
let delta = new - old
if delta >= thresholdDB { return .warmer }
if delta <= -thresholdDB { return .colder }
return .steady
}
func isFresh(now: TimeInterval, staleAfter: TimeInterval = 8) -> Bool {
samples.last.map { now - $0.at <= staleAfter } ?? false
}
private func median(_ values: [Int]) -> Int? {
let sorted = values.sorted()
guard !sorted.isEmpty else { return nil }
let middle = sorted.count / 2
if sorted.count.isMultiple(of: 2) {
return Int((Double(sorted[middle - 1]) + Double(sorted[middle])) / 2.0)
}
return sorted[middle]
}
}
展開完整 main.swift(掃描、選擇、提示音與停止)
import AudioToolbox
import CoreBluetooth
import Dispatch
import Foundation
struct Options {
var survey = false
var name: String?
var id: UUID?
var service: CBUUID?
var sound = false
var timeout: TimeInterval = 120
static func parse(_ args: [String]) throws -> Options {
enum ParseError: Error, CustomStringConvertible {
case message(String)
var description: String {
switch self { case let .message(text): return text }
}
}
var result = Options()
var index = 0
var timeoutWasSet = false
func value(after flag: String) throws -> String {
guard index + 1 < args.count, !args[index + 1].hasPrefix("-") else {
throw ParseError.message("\(flag) 後面缺少值")
}
index += 1
return args[index]
}
func isValidServiceUUID(_ text: String) -> Bool {
let shortLengths = [4, 8]
let hex = CharacterSet(charactersIn: "0123456789abcdefABCDEF")
let isShort = shortLengths.contains(text.count)
&& text.unicodeScalars.allSatisfy { hex.contains($0) }
return isShort || UUID(uuidString: text) != nil
}
while index < args.count {
switch args[index] {
case "--survey":
guard !result.survey else { throw ParseError.message("--survey 不可重複") }
result.survey = true
case "--name":
guard result.name == nil else { throw ParseError.message("--name 不可重複") }
let text = try value(after: "--name")
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw ParseError.message("--name 不可為空")
}
result.name = text
case "--id":
guard result.id == nil else { throw ParseError.message("--id 不可重複") }
let text = try value(after: "--id")
guard let uuid = UUID(uuidString: text) else { throw ParseError.message("--id 不是有效 UUID") }
result.id = uuid
case "--service":
guard result.service == nil else { throw ParseError.message("--service 不可重複") }
let text = try value(after: "--service")
guard isValidServiceUUID(text) else {
throw ParseError.message("--service 不是有效 UUID")
}
result.service = CBUUID(string: text)
case "--sound":
guard !result.sound else { throw ParseError.message("--sound 不可重複") }
result.sound = true
case "--timeout":
guard !timeoutWasSet else { throw ParseError.message("--timeout 不可重複") }
let text = try value(after: "--timeout")
guard let seconds = TimeInterval(text), (3 ... 3600).contains(seconds) else {
throw ParseError.message("--timeout 必須是 3 到 3600 秒")
}
result.timeout = seconds
timeoutWasSet = true
case "-h", "--help":
print(usage)
exit(0)
default: throw ParseError.message("不認得的參數:\(args[index])")
}
index += 1
}
let selectors = [result.name != nil, result.id != nil, result.service != nil].filter { $0 }.count
if result.survey && (selectors > 0 || result.sound) {
throw ParseError.message("--survey 不和目標條件或 --sound 混用")
}
if !result.survey && selectors != 1 {
throw ParseError.message("追蹤模式請且只請提供 --name、--id 或 --service 其中一個")
}
return result
}
}
let usage = """
用法:
blefinder --survey [--timeout 15]
blefinder --name "廣播名稱的一部分" [--sound] [--timeout 120]
blefinder --id UUID [--sound] [--timeout 120]
blefinder --service UUID [--sound] [--timeout 120]
先用 --survey 確認目標真的有在廣播;名稱與 UUID 都只是選擇線索,不是身分證明。
"""
final class Clicker: @unchecked Sendable {
private var soundID: SystemSoundID = 0
private var timer: Timer?
private var rssi: Int?
init?() {
let path = "/System/Library/Sounds/Tink.aiff"
guard FileManager.default.fileExists(atPath: path),
AudioServicesCreateSystemSoundID(URL(fileURLWithPath: path) as CFURL, &soundID) == kAudioServicesNoError
else { return nil }
}
func update(_ rssi: Int?) {
self.rssi = rssi
if timer == nil { schedule() }
}
func stop() {
timer?.invalidate()
timer = nil
rssi = nil
AudioServicesDisposeSystemSoundID(soundID)
}
private func schedule() {
let interval: TimeInterval
if let rssi {
let fraction = min(1, max(0, (Double(rssi) + 95) / 45))
interval = 1.0 - fraction * 0.88
} else {
interval = 1.0
}
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
guard let self else { return }
self.timer = nil
if self.rssi != nil { AudioServicesPlaySystemSound(self.soundID) }
self.schedule()
}
}
}
final class Finder: NSObject, CBCentralManagerDelegate, @unchecked Sendable {
private let options: Options
private var central: CBCentralManager!
private var timeoutTimer: Timer?
private var statusTimer: Timer?
private var filter = SignalFilter()
private var foundTarget = false
private var selectedID: UUID?
private var lastTrend = Trend.learning
private var clicker: Clicker?
private var seen: Set<UUID> = []
private var stopped = false
init(options: Options) {
self.options = options
if options.sound { clicker = Clicker() }
super.init()
if options.sound && clicker == nil {
FileHandle.standardError.write(Data("警告:無法建立提示音,仍會繼續顯示文字。\n".utf8))
}
}
func start() {
print(options.survey ? "掃描附近 BLE 廣播…" : "等待目標廣播…")
central = CBCentralManager(delegate: self, queue: .main)
timeoutTimer = Timer.scheduledTimer(withTimeInterval: options.timeout, repeats: false) { [weak self] _ in
guard let self else { return }
if self.options.survey {
self.finish(code: 0, message: "盤點時間結束,共看見 \(self.seen.count) 個廣播來源。")
} else {
self.finish(code: self.foundTarget ? 0 : 5,
message: self.foundTarget ? "已到達安全時限,停止掃描。" : "時限內沒有看見目標。")
}
}
statusTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.reportStatus()
}
}
func stopForSignal(_ label: String) {
finish(code: 0, message: "收到 \(label),已停止掃描。")
}
func centralManagerDidUpdateState(_ central: CBCentralManager) {
switch central.state {
case .poweredOn:
let services = options.service.map { [$0] }
central.scanForPeripherals(
withServices: services,
options: [CBCentralManagerScanOptionAllowDuplicatesKey: true])
case .unauthorized:
finish(code: 3, message: "藍牙權限遭拒。請到「系統設定 → 隱私權與安全性 → 藍牙」允許目前的終端程式。")
case .poweredOff:
finish(code: 3, message: "藍牙目前關閉。")
case .unsupported:
finish(code: 4, message: "這台 Mac 不支援 CoreBluetooth。")
case .resetting:
print("藍牙正在重設,等待系統恢復…")
case .unknown:
break
@unknown default:
finish(code: 4, message: "遇到未知的藍牙狀態。")
}
}
func centralManager(_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi RSSI: NSNumber) {
let raw = RSSI.intValue
guard (-127 ... 20).contains(raw) else { return }
let localName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
let displayName = localName ?? peripheral.name
if options.survey {
if seen.insert(peripheral.identifier).inserted {
let name = displayName ?? "(未提供名稱)"
print("\(peripheral.identifier.uuidString) RSSI \(raw) dBm \(name)")
}
return
}
let matches: Bool
if let id = options.id {
matches = peripheral.identifier == id
} else if let query = options.name {
matches = displayName?.localizedCaseInsensitiveContains(query) == true
} else {
matches = true // scan itself is filtered by the requested service UUID
}
guard matches else { return }
if let selectedID {
guard peripheral.identifier == selectedID else { return }
} else {
selectedID = peripheral.identifier
let name = displayName ?? "(未提供名稱)"
print("已鎖定本次掃描的第一個相符來源:\(name)")
}
foundTarget = true
filter.add(rssi: raw, at: Date().timeIntervalSinceReferenceDate)
}
private func reportStatus() {
guard !options.survey else { return }
let now = Date().timeIntervalSinceReferenceDate
guard filter.isFresh(now: now), let smooth = filter.latestSmoothed() else {
clicker?.update(nil)
if foundTarget { print("訊號逾時:先停下來,等待新封包。") }
return
}
let trend = filter.trend(now: now)
let changed = trend != lastTrend
lastTrend = trend
print("平滑 RSSI \(smooth) dBm|\(trend.rawValue)\(changed ? "" : "(持續)")")
clicker?.update(smooth)
}
private func finish(code: Int32, message: String) {
guard !stopped else { return }
stopped = true
central?.stopScan()
timeoutTimer?.invalidate()
statusTimer?.invalidate()
clicker?.stop()
print(message)
fflush(stdout)
exit(code)
}
}
do {
let options = try Options.parse(Array(CommandLine.arguments.dropFirst()))
let finder = Finder(options: options)
signal(SIGINT, SIG_IGN)
signal(SIGTERM, SIG_IGN)
let sigint = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
sigint.setEventHandler { finder.stopForSignal("Ctrl-C") }
sigint.resume()
let sigterm = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main)
sigterm.setEventHandler { finder.stopForSignal("SIGTERM") }
sigterm.resume()
finder.start()
RunLoop.main.run()
} catch {
FileHandle.standardError.write(Data("參數錯誤:\(error)\n\n\(usage)\n".utf8))
exit(2)
}展開完整 SignalFilterTests.swift
import XCTest
@testable import blefinder
final class SignalFilterTests: XCTestCase {
func testRejectsInvalidRSSIAndSmoothsSpikeWithMedian() {
var filter = SignalFilter()
for (time, value) in [(-4.0, -70), (-3.0, -69), (-2.0, -25), (-1.0, -71), (0.0, -70)] {
filter.add(rssi: value, at: time)
}
filter.add(rssi: 127, at: 0)
filter.add(rssi: -128, at: 0)
filter.add(rssi: 21, at: 0)
XCTAssertEqual(filter.smoothed(now: 0), -70)
XCTAssertEqual(filter.samples.count, 5)
}
func testAcceptsFullBluetoothRSSIRange() {
var filter = SignalFilter()
filter.add(rssi: -127, at: 0)
filter.add(rssi: 0, at: 1)
filter.add(rssi: 20, at: 2)
XCTAssertEqual(filter.samples.map(\.rssi), [-127, 0, 20])
XCTAssertEqual(filter.smoothed(now: 2), 0)
}
func testWarmerAndColderUseTwoWindows() {
var warmer = SignalFilter()
for t in 0 ..< 6 { warmer.add(rssi: -78 + (t % 2), at: Double(t)) }
for t in 7 ..< 12 { warmer.add(rssi: -62 + (t % 2), at: Double(t)) }
XCTAssertEqual(warmer.trend(now: 11), .warmer)
var colder = SignalFilter()
for t in 0 ..< 6 { colder.add(rssi: -55 + (t % 2), at: Double(t)) }
for t in 7 ..< 12 { colder.add(rssi: -75 + (t % 2), at: Double(t)) }
XCTAssertEqual(colder.trend(now: 11), .colder)
}
func testSteadyLearningAndStale() {
var filter = SignalFilter()
filter.add(rssi: -65, at: 0)
XCTAssertEqual(filter.trend(now: 0), .learning)
XCTAssertTrue(filter.isFresh(now: 8))
XCTAssertFalse(filter.isFresh(now: 8.01))
for t in 1 ..< 12 { filter.add(rssi: -65 + (t % 2), at: Double(t)) }
XCTAssertEqual(filter.trend(now: 11), .steady)
}
func testLatestMedianPersistsUntilEightSecondStaleBoundary() {
var filter = SignalFilter()
filter.add(rssi: -65, at: 0)
XCTAssertNil(filter.smoothed(now: 4.1))
XCTAssertEqual(filter.latestSmoothed(), -65)
XCTAssertTrue(filter.isFresh(now: 7.9))
XCTAssertFalse(filter.isFresh(now: 8.01))
}
func testParserRejectsCrashingAndAmbiguousInputs() {
XCTAssertThrowsError(try Options.parse(["--service", "not-a-uuid"]))
XCTAssertThrowsError(try Options.parse(["--service", ""]))
XCTAssertThrowsError(try Options.parse(["--name", "--sound", "--timeout", "3"]))
XCTAssertThrowsError(try Options.parse(["--name", ""]))
XCTAssertThrowsError(try Options.parse(["--name", "phone", "--name", "watch"]))
XCTAssertThrowsError(try Options.parse(["--survey", "--sound"]))
XCTAssertNoThrow(try Options.parse(["--service", "180D"]))
XCTAssertNoThrow(try Options.parse(["--service", "12345678-1234-1234-1234-1234567890AB"]))
}
}第一次執行時,macOS 可能要求 Bluetooth 權限;請到「系統設定 → 隱私權與安全性 → 藍牙」允許你正在使用的 Terminal 或 iTerm。這是本機 CLI 的實務做法;若要包成可分發的正式 App,還要使用穩定 bundle、簽名,並加入 NSBluetoothAlwaysUsageDescription。
步驟二:先 survey,再明確選定目標
先測純邏輯、再做 release build;這個順序能把「Swift 寫錯」和「現場沒有廣播」分開。
swift test
swift build -c release
.build/release/blefinder --survey --timeout 15

盤點結果中的 CBPeripheral.identifier 是這台 Mac 配給該 peripheral 的 UUID,不是 MAC 位址或全球身分證。名稱也可能缺席、重複或變動;本文的匿名 survey 同時看見具名與未具名來源,數量會隨時間與場地改變。公開截圖前請遮掉名稱與完整 UUID。
若你能在同一次 survey 中可靠確認目標,優先用完整 UUID 追蹤:
.build/release/blefinder --id 你的完整UUID --sound --timeout 120
--id:適合同次掃描已確認的來源,仍只是本機選擇線索。--name:只有名稱唯一、而且真的出現在廣播中才用;程式會鎖定第一個相符來源。--service:比名稱明確,但多台裝置仍可能使用相同 service;手機端還要事先有你控制的 companion app。Apple 說明一般 iOS app 進入背景後不再廣播 local name,service UUID 也會移到僅供明確掃描它的 iOS 裝置發現的 overflow area,因此這條 macOS 工作流應先讓 companion app 保持前景實測。詳見 Apple 背景 BLE 說明。
如果列表裡沒有可確認的目標,就停在這裡。scanForPeripherals 的官方行為是發現正在廣播的 peripherals,不能把「iPhone 已開藍牙」描述成任意 Mac 程式都能喚醒或辨識它。原始 findphone 的 Tracker.swift另行硬編碼 Continuity type bytes 來擴大候選,而程式也註明這些資料只提供裝置類型、不帶 identity;本文因此不拿它辨識特定手機。
步驟三:走近、走遠、隔牆怎麼實測?
不要先找「標準 dBm 表」,先校正你的 Mac、手機與房間。每個情境做 3 次;固定手機朝向、Mac 高度與移動路線,每個定點停 30 秒,記錄封包數、平滑 RSSI、趨勢反應時間與是否 stale。

- 原地基準:先放著不動 30 秒,確認中位數比 raw RSSI 穩定,且畫面多半是「持平」。
- 走近 3–5 公尺:沿同一路線接近,驗證多數試次是否在兩個時間窗都有足夠樣本後顯示「走近中」。
- 走遠 3–5 公尺:反向重做,不要求 RSSI 線性下降,只看趨勢是否多數正確。
- 同距離隔牆:保持幾何距離近似,只改變遮擋;如果訊號更弱或更亂,結論是「牆會造成歧義」,不是「牆等於幾公尺」。
提示音由 Mac 播放,不是讓手機響鈴;越密只代表平滑 RSSI 在這次搜尋中相對更強。看到實物就按 Ctrl-C,找不到則等 120 秒 timeout。程式刻意不設「到 -50 dBm 自動宣布找到」,因為未校正的絕對門檻很容易誤判。
Claude 找手機常見失敗:先看是哪一層壞了
1. Bluetooth access denied
先確認授權的是「啟動程式的終端」,重開 Terminal 後再跑。若狀態是 powered off 或 unsupported,程式應停止並說明原因,而不是一直顯示空畫面。
2. Survey 看不到手機
這通常不是平滑公式的 bug,而是目標此刻沒有送出工具能看見的廣播、名稱未隨封包送出,或已超出可觀測範圍。回到 Find My、打電話或實體搜尋;scanForPeripherals保證的是接收可見廣播,不能把它包裝成喚醒或辨識任意 iPhone。
3. WARMER/colder 一直跳
先停下來補足兩個視窗;再增加 minimumPerWindow、把 threshold 從 4 調到 5 或 6 dB,或延長 recent window。一次只改一個參數,否則不知道改善來自哪裡。
4. 名稱匹配到錯的裝置
不要用含糊的 --name phone。回到 survey、使用完整 UUID;若仍無法確認,就把「拒絕模糊選擇」加入驗收條件。這類防呆比換更強模型有效,做法可延伸到 Coding Agent 的規則與閘門。
從病毒 demo 到可用工具:AI 解題要跑五個迴圈
原案例的公開 git history有 13 個 commits,而且每一個都有 Claude co-author trailer;但這只證明 repo 標記了 AI 協作,不能還原 prompt 或證明完整版本「一次生成」。第一版之後仍修過忽略規則吞掉 source、redact 洩漏名稱、未知參數被靜默忽略、聲音 timer 太慢等問題。可用工具比較接近下面這個迴圈:
- 把目標改寫成可觀測量:「找手機」先變成「收到廣播,移動後 RSSI 趨勢可判讀」。
- 寫失敗邊界:沒有廣播、無法辨識、訊號 stale 就承認不知道。
- 讓 Agent 生成並自測:編譯、單元測試、錯誤參數與停止路徑都要跑,不只看 UI。
- 用真實環境校正:把走近、走遠、牆與人體遮擋變成測試矩陣。
- 找反方 reviewer:專門問「它會不會追錯裝置、一直耗電、洩漏名稱、失聯仍發聲?」再回到程式修正。
如果你想把這套方法用在更大的專案,可接著學 AI Evals與 Claude Code 冷審查流程:前者把「看起來能用」變成回歸條件,後者刻意把生成者與審查者分開。
Claude 找手機 FAQ
1. 任何 iPhone 都能用這個方法找到嗎?
不能。目標必須在附近、開機,而且正在發出 macOS CoreBluetooth 能觀測的訊號;你還必須能從候選中可靠選中它。
2. 手機只要開著 Bluetooth 就夠了嗎?
不夠。「藍牙開啟」與「此刻發出任意 Mac 程式可辨識的廣播」是兩回事;名稱尤其可能不出現在封包裡。
3. RSSI 可以換算成幾公尺嗎?
不能當精準距離。同一距離會因牆、人體、天線朝向與干擾產生不同數值;本文只比較同一現場的短期趨勢。
4. 為什麼 -50 dBm 比 -80 dBm 強?
因為它更接近 0。dBm 是對數刻度;這能用來排序相對強弱,但不會自動給你可靠的房間或距離分類。
5. 一定要先連上手機才能讀 RSSI 嗎?
不一定。掃描 callback 本身就附帶廣播 RSSI;只有使用 CBPeripheral.readRSSI() 時才必須先連線。本文初學版只用掃描資料。
6. 隔牆後變弱,能證明手機在牆後嗎?
不能。牆只是眾多 path-loss 因素之一;變弱也可能來自人體遮擋、手機朝向改變或 2.4 GHz 干擾。
7. --sound 會讓手機發出聲音嗎?
不會。聲音從 Mac 播出,功能像停車雷達;訊號越強,短音越密。手機響鈴仍應用打電話或 Find My。
8. Claude 在這個專案真正省下什麼?
省下的是把需求翻成第一版可執行系統的時間。Bluetooth RSSI 不是 Claude 發明的;價值在於它能建立檔案、串 API、跑測試與修錯,而你負責定義真實世界的成功與失敗。
接著閱讀
左右滑動查看更多推薦
最後一步:把這個方法帶到下一個生活問題
這次的成品不是萬能尋機器,而是一個誠實的 nearby-signal compass。最小完成順序只有六步:先用 Find My/電話排除簡單解;確認有可辨識廣播;讓 Claude 依驗收契約產生程式;跑 swift test 與 release build;完成三情境校正;看到實物或逾時就停止。
下次遇到「房間太吵」「檔案太多」「每天都忘記做某件事」時,也先問同一題:哪一個訊號可以被觀測?什麼結果算成功?失敗時工具要怎麼安全地說不知道?這才是從一句需求走到可用工具,真正可複製的 Claude 解題方法。想繼續做下一個專案,可從 AlphaLab 課程或 AI 專區往下學。






