Raspberry Pi Pico Wで電波時計を合わせる (JJY標準電波エミュレータ)

2023年5月21日日曜日

Raspberry Pi Pico

t f B! P L

Raspberry Pi Pico Wのアプリケーションとして 最少の周辺部品で電波時計むけJJYエミュレータ(時刻合わせ用)を製作しました。

※2023年6月6日: ソースコード修正の内容を反映させました。

時刻合わせ風景

概要

電波時計は電波が届くところで使用するには手間いらずで非常に便利なのですが、電波が届かないところで使用する場合、特に時刻を手動で合わせることが出来ないタイプの場合は不都合が生じます。(上記写真の時計がまさにそうでした。)そこで、電波時計が時刻合わせに利用しているJJY標準電波を生成するエミュレータを検討してみました。標準電波(JJY)の公開仕様によれば、搬送波は40 KHz(東日本)または60 KHz(西日本)の2種類ありますが、0%, 100%の変調を1 Hzにて3種類のデューティで1分単位のパターンを送出する形式の非常にシンプルなものとなっています。周辺回路を最少構成とするためには搬送波の生成部がポイントですが、ある程度の周波数精度が要求されるこのような機能はRaspbery Pi PicoのPIOで実装するのがうってつけです。時刻情報自体はNTPから取得するのがスタンドアローン型としてはシンプルなため、無線LANモジュールが搭載されたRaspberry Pi Pico Wを用いて構成することとしました。使用用途として時刻合わせするためだけの一時的な使用を想定した場合、専用のフェライト・コア・ロッド・アンテナなどを使用せずに、汎用のイヤホンを近くに置くだけで電波時計側が充分に受信することを確認しました。

機能および開発環境

機能概要は以下の通りです。

  • 無線LAN経由のNTPにて現在時刻を取得してJSTに変換
  • 疑似JJY信号を生成
  • 通常時タイムコードと呼び出し符号送出時タイムコード (毎時15分と45分)を生成
  • うるう秒挿入、うるう秒情報 (LS1, LS2)、予備ビット (SU1, SU2)、停波予告ビット (ST1 ~ ST6)は未サポート
  • 搬送波は40 KHz または 60 KHz のどちらかを設定可能でPIOステートマシンにより高精度に生成
  • 変調は1Hzと遅く簡易使用用途としてはそこまでの精度が必要ないと考えられるため、ソフトウェアによる制御を行う

開発環境の概要は以下の通りです。

  • Raspberry Pi Pico Wを使用
  • 開発環境としてはMicroPython (Thonny IDE) を使用
  • 搬送波はRaspberry Pi PicoのPIOを活用して生成。アセンブリをMicroPythonプログラム内に記述

搬送波生成部分以外は時間的には数msecオーダーの精度があれば充分そうであること、PIOアセンブリも記述可能であることから、今回はMicroPythonを使用してみました。

ビルド方法

Windows版Thonnyを利用する場合の手順は以下の通りです。

  • Windows版ThonnyをPCにインストール
  • MicroPython (Raspberry Pi Pico) interpreter firmwareをRaspberry Pi Pico Wに書き込み
  • secrets.pyというファイルを作成してRaspberry Pi Pico Wのストレージに配置
  • https://github.com/elehobica/pico_jjy_txからMicroPythonソースコード (pico_jjy_tx.py) をダウンロード
  • pico_jjy_tx.pyをThonnyから実行
  • PCに接続せずにスタンドアローンとして動作させる場合は、pico_jjy_tx.pyをmain.pyにリネームしてRaspberry Pi Pico Wのストレージに配置

使用方法

電波時計の近くにRaspberry Pi Pico Wを(一時的に)配置できる場合は、以下に示す配線でRaspberry Pi Pico Wのピンに直接有線イヤホンを接続してみてください。イヤホンにダメージを与える可能性がありますので、必ず壊れても良いイヤホンで試してください。40 KHz および 60 KHzは可聴周波数外なので変調オンオフのクリック音以外は基本的には何も聞こえないはずです。

接続図

変調の状態はRaspberry Pi Pico WのオンボードLEDでも確認可能です。また、周波数を10 KHz程度の可聴周波数域に設定すれば耳で確認することも可能です。(電波時計に時刻合わせする場合は必ず40 KHzまたは60 KHzに戻してください。)視覚聴覚的にも確認できる程度の非常にゆっくりとした変調なので教材としても良いかもしれません。慣れると59秒から0秒に該当する短いパルスが2回連続するポイントが判別できるようになると思います。参考までに0秒付近の変調の様子と拡大波形を記載しておきます。

ロジアナ波形1
ロジアナ波形2

コード説明

では、pico_jjy_txプロジェクトのコードを説明していきます。

NTP

WiFi接続された状態でのNTPからの時刻取得はntptime.settime()のみで出来ますのであとはJSTオフセットを加えてローカルタイムを得るだけです。ただし時々ETIMEDOUT例外が発生するので例外処理を加えてリブートするようにしています。しかしこのETIMEDOUTが一度発生するとリセットしても何度も繰り返し発生するようで、一度Raspberry Pi Pico WのUSBケーブルを外して再接続するのが良いようです。


  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))

時刻処理

MicroPythonのtime.localtime()からは時刻情報が8要素のタプルとして返されますが、非常に扱いづらいのでメンバ参照と表示専用のクラスを準備しました。


  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}'

タイムコード列生成

タイムコード列の生成と送出を分離して1分単位のタイムコード列の生成を事前に行います。このようにすることで開始冒頭部分で毎分0秒を待たずに途中から容易に送出できるようにしました。


    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

タイムコード送出

timeモジュールの時刻を参照して各秒の切り替わりタイミングに合わせてからtime.sleep()によって所定のパルス幅を生成するようにしました。このようにすることで各秒の切り替わりタイミングやtime.sleep()にそれぞれ若干の誤差があったとしてもトータルでの誤差蓄積を防ぐことができます。


    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)

以下、秒の切り替わりタイミング合わせ部分です。


  def alignSecondEdge(self):
    t = self.now()
    while t.second == self.now().second:
      time.sleep_ms(1)

搬送波生成PIOアセンブリ

PIOアセンブリにて搬送波生成とON/OFF制御を行います。label()wrap_target()wrap()はラベルや繰り返し範囲を指定するためのものなので、それらを除いた部分が実際の命令でステップ数は3です。sideset指定を有効としているため、各ステップごとに2つのピンの出力状態を同時にコントロールしています。2つのピンを差動出力として使用しており、01がプラス(+)出力、10がマイナス(-)出力、00がゼロ出力に対応しています。(11は使用しません。)差動出力とすることで100%変調の出力中も振幅の中心値を0Vとすることができ、0%変調時とのDCオフセットの違いをなくすことが出来ます。40 KHz出力の場合PIOの動作周波数は倍の80 KHzに設定されており、コントロールピンがHighの場合、最初の2ステップを繰り返すことで、40KHzの信号が生成されます。


@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()

PIOの初期設定とPIOアセンブリのロードを行うにはrp2.StateMachine()をコールします。アセンブリプログラムの指定のほか、PIOの動作周波数、jmp命令で対象となるピン指定(jmp_pin)および .side()で対象となるピン指定(sideset_base)を行います。また、プログラムが'entryPoint'からスタートするようにPIOステートマシンを停止させた状態で初期設定を行います。(スタートアドレス31はラベルアドレスの取得方法がMicroPythonで不明だったため即値で指定しています。)sm.active(True)にてPIOステートマシンが動作開始します。


    # 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)

なお、変調 ON/OFFに使用するPIN_CTRLピンですが、GPIOとしては出力設定にしたものをPIO側ではjmp_pinの入力として使用します。GPIO端子は出力モードでも端子の状態が入力にループバックされている構造のため、このようにすることで上位のプログラムからGPIOに書き込みした値を、PIO側で入力値として受け取り制御に反映することが出来ます。またこのPIN_CTRLピン信号は外部回路で搬送波周波数を別途生成する場合の制御ピンとしても使用可能です。


搬送波ジッター抑制のシステム動作周波数

PIOの動作周波数はシステム動作周波数よりFractional Dividerを経て生成されます。つまり、PIOの動作周波数がシステム動作周波数の整数倍でない場合でもトータルの時間で見た場合に指定の周波数に合わせこむように動作しますが、厳密にはジッターが含まれます。デフォルトのシステム動作周波数 125 MHzの場合は、40 x 2 = 80 KHz, 60 x 2 = 120 KHzどちらも整数倍ではありませんので生成された搬送波は125 MHz 1周期分である 8 nsのジッターを含むことになります。(それでも現実的にはほぼ問題はないはずですが。)システム動作周波数を96 MHzに設定すれば、どちらも整数倍になりますのでFractional Dividerによるジッターは避けられます。


  machine.freq(96000000)  # recommend multiplier of 40000*2 and 60000*2 to avoid jitter

なお周波数を96 MHzにすることで当然のことながら処理パフォーマンスが低下しますので、パフォーマンス影響を排除しながらPIOのFractional Divider入力周波数を96 MHzに設定する方法としてはC++での対応方法にはなりますが、別記事:ジッターの少ないBCK信号、LRCK信号の生成をご参照ください。

最後に

本プロジェクトでは微弱ながら電波を送出することになりますので、お住いの地域の電波関連規制にくれぐれもご注意いただき、その範囲内にて運用されるようご留意ください。本記事の内容は法令の範囲内の運用を何ら保証するものではなく、あくまで各自のご責任において遵守に対するご留意をお願いいたします。ご利用ご参考された結果としていかなる損害、被害が生じたとしても当方では一切の責任を負いません。 詳細は免責事項をご覧ください。


自己紹介

自分の写真
電子工作&プログラミング、オーディオ・音楽

注目の投稿

Raspberry Pi Pico Wで電波時計を合わせる (JJY標準電波エミュレータ)

Raspberry Pi Pico Wのアプリケーションとして 最少の周辺部品で電波時計むけJJYエミュレータ(時刻合わせ用)を製作しました。 ※2023年6月6日: ソースコード修正の内容を反映させました。 時刻合わせ風景 概要 電波時計は電波が届くところで使...

QooQ