天下苦SnapBridge久矣,因此我决定用ESP32做一个替代它的硬件。本篇介绍SnapBridge的配对和下发数据的过程。
前言
上文提到我买Z8了,满心欢喜地设置好SnapBridge之后发现,Z502和Z8不能同时用,也就是说,如果我出门拍鸟想要双持,那么至少有一个相机收不到GPS数据。更何况尼康这个App做的,又费电又容易被杀后台,切出去看一眼地图再切回来,蓝牙就断了,十分不稳定。我没有用App下载照片的用途,我唯一需要的就是这个App把手机的GPS数据传给相机,这样拍摄的照片就有位置信息,方便日后回忆。
关于实现方式,我想了几种方案。第一个就是自己重新写一个App,虽然安卓的蓝牙相关API不好用,但至少是最稳定的。可是我无法解决三星杀后台的问题,更何况三星一直不舍得给电池,弄这么一个App常住后台,怕不是半天就给手机整没电了。Z8和Z9机身上有个10pin针脚,一番搜索之后发现这个10pin针脚可以用NMEA-0183协议给相机传输GPS,但问题有二:首先,这个10pin针脚不好买,好多快门线并没有连GPS的数据线,你把它剪开只会发现没有要用的线;其次则是国行系统在相机菜单内屏蔽了GPS设置,插上了10pin接口的GPS也无法启动,这是国内法律要求的。虽然淘宝上可以给相机刷地区,但我没找到公开资料,自己做不了。我有精神洁癖,所以不打算让别人远程到我电脑上给我的相机搞奇怪的东西。
于是只剩下一种办法,利用SnapBridge的协议给相机写数据。好在我不是第一个觉得尼康App难用的,隔壁gkoh/furble利用M5Stack的设备针对各家厂商做了替代品,是基于ESP32的,虽然尼康设备的支持很有限,但社区已经基本上把握手和消息的格式都逆向出来了,唯一的问题在于这个项目用了NimBLE,它不支持传统蓝牙,因此无法完成尼康的配对。
由于我并没有ESP32的开发经验,所以在购买ESP32和GNSS模块之前,我准备先在借助其他平台验证一下蓝牙握手的部分。
概念验证
一开始我想到了安卓,但安卓的蓝牙栈API实在是太糟糕了,从PoC的角度来讲,整理握手的流程被API搞得很零碎,反而让其他人摸不到头脑。相比之下,Linux的Bluez就好了不少。目前安卓和Linux Bluez(使用Kotlin编写)的代码可以在以下地方找到:
- 安卓:https://github.com/hurui200320/nsg/tree/7bbe7a45f7ef3f0fd2dbe6772181e40c4fa149c7/android
- Bluez:https://github.com/hurui200320/nsg/tree/7bbe7a45f7ef3f0fd2dbe6772181e40c4fa149c7/kotlin-poc
这两个代码都不算完整,但全都实现了完整的握手和下发时间以及位置数据,位置数据是随机生成的。
下面来说说我测试过、能跑通的SnapBridge协议。
智能设备蓝牙协议
时效性提示
以下内容纯靠社区逆向和我的探索及尝试。只保证在2026年7月上旬时有效,未来尼康可能会通过更新修改握手流程,因此不保证以下内容总是能用。建议首先做概念验证确保以下内容仍然可用。尼康相机在菜单中对SnapBridge的叫法是智能设备,所以我的项目名字也选用了类似的命名方法:NSG,全称Nikon Smart GPS,但为了避免吃Nikon的版权炮,名字里没有用Nikon,而是只取了首字母N。
智能设备首先需要配对,配对过程涉及BLE和传统蓝牙双栈,因此对于ESP32设备来说,你必须购买原版的ESP32,不能带任何后缀,什么S3啊P4啊C6啊这些全都是只有BLE没有传统蓝牙的。隔壁nRF52系列也说了硬件不支持传统蓝牙,就算你实现了传统蓝牙栈也没用。
BLE UUID
尼康相机的握手和数据下发主要依靠BLE,其中:
- 相机的Service UUID:
0000de00-3dd4-4255-8d62-6dc7b9bd5561 - 握手(
PAIR)用的Characteristic UUID:00002000-3dd4-4255-8d62-6dc7b9bd5561 - 握手成功后写入智能设备名称(
ID)的Characteristic UUID:00002002-3dd4-4255-8d62-6dc7b9bd5561 - 写入
TIME的Characteristic UUID:00002006-3dd4-4255-8d62-6dc7b9bd5561 - 写入
GEO的Characteristic UUID:00002007-3dd4-4255-8d62-6dc7b9bd5561
相机的BLE广播
相机在BLE广播的时候根据情况调整Manufacture Data,但总会宣告Service UUID。
如果相机当前处于配对模式,则Manufacture Data为空,或者前两个字节按照小端序解读(也可能被有些框架解读为key)不为0x0399。此时发送握手消息即可触发配对流程。
如果相机当前处于已配对模式,则Manufacture Data至少为6字节,前两字节按照小端序解读应为0x0399,随后跟随4字节的device id,同样按照小端序解读。配对多个设备的时候应该在相机里选择要连接哪个设备,这样相机才会在BLE广播中宣告正确的device id。如果相机宣告的device id和我们保存的device id相同,那么我们就可以发送握手消息来重新连接。
BLE握手
BLE握手的消息格式相对固定,但内容依据用途会变化。配对和重连时都需要重新进行BLE握手。
消息格式和阶段
握手消息分为四个阶段,每个阶段的消息格式都是17字节:
| Offset | 长度(字节) | 参数 | 描述 |
|---|---|---|---|
| 0 | 1 | stage | 握手阶段标示,值为0x01、0x02、0x03和0x04之一 |
| 1 | 8 | timestamp | 小端序,虽然名字叫时间戳,但不一定非得是时间戳,随机生成就行 |
| 9 | 4 | device id | 小端序,配对时需要随机生成(需要确保Least Significant Byte为0x01),重连时需要使用保存的值 |
| 13 | 4 | nonce | 小端序,配对时需要随机生成,重连时需要使用保存的值 |
第一阶段:我们需要随机生成一个timestamp,并根据需要填入device id和nonce。设置stage为0x01,序列化后写入PAIR。
第二阶段:读取PAIR获取相机发给我们的握手消息,验证stage为0x02,此时timestamp为相机随机生成的,而device id和nonce是相机生成的challenge id
第三阶段:此时timestamp应当和第一阶段一致,device id和nonce则填入经过blowfish算法和challenge id计算得到的结果。设置stage为0x03,序列化后写入PAIR。
第四阶段:读取PAIR获取相机发给我们的握手消息,验证stage为0x04,此时timestamp通常和第二阶段一致,但不一致也无所谓。而device id和nonce应当按照8字节的ASCII数组解读,应该是相机的内部序列号,但与相机机身的序列号不同。
FurBLE说完成握手后会在NOT1上收到确认成功的消息,但我测试Z502和Z8均没有返回这样的数据。
完成四阶段握手后,如果是配对的话,需要再向ID写入32字节的ASCII数组,这个数组长度固定为32字节,最后一字节应为0,因此设备名称最长为31字节,不足的部分在后面填充0。这里写入的设备名字会被记录在相机的已配对设备中,不必和设备的BLE或BT名称一致。如果懒得做判断,那握手成功后固定写入也可以,重连阶段写入这个characteristic不影响。
配对阶段握手成功后,相机将暴露自己的传统蓝牙地址,设备可以搜索同名设备并发起配对。
重连阶段握手成功后,相机将显示“已连接到智能设备xxxxxx”。此时TIME和GEO将可以写入。
Blowfish
好在尼康当了一回人,使用了标准的Blowfish实现,在JVM和安卓上可以直接:
private val cipher: Cipher = Cipher.getInstance("Blowfish/ECB/NoPadding").apply {
init(Cipher.ENCRYPT_MODE, SecretKeySpec(KEY, "Blowfish"))
}而C和C++可以直接使用https://github.com/cjwagenius/bfish/blob/main/bfish.h,这是发布进入公有领域的代码,因此我们可以自由使用。
其中Blowfish加密的key也是固定的:FF FF AA 55 11 22 33 00
static const uint8_t KEY[] = {
0xFF, 0xFF, 0xAA, 0x55,
0x11, 0x22, 0x33, 0x00
}; private val KEY = byteArrayOf(
0xFF.toByte(), 0xFF.toByte(), 0xAA.toByte(), 0x55.toByte(),
0x11.toByte(), 0x22.toByte(), 0x33.toByte(), 0x00.toByte()
)在计算challenge时,协议中预设了8组salt,每组salt有两个32bit的数据:
SALT[8][2] = {
{0x704066e4, 0x0433d552},
{0xed4b8fac, 0x15f7e47b},
{0x24471f11, 0x8b5ea1fc},
{0x05960c31, 0x2b8c7f41},
{0xfda588c1, 0xeba8b1f3},
{0x99166056, 0x1bd3d550},
{0xcd32687f, 0xa9e28a30},
{0x2a8fe834, 0xdec7ebf4}
}; @Suppress("REDUNDANT_CALL_OF_CONVERSION_METHOD")
private val SALTS = arrayOf(
intArrayOf(0x704066e4.toInt(), 0x0433d552.toInt()),
intArrayOf(0xed4b8fac.toInt(), 0x15f7e47b.toInt()),
intArrayOf(0x24471f11.toInt(), 0x8b5ea1fc.toInt()),
intArrayOf(0x05960c31.toInt(), 0x2b8c7f41.toInt()),
intArrayOf(0xfda588c1.toInt(), 0xeba8b1f3.toInt()),
intArrayOf(0x99166056.toInt(), 0x1bd3d550.toInt()),
intArrayOf(0xcd32687f.toInt(), 0xa9e28a30.toInt()),
intArrayOf(0x2a8fe834.toInt(), 0xdec7ebf4.toInt())
)我们需要遍历这8组salt,找出正确的一组。对于第i组salt,我们首先需要拼接需要进行hash计算的数据:
src[6] = {
SALT[i][0],
SALT[i][1],
cam_ts_lo, cam_ts_hi, // 阶段2的timestamp,相机生成
our_ts_lo, our_ts_hi // 阶段1的timestamp,我们生成
};这里需要注意的是,在进行BLE发送和接收的时候是按照小端序处理的,但计算的时候这些数据必须是大端序。例如相机发送的timestamp是[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08],那么我们接收到的timestamp应该是0x0807060504030201,拆分时低4字节应该是0x04030201,高4字节是0x08070605。但在构建hash的数据时,高低四字节应当分别被转换为大端序表示,因此cam_ts_lo应该是0x01020304,而cam_ts_hi是0x05060708。
将前述数据进行hash计算,这个计算过程是尼康自己整的:
struct HashResult {
uint32_t left;
uint32_t right;
bool valid;
};
HashResult hash(const uint32_t *blocks, size_t count) {
if (count % 2 != 0 || blocks == nullptr) {
return {0, 0, false};
}
uint32_t left = 0x01020304;
uint32_t right = 0x05060708;
for (size_t i = 0; i < count; i += 2) {
uint32_t inL = blocks[i] ^ left;
uint32_t inR = blocks[i + 1] ^ right;
bfblk_t blk;
blk.hi = inL;
blk.lo = inR;
bfish_enblock(&bf, &blk);
left = blk.hi;
right = blk.lo;
}
return {left, right, true};
} fun hash(blocks: IntArray): Pair<Int, Int> {
require(blocks.size % 2 == 0) { "blocks length must be even" }
var left = 0x01020304
var right = 0x05060708
for (i in blocks.indices step 2) {
val inL = blocks[i] xor left
val inR = blocks[i + 1] xor right
val enc = encryptBlock(inL, inR)
left = enc.first
right = enc.second
}
return left to right
}
private fun encryptBlock(left: Int, right: Int): Pair<Int, Int> {
val input = ByteBuffer.allocate(8)
.order(ByteOrder.BIG_ENDIAN)
.putInt(left)
.putInt(right)
.array()
val output = cipher.update(input)
?: throw IllegalStateException("Blowfish encryption failed")
val buffer = ByteBuffer.wrap(output).order(ByteOrder.BIG_ENDIAN)
return buffer.getInt() to buffer.getInt()
}计算得到两个大端序的数字,第一个是阶段2相机产生的device id的大端序,第二个是阶段2相机产生的nonce的大端序。只要两组结果都匹配,就说明我们找到了正确的salt。记录下这组salt,接下来计算阶段3的回应。
阶段3的回应和寻找salt类似,但数组是这样构造的:
src[6] = {
SALT[matched_salt][0],
SALT[matched_salt][1],
h_our_ts_lo, h_our_ts_hi, // 阶段1的timestamp,寻找salt时这里是阶段2的
h_cam_ts_lo, h_cam_ts_hi // 阶段2的timestamp,寻找salt时这里是阶段1的
};放进hash计算后同样得到两个数字,将这两个数字转换回小端序,第一个数字填入阶段3的device id,第二个数字填入阶段3的nonce即可。
这里贴两个测试用例供大家验证:
void testCapturedStage2FindsSaltSixAndBuildsExpectedStage3() {
// Real values captured from the Z50_2 camera handshake.
const PairingMessage stage1(
0x01,
0x677da144ec13e1dbULL,
0x3c3ae501U,
0x3fdaa451U
);
const PairingMessage stage2(
0x02,
0xb9943d5e8026fa29ULL,
0xa8b3f2e4U,
0x16d56a13U
);
const PairingMessage stage3 = engine.verifyStage2AndBuildStage3(stage1, stage2);
TEST_ASSERT_EQUAL_UINT8(0x03, stage3.stage);
TEST_ASSERT_EQUAL_UINT64(stage1.timestamp, stage3.timestamp);
TEST_ASSERT_EQUAL_UINT32(0x79f1ad53U, stage3.device);
TEST_ASSERT_EQUAL_UINT32(0x23838a35U, stage3.nonce);
uint8_t encoded[PairingMessage::SIZE];
stage3.encode(encoded, sizeof(encoded));
static const uint8_t expected[PairingMessage::SIZE] = {
0x03,
0xdb, 0xe1, 0x13, 0xec,
0x44, 0xa1, 0x7d, 0x67,
0x53, 0xad, 0xf1, 0x79,
0x35, 0x8a, 0x83, 0x23
};
TEST_ASSERT_EQUAL_UINT8_ARRAY(expected, encoded, PairingMessage::SIZE);
} @Test
fun capturedStage2FindsSaltSixAndBuildsExpectedStage3() {
// Real values captured from the Z50_2 camera handshake.
val stage1 = PairingMessage(
stage = 0x01,
timestamp = 0x677da144ec13e1dbL,
device = 0x3c3ae501L,
nonce = 0x3fdaa451L
)
val stage2 = PairingMessage(
stage = 0x02,
timestamp = 0xb9943d5e8026fa29uL.toLong(),
device = 0xa8b3f2e4L,
nonce = 0x16d56a13L
)
val stage3 = engine.verifyStage2AndBuildStage3(stage1, stage2)
assertEquals(0x03, stage3.stage)
assertEquals(stage1.timestamp, stage3.timestamp)
assertEquals(0x79f1ad53L, stage3.device)
assertEquals(0x23838a35L, stage3.nonce)
val expectedBytes = byteArrayOf(
0x03.toByte(),
0xdb.toByte(), 0xe1.toByte(), 0x13.toByte(), 0xec.toByte(),
0x44.toByte(), 0xa1.toByte(), 0x7d.toByte(), 0x67.toByte(),
0x53.toByte(), 0xad.toByte(), 0xf1.toByte(), 0x79.toByte(),
0x35.toByte(), 0x8a.toByte(), 0x83.toByte(), 0x23.toByte()
)
assertArrayEquals(expectedBytes, stage3.encode())
}这是根据log抓取来在Z502上的握手数据,保真的。
传统蓝牙配对
在配对模式完成BLE握手后,相机将暴露自己的传统蓝牙。此时任意设备都可以配对,并不一定是进行握手的设备。需要注意的是相机每次的BLE地址都是随机的,只有传统蓝牙的地址是固定的。因此在写代码的时候,不能直接利用BLE地址进行createBond()操作,这样是没用的。必须启动传统蓝牙的扫描,去找和BLE广播一样的设备名字的蓝牙设备,然后手动发起SSP配对。对于嵌入式设备来说,其传统蓝牙profile不能是空的,否则相机会在确认安全码之后握手失败。在ESP32上的解决办法是将蓝牙栈初始化为SPP slave模式,这样相机就能找到ESP32设备的SPP profile。此外,在ESP32上确认安全码之后,即便得到了成功的回调,也建议额外等待一段时间再关闭传统蓝牙,否则相机可能会因为来不及找到我们的SPP profile而失败。
配对成功后我们可以保存相机的BLE名字、device id和nonce,这些是日后重连所必须的信息。至于传统蓝牙,对于我们的使用场景(仅发送TIME和GEO消息)没有什么重要性,可以不必保留传统蓝牙地址,也不用检查传统蓝牙的配对是否存续。
BLE重连
相机的BLE逻辑比较奇怪。开机后会开始广播BLE,此时可以握手重连。但当相机熄屏时,BLE连接会断开,但很快相机会在熄屏状态重新广播BLE。此时握手重连大约要花费22到30秒,而平时只需要几秒钟即可。熄屏状态下重连也可以下发时间和位置信息,并且半按快门唤醒相机后,BLE连接会保持。但唤醒后再次进入熄屏状态,BLE又会断开。
理论上BLE可以同时连接多个,例如ESP32使用的ESP-IDF在默认情况下允许同时连接3个设备,经过我的测试,由于我只有一台Z502和一台Z8,因此只能确认ESP32是可以同步连两台的,两台可以分别下发时间和位置,有效解决了SnapBridge一次只能连一台相机的问题。
同步时间
在下发位置数据之前,我们首先需要同步相机的时间,否则GEO消息中的时间和相机的时间差距太大时,GEO消息会被相机拒绝(表现为写入失败)。
要同步时间,我们需要向TIME写入一个10字节的消息:
| Offset | 长度(字节) | 参数 | 描述 |
|---|---|---|---|
| 0 | 2 | year | 无符号数值,小端序,年份,例如2026 |
| 2 | 1 | month | 无符号数值,月份,1到12月 |
| 3 | 1 | day | 无符号数值,日期,1到31日 |
| 4 | 1 | hour | 无符号数值,小时,0到23时 |
| 5 | 1 | minute | 无符号数值,分钟,0到59分 |
| 6 | 1 | second | 无符号数值,秒,0到59秒 |
| 7 | 1 | dst offset | 尚不明确,应该是夏令时。推测是有符号数值,例如+1表示启用夏令时,调快1小时。为0则禁用。或者是bool,1或非0数值表示启用,0表示禁用 |
| 8 | 1 | tz offset hours | 有符号数值,时区的小时偏移量。例如+8表示东八区,-8表示西八区 |
| 9 | 1 | tz offset minutes | 尚不明确,推测是有符号数值,时区的分钟偏移量 |
这里面的时间都是UTC时间,而后面三位则决定了相机的时区。
同步位置
要同步位置,我们需要向GEO写入一个41字节的消息:
| Offset | 长度(字节) | 参数 | 描述 |
|---|---|---|---|
| 0 | 2 | header | 小端序,固定数值,0x007F |
| 2 | 1 | lat direction | 纬度方向,N (0x4E)或 S(0x53) |
| 3 | 1 | lat degrees | 无符号数值,纬度的度数,0到90度 |
| 4 | 1 | lat minutes | 无符号数值,纬度的分的整数部分,0到59分 |
| 5 | 1 | lat submin1 | 无符号数值,纬度的分的百分位部分,0到99。例如52.1234分,这里是12 |
| 6 | 1 | lat submin2 | 无符号数值,纬度的分的百分位余数,0到99。例如52.1234分,这里是34 |
| 7 | 1 | lon direction | 经度方向,E (0x45)或 W(0x57) |
| 8 | 1 | lon degrees | 无符号数值,经度的度数,0到180度 |
| 9 | 1 | lon minutes | 无符号数值,经度的分的整数部分,0到59分。例如52.1234分,这里是52 |
| 10 | 1 | lon submin1 | 无符号数值,经度的分的百分位部分,0到99。例如52.1234分,这里是12 |
| 11 | 1 | lon submin2 | 无符号数值,经度的分的百分位余数,0到99。例如52.1234分,这里是34 |
| 12 | 1 | satellites | 无符号数值,用于定位的卫星数量 |
| 13 | 1 | alt direction | 海拔方向,0或正海拔高度用P (0x50),负海拔用 M(0x4D) |
| 14 | 2 | alt | 无符号数值,小端序,海拔的绝对值 |
| 16 | 2 | year | 无符号数值,小端序,GPS时间的年份,例如2026 |
| 18 | 1 | month | 无符号数值,GPS时间的月份,1到12 |
| 19 | 1 | day | 无符号数值,GPS时间的日期,1到31 |
| 20 | 1 | hour | 无符号数值,GPS时间的小时,0到23 |
| 21 | 1 | minute | 无符号数值,GPS时间的分钟,0到59 |
| 22 | 1 | second | 无符号数值,GPS时间的秒,0到59 |
| 23 | 1 | subseconds | 无符号数值,GPS时间的厘秒,0到99 |
| 24 | 1 | valid | GPS是否有效,valid fix用0x01,无效则用0x00 |
| 25 | 6 | standard | 字符串WGS-84 |
| 31 | 10 | padding | 全部填充为0 |
在实际操作中,GPS的时间一般用系统的UTC时间,设置厘秒为0。
特别需要注意的是:当设备丢失GNSS fix之后,只能发送一个valid为0x00的数据,不能连续写入valid为0的消息,否则相机会拒绝写入。当重新获得有效的GNSS fix之后,可以随意发送valid为0x01的数据,相机不会拒绝。当valid为0的时候会告诉相机丢弃已保存的位置数据,这样拍出来的照片就不会有位置信息。
在更新频率上,只要消息有效,且相机跟得上,可以很频繁地更新。目前测试过相机可以支持每秒更新一次时间和位置,但这样太费电了。通常15秒或30秒更新一次就很及时了。
后记
以上就是SnapBridge的蓝牙协议介绍了。目前我已经完成了基于M5Stack Core2的原型验证,设计了配对和重连模式的UI与按钮交互逻辑。但最近发现这一套有点不太便携,并且续航不佳。因此重新购买了ESP32 DevKitC和GNSS模块,准备只用ESP32和GNSS,舍弃掉多余的按钮、屏幕什么的,尽量缩小体积、增大续航。下一篇文章准备先更新Core2的部分,等我把轻量化研究明白了还可以再水一篇。
-全文完-

【歪门邪道】用ESP32替代尼康SnapBridge - 协议篇 由 天空 Blond 采用 知识共享 署名 - 非商业性使用 - 相同方式共享 4.0 国际 许可协议进行许可。
本许可协议授权之外的使用权限可以从 https://skyblond.info/about.html 处获得。