当前位置: 华文问答 > 影视

如何「黑入」你的【流浪地球2】笨笨机器人

2023-05-13影视

前一阵子热映的电影【流浪地球2】,相信里面出现过的机器狗「笨笨」给大家留下过深刻的印象。当时商汤科技出了一款 1:2.5 比例复刻的笨笨机器人拼装智能积木。

当时看着感觉挺有趣的就直接入手了。简单来说就是一个可以蓝牙遥控的麦克纳姆轮小车,机械臂部分有齿轮结构,可以手动转动,不过没有连接电机。整个拼装的过程还是挺愉快的,1800 个零件拼了两个晚上才完工。拼装过程没有纸质说明书,依靠的是 APP 中的动态说明书来完成的,过程还算轻松。不过拼完之后使用官方给的 APP 控制笨笨感觉手感比较奇怪,而且这个 APP 竟然要接近 600MB!(虽然知道里面还有动态说明书,AR 体验之类的一堆各种各样的功能,不过估计也很少会用上了……)

于是很自然地萌生了「黑入」笨笨的主意,解出笨笨的蓝牙控制协议,自己写一个轻量级的第三方控制器,这样我就可以以自己想要的方式来控制笨笨了。说干就干,还真写出来了。先放链接:

  • 在线体验链接(当然,得先自己买一个笨笨拼好才能用):https:// supersodasea.github.io/ BenbenController/
  • GitHub 代码链接(欢迎 Star!):https:// github.com/SuperSodaSea /BenbenController
  • 你可能会好奇,这是怎么做到不使用官方的 APP 来控制笨笨的呢?下面来简单介绍一下我是如何「黑入」笨笨,获取到控制笨笨的蓝牙协议的分析过程。

    协议分析

    首先想到的是,既然是乐高 like 积木,那说不定和真·乐高的蓝牙协议是相似的呢。于是在网上查找了一下关于真·乐高的蓝牙协议的相关资料,然而并没有成功。这么一来就只能从笨笨的 APP 入手来进行逆向分析了。

    APK 反编译

    在官网下载一份 Android APK 文件:https:// benben.beyondgravity.cn / ,下面以版本 1.1.1 的文件为例(beyondgravity_v111.apk)。.apk 文件实际上是改了后缀名的 .zip 文件,用你喜欢的解压软件进行解压就可以得到其中的内容。

    Android 程序与普通的 .jar 形式的 Java 程序有所区别,它的代码存放在 classes.dex 中,我们可以用 jadx 对 .dex 文件进行反编译来查看内部的代码(当时一开始使用的是 dex2jar + jd-gui 的组合,后来发现 jadx 更好,所以这里就换成用 jadx 进行介绍了)。

    可以发现代码是经过混淆的,不过不用紧张,因为我们是来找蓝牙协议的,所以应该有地方会调用安卓的蓝牙 API,可以被我们在反编译后的代码文本中找到。我们试一下直接尝试文本搜索一下 Bluetooth 关键字:

    果然找到了一些可疑的类,而且发现 com 包下的一堆类都没有经过混淆,估计是引用的第三方库,这倒是帮了大忙了。研究了一下这个 com.yundongjia 包下的代码,确认了它就是蓝牙遥控笨笨的代码。com.yundongjia.tongble 是蓝牙协议部分,com.yundongjia.tongui 是遥控界面的 UI 代码。

    蓝牙协议

    那么接下来我们要做的就是读一读相关的代码来确认蓝牙逻辑了。首先我们可以在 com.yundongjia.tongble.BleContext 类中找到三个 UUID:

    这些是连接蓝牙设备所需要的 UUID。后面一串 -0000-1000-8000-00805F9B34FB 实际上是蓝牙公用的 Bluetooth Base UUID,所以我们需要的就是前面的 AE3A、AE3B 和 AF30 这几个数字。查找一下引用了这几个字段的代码(在 BleDevice 和 BlueScanThread 这两个类中),可以发现:

  • AF30 是搜索蓝牙设备时所用的 UUID;
  • AE3A 是蓝牙设备 GATT Service 所用的 UUID;
  • AE3B 是蓝牙设备 GATT Characteristic 所用的 UUID。
  • (蓝牙协议本身很复杂,本人也只知道其中的很少一部分,这里就不班门弄斧对其进行详细介绍了这里我们暂时只需要知道蓝牙 BLE 协议里有 GATT、Service、Characteristic 这些东西,并且我们可以通过 UUID 找到这些东西就行了,详细的蓝牙 BLE 协议介绍可以自行进行搜索。)

    到这里我们就可以尝试写一点代码来测试是否能成功连接控制器了。这里我使用了浏览器中的 Web Bluetooth API 进行测试,简单又方便:

    const SERVICE_FILTER_UUID = 0xAF30 ; const SERVICE_DATA_UUID = 0xAE3A ; const CHARACTERISTIC_UUID = 0xAE3B ; const bluetoothDevice = await navigator . bluetooth . requestDevice ({ filters : [ { services : [ SERVICE_FILTER_UUID ] }, ], optionalServices : [ SERVICE_DATA_UUID ], }); const gattServer = bluetoothDevice . gatt ; if ( ! gattServer ) throw new Error ( 'device.gatt do not exist' ); await gattServer . connect (); const service = await gattServer . getPrimaryService ( SERVICE_DATA_UUID ); const characteristic = await service . getCharacteristic ( CHARACTERISTIC_UUID );

    简单来说就是向用户请求连接带有指定 UUID 的蓝牙设备,并在连接后获取对应设备的 GATT Service 和 Characteristic。尝试运行一下,果然能够发现并连接笨笨的控制器:

    成功连接之后,就要发送实际的控制数据了。在代码中继续查找,在 BluetoothDevice 类中我们可以发现一个名为 getSendData 的函数和一个可疑的数组:

    getSendData 函数很明显是将数组中的数据相加计算校验和,并写入数组中指定位置的字节,应该就是计算数据包的校验和部分的代码了,基本上可以确定这个数据就是要发送的蓝牙数据了。那么剩下就是看哪里的代码会修改这个数组(调用 setSendData 方法,上图中未列出)了。很快就能找到 BleContext 类中的 engineRun 方法。

    查看相关代码可以得知参数 engineEnum 是电机的编号(ABCD),z10 是电机方向(false 为正转),i10 是 电机转速(0~127)。比如下面是 ControllerActivity 类中调用 engineRun 方法设置四个电机的方向和转速的代码。

    可以发现官方控制器的代码只分了前后左右四个方向的情况,没有考虑斜向移动,难怪手感会很奇怪。回到上面的 enginRun 代码,不难发现 ABCD 四个电机的数据分别对应上述数组中下标 4~7 的位置。经过后续测试发现:

  • 值为 0 或 128 时电机停转。
  • 对于 ABC 电机而言:
  • 值为 1~127 时电机逆向旋转,为 1 时速度为最大值。
  • 值为 129~255 时电机正向旋转,为 255 时速度为最大值。
  • D 电机的数据与上述描述相反,1~127 为正向旋转,129~255 为逆向旋转。
  • 这里你可能会好奇,前面代码里写的是特判了电机 A,为什么实际上是电机 D 相反呢?这是因为笨笨上的四个麦克纳姆轮有两种不同的手性方向,AD 为一组,BC 为一组(可以脑补一下,对角线上的一对轮子是旋转对称重合的,而同一前后/左右侧的两个轮子是镜像的)。官方控制器的代码是按照相对于电机本身的方向来规定正反转方向的,如果我们统一将电机正转方向规定为让机器人向前运动的方向,这样变换下来就是 D 电机需要将数据反向处理一下了。至于为什么有一个电机的方向是反的就不得而知了 :)

    另一个调用了 setSendData 的地方是在 BluetoothContext 类的 initContext 函数中:

    其中 ExifInterface.MARKER 的值为 (byte)-1,BluetoothActionEnum.ActionId 的值为 1,在这里并不是其名称对应的含义,猜测可能是混淆导致的。这段代码的含义是首次运行时随机生成一个 16 位的序列号,存放在 MobileSerail.dat 文件中,并写入蓝牙数据的下标 1~2 的位置。由于这个序列号是随机生成的,所以实际测试下来在蓝牙数据的 1~2 位置上填任意数据都可以正常。

    那么总结一下,蓝牙数据的格式如下表所示:

    字节 描述
    0 固定为 0xCC。
    1~2 官方控制器在首次启动时会随机生成并保存一个 16 位整数作为此字段的值,实际设为任意值均可正常工作。
    3 固定为 0x02。
    4~7 四个电机(A、B、C、D)的转速值。
    电机 A、B、C: 值为 0 或 128 时停转,为 1~127 为逆向时旋转(为 1 时速度最大),为 129~255 时正向旋转(为 255 时速度最大)。
    电机 D: 值为 0 或 128 时停转,为 1~127 时正向旋转(为 1 时速度最大),为 129~255 时逆向旋转(为 255 时速度最大)。
    8~15 可为任意值。
    16 校验和,值为 1~15 字节之和模 256 的结果。
    17 固定为 0x33。

    按上述格式填好 18 字节的数据包之后,只要以一定频率向之前获取到的控制器 Characteristic 发送这个数据,就能开始控制笨笨了。官方控制器代码中设置为每过 150ms 发送一次数据包,实际测试可以以更短间隔发送,但间隔不能过短。如果只发送一次数据包的话,控制器的输出会有异常,无法正常控制。比如说想让笨笨前进两秒后倒退,就不能只发送一次前进的数据,过两秒才再次发出后退的数据,而是必须要在这两秒内不断反复发送前进的数据才能够正常控制。

    至此我们就能够完全通过自己发送的数据来控制我们的笨笨了!剩下的工作就是包装这个控制器的代码,写一个界面来制作我们自己的第三方控制器了。在我自己编写的控制器中,可以通过触摸(或者鼠标拖动屏幕上的操纵杆)、键盘或者直接连接游戏手柄来控制笨笨了!左摇杆控制前后左右或斜线移动,右摇杆控制原地旋转。下面是通过 XBox 手柄来控制笨笨的演示视频。

    笨笨机器人-XBox 手柄操作演示 https://www.zhihu.com/video/1640744418356428800

    至此,「黑入」笨笨的过程就已经介绍完毕了,实际使用到的代码都已经在 GitHub 上开源:https:// github.com/SuperSodaSea /BenbenController ,欢迎 Star!如果使用中遇到了问题,也欢迎提出 Issue 哦。

    题外话

  • 为什么那个包的名称是 com.yundongjia,蓝牙控制器的名称叫 YX_000000?
  • 搜索后可以查到一个叫宇星模王的做乐高 like 积木的公司。YX = 宇星。

    附送一张控制器内部的电路图:

  • AC21BP09335:蓝牙控制器
  • MX620B:单路全桥驱动
  • SA8328S:双路全桥驱动
  • 有点奇葩的是四个电机还用了两种驱动芯片,两个 MX620B 驱动两个电机,然后剩下两个电机是把一个 SA8328S 拆开来驱动……可能是板子空间不够放不下了吧。