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

в 9:10, , рубрики: BLE, Bluetooth 4.0, bluetooth le, bluez, luminous bt smart bulb, zengge, Разработка для интернета вещей, реверс-инжиниринг

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

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

Автор: madhead

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js