# 魔方进阶新玩儿法? 万物皆可编程, 小米魔方也不例外!

玩魔方小孩。图片来自摄影师Olav Ahrens Røtne[0]

# 前言

好朋友送了我一个魔方, 外貌无奇,甚是有趣! 为何? 原来它是小米智能魔方。除了普通玩法以外, 它可以通过蓝牙连接手机,与之配套的手机App还有初级的魔方教学。不会复原魔方的我跟着步骤,还真 就学会了,可谓是科技圆梦。

魔方教会我复原魔方😳

但,能连接手机, 有教程就够了吗? No, No, No! 就像是很多人看到塑料气泡膜就想上去"挤"爆;作为学 电子信息的人,看到这样的小东西,就想"盘"。仿佛是条件反射一般,就会出现这种OS: “要是可以得到它的 蓝牙数据,岂不是可以用它来作为触发开关,控制各种设备了。可带劲啦!”

于是我开始在整个世界找资料,想要看看有没有人做过了。发现这样的人不很多但还有。因为蓝牙数据 是加密过的, 要得到还真不是很容易。我挺佩服一位叫做Juan的小哥,他找到魔方配套软件的APK文件, 利用反编译等的手段,分析蛛丝马迹,最后破解破解了数据, 然后在浏览器上将魔方的状态可视化出来。[1] 另外有个Youtuber通过ESP32单片机接收蓝牙信号,当魔方复原后,打开气球庆祝🎉。[2] 此外, 我还找到了Cstimer (opens new window), 一个魔方专用的网页计时器。它可以连接小米智能魔方, 已经实现了接收和解密蓝牙数据的功能[3]。前人栽树,我来乘个凉吧。但我不想用单片机,也不想仅仅是在 浏览器里面玩这个魔方。到此时我还没有发现有人用Python来和魔方通信,那就让我来做吧。

# 连接魔方

在摸索当中我了解到,小米魔方使用的是低功耗蓝牙(Bluetooth Low Energy, BLE), 这应该就是它声称 它一颗电池可以用一年的原因。电脑如何连接上魔方的BLE蓝牙呢? 我在尝试了不同的库之后,最终确定Bleak[4]可 以在Linux Mint(20.3) 和 macOS(Monterey 12.3) 成功连接蓝牙和获取数据(Windows应该也可以)。 连接魔方的第一步就是确定它的地址,通过下面的几行代码可以比较方便的扫描电脑周围的蓝牙设备。在魔方休眠 的时候扫描一次,唤醒魔方后再扫描一次, 对比两次的结果判断哪一个是魔方。这样做是因为在最开始电脑端 不一定能够正常地显示魔方的名称 "Mi Smart Magic Cube"。 在Linux系统下,你也可以通过 bluetoothctl scan on这个命令来得到魔方的地址。

import asyncio
from bleak import BleakScanner

async def main():
    devices = await BleakScanner.discover()
    for d in devices:
        print(d)

asyncio.run(main())
1
2
3
4
5
6
7
8
9
55BDE766-7AAF-A55B-C738-25ED769EE827: Unknown
9A8BE917-31DE-C67E-48C4-E9D833A6F0ED: Mi Smart Magic Cube
1
2

有了地址后,我们就可以用这个地址连接魔方了:

import platform, asyncio
from bleak import BleakClient, BleakScanner

ble_address = ("D8:99:3A:4A:9E:8D" if platform.system() != "Darwin" 
                else "9A8BE917-31DE-C67E-48C4-E9D833A6F0ED") # 魔方地址:Windows, Linux的格式和macOS呈现的不同

async def main(ble_address): # 通过地址找魔方
    device = await BleakScanner.find_device_by_address(ble_address, timeout=30.0)
    async with BleakClient(device) as client: # 找到魔方后, 我们便可以操作了它了
        cube_services = await client.get_services() # 看看魔方提供哪些服务
        for service in cube_services:
            print(service)
            for char in service.characteristics:
                print('{} {}'.format(char, char.properties))

asyncio.run(main(ble_address))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
...
0000aadc-0000-1000-8000-00805f9b34fb (Handle: 15): Vendor specific ['read', 'notify']
0000aaab-0000-1000-8000-00805f9b34fb (Handle: 19): Vendor specific ['notify']
0000aaac-0000-1000-8000-00805f9b34fb (Handle: 22): Vendor specific ['write-without-response']
0000fe95-0000-1000-8000-00805f9b34fb (Handle: 24): Xiaomi Inc.
...
1
2
3
4
5
6

可以发现,魔方提供了很多服务。我们现在最关心的是,它的数据是通过哪种服务传过来的? 答案是它:Handle 15。 这个服务的特征有read和notify。Read应该是可读的意思,Notify应该是可以发送信号告诉电脑有数据要来了。 利用前面那串数字(Universally Unique Identifier, UUID)就可以使用这个服务获取数据了。我们 可以不停地去检查数据,也可以当接收到有新数据的消息后再去读去信号。第一种就像是你不停地检查你的邮箱 看有没有新邮件, 第二种就像是是有一个邮件客户端弹出消息告诉你有新邮件。为了舒服和优雅,我当然选第二种。

# 获取数据

import platform, asyncio, signal
from bleak import BleakClient, BleakScanner

END = False # 结束信号是为了让程序合理结束
def sigint_handler(signum, frame): # 按键中断来控制结束信号 Ctrl + C
    global END
    END = True
signal.signal(signal.SIGINT, sigint_handler)

ble_address = ("D8:99:3A:4A:9E:8D" if platform.system() != "Darwin"
                else "9A8BE917-31DE-C67E-48C4-E9D833A6F0ED") # 魔方地址

receive_UUID = "0000aadc-0000-1000-8000-00805f9b34fb" # 接收数据服务UUID
pre_value, value = -1, -1 # 上一次和这一次接受到的蓝牙数据分别放在这两个变量里

def notification_handler(sender, data): # 接收到魔方发出的数据就会进入这个中断函数
    global value # 中断函数只做一件事,更新全局变量value的值
    value = data

async def main(ble_address, char_uuid): # 主要函数
    device = await BleakScanner.find_device_by_address(ble_address, timeout=20.0) # 连接魔方    
    async with BleakClient(device) as client:
        global value, pre_value
        await client.start_notify(char_uuid, notification_handler) # 注册消息中断函数: 有中断数据则执行函数 notification_handler
        while True:
            if value != pre_value: # 若value的值在中断函数中被更新则做相应的处理
                pre_value = value
                print(value) 
                print(list(value))
            else: # 否则就睡觉
                await asyncio.sleep(0.1)
            if END == True: # 接收到按键中断信号(Ctrl + C)时
                break # 跳出循环
        await client.stop_notify(char_uuid) # 通知魔方不要发信号了

asyncio.run(main(ble_address, receive_UUID)) # 运行主要函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

在开始的地方我写了一个按键中断函数sigint_handler, 但在终端按下Ctrl + C时就会触发这个函数, 函数里面让END信号变为真。这样写是方便我们合理地结束程序。在主要函数部分,我们登记一个消息中断函数 notification_handler, 当我们转动魔方的时候就会触发这个函数。在这个函数修改全局变量value的值. value存放的是这次魔方传过来的数据。对应的,pre_value存的是上一次魔方传过来的值。我们在主要 函数中对比这次的数据是否和上次一样。若不一样,说明新数据到了。通过运行结果看,数据是用十六进制表 示的数组,总共有20个值, 每个值用一个字节(八位)表示。那这些值如何表示魔方的状态和旋转变化呢?

bytearray(b'poi6\xb1\xed\xd1\xb5\xe0\x02d\xd0\x99\xa4\xe8\xa1{ \xa7\xf1')
[112, 111, 105, 54, 177, 237, 209, 181, 224, 2, 100, 208, 153, 164, 232, 161, 123, 32, 167, 241]
bytearray(b'P\xc9\xec\xf7\xe9,\x8ek\x93\r}\xdejS\x12\x97\x85\xd3\xa7\x08')
[80, 201, 236, 247, 233, 44, 142, 107, 147, 13, 125, 222, 106, 83, 18, 151, 133, 211, 167, 8]
bytearray(b'CA\xf8/-\xeeO\x82\x0fk\xed1\xc6d\xb6|\xe1*\xa7\xfd')
[67, 65, 248, 47, 45, 238, 79, 130, 15, 107, 237, 49, 198, 100, 182, 124, 225, 42, 167, 253]
1
2
3
4
5
6

# 数据解码

我们知道八位是一个字节,四位是半个字节。为了方便, 我这里叫四个字节为一个“半字”。如上面20个数据 中倒数第二个数是167。用十六进制表示是0xA7, 二进制表示是0b10100111。那么它的的前半字和后半字 分别是0xA和0x7。我们注意到,167在多次数据中都没有变过。通过Cstimer的源码,我了解到若是倒数第二个 字节的数字是167,那就表示魔法的数据就是加过密的。因此这个字节是作为一个是否加密的标志存在的。也就是 说可能有些版本的魔方是没有加密的。利用最后一个字节配合一串已有的数据(钥匙)可以对前18个字节数据解密。 我也试在魔方配套软件的APK文件里面找了一番,发现这是一种叫做Advanced Encryption Standard (AES) 的加密标准。

key = [176,  81, 104, 224,  86, 137, 237, 119,  38,  26, 193, 161,
       210, 126, 150,  81,  93,  13, 236, 249,  89, 235,  88,  24,
       113,  81, 214, 131, 130, 199,   2, 169,  39, 165, 171,  41]

def toHexVal(value, key): # 将接收到的数据解密成十六进制的数据
    raw = list(value)
    k1 = raw[-1] >> 4 & 0xf # 通过移位和与的操作得到倒数第二个半字
    k2 = raw[-1] & 0xf # 最后一个半字
    for i in range(18):
        raw[i] += key[i + k1] + key[i + k2] # 原始数据加上于钥匙相关的操作即可解密数据
    raw = raw[:18] # 保留前18个字节状态和变化数据
    valhex = [] # 以半字的方式将解密后的数据保存成一个长度为36的列表
    for i in range(len(raw)):
        valhex.append(raw[i] >> 4 & 0xf)
        valhex.append(raw[i]  & 0xf)
    return valhex
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

这些数据包含了魔方的状态(角块, 边块信息), 以及这次和上次旋转的面和方向。

20个字节数据
第33, 34个半字节分别记录的是这次旋转的面(4:白色中心面)和方向(3:逆时针)。于是仅用这八位数据我们 就可以有各种玩法。

# 魔方应用

结合一个开源的魔方可视化程序MagicCube (opens new window), 要实现和魔方配套软件一样可视化效果也不是难事!

魔方转动可视化

利用开源的终端音乐播放器Musicbox (opens new window), 从此魔方 变成音乐播放遥控器, 转魔方, 听音乐,很惬意啊!

用魔方控制音乐播放: 上一首/下一首/播放/暂停

MagicCube和Musicbox支持按键控制。因此要实现上面的效果非常简单。拿音乐播放来说,当检测到蓝色面 转动时,按下空格键 即播放或暂停;检测到绿面正转时按下[切换到上一首, 反转时按下]切换到下一首。

# 拓展展望

仅仅用智能魔方一个字节的数据我们得到了不错的应用。当然,还有很多可以拓展的地方。例如,我们还可以 通过其他服务读取到魔方的电量; 有时候魔方返回的状态数据和实际情况不符,我们可以通过写入服务重置它 的状态。此外, 通过读取魔方角块和边块的信息,记录每一步动作(Action)后的魔方状态(State), 我们 可以得到还原魔方的轨迹(A1, S1, A2, S2,...,At, ST),用这些轨迹可以利用模仿学习或者反向强化学 习训练一个和我们自己类似的AI来还原魔方。小小的魔方真的拥有巨大的魔力!

# 致谢结语

我要感谢川哥送我这么有趣的魔方, 感谢前人们无私地奉献开源代码, 感谢Bleak, Cstimer, MagicCube, Musicbox的开发人员。我也将所有代码提交到Github (opens new window)。 从电子信息工程过渡到读凝聚态物理,这几年我都已经快忘了字节,位这么小的单位了。这个项目好像把我带回 大学, 中断函数让我体会到了一种久违的亲切感。我相信经历总会是有用的, 所有的点滴可能会在某一刻 串起来,然后一切就都对了。

# 参考资料

[0] https://unsplash.com/photos/4Ennrbj1svk?utm_source=unsplash&utm_medium=referral&utm_content=creditShareLink
[1] https://medium.com/@juananclaramunt/xiaomi-mi-smart-rubik-cube-ff5a22549f90
[2] https://www.youtube.com/watch?v=1pIV4bjYAK4&t=454s
[3] https://github.com/cs0x7f/cstimer/blob/master/src/js/bluetooth.js
[4] https://github.com/hbldh/bleak

# 视频链接:

编程破解小米魔方 (opens new window)

# 魔方链接

小米智能魔方 (opens new window)