Прошлым летом, когда началась неразбериха с рублём, я решил купить себе что-нибудь забавное, чего в нормальных ценовых условиях никогда не купил бы. Выбор пал на умную управляемую светодиодную лампу "Luminous BT Smart Bulb", про которую, собственно, прочитал до этого здесь же. По-хорошему, для начала нужно было бы купить смартфон с BLE, но на тот момент я не беспокоился о таких мелочах. Лампа приехала, мы немного поигрались с ней на работе, она оказалась довольно прикольной. Но я не мог управлять ею дома, поэтому она отправилась на полку. Один раз, правда, я одолжил лампу коллеге на день рождения маленького ребёнка.
Так продолжалось пока я случайно не узнал, что на моём ноутбуке как раз установлен чип Bluetooth 4.0. Я решил использовать этот факт как-нибудь для управления лампочкой. Программа-минимум — научиться включать/выключать лампочку, устанавливать произвольный цвет или выбирать один из заданных режимов. Что из этого вышло — читайте под катом.
Всё описанное ниже выполнялось на OS Linux Mint 17. Возможно, существуют другие способы работы с BLE стеком. И помните, я не несу ответственность за ваше оборудование.
Разведка боем
Бегло погуглив, я понял, что для работы с BLE в Linux существует команда gatttool, входящая в состав пакета bluez. Но нужно использовать последние версии bluez — 5.x.
У меня bluez не был установлен вообще, а в репозиториях лежит 4.x, поэтому я ставил из исходников. На тот момент последней была версия 5.23.
cd ~/Downloads wget https://www.kernel.org/pub/linux/bluetooth/bluez-5.23.tar.gz tar -xvf bluez-5.23.tar.xz cd bluez-5.23 ./configure
С первого раза ./configure вряд ли завершится успешно: необходимо доставить некоторые пакеты.
sudo aptitude install libdbus-1-dev sudo aptitude install libudev-dev=204-5ubuntu20 sudo aptitude install libical-dev sudo aptitude install libreadline-dev
Для пакета libudev-dev пришлось явно задать версию для соответствия уже установленной libudev.
Прямо из коробки bluez поддерживает интеграцию с systemd, которой у меня нет. Поэтому поддержку пришлось выключить флагом --disable-systemd.
./configure make sudo make install
Ага, я в курсе про checkinstall
Собирается bluez довольно быстро. После сборки у меня-таки появилась заветная команда gatttool и даже кое-как работала. Можно двигаться дальше.
Я ввинтил лампочку в цоколь, заработал последний выбранный режим (как на зло это оказался стробирующий синий цвет), и опробовал свежий инструментарий:
sudo hciconfig hci0 up #поднимаем Host Controller Interface sudo hcitool lescan #запускаем скан LE-девайсов LE Scan ... B4:99:4C:2A:0E:4A (unknown) B4:99:4C:2A:0E:4A (unknown) B4:99:4C:2A:0E:4A (unknown) B4:99:4C:2A:0E:4A (unknown) B4:99:4C:2A:0E:4A LEDnet-4C2A0E4A B4:99:4C:2A:0E:4A (unknown) B4:99:4C:2A:0E:4A LEDnet-4C2A0E4A ...
gatttool -I -b B4:99:4C:2A:0E:4A [B4:99:4C:2A:0E:4A][LE]> characteristics Command Failed: Disconnected [B4:99:4C:2A:0E:4A][LE]> connect Attempting to connect to B4:99:4C:2A:0E:4A Connection successful [B4:99:4C:2A:0E:4A][LE]> <TAB> <TAB> char-desc char-read-uuid char-write-req connect exit included primary sec-level char-read-hnd char-write-cmd characteristics disconnect help mtu quit [B4:99:4C:2A:0E:4A][LE]> primary attr handle: 0x0001, end grp handle: 0x0007 uuid: 0000180a-0000-1000-8000-00805f9b34fb attr handle: 0x0008, end grp handle: 0x000b uuid: 0000180f-0000-1000-8000-00805f9b34fb attr handle: 0x000c, end grp handle: 0x0010 uuid: 0000ffe0-0000-1000-8000-00805f9b34fb attr handle: 0x0011, end grp handle: 0x0014 uuid: 0000ffe5-0000-1000-8000-00805f9b34fb attr handle: 0x0015, end grp handle: 0x0033 uuid: 0000fff0-0000-1000-8000-00805f9b34fb attr handle: 0x0034, end grp handle: 0x0042 uuid: 0000ffd0-0000-1000-8000-00805f9b34fb attr handle: 0x0043, end grp handle: 0x004a uuid: 0000ffc0-0000-1000-8000-00805f9b34fb attr handle: 0x004b, end grp handle: 0x0057 uuid: 0000ffb0-0000-1000-8000-00805f9b34fb attr handle: 0x0058, end grp handle: 0x005f uuid: 0000ffa0-0000-1000-8000-00805f9b34fb attr handle: 0x0060, end grp handle: 0x007e uuid: 0000ff90-0000-1000-8000-00805f9b34fb attr handle: 0x007f, end grp handle: 0x0083 uuid: 0000fc60-0000-1000-8000-00805f9b34fb attr handle: 0x0084, end grp handle: 0xffff uuid: 0000fe00-0000-1000-8000-00805f9b34fb [B4:99:4C:2A:0E:4A][LE]> characteristics handle: 0x0002, char properties: 0x02, char value handle: 0x0003, uuid: 00002a23-0000-1000-8000-00805f9b34fb handle: 0x0004, char properties: 0x02, char value handle: 0x0005, uuid: 00002a26-0000-1000-8000-00805f9b34fb handle: 0x0006, char properties: 0x02, char value handle: 0x0007, uuid: 00002a29-0000-1000-8000-00805f9b34fb handle: 0x0009, char properties: 0x12, char value handle: 0x000a, uuid: 00002a19-0000-1000-8000-00805f9b34fb handle: 0x000d, char properties: 0x10, char value handle: 0x000e, uuid: 0000ffe4-0000-1000-8000-00805f9b34fb handle: 0x0012, char properties: 0x0c, char value handle: 0x0013, uuid: 0000ffe9-0000-1000-8000-00805f9b34fb ...
Итак, на этом этапе я убедился, что соединение с лампочкой с ноутбука — это реальность, а значит дальше надо было искать способы управления. На самом деле, я начал экспериментировать с лампой сразу же, как только соединился с ней и лишь потом прочитал про GATT — протокол, используемый BLE-устройствами. Нужно было поступить наоборот, это сэкономило бы много времени. Поэтому приведу тут абсолютный минимум, необходимый для понимания.
Краш-курс по BLE
В интернете есть небольшая, но хорошая статья на эту тему, и лучше чем в ней я не расскажу. Рекомендую ознакомиться.
Вкратце, BLE-устройства состоят из набора сервисов, которые, в свою очередь, состоят из набора характеристик. Сервисы бывают первичные и вторичные, но это не используется в лампочке. У сервисов и у характеристик есть хэндлы и уникальные идентификаторы (UUID). До прочтения вышеозначенной статьи я не понимал зачем нужны две уникальные характеристики. Ключевая фишка (очень пригодится для понимания кода ниже) в том, что UUID — это тип сервиса / характеристики, а хэндл — это адрес, по которому происходит обращение к сервису / характеристике. Т.е. на устройстве может быть несколько характеристик с каким-то типом (например, несколько термодатчиков, с одинаковыми UUID, но разными адресами). Даже на двух разных устройствах могут быть характеристики с одинаковыми UUID и эти характеристики должны вести себя одинаково. Многие типы имеют закреплённые UUID (например 0x2800 — первичный сервис, 0x180A — сервис с информацией о девайсе и т.д.).
Посмотреть все сервиса / характеристики устройства в gatttool можно командами primary и characteristics соответственно. Прочитать данные можно командой char-read, записать — char-write. Запись и чтение производятся по адресам (хэндлам). Собственно, управление любым BLE-устройством происходит через запись характеристик, а путём их чтения мы узнаём статус устройств.
В целом, этого должно быть достаточно для понимания принципов управления лампой.
Первые шаги
Остался сущий пустяк — выяснить адреса неизвестных характеристик, куда нужно записать магические последовательности байт, что тем или иным образом отразится на лампе. Ну и при этом постараться ничего не испортить.
Изначально я полагал, что достаточно будет снять дампы всех-всех данных с лампы в разных состояниях, сравнить их, и сразу станет понятно что за что отвечает. На деле это оказалось не так. Единственной реально меняющейся от дампа к дампу характеристикой были внутренние часы. Всё же, я приведу код снятия дампа:
#!/usr/bin/env groovy def MAC = 'B4:99:4C:2A:0E:4A' def parsePrimaryEntry = { primaryEntry -> def primaryEntryRegex = /attr handle = (.+), end grp handle = (.+) uuid: (.+)/ def matchers = (primaryEntry =~ primaryEntryRegex) if (matchers){ return [ 'attr_handle' : matchers[0][1], 'end_grp_handle' : matchers[0][2], 'uuid' : matchers[0][3] ] } } def parseNestedEntry = { nestedEntry -> def nestedEntryRegex = /handle = (.+), char properties = (.+), char value handle = (.+), uuid = (.+)/ def matchers = (nestedEntry =~ nestedEntryRegex) if (matchers){ return [ 'handle' : matchers[0][1], 'char_properties' : matchers[0][2], 'char_value_handle' : matchers[0][3], 'uuid' : matchers[0][4] ] } } def parseCharacteristicEntry = { characteristicEntry -> def characteristicEntryRegex = /handle = (.+), uuid = (.+)/ def matchers = (characteristicEntry =~ characteristicEntryRegex) if (matchers){ return [ 'handle' : matchers[0][1], 'uuid' : matchers[0][2] ] } } def charReadByHandle = { handle -> def value = "gatttool -b ${MAC} --char-read -a ${handle}".execute().text.trim() } def charReadByUUID = { uuid -> def value = "gatttool -b ${MAC} --char-read -u ${uuid}".execute().text.trim() } def decode = { string -> def matches = (string =~ /Characteristic value\/descriptor\: (.+)/) if(matches) { return matches[0][1].split().collect {Long.parseLong(it, 16)}.inject(''){acc, value -> acc + (value as char)} } } def dump = [:] dump.entries = [] def primaryEntries = "gatttool -b ${MAC} --primary".execute() primaryEntries.in.eachLine { primaryEntry -> def primaryEntryParsed = parsePrimaryEntry(primaryEntry) def entry = [:] primaryEntryParsed.attr_handle_raw_value = charReadByHandle(primaryEntryParsed.attr_handle) primaryEntryParsed.attr_handle_string_value = decode(primaryEntryParsed.attr_handle_raw_value) primaryEntryParsed.end_grp_handle_raw_value = charReadByHandle(primaryEntryParsed.end_grp_handle) primaryEntryParsed.end_grp_handle_string_value = decode(primaryEntryParsed.end_grp_handle_raw_value) primaryEntryParsed.uuid_raw_value = charReadByUUID(primaryEntryParsed.uuid) entry.primary = primaryEntryParsed if ((primaryEntryParsed?.attr_handle) && (primaryEntryParsed?.end_grp_handle)){ entry.nested = [] def nestedEntries = "gatttool -b ${MAC} --characteristics -s ${primaryEntryParsed.attr_handle} -e ${primaryEntryParsed.end_grp_handle}".execute() nestedEntries.in.eachLine { nestedEntry -> def nestedEntryParsed = parseNestedEntry(nestedEntry) nestedEntryParsed.handle_raw_value = charReadByHandle(nestedEntryParsed.handle) nestedEntryParsed.handle_string_value = decode(nestedEntryParsed.handle_string_value) nestedEntryParsed.char_value_handle_raw_value = charReadByHandle(nestedEntryParsed.char_value_handle) nestedEntryParsed.char_value_handle_string_value = decode(nestedEntryParsed.char_value_handle_raw_value) nestedEntryParsed.uuid_raw_value = charReadByUUID(nestedEntryParsed.uuid) entry.nested.add(nestedEntryParsed) } } dump.entries.add(entry) } dump.characteristics = [] def characteristicEntries = "gatttool -b ${MAC} --char-desc".execute() characteristicEntries.in.eachLine { characteristicEntry -> dump.characteristics.add(parseCharacteristicEntry(characteristicEntry)) } def json = new groovy.json.JsonBuilder(dump).toPrettyString() println json
Из интересного: в снятых дампах можно рассмотреть производителя BLE чипа — "SZ RF STAR CO.,LTD.".
Придётся искать другие пути. Я очень не хотел копаться в мобильных приложениях (не силён в Android и вообще не понимаю в iOS), поэтому я вначале спросил совета у умных дядей на StackOverflow. Никто не ответил и я решил спросить у разработчика приложения под Android. Он тоже не ответил. Оказалось, что в маркете присутствует сразу несколько одинаковых приложений (судя по скриншотам) для управления подобными лампами. Ребята из SuperLegend ответили мне и даже выслали какую-то доку, но, к сожалению, она была не от моей лампочки. Я это выяснил, сравнивая UUID сервисов в коде декомпилированного приложения и в доке. Я сравнил декомпилированный код обоих приложений и он абсолютно одинаковый, возможно мне просто выслали документацию от другой лампы. Переспрашивать я как-то не отважился. Значит, остаётся лишь вариант анализа декомпилированного кода.
Исследование кода
Немного о собственно реверс-инжиниринге. Ни для кого не секрет, что для исследования Android-приложений используются два инструмента — apktool и dex2jar. apktool "разбирает" APK на составляющие: ресурсы, XML-дескрипторы и исполняемый код. Но это не Java-классы, а специальный байт-код — smali. Некоторые утверждают, что он читается проще, чем Java, но я родился слишком недавно, чтобы понимать это без словаря. Тем не менее, ресурсы, извлечённые apktool'ом пригодятся в дальнейшем. Для получения привычных class-файлов используется dex2jar. После этого классы можно декомпилировать обычным декомпилятором. Пользуясь случаем, хотелось бы порекомендовать любой из свежих декомпиляторов: Procyon, CFR или FernFlower. Привычные JAD'ы и прочие JD просто устарели! Ещё я пробовал Krakatau, но этот, похоже, слишком сыроват.
Обычно я использую Procyon, но он плохо переварил входные классы. Код многих методов представлял собой кашу из именованных меток и ничего нельзя было понять. Некоторые методы не поддавались разбору вообще. Как раз в то время ребята из JetBrains открыли свой декомпилятор на Github (FernFlower, за что им отдельное спасибо) и я попробовал его. Он оказался хорош! На выходе получался довольно адекватный Java-код. Правда, он тоже не смог декомпилировать некоторые части, которые, к счастью, оказались по зубам Procyon и CFR. Я взял за основу анализа результат работы FernFlower, а недостающие части заменил теми же кусками из CFR / Procyon (выбирал те, что покрасивее).
Небольшой урок, который я вынес из декомпиляции обфусцированных Android приложений: использовать встроенные в dex2jar средства деобфускации кода. Дело в том что имена классов и методов при сборке Android приложения сокращаются до ничего не значащих одно- и двухбуквенных. dex2jar умеет расширять их до трёх- и пятисимвольных строк, что позволяет проще ориентироваться по коду. Procyon, ЕМНИП, умеет делать то же самое сам по себе. Ещё при использовании Procyon полезной окажется опция -ei, включающая явные импорты и запрещающая использование конструкций типа import a.b.c.* — гораздо проще работать со статическими методами (коих хватает). FernFlower и CFR по умолчанию не используют такие импорты.
apktool d LEDBluetoothV2.apk #вытаскиваем ресурсы d2j-dex2jar.sh LEDBluetoothV2.apk #вытаскиваем Java-байткод d2j-init-deobf.sh -f -o deobf LEDBluetoothV2-dex2jar.jar #инициализируем таблицу деобфускации (будет сохранена в файле deobf) d2j-jar-remap.sh -f -c deobf -o LEDBluetoothV2-dex2jar-deobf.jar LEDBluetoothV2-dex2jar.jar #слегка улучшаем код mkdir src_fern java -jar ~Projects/fernflower/fernflower.jar LEDBluetoothV2-dex2jar-deobf.jar src_fern java -jar /tools/procyon/procyon-decompiler-0.5.27.jar LEDBluetoothV2-dex2jar-deobf.jar -ei -o src_procyon java -jar /tools/cfr/cfr_0_94.jar LEDBluetoothV2-dex2jar-deobf.jar --outputdir src_cfr
Я прошёлся по коду и заменил все вхождения $FF: Couldn't be decompiled на тот же код, сгенерированный другими декомпиляторами. Затем я открыл код в IntelliJ IDEA с Android плагином, настроил Android SDK (нужную версию можно узнать в выхлопе apktool) и, вуаля!, можно разбираться.
С чего же начать? После прочтения статьи про работу с BLE на Android стало очевидным, что в первую очередь нужно искать классы из пакета android.bluetooth, например android.bluetooth.BluetoothGatt. Похоже, что весь код по работе с BLE в этом приложении сосредоточен в пакете com.Zengge.LEDBluetoothV2.COMM. Работа с характеристиками происходит в классах C149c и C144f (названия могут быть другими, если вы проделываете это сами).
package com.Zengge.LEDBluetoothV2.COMM; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothGatt; import android.bluetooth.BluetoothGattCallback; import android.bluetooth.BluetoothGattCharacteristic; import android.bluetooth.BluetoothGattService; import android.content.Context; import com.Zengge.LEDBluetoothV2.COMM.C145g; import com.Zengge.LEDBluetoothV2.COMM.C149c; import java.util.Iterator; import smb.p06a.C087a; public class C144f extends C149c { static Object Fr = new Object(); C144f fm = this; BluetoothGattService fn; BluetoothGattService fo; boolean fp = false; Object fq = new Object(); boolean fs = false; BluetoothGattCallback ft = new C145g(this); BluetoothGattCharacteristic fu; BluetoothGattCharacteristic fv; public C144f(BluetoothDevice var1) { super(var1); this.fb = var1; } // $FF: synthetic method static BluetoothGattCharacteristic Ma(C144f var0) { if(var0.fd == null) { return null; } else { Iterator var1 = var0.fd.getCharacteristics().iterator(); while(var1.hasNext()) { BluetoothGattCharacteristic var2 = (BluetoothGattCharacteristic)var1.next(); if(Long.toHexString(var2.getUuid().getMostSignificantBits()).substring(0, 4).equalsIgnoreCase("FFE4")) { return var2; } } return null; } } // $FF: synthetic method static void Mb(C144f var0) { var0.setChanged(); } private BluetoothGattCharacteristic mpj() { if(this.fo == null) { return null; } else { Iterator var1 = this.fo.getCharacteristics().iterator(); while(var1.hasNext()) { BluetoothGattCharacteristic var2 = (BluetoothGattCharacteristic)var1.next(); if(Long.toHexString(var2.getUuid().getMostSignificantBits()).substring(0, 4).equalsIgnoreCase("FE01")) { return var2; } } return null; } } public final BluetoothGatt mPa() { return this.fc; } public final void mPa(byte[] var1) { if(var1.length <= 20) { this.mPa((byte[])var1, 1); } else { this.mPa((byte[])var1, 2); } } public final void mPa(byte[] var1, int var2) { BluetoothGattCharacteristic var3; if(this.ff != null) { var3 = this.ff; } else { Iterator var4 = this.fe.getCharacteristics().iterator(); while(true) { if(!var4.hasNext()) { var3 = null; break; } var3 = (BluetoothGattCharacteristic)var4.next(); if(Long.toHexString(var3.getUuid().getMostSignificantBits()).substring(0, 4).equalsIgnoreCase("FFE9")) { this.ff = var3; break; } } } if(var3 != null) { var3.setWriteType(var2); var3.setValue(var1); this.fc.writeCharacteristic(var3); (new StringBuilder("---sendData:")).append(C087a.MPb(var1)).append(" by:").append((Object)var3.getUuid()).toString(); } } public final boolean mPa(Context context, int n) { synchronized (C144f.Fr) { synchronized (this.fq) { if (this.fc == null) { this.fc = this.fb.connectGatt(context, false, this.ft); } if (!(this.fp || this.fc.connect())) { throw new Exception("the connection attempt initiated failed."); } this.fs = false; this.fq.wait(n); } boolean bl = this.fs; this.fs = false; } return bl; } public final void mPb(byte[] var1) { BluetoothGattCharacteristic var2; if(this.fn == null) { var2 = null; } else { Iterator var3 = this.fn.getCharacteristics().iterator(); do { if(!var3.hasNext()) { var2 = null; break; } var2 = (BluetoothGattCharacteristic)var3.next(); } while(!Long.toHexString(var2.getUuid().getMostSignificantBits()).substring(0, 4).equalsIgnoreCase("FFF1")); } if(var2 != null) { var2.setWriteType(2); var2.setValue(var1); this.fc.writeCharacteristic(var2); } } public final boolean mPb() { return this.fc != null && this.fd != null && this.fe != null; } public final void mPc(byte[] var1) { BluetoothGattCharacteristic var2; if(this.fn == null) { var2 = null; } else { Iterator var3 = this.fn.getCharacteristics().iterator(); do { if(!var3.hasNext()) { var2 = null; break; } var2 = (BluetoothGattCharacteristic)var3.next(); } while(!Long.toHexString(var2.getUuid().getMostSignificantBits()).substring(0, 4).equalsIgnoreCase("FFF2")); } if(var2 != null) { var2.setWriteType(2); var2.setValue(var1); this.fc.writeCharacteristic(var2); } } public final boolean mPc() { return this.fp; } public final void mPd() { if(this.fc != null) { this.fc.disconnect(); this.fc.close(); this.fc = null; } this.fd = null; this.fe = null; this.fp = false; } public final void mPd(byte[] var1) { BluetoothGattCharacteristic var2 = this.mpj(); if(var2 != null) { var2.setWriteType(2); var2.setValue(var1); this.fc.writeCharacteristic(var2); } } public final void mPe() { if(this.fu == null) { BluetoothGattCharacteristic var1; if(this.fn == null) { var1 = null; } else { Iterator var2 = this.fn.getCharacteristics().iterator(); do { if(!var2.hasNext()) { var1 = null; break; } var1 = (BluetoothGattCharacteristic)var2.next(); } while(!Long.toHexString(var1.getUuid().getMostSignificantBits()).substring(0, 4).equalsIgnoreCase("FFF3")); } this.fu = var1; } this.fc.readCharacteristic(this.fu); } public final void mPf() { if(this.fv == null) { this.fv = this.mpj(); } this.fc.readCharacteristic(this.fv); } public final BluetoothGattCharacteristic mPg() { if(this.fo == null) { return null; } else { Iterator var1 = this.fo.getCharacteristics().iterator(); while(var1.hasNext()) { BluetoothGattCharacteristic var2 = (BluetoothGattCharacteristic)var1.next(); if(Long.toHexString(var2.getUuid().getMostSignificantBits()).substring(0, 4).equalsIgnoreCase("FE03")) { return var2; } } return null; } } public final BluetoothGattCharacteristic mPh() { if(this.fo == null) { return null; } else { Iterator var1 = this.fo.getCharacteristics().iterator(); while(var1.hasNext()) { BluetoothGattCharacteristic var2 = (BluetoothGattCharacteristic)var1.next(); if(Long.toHexString(var2.getUuid().getMostSignificantBits()).substring(0, 4).equalsIgnoreCase("FE05")) { return var2; } } return null; } } public final BluetoothGattCharacteristic mPi() { if(this.fo == null) { return null; } else { Iterator var1 = this.fo.getCharacteristics().iterator(); while(var1.hasNext()) { BluetoothGattCharacteristic var2 = (BluetoothGattCharacteristic)var1.next(); if(Long.toHexString(var2.getUuid().getMostSignificantBits()).substring(0, 4).equalsIgnoreCase("FE06")) { return var2; } } return null; } } }
Да, и вот с этим придётся работать
Обратите внимание, характеристики ищутся по UUID (типам), так как адреса могут быть разными на разных лампах (не забыли краш-курс по BLE?).
Я потратил несколько вечеров, переименовывая методы во что-нибудь значащее, типа find_FE03_Characteristic или setAndWrite_FFE9, и просто изучая случайные куски кода. Логика начала потихоньку проясняться.
Стало понятно, что те два класса (C149c и C144f) — это своего рода подключения к лампочкам. Похоже, на каждую лампочку создаётся экземпляр подключения и через него происходит общение с лампой. Почему два класса?
public final void handleMessage(Message var1) { if (var1.what == 0) { C156j.Ma(C157k.Ma(this.fa)); C157k.Ma(this.fa).notifyObservers(); } else if (var1.what == 1) { BluetoothDevice var2 = (BluetoothDevice) var1.obj; (new StringBuilder("onLeScan handleMessage bleDevice:")).append(var2.getName()).toString(); if (var2 != null) { String var3 = var2.getAddress(); String var4 = var2.getName(); if (!C156j.Mb(C157k.Ma(this.fa)).containsKey(var3)) { if (var4 == null) { C144f var5 = new C144f(var2); C156j.Mb(C157k.Ma(this.fa)).put(var3, var5); return; } Boolean isNot_LEDBLUE_or_LEDBLE; if (!var4.startsWith("LEDBlue") && !var4.startsWith("LEDBLE")) { isNot_LEDBLUE_or_LEDBLE = true; } else { isNot_LEDBLUE_or_LEDBLE = false; } if (isNot_LEDBLUE_or_LEDBLE.booleanValue()) { C144f var7 = new C144f(var2); C156j.Mb(C157k.Ma(this.fa)).put(var3, var7); return; } C149c var8 = new C149c(var2); C156j.Mb(C157k.Ma(this.fa)).put(var3, var8); return; } } } }
Этот код вызывается для каждого обнаруженного девайса. Похоже, существует два типа ламп. Имена первых начинаются с "LEDBlue" или "LEDBLE". Имена вторых — не начинаются. Для работы с "LEDBlue" / "LEDBLE" лампами используется класс C149c, для работы с остальными — C144f. Имя моей лампочки — "LEDnet-4C2A0E4A", значит она относится ко второму типу ламп. Ещё я заметил в паре мест сравнение версии устройства с константой "3". Если версия больше трёх — используется класс С114f (второй тип ламп). Что ж, повод считать, что у меня лампа последних версий. Далее по тексту я буду называть "LEDBlue" и "LEDBLE" лампы "старыми", а остальные — "новыми".
Периодически в декомпилированном коде встречаются неиспользованные StringBuilder'ы — непокошенное во время сборки логирование. Из этих строк можно узнать много интересного, например имена методов, или хотя бы их предназначение. Помогают и сообщения об ошибках:
private boolean startRequestIsPowerOn() { boolean bl; block9: { Object object = Fd; // MONITORENTER : object Object object2 = this.fc; // MONITORENTER : object2 this.fb = null; this.fa.setAndRead_FFF3_Characteristic(); this.fc.wait(5000); // MONITOREXIT : object2 if (this.fb == null) { throw new Exception("request time out:startRequestIsPowerOn!"); } if (this.fb[0] != 0x3f) { byte by = this.fb[0]; bl = false; if (by != -1) break block9; } bl = true; } this.fb = null; // MONITOREXIT : object return bl; }
Весь код пестрит synchronized-блоками (MONITOREXIT — декомпиляции не поддаётся), wait'ами и notify'ями. То ли это результат декомпиляции, то ли под Android так принято писать, то ли автор… Ещё много Observable'ов. Будь он даже не обфусцирован — читался бы сложно.
Ага! Читаем характеристику с типом FFF3 и узнаём, включена ли лампа. Проверяем на лампочке (ну когда уже там практика по расписанию?): если там записано 0xFF, значит лампа включена. Скоро мы научимся выключать лампу программно и узнаем, что в выключенном состоянии там хранится 0x3B.
gatttool -b B4:99:4C:2A:0E:4A --char-read -a 0x001d Characteristic value/descriptor: 3f gatttool -b B4:99:4C:2A:0E:4A --char-read -a 0x001d Characteristic value/descriptor: 3b
Здесь и далее будем использовать неинтерактивный режим gatttool (без флага -I). Адреса характеристик можно узнать из дампа.
Код включения / выключения чуть сложнее. Для этого нужно отправить два "пакета" данных в разные характеристики. Я провёл аналогию: мы "переводим" лампу в режим управления питанием, а затем, собственно, управляем питанием:
public static C153o switchBulb(final C144f c144f) { boolean b = true; final C153o c153o = new C153o(); final C142h c142h = new C142h(c144f); try { final boolean mPb = c142h.requestIsPowerOn(); c142h.write_0x4_to_FFF1(); Thread.sleep(200L); if (mPb) { b = false; } c142h.switchBulb(b); c153o.initWithData(true); return c153o; } catch (Exception ex) { c153o.setErrorMessage(ex.getMessage()); return c153o; } finally { c142h.mPa(); } } ... // C142h public final void switchBulb(boolean on) { if (on) { byte[] var2 = new byte[]{(byte) 0x3f}; this.fa.setAndWrite_FFF2_Characteristic(var2); } else { byte[] var3 = new byte[]{(byte) 0x00}; this.fa.setAndWrite_FFF2_Characteristic(var3); } }
Я намеренно опускаю классы, приводя код методов. Названия у вас будут другими, так что искать лучше по магическим константам.
Итак, для включения / выключения лампы нужно отправить 0x04 в характеристику с типом FFF1, подождать 200 мс, и отправить флаг питания в характеристику FFF2.
gatttool -b B4:99:4C:2A:0E:4A --char-write-req -a 0x0017 -n 04 && sleep 0.2s && gatttool -b B4:99:4C:2A:0E:4A --char-write-req -a 0x001a -n 00 #выкл gatttool -b B4:99:4C:2A:0E:4A --char-write-req -a 0x0017 -n 04 && sleep 0.2s && gatttool -b B4:99:4C:2A:0E:4A --char-write-req -a 0x001a -n 3F #вкл
Обратите внимание, как задаются значения для записи (параметр -n) — просто строка, по два символа на байт, никаких префиксов типа 0x.
// неважно где я это откопал while (var3.hasNext()) { String var4 = (String) var3.next(); C149c var5 = var2.mPb(var4); if (var5.getClass() == C144f.class) { if (var2.mPc(var4).mPe() >= 3) { if (var5 != null) { // Если лампа "новая", то используем описанный выше код C148b.switchBulb((C144f) var5, Boolean.valueOf(this.fpc)); } } else { // Иначе, отсылаем совершенно другие байты... var2.mPa(var4, C152n.generateSwitchBulbPowerCommandBytes(this.fpc)); } } else { // ...по совершенно другому адресу var2.mPa(var4, C152n.generateSwitchBulbPowerCommandBytes(this.fpc)); } } ... // var2's class public final boolean mPa(String string, byte[] arrby) { Object object = Fpe; synchronized (object) { C149c c149c = (C149c) this.fpf.get(string); if (c149c == null) return false; c149c.setAndWrite_FFE9(arrby); return true; } } ... public static byte[] generateSwitchBulbPowerCommandBytes(boolean on) { byte[] var1 = new byte[]{(byte) 0xCC, (byte) 0, (byte) 0}; if (on) { var1[1] = 0x23; } else { var1[1] = 0x24; } var1[2] = 0x33; return var1; }
Нужно отправлять [0xCC, (0x23|0x24), 0x33] в характеристику с типом FFE9. Я не уверен, что 0x23 == вкл, а 0x24 == выкл. Проверить мне не на чем.
Итак, с питанием всё понятно. Разберёмся, как задавать произвольный статичный цвет. Присматриваясь к коду, замечаем непереименованный класс LEDRGBFragment, видим там следующее:
static void Ma(LEDRGBFragment var0, int var1) { int red = Color.red(var1); int green = Color.green(var1); int blue = Color.blue(var1); if (var0.fb == C014a.FPf) { byte[] var5 = C152n.MPa(red, green, blue); if (!C156j.MPa().mPa(var0.fa, var5)) { var0.getActivity().finish(); } } else if (var0.fb == C014a.FPb || var0.fb == C014a.FPc || var0.fb == C014a.FPd) { byte[] var6 = C152n.MPb(red, green, blue); if (!C156j.MPa().mPa(var0.fa, var6)) { var0.getActivity().finish(); return; } } } ... //C152n.MPa public static byte[] MPb(int red, int green, int blue) { return new byte[]{(byte) 0x56, (byte) red, (byte) green, (byte) blue, (byte) 0x00, (byte) 0xF0, (byte) 0xAA}; } ... //C156j.MPa().mPa public final boolean mPa(final String[] array, final byte[] array2) { boolean b = true; synchronized (C156j.Fpe) { boolean b2; for (int length = array.length, i = 0; i < length; ++i, b = b2) { final C149c c149c = this.fpf.get(array[i]); if (c149c != null && c149c.isServicesAndGattSet()) { c149c.setAndWrite_FFE9(array2); b2 = b; } else { b2 = false; } } return b; } }
Отправляем [0x56, <red>, <green>, <blue>, 0x00, 0xF0, 0xAA] в характеристику с типом FFE9 (вообще, похоже, это основная характеристика для управления лампочкой) и цвет меняется на произвольный. В классе C152n есть ещё несколько похожих методов, но те байты не возымели эффекта на лампу.
gatttool -b B4:99:4C:2A:0E:4A --char-write -a 0x0013 -n 56FF000000F0AA #красный gatttool -b B4:99:4C:2A:0E:4A --char-write -a 0x0013 -n 5600FF0000F0AA #зелёный gatttool -b B4:99:4C:2A:0E:4A --char-write -a 0x0013 -n 560000FF00F0AA #синий gatttool -b B4:99:4C:2A:0E:4A --char-write -a 0x0013 -n 565A009D00F0AA #мой любимый
Рядом с LEDRGBFragment лежит ещё один подозрительный класс — LEDWarmWhileFragment. Он посылает похожую последовательность ([0x56, 0x00, 0x00, 0x00, <value>, 0x0F, 0xAA]) всё в ту же характеристику:
static void Ma(LEDWarmWhileFragment var0, float var1) { if (var1 == 0.0F) { var1 = 0.01F; } if (var0.fb == C014a.FPe) { C156j.MPa().mPa(var0.fa, C152n.MPa(0, 0, 0, (int) (var1 * 255.0F))); } else { if (var0.fb == C014a.FPb || var0.fb == C014a.FPc || var0.fb == C014a.FPd) { int var3 = (int) (var1 * 255.0F); byte[] var4 = new byte[]{(byte) 0x56, (byte) 0, (byte) 0, (byte) 0, (byte) var3, (byte) 0x0F, (byte) 0xAA}; C156j.MPa().mPa(var0.fa, var4); return; } if (var0.fb == C014a.FPi || var0.fb == C014a.FPh || var0.fb == C014a.FPg) { C156j.MPa().mPa(var0.fa, C152n.MPa((int) (var1 * 255.0F), 0)); return; } } }
Опытным путём я установил, что это белый цвет с заданной яркостью. "Warm While", хе-хе. Я бы сказал, что тут налицо очепятка и физическая неточность. Под словом "warm" (цветовая температура?) я понимал немного другое. В принципе, того же эффекта можно достичь записывая "оттенки серого" в RGB.
Так что там с предустановленными режимами? Посмотрим на ресурсы, вытянутые apktool'ом:
... <string name="java_Mode_01">1.Seven color cross fade</string> <string name="java_Mode_02">2.Red gradual change</string> <string name="java_Mode_03">3.Green gradual change</string> <string name="java_Mode_04">4.Blue gradual change</string> <string name="java_Mode_05">5.Yellow gradual change</string> <string name="java_Mode_06">6.Cyan gradual change</string> <string name="java_Mode_07">7.Purple gradual change</string> <string name="java_Mode_08">8.White gradual change</string> <string name="java_Mode_09">9.Red, Green cross fade</string> <string name="java_Mode_10">10.Red blue cross fade</string> <string name="java_Mode_11">11.Green blue cross fade</string> <string name="java_Mode_13">13.Red strobe flash</string> <string name="java_Mode_12">12.Seven color stobe flash</string> <string name="java_Mode_14">14.Green strobe flash</string> <string name="java_Mode_15">15.Blue strobe flash</string> <string name="java_Mode_16">16.Yellow strobe flash</string> <string name="java_Mode_17">17.Cyan strobe flash</string> <string name="java_Mode_18">18.Purple strobe flash</string> <string name="java_Mode_19">19.White strobe flash</string> <string name="java_Mode_20">20.Seven color jumping change</string> ...
Далее, ищем числовые эквиваленты имён:
... <public type="string" name="java_Mode_01" id="0x7f08003f" /> <public type="string" name="java_Mode_02" id="0x7f080040" /> <public type="string" name="java_Mode_03" id="0x7f080041" /> <public type="string" name="java_Mode_04" id="0x7f080042" /> <public type="string" name="java_Mode_05" id="0x7f080043" /> <public type="string" name="java_Mode_06" id="0x7f080044" /> <public type="string" name="java_Mode_07" id="0x7f080045" /> <public type="string" name="java_Mode_08" id="0x7f080046" /> <public type="string" name="java_Mode_09" id="0x7f080047" /> <public type="string" name="java_Mode_10" id="0x7f080048" /> <public type="string" name="java_Mode_11" id="0x7f080049" /> <public type="string" name="java_Mode_13" id="0x7f08004a" /> <public type="string" name="java_Mode_12" id="0x7f08004b" /> <public type="string" name="java_Mode_14" id="0x7f08004c" /> <public type="string" name="java_Mode_15" id="0x7f08004d" /> <public type="string" name="java_Mode_16" id="0x7f08004e" /> <public type="string" name="java_Mode_17" id="0x7f08004f" /> <public type="string" name="java_Mode_18" id="0x7f080050" /> <public type="string" name="java_Mode_19" id="0x7f080051" /> <public type="string" name="java_Mode_20" id="0x7f080052" /> ...
Ищем по коду любой id (не забываем, что после декомпиляции все числа представлены в десятичном виде). Находится одно совпадение. Трёхходовочка, немного рефакторинга и, вуаля!, список предустановленных режимов у нас на руках:
public static ArrayList<BuiltInMode> MPa(Context var0) { ArrayList<BuiltInMode> result = new ArrayList(); result.add(new BuiltInMode((byte) 0x25, "1.Seven color cross fade")); result.add(new BuiltInMode((byte) 0x26, "2.Red gradual change")); result.add(new BuiltInMode((byte) 0x27, "3.Green gradual change")); result.add(new BuiltInMode((byte) 0x28, "4.Blue gradual change")); result.add(new BuiltInMode((byte) 0x29, "5.Yellow gradual change")); result.add(new BuiltInMode((byte) 0x2a, "6.Cyan gradual change")); result.add(new BuiltInMode((byte) 0x2b, "7.Purple gradual change")); result.add(new BuiltInMode((byte) 0x2c, "8.White gradual change")); result.add(new BuiltInMode((byte) 0x2d, "9.Red, Green cross fade")); result.add(new BuiltInMode((byte) 0x2e, "10.Red blue cross fade")); result.add(new BuiltInMode((byte) 0x2f, "11.Green blue cross fade")); result.add(new BuiltInMode((byte) 0x30, "12.Seven color stobe flash")); result.add(new BuiltInMode((byte) 0x31, "13.Red strobe flash")); result.add(new BuiltInMode((byte) 0x32, "14.Green strobe flash")); result.add(new BuiltInMode((byte) 0x33, "15.Blue strobe flash")); result.add(new BuiltInMode((byte) 0x34, "16.Yellow strobe flash")); result.add(new BuiltInMode((byte) 0x35, "17.Cyan strobe flash")); result.add(new BuiltInMode((byte) 0x36, "18.Purple strobe flash")); result.add(new BuiltInMode((byte) 0x37, "19.White strobe flash")); result.add(new BuiltInMode((byte) 0x38, "20.Seven color jumping change")); return result; }
Дальше всё просто. Смотрим Call Hierarchy (о, как я полюбил эту фичу за последнее время) этого метода, попадаем в некий LEDFunctionsFragment, а там:
static void setPredefinedMode(LEDFunctionsFragment var0, int builtInModeIndex, float frequency) { // Внимательному читателю уже знаком метод mPa, отправляющий данные в FFE9 C156j.MPa().mPa(var0.fa, new byte[]{ (byte) 0xBB, (byte) (var0.fi.get(builtInModeIndex)).modeIdByte, (byte) (31 - Math.round(29.0F * frequency)), (byte) 0x44}); }
Третьим байтом тут задаётся скорость работы режима. 0x01 — самая быстрая смена цветов, 0x1F — самая медленная. Моя лампочка принимает значения и больше 0x1F и работает ещё медленнее.
gatttool -b B4:99:4C:2A:0E:4A --char-write -a 0x0013 -n BB250144 #циклически меняет цвета
Программа-минимум выполнена! Конечно, полный функционал лампы гораздо шире; это видно и по коду, и по инструкции. Лампа умеет включаться / выключаться / менять режимы по расписанию и прикидываться цветомузыкой. Пока что я не анализировал этот функционал. Правда, для включения и выключения по расписанию на лампе есть часы, формат которых довольно простой, поэтому приведу наработки ниже.
Часы в "новых" лампах "расположены" в характеристике с типом FE01. В коде она используется и для чтения, и для записи. Сразу приведу код и пример его использования (в отдельном groovysh):
Эти три замыкания служат для создания значения, пригодного к записи в часы и для преобразования внутреннего формата в человекочитаемый
createDateArray = { def instance = Calendar.getInstance(); def year = instance.get(Calendar.YEAR); def month = 1 + instance.get(Calendar.MONTH); // +1 in order to Jan to be "1" def date = instance.get(Calendar.DAY_OF_MONTH); def hour = instance.get(Calendar.HOUR_OF_DAY); def minute = instance.get(Calendar.MINUTE); def second = instance.get(Calendar.SECOND); [(byte)second, (byte)minute, (byte)hour, (byte)date, (byte)month, (byte)(year & 0xFF), (byte)(0xFF & year >> 8)] as byte[] } createDateValue = { createDateArray().collect{Integer.toHexString(it & 0xFF)}.inject(''){acc, val -> acc + val.padLeft(2, '0')} } parseDate = { string -> def array = string.split().collect{Integer.parseInt(it, 16)} def year = (array[6] << 8) | (array[5]) def month = array[4] - 1 def date = array[3] def hour = array[2] def minute = array[1] def second = array[0] def calendar = Calendar.getInstance() calendar.set(year, month, date, hour, minute, second) calendar.time }
gatttool -b B4:99:4C:2A:0E:4A --char-read -a 0x0086 Characteristic value/descriptor: 08 36 01 01 01 d0 07 groovy:000> parseDate('08 36 01 01 01 d0 07') ===> Sat Jan 01 01:54:08 FET 2000 groovy:000> createDateValue() ===> 3b1f011e01df07 gatttool -b B4:99:4C:2A:0E:4A --char-write -a 0x0086 -n 3b1f011e01df07 gatttool -b B4:99:4C:2A:0E:4A --char-read -a 0x0086 Characteristic value/descriptor: 04 20 01 1e 01 df 07 groovy:000> parseDate('04 20 01 1e 01 df 07') ===> Fri Jan 30 01:32:04 FET 2015
На старых лампах часы задаются с помощью всё той же характеристики FFE9. Там вообще любая запись данных происходит в эту характеристику, а чтение — из FFE4.
Напоследок
Управлять лампочкой из консоли не очень удобно, так что, возможно, при наличии свободного времени я продолжу баловаться с ней на более высоком уровне. На C++ наверно вряд ли смогу написать что-нибудь запускаемое, но обёртки над libbluetooth есть даже под node.js, так что надежда есть.
И видео, как это работает, чтоб не думали, что это какое-то шарлатанство. Прошу прощения за дыхоту и качество — снимал на девайс из pre-BLE эпохи:
