[Из песочницы] Управление «умной» BLE лампой без смартфона

Прошлым летом, когда началась неразбериха с рублём, я решил купить себе что-нибудь забавное, чего в нормальных ценовых условиях никогда не купил бы. Выбор пал на умную управляемую светодиодную лампу «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 … Лампочка видна в списке — это меня обнадёжило. Пробуем соединиться (нужно использовать MAC-адрес из первого столбца): 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]> 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: 0×0001, end grp handle: 0×0007 uuid: 0000180a-0000–1000–8000–00805f9b34fb attr handle: 0×0008, end grp handle: 0×000b uuid: 0000180f-0000–1000–8000–00805f9b34fb attr handle: 0×000c, end grp handle: 0×0010 uuid: 0000ffe0–0000–1000–8000–00805f9b34fb attr handle: 0×0011, end grp handle: 0×0014 uuid: 0000ffe5–0000–1000–8000–00805f9b34fb attr handle: 0×0015, end grp handle: 0×0033 uuid: 0000fff0–0000–1000–8000–00805f9b34fb attr handle: 0×0034, end grp handle: 0×0042 uuid: 0000ffd0–0000–1000–8000–00805f9b34fb attr handle: 0×0043, end grp handle: 0×004a uuid: 0000ffc0–0000–1000–8000–00805f9b34fb attr handle: 0×004b, end grp handle: 0×0057 uuid: 0000ffb0–0000–1000–8000–00805f9b34fb attr handle: 0×0058, end grp handle: 0×005f uuid: 0000ffa0–0000–1000–8000–00805f9b34fb attr handle: 0×0060, end grp handle: 0×007e uuid: 0000ff90–0000–1000–8000–00805f9b34fb attr handle: 0×007f, end grp handle: 0×0083 uuid: 0000fc60–0000–1000–8000–00805f9b34fb attr handle: 0×0084, end grp handle: 0xffff uuid: 0000fe00–0000–1000–8000–00805f9b34fb [B4:99:4C:2A:0E:4A][LE]> characteristics handle: 0×0002, char properties: 0×02, char value handle: 0×0003, uuid: 00002a23–0000–1000–8000–00805f9b34fb handle: 0×0004, char properties: 0×02, char value handle: 0×0005, uuid: 00002a26–0000–1000–8000–00805f9b34fb handle: 0×0006, char properties: 0×02, char value handle: 0×0007, uuid: 00002a29–0000–1000–8000–00805f9b34fb handle: 0×0009, char properties: 0×12, char value handle: 0×000a, uuid: 00002a19–0000–1000–8000–00805f9b34fb handle: 0×000d, char properties: 0×10, char value handle: 0×000e, uuid: 0000ffe4–0000–1000–8000–00805f9b34fb handle: 0×0012, char properties: 0×0c, char value handle: 0×0013, uuid: 0000ffe9–0000–1000–8000–00805f9b34fb … Итак, на этом этапе я убедился, что соединение с лампочкой с ноутбука — это реальность, а значит дальше надо было искать способы управления. На самом деле, я начал экспериментировать с лампой сразу же, как только соединился с ней и лишь потом прочитал про GATT — протокол, используемый BLE-устройствами. Нужно было поступить наоборот, это сэкономило бы много времени. Поэтому приведу тут абсолютный минимум, необходимый для понимания.Краш-курс по BLE В интернете есть небольшая, но хорошая статья на эту тему, и лучше чем в ней я не расскажу. Рекомендую ознакомиться.Вкратце, BLE-устройства состоят из набора сервисов, которые, в свою очередь, состоят из набора характеристик. Сервисы бывают первичные и вторичные, но это не используется в лампочке. У сервисов и у характеристик есть хэндлы и уникальные идентификаторы (UUID). До прочтения вышеозначенной статьи я не понимал зачем нужны две уникальные характеристики. Ключевая фишка (очень пригодится для понимания кода ниже) в том, что UUID — это тип сервиса / характеристики, а хэндл — это адрес, по которому происходит обращение к сервису / характеристике. Т.е. на устройстве может быть несколько характеристик с каким-то типом (например, несколько термодатчиков, с одинаковыми UUID, но разными адресами). Даже на двух разных устройствах могут быть характеристики с одинаковыми UUID и эти характеристики должны вести себя одинаково. Многие типы имеют закреплённые UUID (например 0×2800 — первичный сервис, 0×180A — сервис с информацией о девайсе и т.д.).

Посмотреть все сервиса / характеристики устройства в 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 по умолчанию не используют такие импорты.

Итак, apk скачана в рабочую папку, декомпилируем:

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 (названия могут быть другими, если вы проделываете это сами).

Например, 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] != 0×3f) { 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, значит лампа включена. Скоро мы научимся выключать лампу программно и узнаем, что в выключенном состоянии там хранится 0×3B.Из шелла это можно сделать так:

gatttool -b B4:99:4C:2A:0E:4A --char-read -a 0×001d Characteristic value/descriptor: 3f

gatttool -b B4:99:4C:2A:0E:4A --char-read -a 0×001d 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_0×4_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) 0×3f}; this.fa.setAndWrite_FFF2_Characteristic (var2); } else { byte[] var3 = new byte[]{(byte) 0×00}; this.fa.setAndWrite_FFF2_Characteristic (var3); } }

Я намеренно опускаю классы, приводя код методов. Названия у вас будут другими, так что искать лучше по магическим константам. Итак, для включения / выключения лампы нужно отправить 0×04 в характеристику с типом FFF1, подождать 200 мс, и отправить флаг питания в характеристику FFF2.Магия шелла:

gatttool -b B4:99:4C:2A:0E:4A --char-write-req -a 0×0017 -n 04 && sleep 0.2s && gatttool -b B4:99:4C:2A:0E:4A --char-write-req -a 0×001a -n 00 #выкл gatttool -b B4:99:4C:2A:0E:4A --char-write-req -a 0×0017 -n 04 && sleep 0.2s && gatttool -b B4:99:4C:2A:0E:4A --char-write-req -a 0×001a -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] = 0×23; } else { var1[1] = 0×24; }

var1[2] = 0×33; return var1; } Нужно отправлять [0xCC, (0×23|0×24), 0×33] в характеристику с типом FFE9. Я не уверен, что 0×23 == вкл, а 0×24 == выкл. Проверить мне не на чем.Итак, с питанием всё понятно. Разберёмся, как задавать произвольный статичный цвет. Присматриваясь к коду, замечаем непереименованный класс 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) 0×56, (byte) red, (byte) green, (byte) blue, (byte) 0×00, (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, , , , 0×00, 0xF0, 0xAA] в характеристику с типом FFE9 (вообще, похоже, это основная характеристика для управления лампочкой) и цвет меняется на произвольный. В классе C152n есть ещё несколько похожих методов, но те байты не возымели эффекта на лампу. Итак: gatttool -b B4:99:4C:2A:0E:4A --char-write -a 0×0013 -n 56FF000000F0AA #красный gatttool -b B4:99:4C:2A:0E:4A --char-write -a 0×0013 -n 5600FF0000F0AA #зелёный gatttool -b B4:99:4C:2A:0E:4A --char-write -a 0×0013 -n 560000FF00F0AA #синий gatttool -b B4:99:4C:2A:0E:4A --char-write -a 0×0013 -n 565A009D00F0AA #мой любимый Рядом с LEDRGBFragment лежит ещё один подозрительный класс — LEDWarmWhileFragment. Он посылает похожую последовательность ([0×56, 0×00, 0×00, 0×00, , 0×0F, 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) 0×56, (byte) 0, (byte) 0, (byte) 0, (byte) var3, (byte) 0×0F, (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’ом:

Где-то в strings.xml … 1.Seven color cross fade 2.Red gradual change 3.Green gradual change 4.Blue gradual change 5.Yellow gradual change 6.Cyan gradual change 7.Purple gradual change 8.White gradual change 9.Red, Green cross fade 10.Red blue cross fade 11.Green blue cross fade 13.Red strobe flash 12.Seven color stobe flash 14.Green strobe flash 15.Blue strobe flash 16.Yellow strobe flash 17.Cyan strobe flash 18.Purple strobe flash 19.White strobe flash 20.Seven color jumping change … Далее, ищем числовые эквиваленты имён: Кусок public.xml … … Ищем по коду любой id (не забываем, что после декомпиляции все числа представлены в десятичном виде). Находится одно совпадение. Трёхходовочка, немного рефакторинга и, вуаля!, список предустановленных режимов у нас на руках: Список предустановленных режимов public static ArrayList MPa (Context var0) { ArrayList result = new ArrayList ();

result.add (new BuiltInMode ((byte) 0×25,»1.Seven color cross fade»)); result.add (new BuiltInMode ((byte) 0×26,»2.Red gradual change»)); result.add (new BuiltInMode ((byte) 0×27,»3.Green gradual change»)); result.add (new BuiltInMode ((byte) 0×28,»4.Blue gradual change»)); result.add (new BuiltInMode ((byte) 0×29,»5.Yellow gradual change»)); result.add (new BuiltInMode ((byte) 0×2a,»6.Cyan gradual change»)); result.add (new BuiltInMode ((byte) 0×2b,»7.Purple gradual change»)); result.add (new BuiltInMode ((byte) 0×2c,»8.White gradual change»)); result.add (new BuiltInMode ((byte) 0×2d,»9.Red, Green cross fade»)); result.add (new BuiltInMode ((byte) 0×2e,»10.Red blue cross fade»)); result.add (new BuiltInMode ((byte) 0×2f,»11.Green blue cross fade»)); result.add (new BuiltInMode ((byte) 0×30,»12.Seven color stobe flash»)); result.add (new BuiltInMode ((byte) 0×31,»13.Red strobe flash»)); result.add (new BuiltInMode ((byte) 0×32,»14.Green strobe flash»)); result.add (new BuiltInMode ((byte) 0×33,»15.Blue strobe flash»)); result.add (new BuiltInMode ((byte) 0×34,»16.Yellow strobe flash»)); result.add (new BuiltInMode ((byte) 0×35,»17.Cyan strobe flash»)); result.add (new BuiltInMode ((byte) 0×36,»18.Purple strobe flash»)); result.add (new BuiltInMode ((byte) 0×37,»19.White strobe flash»)); result.add (new BuiltInMode ((byte) 0×38,»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) 0×44}); } Третьим байтом тут задаётся скорость работы режима. 0×01 — самая быстрая смена цветов, 0×1F — самая медленная. Моя лампочка принимает значения и больше 0×1F и работает ещё медленнее. Из консоли: gatttool -b B4:99:4C:2A:0E:4A --char-write -a 0×0013 -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 0×0086 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 0×0086 -n 3b1f011e01df07

gatttool -b B4:99:4C:2A:0E:4A --char-read -a 0×0086 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 эпохи:[embedded content]

© Habrahabr.ru