Прошлым летом, когда началась неразбериха с рублём, я решил купить себе что-нибудь забавное, чего в нормальных ценовых условиях никогда не купил бы. Выбор пал на умную управляемую светодиодную лампу «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]> <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 по умолчанию не используют такие импорты.
Итак, 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
(названия могут быть другими, если вы проделываете это сами).
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 эпохи:
Автор: madhead