[Из песочницы] Управление «умной» 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]>
Посмотреть все сервиса / характеристики устройства в 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,
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
…
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]