0%

记与一款 JJY 电波钟模块的邂逅

某日,博主在逛淘宝时,发现了一款电波钟模块。

JJY 电波钟模块

博主第一次听说长波授时的概念还是在上初中的时候,出于兴趣,当年也下单了过一个 CASIO 出口转内销的电波授时闹钟。如今距离买下这款闹钟已有快七年时间了,虽然当年买的电波钟外壳已经泛黄,但是时间仍然分毫不差。如今刷到这么一个模块,又唤起了博主的回忆。

在这七年间,博主也曾经尝试弄清楚电波授时的解码原理以及数据报文格式,但是迫于当年的知识面与技术并不到位,所以在几次尝试与几次失败后,博主便放弃了。

而如今博主作为一个通信工程大学牲,想要再次挑战弄懂 JJY 长波授时的时间码结构,并尝试编写程序解码准确时间,于是果断下单了这款模块。

在收到模块后,经过两天的不懈努力,博主也终于成功达成了目标,了结了七年来的心事。这篇文章,会分享博主玩转 JJY 时间码模块的过程。

模块引脚与电平逻辑

几天后模块到手了,由于模块是拆机模块,成色老旧,为了避免断线和焊盘脱落,博主先用热溶胶加固了比较脆弱的位置,同时给引脚位置加装了 2.54mm 排针。

这款模块用了的经典的牛屎封装,经过一番 Google,得知真正的核心貌似用的是 MAS6180B。另外,这款模块的原理,实际上就是透过 MAS6180B 将长波授时信号进行放大与解调,并透过内部的比较器将其二进制输出。

这个模块有 5 个引脚,经过博主一番尝试和整理,其功能如下。

引脚名字 引脚功能 逻辑电平 默认状态
60K1 60 kHz 使能 高电平使能 低电平
PON1 模块使能 低电平使能 高电平
3V1 3.3V 电源输入 - -
TCO1 信号脉冲 - -
GND1 电源接地 - -

在整理引脚的过程中,博主也发现不同 JJY 模块间的引脚排布、引脚逻辑电平、引脚功能都不尽相同,所以在使用模块前,一定要先确认好模块的引脚功能和逻辑电平,以免翻车损坏模块。

博主的 JJY 模块
`

模块测试

为了简化开发流程,博主使用 Arduino UNO 来测试模块的功能。但是在测试模块的过程中,博主发现模块的 TCO1 输出的脉冲电压只有 1V 左右,无法直接连接到 Arduino 的数字输入引脚。

由于使用的是 Arduino 单片机,而且博主也不想额外搭建电平转换电路,所以博主决定将模块的 TCO1 输出接到 Arduino 的模拟输入引脚上,然后透过 Arduino 的 analogRead() 函数来读取 TCO1 的电压值,进而分析出其电平高低。

JJY 时间码结构

在编写程序前,我们需要先了解一下 JJY 时间码的结构。NICT 在官网给出的时间码结构如下图所示。

NICT 时间结构

JJY 的脉冲宽度总共分为 3 种,分别是 200ms、500ms、800ms,其中 200ms 的脉冲代表标记符,500ms 的脉冲代表逻辑 1,800ms 的脉冲代表逻辑 0

在 NICT 给出的结构图中,可以看出,JJY 一帧完整的时间数据总共有 60 位,每一位对应到一个固定宽度的脉冲。透过上图,可以将时间主要位的索引整理如下表。

时间位名称 位数索引
分钟 [1:8]
小时 [12:18]
天数 [22:33]
年份 [41:48]
星期 [50:52]

此外,JJY 对时间的编码形式是 BCD 码的一种变体,但是计算方式和 BCD 码相同。例如,若在分钟位接收到的数据是 00110010,结合上图中所给的位权重,那么将该数据换算成 10 进制,即分钟数为 12,其算式如下。

1
0*40 + 0*20 + 1*10 + 1*0 + 0*8 + 0*4 + 1*2 + 0*1 = 12

根据上图,还可以得出的一个结论是,当连续收到两次标记符信号时,就可以确认当前的时间是第 0 秒,也就是某一分钟的起始。

另外,JJY 的时间码还带有闰秒信息,当闰秒发生时,JJY 会在 59 秒的位置插入一个 800ms 的脉冲,用来表示闰秒。但是本文的目标只是实现对 JJY 的简单解码,所以不会对闰秒进行处理,也不会对奇偶校验码做处理。

硬件搭建

硬件连线图如下所示。

硬件连线图

引脚的连线方式如下表所示。

模块引脚 单片机引脚 备注
JJY 模块 GND1 GND
JJY 模块 TCO1 A0 需要接入 Arduino 模拟引脚
JJY 模块 3V1 3.3V 须 3.3V 供电避免烧坏模块
JJY 模块 PON1 11
JJY 模块 60K1 10 低电平 40 kHz,高电平 60 kHz

由于博主的 Arduino UNO 临时没有找到,所以博主使用的是引脚功能是一样的,所以不会影响程序的编写。

下面是博主的硬件搭建图。

博主的硬件搭建图

程序编写

Github 仓库:github.com/bclswl0827/JJY-Clock

博主已经写好了程序,也开源到了 GitHub 上,经过测试可以正常解码。下文介绍一些关键点与代码。

另外,博主使用的 IDE 是 PlatformIO,编译固件时需要注意。

重写 digitalRead() 函数

由于 JJY 模块输出的电平高低与 Arduino 的电平标准不同,所以可以使用 analogRead 来读取引脚模拟值,并根据人为的设定电平阈值来判断逻辑 1 或 0,因此,只需要对 digitalRead() 函数进行重写。

1
2
3
int digitalRead(int pin) {
return analogRead(pin) > 100 ? LOW : HIGH;
}

声明每一位权重

前面提到过,JJY 的时间码是 BCD 码的一种变体,所以需要声明位权重,用来计算时间码的十进制值。

这里透过枚举类型来声明位权重,如下所示。

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
// 分钟位权重
enum minute {
MINUTE_0 = 40,
MINUTE_1 = 20,
MINUTE_2 = 10,
MINUTE_3 = 0,
MINUTE_4 = 8,
MINUTE_5 = 4,
MINUTE_6 = 2,
MINUTE_7 = 1,
};

// 小时位权重
enum hour {
HOUR_0 = 20,
HOUR_1 = 10,
HOUR_2 = 0,
HOUR_3 = 8,
HOUR_4 = 4,
HOUR_5 = 2,
HOUR_6 = 1,
};

// 天数位权重
enum day {
DAY_0 = 200,
DAY_1 = 100,
DAY_2 = 0,
DAY_3 = 80,
DAY_4 = 40,
DAY_5 = 20,
DAY_6 = 10,
DAY_7 = 8,
DAY_8 = 4,
DAY_9 = 2,
DAY_10 = 1,
};

// 年份位权重
enum year {
YEAR_0 = 80,
YEAR_1 = 40,
YEAR_2 = 20,
YEAR_3 = 10,
YEAR_4 = 8,
YEAR_5 = 4,
YEAR_6 = 2,
YEAR_7 = 1,
};

// 星期位权重
enum week {
WEEK_0 = 4,
WEEK_1 = 2,
WEEK_2 = 1,
};

天数转月份与日期

由于 JJY 的天数位是从每年的第一天开始计数的,所以需要将天数转换成月份日期。

由于存在闰年,所以在调用函数时,除了传入天数,还需要传入是否为闰年的标记。

下方是天数转月份的代码,代码中,天数统一扩大了 1000 倍,这样可以避免浮点数运算带来的精度丢失问题。

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
uint8_t Days2Month(uint8_t days, uint8_t leap) {
uint8_t rate = days * 1000 / 365;
if (rate <= 31000 / 365) {
return 1;
} else if (rate <= (leap ? 60000 : 59000) / 365) {
return 2;
} else if (rate <= (leap ? 91000 : 90000) / 365) {
return 3;
} else if (rate <= (leap ? 121000 : 120000) / 365) {
return 4;
} else if (rate <= (leap ? 152000 : 151000) / 365) {
return 5;
} else if (rate <= (leap ? 182000 : 181000) / 365) {
return 6;
} else if (rate <= (leap ? 213000 : 212000) / 365) {
return 7;
} else if (rate <= (leap ? 244000 : 243000) / 365) {
return 8;
} else if (rate <= (leap ? 274000 : 273000) / 365) {
return 9;
} else if (rate <= (leap ? 305000 : 304000) / 365) {
return 10;
} else if (rate <= (leap ? 335000 : 334000) / 365) {
return 11;
}
return 12;
}

下方是天数转日期的代码。

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
uint8_t Days2Date(uint8_t days, uint8_t leap) {
switch (Days2Month(days, leap)) {
case 1:
return 31 - (31 - days);
case 2:
return (leap ? 29 : 28) - ((leap ? 60 : 59) - days);
case 3:
return 31 - ((leap ? 91 : 90) - days);
case 4:
return 30 - ((leap ? 121 : 120) - days);
case 5:
return 31 - ((leap ? 152 : 151) - days);
case 6:
return 30 - ((leap ? 182 : 181) - days);
case 7:
return 31 - ((leap ? 213 : 212) - days);
case 8:
return 31 - ((leap ? 244 : 243) - days);
case 9:
return 30 - ((leap ? 274 : 273) - days);
case 10:
return 31 - ((leap ? 305 : 304) - days);
case 11:
return 30 - ((leap ? 335 : 334) - days);
default:
return 31 - ((leap ? 366 : 28) - days);
}
}

取得同步标记

前面也已经提到过,当连续收到两次标记符信号时,就可以确认 JJY 新的一帧数据已经开始。

所以,需要声明一个变量或者结构体,用来记录上一次脉冲的宽度,并与当前脉冲的宽度进行比较,若判断到上一次与这一次的脉冲宽度都是标记符信号,就可以确认 JJY 新的一帧数据已经开始。

下面是声明的用来存储状态的结构体。

1
2
3
4
5
6
7
8
struct JJYStatus {
bool status; // 是否有数据
uint16_t time; // 记录起始时间用于后续计算
uint16_t previous; // 记录上一次起始时间
uint16_t cycle; // 记录方波周期时间
uint16_t pulse; // 记录当前脉冲时间
uint16_t previousPulse; // 记录上次脉冲时间
} JJYStatus;

解码器缓存

解码器缓存也为一个结构体,用来保存解码数据,存储解码器的状态,记录是否已经完成解码,以及一个用于记录数据缓存位置的计数器。

由于实际解码时,由于标记符会直接丢弃,所以缓存大小为 53 个数据。

1
2
3
4
5
6
struct DecoderStatus {
bool status; // 解码器运行状态
bool done; // 解码是否完成
uint8_t counter; // 数据缓存位置计数器
int data[53]; // 除去标记符后的数据缓存
} DecoderStatus;

由于抛弃了标记符,所以上文提到的时间位索引表,也应做相应的调整。

成果

最后附上博主的成果,数据透过串口终端输出,可以看到,数据已经成功解码。

成功解码时间