As a Raspberry Pi Pico W application, I built a JJY emulator for radio-controlled clocks (for time synchronization) with minimal peripheral components.
*June 6, 2023: Reflected source code modification content.
![]() |
| Time Synchronization Scene |
Overview
Radio-controlled clocks are extremely convenient and hassle-free when used where the signal reaches, but problems arise when used where the signal doesn't reach, especially for types that cannot be manually set. (The clock in the photo above was exactly such a case.) Therefore, I explored building an emulator that generates the JJY standard radio waves used by radio-controlled clocks for time synchronization. published specifications of the standard radio wave (JJY), there are two types of carrier frequencies: 40 KHz (East Japan) and 60 KHz (West Japan), but it is a very simple format that transmits patterns in one-minute units with 0%/100% modulation at 1 Hz using three types of duty cycles. The key to minimizing peripheral circuitry is the carrier generation part, and the PIO of the Raspberry Pi Pico is ideal for implementing such functionality that requires a certain degree of frequency accuracy. Since obtaining time information from NTP is the simplest approach for a standalone unit, Raspberry Pi Pico W with its built-in wireless LAN module was chosen. Assuming temporary use solely for time synchronization, it was confirmed that the radio-controlled clock can sufficiently receive the signal by simply placing generic earphones nearby, without using dedicated ferrite core rod antennas.
Features and Development Environment
The feature overview is as follows.
- Obtain current time via NTP over wireless LAN and convert to JST
- Generate pseudo JJY signal
- Generate normal timecode and call sign transmission timecode (at 15 and 45 minutes past each hour)
- Leap second insertion, leap second information (LS1, LS2), spare bits (SU1, SU2), and transmission suspension notice bits (ST1 ~ ST6) are not supported
- Carrier wave can be set to either 40 KHz or 60 KHz, generated with high precision by the PIO state machine
- Since modulation is slow at 1Hz and such precision is considered unnecessary for simple use cases, it is controlled by software
The development environment overview is as follows.
- Uses Raspberry Pi Pico W
- Uses MicroPython (Thonny IDE) as the development environment
- Carrier wave is generated using Raspberry Pi Pico's PIO. Assembly is written within the MicroPython program
Since precision on the order of a few milliseconds seems sufficient for parts other than carrier generation, and PIO assembly can also be written, MicroPython was used this time.
Build Method
The procedure when using the Windows version of Thonny is as follows.
- Install the Windows version of Thonny on PC
- Write MicroPython (Raspberry Pi Pico) interpreter firmware to Raspberry Pi Pico W
- Create a file called secrets.py and place it in Raspberry Pi Pico W's storage
- https://github.com/elehobica/pico_jjy_tx and download the MicroPython source code (pico_jjy_tx.py)
- Run pico_jjy_tx.py from Thonny
- To operate standalone without PC connection, rename pico_jjy_tx.py to main.py and place it in Raspberry Pi Pico W's storage
Usage
If you can (temporarily) place the Raspberry Pi Pico W near the radio-controlled clock, try connecting wired earphones directly to the Raspberry Pi Pico W pins using the wiring shown below. Since there is a possibility of damaging the earphones, please use earphones you don't mind breaking. Since 40 KHz and 60 KHz are outside the audible frequency range, you should basically hear nothing except click sounds from modulation on/off.
![]() |
| Connection Diagram |
The modulation state can also be confirmed with the Raspberry Pi Pico W's onboard LED. Additionally, if the frequency is set to an audible range of about 10 KHz, it can also be confirmed by ear. (Be sure to set it back to 40 KHz or 60 KHz when synchronizing a radio-controlled clock.) Since the modulation is very slow, visually and audibly confirmable, it may also serve as good educational material. With practice, you should be able to identify the point where two short pulses corresponding to seconds 59 and 0 occur consecutively. For reference, the modulation pattern near second 0 and enlarged waveforms are shown below.
![]() |
| Logic Analyzer Waveform 1 |
![]() |
| Logic Analyzer Waveform 2 |
Code Description
Now, let's explain the code of the pico_jjy_tx project.
NTP
Obtaining time from NTP while connected to WiFi can be done with just ntptime.settime(), and then simply adding the JST offset to obtain local time. However, since ETIMEDOUT exceptions occasionally occur, exception handling was added to reboot. Once this ETIMEDOUT occurs, it seems to repeatedly occur even after reset, so disconnecting and reconnecting the Raspberry Pi Pico W's USB cable appears to be the best solution.
def __setNtpTime(self, offsetHour: int) -> TimeTuple:
time.sleep(1)
try:
ntptime.settime()
except OSError as e:
if e.args[0] == 110:
# reset when OSError: [Errno 110] ETIMEDOUT
print(e)
time.sleep(5)
machine.reset()
return self.TimeTuple(utime.localtime(utime.mktime(utime.localtime()) + offsetHour*3600))
Time Processing
MicroPython's time.localtime() returns time information as an 8-element tuple, which is very cumbersome to handle, so a class for member access and display was prepared.
class TimeTuple:
def __init__(self, timeTuple: tuple):
self.year, self.month, self.mday, self.hour, self.minute, self.second, self.weekday, self.yearday = timeTuple
def __str__(self):
wday = ('Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun')[self.weekday]
return f'{self.year:04d}/{self.month:02d}/{self.mday:02d} {wday} {self.hour:02d}:{self.minute:02d}:{self.second:02d}'
Timecode Sequence Generation
The timecode sequence generation and transmission are separated, with the one-minute timecode sequence generated in advance. This allows easy transmission from mid-sequence at the start without waiting for second 0 of each minute.
def genTimecode(t: LocalTime.TimeTuple) -> list:
vector = []
# 0 ~ 9
vector += marker(name='M') + bcd(t.minute // 10, 3) + bin(0) + bcd(t.minute) + marker(name='P1')
# 10 ~ 19
vector += bin(0, 2) + bcd(t.hour // 10, 2) + bin(0) + bcd(t.hour) + marker(name='P2')
# 20 ~ 29
vector += bin(0, 2) + bcd(t.yearday // 100, 2) + bin(0) + bcd(t.yearday // 10) + marker(name='P3')
# Parity
pa1 = sum(vector[12:14] + vector[15:19])
pa2 = sum(vector[1:4] + vector[5:9])
if not (t.minute == 15 or t.minute == 45):
# 30 ~ 39
vector += bcd(t.yearday) + bin(0, 2) + bin(pa1) + bin(pa2) + bin(0, name='SU1') + marker(name='P4')
# 40 ~ 49
vector += bin(0, name='SU2') + bcd(t.year // 10) + bcd(t.year) + marker(name='P5')
# 50 ~ 59
vector += bcd((t.weekday + 1) % 7, 3) + bin(0, name='LS1') + bin(0, name='LS2') + bin(0, 4) + marker(name='P0')
else:
# 30 ~ 39
vector += bcd(t.yearday) + bin(0, 2) + bin(pa1) + bin(pa2) + bin(0) + marker(name='P4')
# 40 ~ 49
vector += bin(0, 9, name='Call') + marker(name='P5')
# 50 ~ 59
vector += bin(0, 6, name='ST1-ST6') + bin(0, 3) + marker(name='P0')
return vector
Timecode Transmission
By referencing the time module's clock and aligning with the transition timing of each second, time.sleep() generates the prescribed pulse width. This way, even if there are slight errors in each second's transition timing or time.sleep(), total error accumulation can be prevented.
def sendTimecode(vector: list) -> None:
for value in vector:
self.lcTime.alignSecondEdge()
self.__control(True)
if value == 0: # bit 0
pulseWidth = 0.8
elif value == 1: # bit 1
pulseWidth = 0.5
else: # marker
pulseWidth = 0.2
time.sleep(pulseWidth)
self.__control(False)
Below is the second transition timing alignment part.
def alignSecondEdge(self):
t = self.now()
while t.second == self.now().second:
time.sleep_ms(1)
Carrier Wave Generation PIO Assembly
The PIO assembly handles carrier wave generation and ON/OFF control. label() and wrap_target() and wrap() are for specifying labels and loop ranges, so the actual instructions excluding those have 3 steps. sideset specification is enabled, controlling the output state of two pins simultaneously at each step. The two pins are used as differential outputs, where 01 corresponds to positive (+) output, 10 to negative (-) output, and 00 to zero output. (11 is not used.) By using differential output, the center amplitude can be maintained at 0V even during 100% modulation output, eliminating the DC offset difference from 0% modulation. For 40 KHz output, the PIO operating frequency is set to double at 80 KHz, and when the control pin is High, the 40 KHz signal is generated by repeating the first 2 steps.
@rp2.asm_pio(sideset_init = (rp2.PIO.OUT_LOW, rp2.PIO.OUT_LOW))
def oscillatorPioAsm():
P = 0b01 # drive +
N = 0b10 # drive -
Z = 0b00 # drive zero
# # addr
label('loop')
nop() .side(P) # 29
jmp(pin, 'loop').side(N) # 30
wrap_target()
label('entryPoint')
jmp(pin, 'loop').side(Z) # 31
wrap()
To perform PIO initialization and load the PIO assembly, rp2.StateMachine() is called. In addition to specifying the assembly program, the PIO operating frequency, jmp target pin specification (jmp_pin) and .side() target pin specification (sideset_base) are configured. Additionally, initial configuration is performed with the PIO state machine stopped so that the program starts from 'entryPoint'. (Start address 31 is specified as an immediate value because the method to obtain label addresses was unknown in MicroPython.) sm.active(True) starts the PIO state machine operation.
# start PIO
sm = rp2.StateMachine(0, self.pioAsm, freq = self.freq*2, jmp_pin = self.ctrlPins[0], sideset_base = self.modOutPinBase)
sm.active(False)
entryPoint = 31
sm.exec(f'set(y, {entryPoint})')
sm.exec('mov(pc, y)')
sm.active(True)
Note that the PIN_CTRL pin used for modulation ON/OFF is set as output for GPIO but used as jmp_pin input on the PIO side. Since GPIO terminals have a structure where the terminal state is looped back to input even in output mode, values written to GPIO from the upper-level program can be received as input values on the PIO side and reflected in control. This PIN_CTRL pin signal can also be used as a control pin when generating the carrier frequency separately with an external circuit.
System Operating Frequency for Carrier Jitter Suppression
The PIO operating frequency is generated from the system operating frequency through a Fractional Divider. This means that even if the PIO operating frequency is not an integer multiple of the system operating frequency, it will adjust to the specified frequency when viewed over total time, but strictly speaking, jitter is included. With the default system operating frequency of 125 MHz, neither 40 x 2 = 80 KHz nor 60 x 2 = 120 KHz is an integer multiple, so the generated carrier wave will contain jitter of 8 ns, which is one period of 125 MHz. (In practice, this should be virtually no problem.) Setting the system operating frequency to 96 MHz makes both integer multiples, avoiding jitter from the Fractional Divider.
machine.freq(96000000) # recommend multiplier of 40000*2 and 60000*2 to avoid jitter
Note that setting the frequency to 96 MHz naturally degrades processing performance. As a method to set the PIO Fractional Divider input frequency to 96 MHz while eliminating performance impact, a C++ approach is available in a separate article: Generating BCK and LRCK Signals with Less Jitter.
Final Notes
This project involves transmitting weak radio waves, so please pay careful attention to radio-related regulations in your region and ensure operation within those limits. The contents of this article do not guarantee operation within legal bounds in any way, and compliance is solely the responsibility of each individual. I assume no responsibility whatsoever for any damage or harm resulting from use or reference to this content. For details, please see the Disclaimer.





No comments:
Post a Comment