Как AirPods Pro 3 подружились с iPod touch 4

iPod touch 4G, iOS 6.1.6, AirPods Pro 3. Паратся. Играют. И раз в полминуты отваливаются — даже посреди трека. После скипа звук пропадает насовсем, хотя в статусбаре Bluetooth ещё горит.

Это история про два бага, которые притворялись одним, про десять теорий, которые были правдоподобны и неправильны, и про один байт 0x0D, которого в кадре не оказалось.

Проект в итоге называется LegacyAir. Внутри — два независимых модуля: keep-alive в SpringBoard и патч строки в BTServer.

Что уже было до нас

Amy (asentientbot/ios-6-pods-hack) решила задачу, которую на iPhone 4S с AirPods 2 обычно и видят: iOS 6 закрывает A2DP, когда ничего не играет, AirPods считают линк idle и уходят.

Фикс — крутить тишину, пока подключено любое Bluetooth-устройство. Девяносто строк, MP3 внутри бинарника, новый плеер каждые 50 секунд, до трёх штук с перекрытием:

Silence.m
-(void)refresh
{
    if(!self.active)
    {
        self.players.removeAllObjects;
        return;
    }

    AVAudioPlayer* player=[AVAudioPlayer.alloc initWithData:self.data error:nil].autorelease;
    player.play;
    [self.players addObject:player];
    if(self.players.count>2)
        [self.players removeObjectAtIndex:0];
}

На телефоне этого часто хватает. На iPod без телефонного стека и с Pro 3 — нет. Отвал идёт во время трека. Это уже не idle после паузы.

Первая мысль была инженерно правильная и фактически ложная: «улучшим keep-alive, и отвалы пропадут».

Подозрение №1: тишина недостаточно непрерывна

Оригинал казался грубым. MP3, таймер на 50 секунд, три плеера. Мы переписали «как надо»:

БылоСтало (и это сломалось)
MP3 из silence.hLPCM WAV из нулей в памяти
Новый плеер каждые 50 сОдин плеер, numberOfLoops = -1
Нет setActive:setActive:YES/NO из SpringBoard
BOOL «наушники есть»Set адресов устройств

LPCM звучал разумно: нет кодека, нет нагрузки на A4, loop по сэмплам. Собрать это на современном Xcode — отдельный квест. Текущий SDK не отдаёт armv7 и тащит AVAudioPlayer в AVFAudio, которого на iOS 6 нет. Пришлось писать .tbd-стабы с install-name как в 2012-м и линковать -nostdlib.

Твик встал. Примерно через 35 секунд наушники отключились и подключились снова. Трек ещё играл.

Keep-alive тут ни при чём. Он не вызывал этот цикл и не лечил его.

Как вообще смотреть, что происходит

На джейлбрейкнутом iPod touch 4 почти нет userland. Нет grep, head, нормального syslog. SSH — dropbear, которому нужны HostKeyAlgorithms +ssh-rsa.

Полезное, что удалось включить:

  1. idevicesyslog с Mac по USB. Иногда умирает посреди сессии.
  2. Недокументированный verbose BTServer:
DiagnosticMode = true
DefaultLevel = "Debug"          ← именно строка
StackDebugEnabled = "on"

Файл: /var/mobile/Library/Preferences/com.apple.MobileBluetooth.debug.plist. Если положить в DefaultLevel integer 5, демон молча игнорирует: он проверяет CFGetTypeID == CFStringGetTypeID. Эта ошибка стоила круга «почему логов нет».

3. HCI packet log. Ключ HCITraces в plist не работает — это бит внутреннего флага. Чтобы писался /var/mobile/Library/Logs/BTServer_hci.pklg, пришлось из dylib вызвать штатную процедуру демона (0x00077acc) секунд через 15 после старта стека. Если вызвать только «открыть файл» — получится валидный пустой pklg. Ещё одна шишка.

Текстовый лог говорит «disconnect 10719 / 10927». Это внутренние коды. Они не говорят, кто повесил трубку.

Галерея тупиков

Каждая теория ниже была логичной. Каждая проверялась на устройстве. Ни одна не была причиной отвалов.

Вычеркнуть Hands-Free из plist

com.apple.MobileBluetooth.devices.plist, убрать ServiceHandsfree. Файл правился. После реконнекта демон перезаписывал его сервисами с эфира.

Имя AirPods Pro 3 … - Find My тоже казалось подозрительным. Это стандартное имя для не-Apple хоста. На Switch 2 то же самое.

Хуки в SpringBoard

ObjC-swizzle на connect / «поддерживает ли сервис». Алерт «filter on» появлялся. На HFP селекторы не стреляли: строки BTM: в логе SpringBoard — зеркало событий демона, не решение. Профили выбирает BTServer.

Заглушить HFP в демоне (nohfp.c)

Патч HandsfreeGateway::getRFCOMMChannel → -1. По пути:

Отвалы те же. Наушники сами открывают HFP. Резать канал бессмысленно: надо на него отвечать.

«iPod рвёт, потому что нет телефонии»

По таймингу (~10 с) похоже на правду. По механизму — нет. HCI потом показал: Disconnect шлют наушники (0x13 / 0x14). iPod в этих циклах HCI Disconnect не отправляет.

Siri на устройстве, где Siri нет (siri.c)

В текстовом логе: SLC → что-то про +XAPL → ровно 10.03 с → разрыв. Гипотеза: AT+APLSIRI?, getSiriStatus на iPod возвращает мусор, протокол ждёт 1 или 2.

Патч шесть байт: movs r0, #1; bx lr. Встал. Отвалы остались.

Уточнение: getSiriStatus зовётся внутри обработки +XAPL, не как отдельная команда. Закрыли не тот вопрос.

Кодек AAC сломан, давайте SBC

В логе правда странно:

SetAacDataRate  AAC frame len: 1 bytes, num frames: 882,
bitrate: 256 kb/s, rtp intr: 27

Планировщик AAC в iOS 6 путает байты и кадры. RTP timestamp ползёт ~879 единиц в секунду вместо 44100. Выглядит как причина, почему наушники бесятся.

sbc.c заставил chooser взять SBC. Лог: SBC выбран. Звука нет. Отвалы есть.

noaac.c отверг AAC на разборе endpoint. Снова тишина. SBC из того же трейса ffmpeg -f sbc декодирует в нормальную музыку. Наушники с этого источника SBC просто не играют. AAC оставлять.

Quirk noAac в pincode_defaults.db для OUI AirPods не матчился.

⚠️ Не путать «странный планировщик» и «кто рвёт линк»
AAC в iOS 6 действительно планирует отправку криво. Это не тот баг, из‑за которого ACL падает каждые 40 секунд. Мы потеряли на этом два модуля и несколько вечеров тишины в наушниках.

Ещё проверяли с выключенным Wi‑Fi, с надетыми наушниками, с полностью снятым твиком. Цикл тот же. Ошибки mediaserverd (sample rate is 0) следуют за disconnect, не предшествуют ему.

Кто повесил трубку

Когда pklg наконец стал непустым, картина перевернулась.

Каждый teardown — HCI Disconnection Complete:

Инициатор — наушники. Медиа-пакеты идут почти до конца (последний audio за 13–18 мс до разрыва). Отказ L2CAP PSM 0x001F (ATT) они игнорируют. «Pending» на L2CAP — норма, не отказ.

Вопрос сменился с «почему iPod отключает» на «чем наушники недовольны».

Подозрение №2: iPod молчит на Apple AT

HFP после открытия RFCOMM — обычный SLC. AT+BRSF, AT+CIND, AT+CMER — iPod отвечает OK сразу.

Потом Apple-расширение:

AT+XAPL=<id>,<features>\r

Сначала парсер трейса терял исходящие кадры (фрагменты RFCOMM, заголовок перед текстом). Гипотеза была: iPod не отвечает на XAPL. Поиск байт XAPL=iPhone в pklg: отвечает пять раз из пяти.

+XAPL=iPhone,15
OK

Сразу после этого наушники шлют уровень батареи:

AT+IPHONEACCEV=1,1,76

На это iPod не отвечает ничем. Ни OK, ни ERROR. В текстовом логе демона этой команды нет вообще. В сыром HCI она есть.

Это уже не «забыли обработчик». Это «строка не дошла до парсера».

Байт, которого нет

Спецификация AT: команда заканчивается CR, 0x0D. iOS 6 копит байты и ищет CR (memchr на 0x0d). Без CR строки нет.

Рядом в том же трейсе:

AT+XAPL=…  0D  …   ← есть CR, iPod отвечает
AT+IPHONEACCEV=1,1,76             …   ← кадр обрывается на «76»

Все остальные AT-линии с 0x0D. Эта — нет. Не политика протокола, а сломанная сборка одного пакета: забыли \r, обрезали буфер, упёрлись в границу RFCOMM.

Дальше по часам, три цикла подряд:

  1. Наушники ждут ответ 10.035 / 10.041 / 10.030 с — классический AT timeout.
  2. Закрывают HFP, открывают снова, снова XAPL → OK, снова IPHONEACCEV без CR.
  3. После второго круга рвут весь ACL.

Снаружи: отвал каждые ~28–55 секунд (два таймаута плюс A2DP/AVRCP retry). Похоже на «таймер прошивки на 45 секунд», если не смотреть HCI.

t ≈ 0.0    HFP, SLC, все OK
t ≈ 0.3    AT+XAPL → +XAPL=iPhone,15 OK
t ≈ 0.4    AT+IPHONEACCEV=1,1,76     без CR, iPod молчит
t ≈ 10.4   pods закрывают HFP
t ≈ 10.5   reconnect, то же самое
t ≈ 20.5   второй timeout → ACL down (remote)

Почему они шлют IPHONEACCEV вообще: хост в +XAPL=iPhone,15 объявил feature bitmask. 15 = 0b1111, среди бит — battery reporting. Аксессуар репортит заряд, потому что его попросили.

Учить парсер iOS 6 глотать строку без CR — хрупкий патч thumb-2. Проще не просить заряд.

Фикс отвалов: две цифры в строке

+XAPL=iPhone,15  →  +XAPL=iPhone,00

Маска ноль при любой нумерации бит выключает battery reporting. Команду не шлют. Ждать нечего. HFP-цикл исчезает.

Первая версия патчила фиксированный адрес 0x00148e87 в конкретной сборке демона. Работает только на 10B500 / MobileBluetooth 85.10.1.

Финальная ищет префикс в __cstring загруженного BTServer и переписывает две цифры после запятой. Адрес не нужен. Нет строки — демон не трогаем.

xapl.c
static const char replyPrefix[]="+XAPL=iPhone,";
static const uint8_t maskPatch[]={'0','0'};

if(memcmp(candidate, replyPrefix, PREFIX_LENGTH)==0
   && isDigit(mask[0]) && isDigit(mask[1]))
{
    writeData(mask, maskPatch, 2);
}

Писать через vm_protect(... VM_PROT_COPY), байты копировать циклом. Inject только в BTServer, линк только libSystem. Sandbox не пускает в /var/mobile — лог в syslog.

Цена: iPod не знает процент батареи. Показывать его всё равно некуда. На iPhone обнулять всю маску уже грубо — модуль для хостов без нормального ответа на follow-up.

После этого: минуты без teardown. Раньше — каждые полминуты.

Отвалы закрыты. История на этом не кончилась.

Второй баг: тишина после скипа

Линк живой. После конца трека, скипа, иногда seek:

Выключили keep-alive, чтобы не путать. Симптом остался. Значит это не «твик мешает», а отдельная дыра в A2DP.

HCI без keep-alive: iPod продолжает слать музыку (пакеты даже крупнее, чем когда звук был слышен). AVRCP SetAbsoluteVolume 0x4C каждый раз ACCEPTED. На скипе — дыра 0.3–0.9 с в media packets без AVDTP Suspend/Start. После дыры декодер наушников залипает до reconnect.

Именно это изначально лечил Amy: не давать микшеру замолчать на стыке треков. Наш keep-alive в этот момент keep-alive’ом не был.

Подозрение №3: мы сломали то, что у оригинала работало

Сравнили с main_orig.m. Три расхождения, все значимые.

Цифровые нули — это не звук

Плеер в нашем логе:

tick players 1 [on 4.50/10.00 vol 1.00]

BTServer в ту же миллисекунду:

StartStreaming
… 2.93 с …
StopStreaming
… ровно 8.000 с …
StartStreaming

Плеер играет. mediaserverd считает, что аудио нет, и гасит вывод в Bluetooth. Цикл ~11 с навечно. Keep-alive хуже, чем его отсутствие: он долбит A2DP рестартами.

Те самые «2.93 с», которые мы валили на AAC, часто были нашим нулевым буфером.

Проверка причинности — слышимый тон (level 900, 1 кГц): поток непрерывный, писк без обрывов. Значит дело в содержимом буфера, не в плеере.

Рабочий буфер: 44.1 кГц стерео (как Music), четверть частоты дискретизации, амплитуда 2 из 32767 (~−84 дБ):

silence.c
#define TONE_HZ (SILENCE_RATE/4)  /* 0, +2, 0, -2, … */
#define TONE_LEVEL 2

Формально не тишина. Услышать нельзя. StopStreaming пропадает. Скип переживается.

⚠️ numberOfLoops = -1 на iOS 6 здесь не работает
Оригинал не зря держал перекрывающиеся плееры. Непрерывность — это новый плеер за две секунды до конца текущего, а не флаг лупа. Retire только того экземпляра, который доиграл: снести все сразу — обрезать уже звучащего наследника.

setActive: оригинал не вызывал. Явная активация сессии из SpringBoard может вырвать route у Music. Хватает play.

Громкий тестовый писк уходил в динамик iPod, если выключить Bluetooth и не подождать grace. Keep-alive живёт до ~31 с после последнего устройства (16 с сверка + 15 с пауза). Если connectedDevices вернул nil при выключенном BT, старый код оставлял прежний список — тон в динамик навсегда. Теперь enabled / powered == NO значит «устройств нет».

После respring наушники часто уже connected, connect-notification не приходит. Keep-alive поднимается через adopt, но декодер уже мог залипнуть на дыре A2DP — тогда помогает тумблер Bluetooth. Это та же механика, что mute после скипа.

Соседний твик и почему мы его не скопировали

AirPodsReconnectFix чинит ту же связку с другой стороны. README обещает сброс «таймера 45 с» через -connectWithServices: каждые 18 секунд. В коде честнее: drop still happens, основной deliverable — burst reconnect и resume музыки.

Это рабочий костыль после падения, не устранение причины. Периодический connectWithServices на живом устройстве — лишний renegotiate. Их silent MP3 + overlap — тот же keep-alive, что у Amy, и он не останавливает ACL-drop. Согласуется с нами.

Мы патчим стимул сломанной AT-команды. Они быстро поднимают линк, если он всё же упал. Разный слой.

Что в итоге в пакете

Два dylib в одном .deb:

legacyair.dylib       → SpringBoard   keep-alive, тон −84 дБ, overlap
legacyair-xapl.dylib  → BTServer      +XAPL=iPhone,00

hcitrace.c собирается, в пакет не кладётся: пишет мегабайты в минуту.

Не чинит: ear detection, iCloud auto-switch, сосуществование Wi‑Fi/BT на одной антенне. Иконка гарнитуры в статусбаре может мигать — это кэш/supportsBatteryLevel, не возврат IPHONEACCEV.

Сборка: ./build.tool на текущем Xcode, без старого SDK и без Theos.

Чему научили шишки

  1. Текстовый лог демона лжёт молчанием. Команда без CR не появляется в логе. Без HCI её как будто нет.
  2. «Кто disconnect» — reason code в HCI, не 10719/10927.
  3. Несколько настоящих багов (кривой AAC, отсутствие Siri, HFP advertise) могут жить рядом с одной причиной drop. Чинить соседний баг безопасно и бесполезно.
  4. Keep-alive можно сломать «улучшениями» сильнее, чем оригинал на MP3: нули, loop flag, setActive:.
  5. Два лога рядом (наш tick и StopStreaming) — единственный чистый proof, что плеер жив, а система считает иначе.
  6. Патч строки переносимее патча кода по VA. Фиксированный адрес оставить диагностике.
  7. Удалить Substrate plist ≠ отключить dylib.
  8. DefaultLevel в debug-plist — строка. Integer проглатывается.

Оба симптома на iPod touch 4 + iOS 6.1.6 + AirPods Pro 3 закрыты. Отвалы — отсутствующий CR в одной AT-команде. Тишина после скипа — A2DP, которому нельзя давать дыру, и буфер, который нельзя делать из нулей.

Сложность надо заслужить измерением, а не самой красивой теорией из лога.