MENU

【歪门邪道】用ESP32替代尼康SnapBridge - 协议篇

August 1, 2026 • 瞎折腾

天下苦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编写)的代码可以在以下地方找到:

这两个代码都不算完整,但全都实现了完整的握手和下发时间以及位置数据,位置数据是随机生成的。

下面来说说我测试过、能跑通的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长度(字节)参数描述
01stage握手阶段标示,值为0x010x020x030x04之一
18timestamp小端序,虽然名字叫时间戳,但不一定非得是时间戳,随机生成就行
94device id小端序,配对时需要随机生成(需要确保Least Significant Byte为0x01),重连时需要使用保存的值
134nonce小端序,配对时需要随机生成,重连时需要使用保存的值

第一阶段:我们需要随机生成一个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

开源许可

以下代码复制自 https://github.com/hurui200320/nsg ,该仓库使用AGPL v3授权。请读者在复制粘贴代码时记得遵守该许可证。

好在尼康当了一回人,使用了标准的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_hi0x05060708

将前述数据进行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长度(字节)参数描述
02year无符号数值,小端序,年份,例如2026
21month无符号数值,月份,1到12月
31day无符号数值,日期,1到31日
41hour无符号数值,小时,0到23时
51minute无符号数值,分钟,0到59分
61second无符号数值,秒,0到59秒
71dst offset尚不明确,应该是夏令时。推测是有符号数值,例如+1表示启用夏令时,调快1小时。为0则禁用。或者是bool,1或非0数值表示启用,0表示禁用
81tz offset hours有符号数值,时区的小时偏移量。例如+8表示东八区,-8表示西八区
91tz offset minutes尚不明确,推测是有符号数值,时区的分钟偏移量

这里面的时间都是UTC时间,而后面三位则决定了相机的时区。

同步位置

要同步位置,我们需要向GEO写入一个41字节的消息:

Offset长度(字节)参数描述
02header小端序,固定数值,0x007F
21lat direction纬度方向,N0x4E)或 S0x53
31lat degrees无符号数值,纬度的度数,0到90度
41lat minutes无符号数值,纬度的分的整数部分,0到59分
51lat submin1无符号数值,纬度的分的百分位部分,0到99。例如52.1234分,这里是12
61lat submin2无符号数值,纬度的分的百分位余数,0到99。例如52.1234分,这里是34
71lon direction经度方向,E0x45)或 W0x57
81lon degrees无符号数值,经度的度数,0到180度
91lon minutes无符号数值,经度的分的整数部分,0到59分。例如52.1234分,这里是52
101lon submin1无符号数值,经度的分的百分位部分,0到99。例如52.1234分,这里是12
111lon submin2无符号数值,经度的分的百分位余数,0到99。例如52.1234分,这里是34
121satellites无符号数值,用于定位的卫星数量
131alt direction海拔方向,0或正海拔高度用P0x50),负海拔用 M0x4D
142alt无符号数值,小端序,海拔的绝对值
162year无符号数值,小端序,GPS时间的年份,例如2026
181month无符号数值,GPS时间的月份,1到12
191day无符号数值,GPS时间的日期,1到31
201hour无符号数值,GPS时间的小时,0到23
211minute无符号数值,GPS时间的分钟,0到59
221second无符号数值,GPS时间的秒,0到59
231subseconds无符号数值,GPS时间的厘秒,0到99
241validGPS是否有效,valid fix用0x01,无效则用0x00
256standard字符串WGS-84
3110padding全部填充为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 处获得。

Archives QR Code
QR Code for this page
Tipping QR Code