Easy Live 电玩飞机杯测评:并尝试破解一下它

前言

这,是一个非常美妙的周五的晚上。

我,在上个月的鬼迷心窍下,买了一个电动的杯子。

1709915710906

然后经过了这近一个月的体验后,决定来给大伙做个简单的开箱。

我知道肯定有人会说这是贤者模式下写的,然而并不是这样(;´д `)ゞ

开箱!

狗东的物流还是挺快的,买了之后没几天就到了。产品的外包装相当大,当时收到快递的时候说里面装着一个 Switch 或者主板我都信,就是怪轻的。

1709952429468

打开之后里面就只有本体、手机支架、充电线。

1709952690394

不过嘛,我收到的产品并不像是商品描述页所说的“手柄半透明”和“肉色内胆”。

1709953166059

1709953219957

后面去问了问客服,说现在发的就是这样的,不知道是商品描述页没更新还是质量控制没管理好新旧一起发。

先说结论

先说结论吧,因为挺多人看这个就是为了想知道值不值得买:这个杯子我其实不怎么推荐以原价或较低折扣购买,主要有以下几点原因:

  1. 溢价过高。这玩意机器 + 内胆合起来要近 300 软(还是折后),而单独购买内胆则最低需要 189 软,这会导致维护费用随时间推移而飙升。
  2. 内胆容易损坏。没错,这玩意我到手才用了大概 3 次就发现顶部出现细孔。
  3. 加热功能鸡肋,烘干功能更是没有。
  4. 物料混发。我收到的机器手柄还是旧版本的手柄,按键颜色却是新版本的,而且内胆颜色也是白色并不是肉色。
  5. Android 端的 APP 下载麻烦。控制用的 APP 并未上架 Android 系统的 Play 商店或各品牌内置的应用商店,需要去应用宝等第三方应用商店进行下载。
  6. APP 内包含付费内容且可控性较差。APP 明明是蓝牙控制,却一定要登录账户且联网才能使用。里面的付费内容的话是一些带剧情和画面的语音交互,虽然你绑定设备后会送一个给你但其他的要自己买。
  7. 声音挺大的(都电动了这也正常,一个人住不是啥大问题)。

如果看到上面列举出来的缺点,已经没了购买的欲望的话,那就可以不用继续看下去了,因为这将浪费掉你宝贵的 10 分钟。

内胆

它的内胆用的是 TPE 材质,出油问题属于通病,不过因为大部分的时间都是安装在机器里面,倒也没什么影响了。

内胆软硬适中,不是很软的那种,刺激性不错。但是!它的内胆和价格比起来就很薄了,顶部与侧面的壁厚估计是在 6mm 左右,因此如果是追求包裹感的就不用考虑它了。

而且内胆的顶部虽然看起来并不是那种薄到透过来容易很快就穿的类型,但在我第 3 次使用的时候,因为润滑油加多了,偶然间就发现顶部的中间位置貌似出现了一些润滑油。后面在清洗的时候就留意了一下,加水进去后,用手按压,能泚水。

1709954534313

害,这里就先不考虑是不是原厂发过来就是有个洞的,但我寻思我的武器长度也不至于给他开个洞啊…而且在这之后,我就因为怕润滑油漏出来进到机器里面就放少一点,后面爽起来了就开到速度最快的挡位。

然而,现实世界和 Gal 里是不同的,在现实世界就算你超越了光速也不会穿越到平行世界,而是会给你磨秃噜皮。

1709955084462

在此背景下,我写下了这一篇测评。(你说我什么不放在开头?因为丢脸 இ௰இ)

而且在这之后也发现伸缩部位的连接处似乎也出现了磨损或疲劳损坏的情况:

1709955713768

总的来说这个内胆和这个价格比起来…似乎不太对的上,希望能用久一点吧。

功能

商品描述页说的功能挺好的,有:APP 控制、PC 互动(画饼)、伸缩、震动、加热、烘干(无),这里我就一个个介(点)绍(评)。

伸缩

一共有 8 个挡位,前 3 个为慢、中、快速,剩下的 5 个是不同速度曲线的伸缩模式。

震动

一共有 10 个挡位,前 3 个为低、中、高频率,剩下的 7 个为不同频率曲线的震动模式。

加热和烘干

为什么要放在一起说呢,因为不论是手动控制还是 APP 控制,都是只有加热的夏冬两个挡位,没有什么烘干模式。

我买之前最吸引我的应该就是这个“烘干”了,因为…吸水棒什么的容易忘记去用,隔一段时间还得换。

因为我最开始其实是在张大妈的信息流里面看到这个产品的,不过不叫 Easy Live 电玩飞机杯 而是叫 大象电玩飞机杯。例如在 3 月 5 号的这个推文里指向的商品页面中有提到:

1709969468196

而这个 大象电玩飞机杯Easy Live 电玩飞机杯 是什么关系呢?我姑且认为他们其实就是一个产品,不过大象卖的是旧版本的货,而隔壁 Easy Live 卖的则是新版本的。

于是我买的时候没仔细看 Easy Live 是怎么描述的,结果到手后才发现找不到这个功能,再看看 Easy Live 的页面,发现其用的是:

1709969713054

我当时脑瓜子就嗡嗡的.jpg

不过他的这个介绍视频内容倒是还没改:

1709970925745

行吧,而且这个加热烘干其实只能说是有一点用,但不多。还是需要先用吸水棒把水都吸的差不多了,再开它提高一点温度促进内部水汽的排出。但加热的时候不要一直合上盖子,不然水汽出不去,等里面热起来就可以打开盖子排出去了。

APP 控制

它的 APP 控制似乎是一个买点,需要下载名为“點逗”的 APP,对于果子用户可以直接去 App Store 下载,但 Android 用户就只能去第三方应用商店,例如某讯的应用宝下载了。

下载 APP 后需要使用邮箱或者手机号注册一个账户才能使用,并且在断网的情况下是无法使用的,即使是你登录成功后再断开网络也是无法连接杯子,会提示:

您的网络已断开,请检查网络连接。。。

只有在已经打开 APP,并成功连接了杯子之后再断开网络的情况下才能离线控制。低情商一点的说法是如果它服务器被攻击或者是后面停止维护了,那这个蓝牙控制功能就算是废了。

APP 内控制区一共有:视频互动、遥控器、伸缩、震动、声音这五个选项卡。

视频互动

就是让你在 APP 里面打开一个视频,然后视频可以用杯子上的实体按键来控制。并且在播放的时候,可以根据视频的声音,以某种规则映射到伸缩的速度曲线上。还能在播放页面手动控制伸缩、震动和加热的挡位。

因为我用电脑的情况多,所以这个功能我没用过,不做评价。

遥控器

其实就是杯子上的实体按键的功能,可以参考“APP 控制”之前的内容

伸缩

这个也是我最开始比较期待的一点,因为上面的“遥控器”和杯子上的实体按键其实都只是选择了不同的程序而已,可调节范围不多。而既然 APP 中有单独的一页来控制,而且还用上了从 0-100 的调节范围,我想应该能无极调节吧,结果它是从 0 直接跳到 40,再从 40 跳到 65,在 65 到 100 之间的才算是有比较平滑的变化。

震动

这个倒是还好,可调节范围是 0~100,中间没有出现非常突兀的跳变,调节粒度还是挺细的。

声音

这个玩意,貌似是使用手机麦克风捕获环境声,根据环境声响度的变化来调节伸缩的速度,类似前面的“视频互动”的功能。不过这玩意开着之后因为机器距离手机非常近,因此会越来越快,估计达不到功能所设计的“根据用户发出的声音来智能调节伸缩速度”吧(

破解蓝牙协议

前面也提到过,我更多的使用场景是电脑而不是手机,而且这个蓝牙控制功能还不能在离线的情况下使用,这就非常的蛋疼了(物理)。于是在被强制贤者化的时间里,我开始琢磨起它是怎么用蓝牙进行控制的。

有做 Android 开发的同学可能就直接起手就对上面的控制 APP 进行逆向研究了,不过我对 Android 开发学的不多,因此这里直接赌一把,从蓝牙通信上入手。

环境准备

这里因为需要对 APP 进行抓包,因此需要使用一台 Android 机,果子不可以。并且在手机上安装好控制用的 APP 与蓝牙调试软件,这里推荐 BLE 调试助手nRF Connect,这两个软件都没有广告。最后在电脑上安装 Wireshark 等软件,方便对抓包得到的文件进行查看。

确定杯子的蓝牙信号

因为后面的抓包会捕获到周围的许多蓝牙信号,多余的数据包会对分析工作造成阻力。因此需要先确定哪个信号才是我们需要的,并获取其 MAC 地址。

打开前面提到的蓝牙调试软件,这里以 nRF Connect 为例子。打开软件后列表下拉一下,会自动搜寻附近的蓝牙设备。我们只需要把手机贴近杯子,哪个延迟最低,哪个蓝牙设备就是这个机器。

1709975838400

例如我这里的这个延迟为 33ms 的设备就是我们需要找的,记下他的 MAC 地址:41:42:59:30:43:FF。再点一下 CONNECT 连接进去,把服务也给截图保存留着备用。

1710125168994

抓包

这里不同系统下会略有区别,这里仅以 MIUI 系统抓取 BTSnoop 日志作为参考。

抓包需要开启开发者模式,在开启了之后,可以在选项中找到 打开蓝牙数据包日志 的项目,选择 已启用 后,通常就能在 /MIUI/debug_log/common/com.android.bluetooth 目录下找到抓包得到的 btsnoop_hci.log 文件。

1710125317268

使用拨号盘代码

但小米不愧是小米,真系零舍唔同。它虽然有这个目录,但这个目录是空的,使用 *#*#5959#*#* 来收集也是一样。这样就有点难受了,考虑到可能是系统太新(指 MIUI14)导致的,于是用旧的手机来试试。我的旧手机用的是 MIUI12,但打开开发者的选项后也是抓不到,只能通过:

  1. 打开开发者模式。
  2. 确定开发者选项中的 打开蓝牙数据包日志 的项目为 已停用
  3. 使用 *#*#5959#*#* 来收集蓝牙日志。

来抓取数据包,而且经常会出现只抓到空文件(只有 16 字节)的情况。(金凡!)

在多次折腾之后,对“遥控器”中的功能进行多次抓包,得到抓包后的文件。之后把抓包得到的文件拖回本地进行分析即可。

使用 ADB

话虽这么说,但我分析到震动的时候发现他喵的 APP 闪退了,而且重启也不管用。这就难办了,因为我的主力机能正常用这个 APP 但不能抓包;而备用机能抓到包,但会在震动功能页闪退。因此这里我用另一种方法对主力机的蓝牙进行抓包。[1]

首先还是需要打开开发者模式和蓝牙数据包日志,然后重启蓝牙(关闭蓝牙开关,再打开蓝牙开关)后就开始记录数据。

而在需要抓包的操作完成之后,在终端中使用 ADB 连接手机,输入如下的命令抓取调试信息:

1
adb bugreport filename

输入命令后得等一会,这个时间取决于你与手机之间的连接速度。在等待结束后,你就能在你终端的当前目录下找到取回的文件。如果你把上面的命令一个字也没改的话,那么你应该能看到一个名为 filename 的 zip 压缩包。双击打开那个压缩包,进入路径 FS/data/misc/bluetooth/logs,就能看到类似如下 BT_HCI_2024_0310_223354.cfa 的文件。

1710125792697

它其实也是 BTSnoop 日志的一种,单独解压出来用软件进行分析即可。

协议分析

使用 Wireshark 打开抓包得到的文件后,会出现很多类似下图的蓝牙通信包,但这些都不是我们需要的,而且会充斥在通信的整个过程中。

1709977139565

因此我们需要用到上面获取到的 MAC 地址,来对数据包进行筛选。例如这里我使用了 bthci_acl.dst.bd_addr == 41:42:59:30:43:ff || bthci_acl.src.bd_addr == 41:42:59:30:43:ff 来筛选出来自 41:42:59:30:43:ff 的数据包与发往 41:42:59:30:43:ff 的数据包,得到 app 和杯子之间的蓝牙通信内容。

1709977625608

接着对里面的通信过程进行分析,根据 Info 栏的内容我们可以知道这条数据包大概是用于干什么的,并且已经知道我以 10 秒为间隔进行操作。所以只需要在找到在 Time 栏中以 10 秒为间隔发送出去的数据包就能知道在我对杯子进行控制时 APP 都发送了什么数据过去。通过 Info 栏中的信息可以知道,图中的通信都是与控制无关的内容,都是些用来获取服务的数据包。

因此我们可以继续往下找,在看到这里时,可以注意到这里重新设置了 MTU,表示后面很有可能就是程序正式发送出去的控制命令了。

1709978355200

并且能够发现在设置了 MTU 之后,有多个 Info 为 Sent Read Request, Handle: 0x000c (Unknown: Car Connectivity Consortium, LLC) 的数据包,并且他们发送的间隔和我之前操作的间隔差不多。

于是直接查看这些数据包的内容:

1709978610026

由上图中的信息可以知道,我的控制命令很有可能是通过 0xfff0 这个自定义服务下的特征 0xfff5 来接收的,而发送的操作码为 0x52,值为 55aaf001011166f4ff00ffffff,但我目前不知道这个值到底是什么意思。因此需要继续找后面 APP 发送出去的值,并将他们汇总起来观察一下:

1
2
3
4
5
6
7
8
9
10
11
# 不知道
55aaf001011166f4ff00ffffff
55aaf001021166f4ff00ffffff

# 切换伸缩模式
55aaf001011166f3f10000
55aaf001021166f3f00000
55aaf001031166f3f20000
55aaf001041166f3f00000
55aaf001051166f3f30000
55aaf001061166f3f00000

把数据这样放在一起后,我们不难发现他们具有一定的规律:

  • 前 4 个字节固定为 0x55aaf001,意义不明。
  • 第 5 个字节会从 0x01 开始递增,可能是代表数据包序号,防止乱序?
  • 第 6、7 字节固定为 0x1166,意义不明。
  • 第 8 字节可能代表功能,0xf3 为伸缩?
  • 第 9 字节可能代表强度或挡位,0xf0 为关闭,0xf1…0xf8 分别代表 8 个伸缩挡位。
  • 第 10、11 字节功能不明。

再对第二次抓包的结果观察一下:

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
37
38
39
40
# 未知功能指令
55aaf001031166f4ff00ffffff
55aaf001041166f4ff00ffffff

# 加热功能切换:H1->关->H1->关->H2->关->H2->关
55aaf001011166f403
55aaf001021166f404
55aaf001031166f403
55aaf001041166f404
55aaf001051166f405
55aaf001061166f406
55aaf001071166f405
55aaf001081166f406

# 接上前面的未知功能指令
55aaf001051166f4ff00ffffff

# 震动:1->0->1->0->2->0->2->0->3->0->3->0
55aaf001011266f9f1
55aaf001021266f9f0
55aaf001031266f9f1
55aaf001041266f9f0
55aaf001051266f9f2
55aaf001061266f9f0
55aaf001071266f9f2
55aaf001081266f9f0
55aaf001091266f9f3
55aaf0010a1266f9f0
55aaf0010b1266f9f3
55aaf0010c1266f9f0

# 伸缩挡位:1->0->1->0->2->0->2->0
55aaf001011166f3f10000
55aaf001021166f3f00000
55aaf001031166f3f10000
55aaf001041166f3f00000
55aaf001051166f3f20000
55aaf001061166f3f00000
55aaf001071166f3f20000
55aaf001081166f3f00000

通过这一次的抓包,我们已经基本摸清了 APP 中“遥控器”页的控制方式。但显然这还不够,因为我其实是希望能更加自由的控制“伸缩”页中的速度。因此需要继续使用上面提到的分析方式,对数据包进行分析。后续分析的过程这里就按下不表了,直接放分析结果。

分析结果

通过对数据包进行分析,可以知道发送的数据有如下的结构:

字节名称描述
0:3可能是某种协议头?取固定值 0x55AAF001
4序号可能用于避免乱序或漏收。
从 0x01 开始递增,并且各个功能段的序号是独立的
5不明该字节通常为 0x11、0x12、0x0E。
并未发现有明显的变化规律,并且这里取不同的值也不会影响命令的执行。
6固定值该字节取固定值 0x66,但取任意其他值也不会影响命令的执行。
7功能用于指定控制的功能:
- 0xF1:调速模式
- 0xF3:伸缩模式
- 0xF4:加热模式
- 0xF9:震动模式
8模式- 调速模式:恒定为 0x01
- 伸缩模式:0xF0…0xF8。0xF0 为关闭,0xF1…0xF3 为低中高速,0xF4…0xF8 为 5 种预设的速度曲线。
- 加热模式:0x00 | 0x03 | 0x04 | 0x05 | 0x06。0x03 为恒温 40 度,0x05 为恒温 45 度,其他值为关闭。
- 震动模式:0xF0…0xFA。0xF0 为关闭,0xF1…0xF3 为低中强震,0xF4…0xFA 为 7 种预设的震频曲线。
9对象只有在调速模式下才需要,用于指定调速的对象
- 震动:0x00
- 伸缩:0x01
A:B固定值取固定值 0x0201
C速度0x00…0x64(0-100)
伸缩或震动的速度,直接 10 进制转为 16 进制即可
D固定值(可选)固定为 0x02,不使用也没有影响
E速度0x00…0x64(0-100)
(可选)伸缩或震动的速度,直接 10 进制转为 16 进制即可,不使用也没有影响

根据上述规则,便可以开始愉快的测试了。

这我图方便,使用 python 进行测试。使用的测试代码如下:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
import asyncio
from bleak import BleakClient

address = "41:42:59:30:43:FF"
UUID = "0000fff5-0000-1000-8000-00805f9b34fb"

vibration_model_command_count = 0x00
telescopic_model_command_count = 0x00
heating_model_command_count = 0x00
speed_model_command_count = 0x00


def print_hex(bytes):
l = [hex(int(i)) for i in bytes]
print(" ".join(l))

def 杯子(function: int, model: int, object: int = None, percentage: int = None):
global vibration_model_command_count
global telescopic_model_command_count
global heating_model_command_count
global speed_model_command_count

head = [0x55, 0xAA, 0xF0, 0x01]
count = 0
unknown_value = [0x11]
fixed_value = [[0x66], [0x02, 0x01], [0x02]]
match function:
case 0xF1:
speed_model_command_count += 1
count = speed_model_command_count
case 0xF3:
telescopic_model_command_count += 1
count = telescopic_model_command_count
case 0xF4:
heating_model_command_count += 1
count = heating_model_command_count
match model:
case 0:
model = 0x00
case 1:
model = 0x03
case 2:
model = 0x05
case 0xF9:
vibration_model_command_count += 1
count = vibration_model_command_count

if function == 0xF1:
return (
bytes(head)
+ count.to_bytes(1, "big")
+ bytes(unknown_value)
+ bytes(fixed_value[0])
+ function.to_bytes(1, "big")
+ model.to_bytes(1, "big")
+ object.to_bytes(1, "big")
+ bytes(fixed_value[1])
+ percentage.to_bytes(1, "big")
+ bytes(fixed_value[2])
+ percentage.to_bytes(1, "big")
)
else:
return (
bytes(head)
+ count.to_bytes(1, "big")
+ bytes(unknown_value)
+ bytes(fixed_value[0])
+ function.to_bytes(1, "big")
+ model.to_bytes(1, "big")
)

async def main(address):
async with BleakClient(address) as client:
try:
# 是否连接
if not client.is_connected:
client.connect()
print(f"Connected: {client.is_connected}")
# 是否配对
paired = await client.pair(protection_model=2)
print(f"Paired: {paired}")

if client.is_connected and paired:
# 加热挡位遍历
for model in range(1,3):
print(f"加热挡位:{model}")
command = 杯子(0xF4,model)
print_hex(command)
await client.write_gatt_char(UUID,command)
await asyncio.sleep(3.0)

command = 杯子(0xF4,0)
print_hex(command)
await client.write_gatt_char(UUID,command,True)

# 震动挡位遍历
for model in range(1,11):
print(f"震动挡位:{model}")
model += 0xF0
command = 杯子(0xF9,model)
print_hex(command)
await client.write_gatt_char(UUID,command)
await asyncio.sleep(3.0)

command = 杯子(0xF9,0)
print_hex(command)
await client.write_gatt_char(UUID,command,True)

# 伸缩挡位遍历
for model in range(1,9):
print(f"伸缩挡位:{model}")
model += 0xF0
command = 杯子(0xF3,model)
print_hex(command)
await client.write_gatt_char(UUID,command)
await asyncio.sleep(3.0)

command = 杯子(0xF3,0)
print_hex(command)
await client.write_gatt_char(UUID,command,True)

# 震动速度遍历
for speed in range(1,101):
print(f"震动速度:{speed}%")
command = 杯子(0xF1,0x01,0x00,speed)
print_hex(command)
await client.write_gatt_char(UUID,command)
await asyncio.sleep(3.0)

command = 杯子(0xF1,0x01,0x00,0)
print_hex(command)
await client.write_gatt_char(UUID,command,True)

# 伸缩速度遍历
for speed in range(1, 101):
print(f"伸缩速度:{speed}%")
command = 杯子(0xF1,0x01,0x01,speed)
print_hex(command)
await client.write_gatt_char(UUID,command)
await asyncio.sleep(3.0)

command = 杯子(0xF1,0x01,0x01,0)
print_hex(command)
await client.write_gatt_char(UUID,command,True)

except Exception as e:
print(f"Exception during write_and_listen loop: {e}")
finally:
# 断开与蓝牙设备的连接
await client.disconnect()
print("设备已断开")

asyncio.run(main(address))

在经过测试后发现,他的调速模式最低的响应值为 6,低于 6 的速度都没有任何响应。并且全过程应该是细粒度调整的,至少震动的是,而伸缩的因为变化范围没有震动的广,而且也没法很直观的看到明显的变化(颓了),所以就感知不大明显了,但至少 6 是比 APP 里面最低的 40 要慢一些的。

尾声

害,写到这里的时候,已经是 1 点钟了,我美妙的周末伴随着我的文章也跟着结束了。下周末有空的话打算写点控制程序来试试(x


  1. Capture and read bug reports | Android Studio | Android Developers ↩︎