Pull to refresh

Управление «умной» BLE лампой без смартфона

Reading time 25 min
Views 19K

Прошлым летом, когда началась неразбериха с рублём, я решил купить себе что-нибудь забавное, чего в нормальных ценовых условиях никогда не купил бы. Выбор пал на умную управляемую светодиодную лампу "Luminous BT Smart Bulb", про которую, собственно, прочитал до этого здесь же. По-хорошему, для начала нужно было бы купить смартфон с BLE, но на тот момент я не беспокоился о таких мелочах. Лампа приехала, мы немного поигрались с ней на работе, она оказалась довольно прикольной. Но я не мог управлять ею дома, поэтому она отправилась на полку. Один раз, правда, я одолжил лампу коллеге на день рождения маленького ребёнка.


Так продолжалось пока я случайно не узнал, что на моём ноутбуке как раз установлен чип Bluetooth 4.0. Я решил использовать этот факт как-нибудь для управления лампочкой. Программа-минимум — научиться включать/выключать лампочку, устанавливать произвольный цвет или выбирать один из заданных режимов. Что из этого вышло — читайте под катом.



Всё описанное ниже выполнялось на OS Linux Mint 17. Возможно, существуют другие способы работы с BLE стеком. И помните, я не несу ответственность за ваше оборудование.


Разведка боем


Бегло погуглив, я понял, что для работы с BLE в Linux существует команда gatttool, входящая в состав пакета bluez. Но нужно использовать последние версии bluez5.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 (названия могут быть другими, если вы проделываете это сами).


Например, 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'ом:


Где-то в strings.xml
...
<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.xml
...
<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 эпохи:


Tags:
Hubs:
+15
Comments 7
Comments Comments 7

Articles