2020年11月21日土曜日

衛星電波時計 (GPS時計) の自作

原子時計を搭載した測位衛星 (GPS衛星など) からの電波を受信して時刻を補正するタイプの時計を作りました。

特徴

直径20 cm の黒い円形基板を文字盤として使い、針の代わりに外周のLED 132個で時・分・秒を示します。


時刻の設定は完全に衛星任せで、手動設定はできません。
一度衛星からの信号を受信して時刻合わせができれば、その後全く受信できなくなってもマイコンの水晶を基準にクオーツ時計として動作します。

中央下部にある16x2のLCDに年月日・曜日・時刻・気温を表示します。基板の色に合わせて黒地に白文字のAQM1602Y-NLW-FBWを選んでみました。

LCDの上にあるフォトトランジスタで周囲の明るさを見て、LCDのバックライトと各LEDの明るさをPWMで調整します。周囲が暗いと時計の表示も自動で暗くなるので、寝室に置いても目障りになりません。逆に、明るい部屋においても表示が見えづらくなることはありません。

「12」の文字の下のLEDは電波の受信状態を表すパイロットランプです。緑は時刻同期OK、赤は同期できず(点滅周期の関係で、写真には写っていません)。

時計の設置場所が電波の入感状況による制約を受けないよう、受信モジュールは外付けです。秋月電子で販売されているGT-902PMGGを使用しています。

回路上にホコリが積もるのを少しは防げるよう、秋月電子のB基板サイズのアクリル板を取り付けられます。

基板下部の穴に長いスペーサを取り付けると置き時計になり、

アクリル板固定用の穴を活用すると掛け時計にもなります。


回路・実装

回路は上の図のとおり、かなりシンプルです。

マイコンは8 MHz 動作のATmega328P-AUです。プログラムはICSPで書き込む仕様です。
ICSP端子の配列はArduino UNOなどと同じで、下図のようになっています。

黒い基板を接写するとホコリが目立つので唐突にKiCadのCG画像でごまかす)

衛星受信モジュールと非同期のシリアル通信を行う都合上、周波数のバラツキが大きい内蔵CR発振器はまず使えません。GNSSの信号が受信できない間もある程度正確な時計として動作してほしいのでセラミック発振子も避けて、順当に水晶発振子を使っています。

マイコンには、LEDドライバ・温度センサ・LCDがI2Cでつながっています。

分・秒を表示する合計120個のLEDのドライバはHT16K33です。HT16K33が制御できるLEDは最大128個なので、時を表示する12個+受信パイロットランプ2個はマイコンのピンで直接駆動することにしました。

温度センサはADT7410です。ワンショットモードで気温を10秒に1回測定します。
実は、試作基板では気温の測定結果が実際よりも3℃ほど高く出てしまっていました。試作基板でいろいろ実験を重ねて得た知見を元に、本番基板では以下のような対策をほどこしたところ、他の複数の温度計とほぼ同じ値が出るようになりました。

  • センサの実装位置を他の部品から離す
  • センサの周囲に基板のスリットを設ける
  • センサに繋がる配線はできるだけ細くし (0.25 mm) 、ベタGNDからは遠ざける
  • センサICの足を曲げ、基板から浮かせて実装する
  • ACアダプタの電圧を9 V から6 V に変更し、5 V レギュレータの発熱を1/4に抑える (これに伴いレギュレータも7805からNJM2845に変更)
  • クロックを16 MHzから8 MHzに変え、マイコンの発熱を半減させる

自作基板に気温センサを付けたのは初めてで、こんなに配慮が必要なものだとは知らなかったので勉強になりました。手で触れても分からない程度の発熱であっても測定値にかなり効いてきます。

衛星受信モジュールはUART (9600 bps) でマイコンと繋がっています。 マイコンは5 Vで受信モジュールの信号レベルは3 Vなのでレベル変換回路を入れてあります。通信は受信モジュールからマイコンへ一方的にデータを送りつけるだけですから、マイコンから受信モジュールへデータを送るラインは (基板には一応ありますが) 接続していません。

受信モジュールは基板裏側にXHコネクタで接続します。

このためにわざわざ圧着工具 (Engineer PA-09) を買いました。


ソフト
私はマイコンにわか勢なので、いつもArduinoばかり使っています。本作も例に漏れず、ATmega328P-AUをArduino Pro mini として動作させています。
スケッチ(プログラム)は500行程度になりました。衛星受信モジュールから送られてくるデータをエンコードするTinyGPS++ライブラリと、RTCなしでArduinoを時計にできるTimeライブラリのおかげでだいぶ助かりました。

時刻の補正に関する部分は

  1. 毎秒補正する
  2. マイコン内部の時刻と衛星から取得した時刻が2秒以上ずれたときだけ補正する
の2種類を書いて試してみましたが、特にどちらでも問題は無さそうです。
参考までに、1. のスケッチを下に置いておきます。ガバガバコードで恥ずかしい限りですが…
#include <Wire.h>
#include <I2CLiquidCrystal.h>
#include <TinyGPS++.h>
#include <TimeLib.h>

TinyGPSPlus gps;
I2CLiquidCrystal lcd(40, (bool)false);  //true = 5V, false = 3.3V
unsigned long t = 0;
unsigned long t2 = 0;
byte day_old = 0;
byte hourPin;
byte duty = 255;
bool GPSstatus = 0;
bool GPSstatusB4 = 0;
bool tempSet = 0;

const int offset = 9;  //time difference from UTC to local time (hour)

#define HT16K33 (0x70)  //HT16K33 I2C address
#define ADT7410 (0x48)  //ADT7410 I2C address

//write to HT16K33
void LED_out(byte COM, byte ROW = 16)  //COM = 0-7, ROW = 0-15
{
  Wire.beginTransmission(HT16K33);
  Wire.write(COM * 2); //COM0 ROW0-7
  if (ROW < 8) {
    Wire.write(1 << (ROW));
    Wire.write(0);
  }
  else if (ROW < 16) {
    Wire.write(0);
    Wire.write(1 << (ROW - 8));
  }
  else {
    Wire.write(0);
    Wire.write(0);
  }
  Wire.endTransmission();
}

//control minute LEDs (CCW when d = 0) 
void LED_minute(byte m, bool d = 1) {

  if (m < 15) {
    if (m == 0 && d == 1) LED_out(0);
    if (m == 14 && d == 0) LED_out(4);
    LED_out(6, m);
  }
  else if (m < 30) {
    if (m == 15 && d == 1) LED_out(6);
    if (m == 29 && d == 0) LED_out(2);
    LED_out(4, 29 - m);
  }
  else if (m < 45) {
    if (m == 30 && d == 1) LED_out(4);
    if (m == 44 && d == 0) LED_out(0);
    LED_out(2, m - 30);
  }
  else if (m < 60) {
    if (m == 45 && d == 1) LED_out(2);
    if (m == 59 && d == 0) LED_out(6);
    LED_out(0, 59 - m);
  }
}

//control second LEDs (CCW when d = 0) 
void LED_second(byte s, bool d = 1) {
  if (s < 15) {
    if (s == 0 && d == 1) LED_out(1);
    if (s == 14 && d == 0) LED_out(5);
    LED_out(7, s);
  }
  else if (s < 30) {
    if (s == 15 && d == 1) LED_out(7);
    if (s == 29 && d == 0) LED_out(3);
    LED_out(5, 29 - s);
  }
  else if (s < 45) {
    if (s == 30 && d == 1) LED_out(5);
    if (s == 44 && d == 0) LED_out(1);
    LED_out(3, s - 30);
  }
  else if (s < 60) {
    if (s == 45 && d == 1) LED_out(3);
    if (s == 59 && d == 0) LED_out(7);
    LED_out(1, 59 - s);
  }
}

void LED_hour(byte h) {
  h = h % 12;
  if (h == 0 || h == 5 || h == 6 || h == 11) {
    hourPin = 9;
    digitalWrite(10, 0);
    digitalWrite(11, 0);
  }
  else if (h == 1 || h == 4 || h == 7 || h == 10) {
    hourPin = 10;
    digitalWrite(9, 0);
    digitalWrite(11, 0);
  }
  else if (h == 2 || h == 3 || h == 8 || h == 9) {
    hourPin = 11;
    digitalWrite(9, 0);
    digitalWrite(10, 0);
  }
  analogWrite(hourPin, duty);

  if (h < 3) {
    pinMode(2, INPUT);
    pinMode(7, INPUT);
    pinMode(8, INPUT);
    pinMode(13, OUTPUT);
    digitalWrite(13, LOW);
  }
  else if (h < 6) {
    pinMode(13, INPUT);
    pinMode(7, INPUT);
    pinMode(8, INPUT);
    pinMode(2, OUTPUT);
    digitalWrite(2, LOW);
  }
  else if (h < 9) {
    pinMode(2, INPUT);
    pinMode(13, INPUT);
    pinMode(8, INPUT);
    pinMode(7, OUTPUT);
    digitalWrite(7, LOW);
  }
  else if (h < 12) {
    pinMode(2, INPUT);
    pinMode(13, INPUT);
    pinMode(7, INPUT);
    pinMode(8, OUTPUT);
    digitalWrite(8, LOW);
  }
}

void printTime(bool a = 0) {
  unsigned long t = now() + offset * 3600UL;  //UTC to local time
  //re-print only updated part
  if (a == 0) {
    if (second(t) == 0) {
      lcd.setCursor(3, 1);
      if (minute(t) < 10) lcd.print(F("0"));
      lcd.print(minute(t));
      LED_minute(minute(t));

      if (minute(t) == 0) {
        lcd.setCursor(0, 1);
        if (hour(t) < 10) lcd.print(F("0"));
        lcd.print(hour(t));
        LED_hour(hour(t));
      }
    }

    lcd.setCursor(6, 1);
    if (second(t) < 10) lcd.print(F("0"));
    lcd.print(second(t)); 
    LED_second(second(t));

    if (day_old != day(t)) {
      day_old = day(t);
      lcd.setCursor(0, 0);
      lcd.print(year(t));
      lcd.print(F("/"));
      if (month(t) < 10) lcd.print(F("0"));
      lcd.print(month(t));
      lcd.print(F("/"));
      if (day(t) < 10) lcd.print(F("0"));
      lcd.print(day(t));
      lcd.print(F(" ("));
      switch (weekday(t)) {
      case 1: lcd.print(F("Sun")); break;
      case 2: lcd.print(F("Mon")); break;
      case 3: lcd.print(F("Tue")); break;
      case 4: lcd.print(F("Wed")); break;
      case 5: lcd.print(F("Thu")); break;
      case 6: lcd.print(F("Fri")); break;
      case 7: lcd.print(F("Sat")); break;
      default: lcd.print(F("???"));
      }
      lcd.print(F(")"));
    }

  }
  //force re-print
  else {
    lcd.setCursor(14, 1);
    lcd.write(byte(1));
    lcd.print(F("C"));
    lcd.setCursor(0, 1);
    if (hour(t) < 10) lcd.print(F("0"));
    lcd.print(hour(t));
    LED_hour(hour(t));

    lcd.print(F(":"));
    if (minute(t) < 10) lcd.print(F("0"));
    lcd.print(minute(t));
    LED_minute(minute(t));

    lcd.print(F(":"));
    if (second(t) < 10) lcd.print(F("0"));
    lcd.print(second(t));
    LED_second(second(t));

    day_old = day(t);
    lcd.setCursor(0, 0);
    lcd.print(year(t));
    lcd.print(F("/"));
    if (month(t) < 10) lcd.print(F("0"));
    lcd.print(month(t));
    lcd.print(F("/"));
    if (day(t) < 10) lcd.print(F("0"));
    lcd.print(day(t));
    lcd.print(F(" ("));
    switch (weekday(t)) {
    case 1: lcd.print(F("Sun")); break;
    case 2: lcd.print(F("Mon")); break;
    case 3: lcd.print(F("Tue")); break;
    case 4: lcd.print(F("Wed")); break;
    case 5: lcd.print(F("Thu")); break;
    case 6: lcd.print(F("Fri")); break;
    case 7: lcd.print(F("Sat")); break;
    default: lcd.print(F("???"));
    }
    lcd.print(F(")"));
  }
}

void setLuminance(bool a = 0)
{
  unsigned int lumi;
  if (a) {
    lumi = 1023;
  }
  else {
    lumi = analogRead(6);
  }
  if (lumi > 15) {
    duty = lumi / 4;
  }
  else {
    duty = 4;
  }
  analogWrite(3, duty); //LCD backlight illuminance adjustment (0-255)
  analogWrite(hourPin, duty); //hour LED illuminance adjustment (0-255)
  Wire.beginTransmission(HT16K33);
  Wire.write(0b11100000 | lumi / 64); //minute and second LED illuminance adjustment (0-15)
  Wire.endTransmission();
}

//clear all LEDs
void LED_clear()
{
  Wire.beginTransmission(HT16K33);
  Wire.write(0x00);
  for (int i = 0; i < 8; i++) {
    Wire.write(B00000000);
    Wire.write(B00000000);
  }
  Wire.endTransmission();
  pinMode(2, OUTPUT);
  pinMode(7, OUTPUT);
  pinMode(8, OUTPUT);
  pinMode(13, OUTPUT);
  digitalWrite(2, LOW);
  digitalWrite(7, LOW);
  digitalWrite(8, LOW);
  digitalWrite(13, LOW);
  digitalWrite(9, 0);
  digitalWrite(10, 0);
  digitalWrite(11, 0);
}

//temp. measurement
void getTemp() {
  //set to one-shot mode
  if (millis() >= t2 && tempSet == 0) {
    t2 = millis();
    Wire.beginTransmission(ADT7410);
    Wire.write(0x03); // Configuration register
    Wire.write(0b10100000); //16 bit, one shot mode                
    Wire.endTransmission();
    tempSet = 1;
  }

  //acquire and display
  if (millis() >= t2 + 400 && tempSet == 1) {
    t2 += 10000;
    char buf[8];
    float temp;
    Wire.requestFrom(ADT7410, 2);
    unsigned int val = (Wire.read() << 8) | Wire.read();

    if (val & 0x8000) {
      temp = (val - 65536) / 128.0;
    }
    else {
      temp = val / 128.0;
    }

    lcd.setCursor(9, 1);
    lcd.print(dtostrf(temp, 5, 1, buf));
    tempSet = 0;
  }
}

void setup() {
  pinMode(2, OUTPUT);
  pinMode(3, OUTPUT);
  pinMode(7, OUTPUT);
  pinMode(8, OUTPUT);
  pinMode(9, OUTPUT);
  pinMode(10, OUTPUT);
  pinMode(11, OUTPUT);
  pinMode(13, OUTPUT);
  pinMode(16, OUTPUT);
  pinMode(17, OUTPUT);

  Wire.begin();
  Serial.begin(9600);

  Wire.beginTransmission(HT16K33);
  Wire.write(0b00100001);  //system set
  Wire.endTransmission();
  Wire.beginTransmission(HT16K33);
  Wire.write(0b10100000);  //row_int set
  Wire.endTransmission();
  Wire.beginTransmission(HT16K33);
  Wire.write(0b10000001); //Display set
  Wire.endTransmission();

  LED_clear();

  digitalWrite(2, 0);
  digitalWrite(13, 0);
  digitalWrite(7, 0);
  digitalWrite(8, 0);

  lcd.begin(16, 2);
  lcd.clear(); 
  lcd.setCursor(0, 0);

  lcd.print(F("   GNSS CLOCK   "));
  lcd.setCursor(0, 1);
  lcd.print(F("    by JO4EFC   "));

  //power-up motion
  for (unsigned int i = 0; i < 256; i++) {
    if (i % 100 == 0) {
      digitalWrite(9, 1);
      digitalWrite(10, 1);
      digitalWrite(11, 1);
    }
    else if (i % 50 == 0) {
      digitalWrite(9, 0);
      digitalWrite(10, 0);
      digitalWrite(11, 0);
    }
    analogWrite(3, i);
    delay(5);
  }

  setLuminance(1);

  for (byte i = 0; i < 60; i++) {
    LED_second(i);
    LED_minute(59 - i, 0);
    delay(25);
  }

  for (byte i = 0; i < 60; i++) {
    LED_minute(i);
    LED_second(59 - i, 0);
    delay(25);
  }

  LED_clear();
  lcd.clear();

  //create and display the Celsius degree symbol 
  byte charData[8] = {
  B00111,
  B00101,
  B00111,
  B00000,
  B00000,
  B00000,
  B00000,
  };
  lcd.createChar(1, charData);
  lcd.setCursor(14, 1);
  lcd.write(byte(1));
  lcd.print("C");

  //wait for receiver connection
  while (Serial.available() == 0) {
    lcd.setCursor(0, 0);
    lcd.print("No receiver");
    LED_second(0);
    LED_minute(0);
    LED_hour(0);
    getTemp();
    setLuminance();
    analogWrite(5, duty);  //status LED
  }

  LED_clear();

  //Wait for signal
  lcd.setCursor(0, 0);
  lcd.print("Searching...");
  for (int8_t i = 0; GPSstatus == 0; i++) {
    while (Serial.available() > 0) {
      gps.encode(Serial.read());
    }
    if (gps.date.day() != 0) {
      GPSstatus = 1;
    }
    LED_second(i);
    if (i < 30) {
      LED_minute(i + 30);
    }
    else {
      LED_minute(i - 30);
    }
    if (i == 59) {
      i = -1;
    }
    getTemp();
    setLuminance();
    analogWrite(5, duty);  //status LED
    delay(25);
  }
}

void loop() {
  //acquisition of data from the receiver
  while (Serial.available() > 0) {
    gps.encode(Serial.read());
  }
  //time calib. 
  if (millis() >= t + 1000) {
    t += 1000;
    if (gps.time.age() < 1000 && gps.date.day() != 0) {
      setTime(0);
      year();  //force reset the current time to avoid bug
      setTime(gps.time.hour(), gps.time.minute(), gps.time.second(), gps.date.day(), gps.date.month(), gps.date.year());
      GPSstatus = 1;
      analogWrite(6, duty); //turn on status LED
      digitalWrite(5, LOW);
    }
    else {
      GPSstatus = 0;
      analogWrite(5, duty);
      digitalWrite(6, LOW);
    }

    if (GPSstatus != GPSstatusB4) {
      LED_clear();
      GPSstatusB4 = GPSstatus;
      printTime(1);  //force re-print all parts
    }
    else {
      printTime();  //re-print only updated parts
    }
  }

  //turn off status LEDs
  if (millis() > t + 100) {
    digitalWrite(5, LOW);
    digitalWrite(6, LOW);
  }
  setLuminance(); //adjust illuminance 
  getTemp();  //measure temperature
}


使用
私の住んでいる部屋からはほとんど空が見えないのでちゃんと衛星からの信号を捉えられるか心配でしたが、特に問題はありませんでした。窓際にGPSモジュールを置いておけば、電源を入れてから長くても3分で同期してくれます。一旦信号を捉えてしまえば、その後は窓から離れた場所に持って行っても同期が外れることはありません。

周波数が高いだけあって、JJYを受信するタイプの電波時計よりも周囲の家電等のノイズに影響されにくいのは大きなメリットです。私の部屋では電波時計がJJYを拾ってくれないことがよくあるのですが、この衛星電波時計は常に同期状態を維持してくれます。

時計の針の代わりにLEDが付いていることになかなか慣れず、またLCDが小さいので、視認性はあまり良くありません。掛け時計にするより机の上に置いて使う方が良さそうです。

電源ONから同期までの様子を動画に撮ってYouTubeにあげてみました。


頒布
余った基板をBOOTHに出しておいたので、興味のある方はどうぞ。部品配置図・部品表付きです。
リンク: 
衛星電波時計基板 
 https://jo4efc.booth.pm/items/2525377
※2020/11/21追記 早速売り切れました。ありがとうございます。次回入荷は未定です。

0 件のコメント:

コメントを投稿