Загрузка данных
#!/data/data/com.termux/files/usr/bin/bash
# NinebotBLEScanner installer — Termux / Linux / macOS
set -e
ROOT="NinebotBLEScanner"
PKG="com/ninebot/ble"
echo "[*] Создаю дерево: $ROOT"
mkdir -p "$ROOT/app/src/main/java/$PKG"
mkdir -p "$ROOT/app/src/main/res/layout"
mkdir -p "$ROOT/app/src/main/res/values"
# ---- root build.gradle ----
cat > "$ROOT/build.gradle" <<'EOF'
plugins {
id 'com.android.application' version '8.1.4' apply false
}
EOF
# ---- settings.gradle ----
cat > "$ROOT/settings.gradle" <<'EOF'
pluginManagement {
repositories { google(); mavenCentral(); gradlePluginPortal() }
}
dependencyResolutionManagement {
repositories { google(); mavenCentral() }
}
rootProject.name = "NinebotBLEScanner"
include ':app'
EOF
# ---- app/build.gradle ----
cat > "$ROOT/app/build.gradle" <<'EOF'
plugins { id 'com.android.application' }
android {
namespace 'com.ninebot.ble'
compileSdk 34
defaultConfig {
applicationId "com.ninebot.ble"
minSdk 24
targetSdk 34
versionCode 1
versionName "1.0"
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
}
EOF
# ---- AndroidManifest.xml ----
cat > "$ROOT/app/src/main/AndroidManifest.xml" <<'EOF'
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>
<application android:label="NinebotBLE" android:theme="@android:style/Theme.Material.Dark">
<activity android:name=".MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
</application>
</manifest>
EOF
# ---- layout ----
cat > "$ROOT/app/src/main/res/layout/activity_main.xml" <<'EOF'
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:padding="12dp"
android:layout_width="match_parent"
android:layout_height="match_parent">
<Button android:id="@+id/btnScan"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="SCAN BLE"/>
<Button android:id="@+id/btnStop"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="STOP"/>
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<TextView android:id="@+id/tvLog"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textIsSelectable="true"
android:fontFamily="monospace"
android:textSize="11sp"/>
</ScrollView>
</LinearLayout>
EOF
# ---- strings ----
cat > "$ROOT/app/src/main/res/values/strings.xml" <<'EOF'
<?xml version="1.0" encoding="utf-8"?>
<resources><string name="app_name">NinebotBLE</string></resources>
EOF
# ---- MainActivity.java ----
cat > "$ROOT/app/src/main/java/$PKG/MainActivity.java" <<'EOF'
package com.ninebot.ble;
import android.Manifest;
import android.bluetooth.*;
import android.bluetooth.le.*;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.widget.Button;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import java.util.*;
public class MainActivity extends AppCompatActivity {
private static final UUID UUID_NINEBOT = UUID.fromString("0000fe95-0000-1000-8000-00805f9b34fb");
private static final UUID UUID_WRITE = UUID.fromString("00000001-0000-1000-8000-00805f9b34fb");
private static final UUID UUID_NOTIFY = UUID.fromString("00000002-0000-1000-8000-00805f9b34fb");
// известные ключи Ninebot по моделям (байты аутентификации)
private static final Map<String, byte[]> KEYS = new LinkedHashMap<>();
static {
KEYS.put("ES1/ES2", new byte[]{(byte)0x97,(byte)0xB5,(byte)0x3D,(byte)0x4C,(byte)0x4D,(byte)0x6B,(byte)0x7C,(byte)0x2A,(byte)0x8A,(byte)0x2D,(byte)0x5C,(byte)0x8A,(byte)0x7F,(byte)0x3D,(byte)0x6E,(byte)0x4E});
KEYS.put("Max/G30", new byte[]{(byte)0x00,(byte)0x00,(byte)0x00,(byte)0x00,(byte)0x00,(byte)0x00,(byte)0x00,(byte)0x00,(byte)0x00,(byte)0x00,(byte)0x00,(byte)0x00,(byte)0x00,(byte)0x00,(byte)0x00,(byte)0x00});
KEYS.put("Generic", new byte[]{0x21,(byte)0x80,(byte)0x54,(byte)0x2B,(byte)0x4D,(byte)0x4E,(byte)0x6F,(byte)0x66,(byte)0x77,(byte)0x88,(byte)0x99,(byte)0xAA,(byte)0xBB,(byte)0xCC,(byte)0xDD,(byte)0xEE});
}
private BluetoothLeScanner scanner;
private BluetoothGatt gatt;
private TextView tvLog;
private final Handler ui = new Handler(Looper.getMainLooper());
private final Map<String, BluetoothDevice> found = new LinkedHashMap<>();
@Override
protected void onCreate(Bundle b) {
super.onCreate(b);
setContentView(R.layout.activity_main);
tvLog = findViewById(R.id.tvLog);
Button btnScan = findViewById(R.id.btnScan);
Button btnStop = findViewById(R.id.btnStop);
btnScan.setOnClickListener(v -> startScan());
btnStop.setOnClickListener(v -> stopScan());
requestPerms();
BluetoothManager bm = (BluetoothManager) getSystemService(BLUETOOTH_SERVICE);
if (bm != null && bm.getAdapter() != null) {
scanner = bm.getAdapter().getBluetoothLeScanner();
}
}
private void requestPerms() {
List<String> p = new ArrayList<>();
if (Build.VERSION.SDK_INT >= 31) {
p.add(Manifest.permission.BLUETOOTH_SCAN);
p.add(Manifest.permission.BLUETOOTH_CONNECT);
} else {
p.add(Manifest.permission.ACCESS_FINE_LOCATION);
}
ActivityCompat.requestPermissions(this, p.toArray(new String[0]), 1);
}
private void log(String s) {
ui.post(() -> tvLog.append(s + "\n"));
}
private void startScan() {
if (scanner == null) { log("[!] BLE недоступен"); return; }
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED
&& Build.VERSION.SDK_INT >= 31) { log("[!] нет разрешения SCAN"); return; }
found.clear();
log("[*] скан 10с...");
ScanSettings ss = new ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build();
scanner.startScan(null, ss, cb);
ui.postDelayed(this::stopScan, 10000);
}
private void stopScan() {
try { if (scanner != null) scanner.stopScan(cb); } catch (Exception ignored) {}
log("[*] скан стоп. найдено: " + found.size());
for (BluetoothDevice d : found.values()) {
String name = d.getName() == null ? "?" : d.getName();
if (name.toLowerCase().contains("ninebot") || name.toLowerCase().contains("segway")
|| name.toUpperCase().startsWith("NB") || name.toUpperCase().startsWith("MIS")) {
log("[+] Ninebot target: " + name + " " + d.getAddress());
connectAndAuth(d);
return;
}
}
log("[-] Ninebot не найден среди видимых");
}
private final ScanCallback cb = new ScanCallback() {
@Override public void onScanResult(int t, ScanResult r) {
BluetoothDevice d = r.getDevice();
found.put(d.getAddress(), d);
String n = d.getName() == null ? "?" : d.getName();
log("• " + n + " | " + d.getAddress() + " | rssi " + r.getRssi());
}
@Override public void onScanFailed(int e) { log("[!] scan fail " + e); }
};
private void connectAndAuth(BluetoothDevice dev) {
log("[*] connect -> " + dev.getAddress());
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED
&& Build.VERSION.SDK_INT >= 31) return;
gatt = dev.connectGatt(this, false, gattCb, BluetoothDevice.TRANSPORT_LE);
}
private final BluetoothGattCallback gattCb = new BluetoothGattCallback() {
@Override public void onConnectionStateChange(BluetoothGatt g, int status, int newState) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
log("[+] connected, discover services");
if (ActivityCompat.checkSelfPermission(MainActivity.this, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) return;
g.discoverServices();
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
log("[-] disconnected");
}
}
@Override public void onServicesDiscovered(BluetoothGatt g, int status) {
log("[*] services: " + g.getServices().size());
BluetoothGattCharacteristic w = null, n = null;
for (BluetoothGattService s : g.getServices()) {
log(" svc " + s.getUuid());
for (BluetoothGattCharacteristic c : s.getCharacteristics()) {
log(" chr " + c.getUuid() + " props=" + c.getProperties());
if (c.getUuid().equals(UUID_WRITE)) w = c;
if (c.getUuid().equals(UUID_NOTIFY)) n = c;
}
}
if (w == null) {
// fallback: ищем любую write/notify пару
for (BluetoothGattService s : g.getServices()) {
for (BluetoothGattCharacteristic c : s.getCharacteristics()) {
int p = c.getProperties();
if ((p & BluetoothGattCharacteristic.PROPERTY_WRITE) != 0 && w == null) w = c;
if ((p & BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0 && n == null) n = c;
}
}
}
if (w == null) { log("[!] write-характеристика не найдена"); return; }
if (ActivityCompat.checkSelfPermission(MainActivity.this, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) return;
if (n != null) {
g.setCharacteristicNotification(n, true);
BluetoothGattDescriptor d = n.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"));
if (d != null) { d.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); g.writeDescriptor(d); }
}
log("[*] пробую ключи аутентификации...");
tryKeys(g, w);
}
@Override public void onCharacteristicChanged(BluetoothGatt g, BluetoothGattCharacteristic c) {
byte[] v = c.getValue();
log("[<-] " + hex(v));
}
@Override public void onCharacteristicWrite(BluetoothGatt g, BluetoothGattCharacteristic c, int s) {
log("[->] write status=" + s + " " + hex(c.getValue()));
}
};
private void tryKeys(BluetoothGatt g, BluetoothGattCharacteristic w) {
for (Map.Entry<String, byte[]> e : KEYS.entrySet()) {
byte[] frame = new byte[18];
frame[0] = 0x5A; frame[1] = 0x5A; frame[2] = 0x10; // header Ninebot
System.arraycopy(e.getValue(), 0, frame, 3, 16);
frame[2] = (byte)(frame.length - 3);
log("[*] try key: " + e.getKey());
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) return;
w.setValue(frame);
g.writeCharacteristic(w);
try { Thread.sleep(300); } catch (InterruptedException ignored) {}
}
log("[*] ключи отработаны. смотри ответы в логе [<-]");
}
private static String hex(byte[] b) {
if (b == null) return "null";
StringBuilder s = new StringBuilder();
for (byte x : b) s.append(String.format("%02X ", x));
return s.toString().trim();
}
}
EOF
echo "[+] файлы созданы."
# --- попытка сборки ---
if command -v gradle >/dev/null 2>&1; then
echo "[*] gradle найден — запускаю сборку"
cd "$ROOT"
gradle wrapper --gradle-version 8.2 || true
./gradlew assembleDebug || echo "[!] сборка упала — нужен Android SDK + local.properties"
echo "[*] APK путь: app/build/outputs/apk/debug/app-debug.apk"
else
echo "[!] gradle не найден. установи:"
echo " Termux: pkg install openjdk-17 gradle android-sdk"
echo " или открой папку в Android Studio"
fi
echo "[✓] готово. проект в ./$ROOT"