こんにちは、ENGかぴです。
GPSモジュールは現在地の情報や時刻情報などを衛星からの電波を受信してユーザーに通知するモジュールです。GPSモジュールのデータはシリアル通信を使って得られますが、1ppsと組み合わせることで測位後のデータと判断してデータを得られます。
秋月電子の「GPS受信機キット:AE-GYSFDMAXB」を使ってGPSモジュールから日付と時刻情報を取得してLCDに表示する方法を説明しています。LCDはQAPASS1602(スターターキットに付属)を使用しています。
GPSモジュール用のライブラリを追加して時刻情報を取得して動作確認したことを下記記事にまとめています。
ArduinoのライブラリでGPSモジュールで時刻を取得する
Arduino UNO(以下Arduinoとします。)を対象とします。Arduinoのライブラリーを使用して動作確認したことをまとめています。
Arduinoで学べるマイコンのソフト開発と標準ライブラリの使い方
GPSモジュールの使い方
GPSモジュールはArduinoのシリアル通信とGPSモジュールを配線するだけでデータを取得することができます。デフォルト設定では不要なデータも含まれてしまうため必要なデータだけが受信できるようにGPSモジュールの設定を行います。
GPSモジュールは秋月電子の「GPS受信機キット:AE-GYSFDMAXB」を使っているため下記リンクのデータシート(技術資料)を一部抜粋して説明します。
GPSモジュールの電文(NMEAパケット)
GPSモジュールはメーカー問わず基本的にNMEAパケットフォーマットに従ってデータを送信するようになっています。NEMAパケットフォーマットは以下の通りです。
NMEAパケットは最後のCR・LFを除いて文字コード(アスキーコード)となります。各パラメータをまとめます。
パケット | 内容 |
---|---|
Preamble | 1バイトの文字’$’ |
Talker ID | 4バイトの文字”PMTK”(メーカーによって異なる) |
Packet Type | 3バイトの文字”000″から”999″(電文の番号) |
Data Field | 任意の文字列で、Packet Typeによってデータ長が異なる。 設定項目が複数ある場合は”,”で区切る。 |
* | 1バイトの文字でData Fieldの終わり判定に使用する。 |
CHK1、CHK2 | Preambleと*までの間のチェックサム(EOR)値を2バイト文字で表現する。 |
CR、LF | 2バイトのバイナリデータCR:0x0D、LF:0x0Aとなる。 |
GYSFDMAXBの仕様書ではCHK1、CHK2の説明にPreambleと*までのチェックサム値と記載されているため各バイトを加算して1バイトデータを文字列としてチェックサムとして計算してしまいそうですが、EORで計算する必要があります。
チェックサムが一致しない場合はGPSモジュール側で無効なパケットとして処理されてしまいます。
出力制御のフォーマット
GPSモジュールには各種データを出力する項目を選択する314パケットがあります。今回使用しているAE-GYSFDMAXBにおいてはDate Fieldは全部で19の設定項目がありますが、実装されている項目は以下の通りです。
種別 | 内容 | 設定番号 |
---|---|---|
GPGLL | 位置情報(緯度、経度)を出力する。 | 0 |
GPRMC | 位置情報と時刻(UTC時刻)、速度と方位を同時に出力する。 | 1 |
GPVTG | 方位と速度を出力する。 | 2 |
GPGGA | 位置情報(緯度、経度)、GPS測位状態、測位衛星数を出力する。 | 3 |
GPGSA | GPS衛星の使用衛星番号、各種DOP、動作モードを出力する。 | 4 |
GPGSV | GPS衛星の生成情報を出力する。 | 5 |
GPZDA | 時刻(UTC時刻)と日付を出力する。 | 17 |
GPSモジュールから日付と時刻を含む情報を取得したいので「314 PMTK_API_SET_NMEA_OUTPUT」の設定からGPZDA interval以外を出力しないように設定します。314フォーマットにおいて対象の番号の周期設定を変更することでインターバルのタイミングを任意に設定できます。
char buf314[]= "PMTK314,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0";
//正式なフォーマットは$,*checksum,CR,LFを追加する
例のように設定番号の17番目以外のインターバルを0にすることでGPSモジュールからの情報を制限することができます。GPZDAにおいては1にしているため1秒周期でGPZDAが出力されます。他の項目についても出力したい場合は0~5までの任意のタイミングに設定します。
初期化処理で314パケットが受け付けられなかった場合は受信データにGPZDA以外のデータが来た場合に再度314パケットを送出するようにして受け付けるまで繰り返すようにしています。
リスタートには大きくホットスタートとコールドスタートがありますが、今回使用しているGPSモジュールにおいては4つの項目に分類されています。
種別 | 説明 |
---|---|
HOT START | 利用可能なデータを保持したままのリスタートする。 |
WARM START | リスタート後エフェメリス(衛星の測位情報)が使用できない。 |
COLD START | リスタート後、時間・位置・日付・エフェメリスのデータが使用できない。 |
FULL COLD START | COLD STARTよりもさらにシステム/ユーザーのコンフィグレーションデータをクリアする。 |
電源ON以降(モジュールは電源ON後で最大1500ms準備時間が必要)にGPSモジュールが取得したデータを無駄にせずにリスタートができるホットスタートを使用することで初期化を行い、314フォーマットによる出力制御設定を行います。
時間データの受け取りタイミング
GPSモジュールは衛星を測位する前からシリアル通信でデータを通知します。データが確定していない場合のデータで時刻や測位情報として採用してしまうと不確定な情報が表示されることになります。
GPSモジュールは測位が完了し正確なデータとして確定できた時1ppsピンからパルスを出力して通知する機能を持っています。1ppsピンの状態を監視してパルスが生成されていれば正常な情報であると判断してGPSモジュールの情報として処理するようにします。
void loop(){
GpsMain();
if( digitalRead(PIN_DI1) == 0 ){ //LOWアクティブなので0で判定
gpsok = true; //GPSのデータが確定
timgpswait = GPS_WAIT;
}
if( timgpswait == TIME_UP ){ //1ppsが規定時間途絶えた
timgpswait = TIME_OFF;
gpsok = false; //GPSのデータが未確定とする
}
}
/*** RTCメイン処理 ***/
void GpsMain(){
if( gpsok ){ //GPSのデータが確定していれば以下の処理
//LCDに時刻を表示する
}
}
GPSモジュールからの受信データの受け入れ
GPSモジュールから受信するデータもNMEAパケットに従っているためNMEAパケットの’$’から’*’までのデータを確認することでパケットタイプによるデータの管理ができます。
/* RX function add */
void RxDataChk(){
//受信データ数の算出
if( rxgps.buf[rp] == '$' ){ //ヘッダーの確認
flg = false;
for(uint16_t i=0; i< rxsz; i++ ){
if( rxgps.buf[rp] == '*' ){ //'*'までのデータ数を確認
flg = true;
sz = i+1;
allsz = sz + 4; //チェックサムと制御文字を含めた全体を受信するため+4
}
if(++rp >= RING_SZ ){
rp = 0;
}
}
if( rxsz >= allsz && flg){
//受信データを一時的に保管
sum = sumbcc(&RxData[1], sz-2); //チェックサムの計算
if( RxData[sz] == sum_h && RxData[sz+1] == sum_l ){
ControlSet(); //チェックサムで受け入れた後の処理
}
}else{
if(++rxgps.rp >= RING_SZ ){
rxgps.rp = 0;
}
}
}
データ長はGPSモジュールから出力される種別に応じて異なるため、データ長の確認を行ってから必要なデータ数のみ一時的に受け入れチェックサム(EOR)の計算を行い、異常がなければデータを受け入れる処理を行います。
void ControlSet(){
if( RxData[0] =='$' && RxData[1] =='G'
&& RxData[2] =='P' && RxData[3] =='Z'
&& RxData[4] =='D' && RxData[5] =='A' ){
//LCD表示用のデータを生成するなど必要な処理を追加
}
受け入れたデータのパケットタイプから必要なパケットのデータであるかの確認を行い対象の処理を行います。上記の例では「$GPZDA」のパケットであることを判断してLCD用のデータを生成しています。
位置情報を示すパケットに対してデータを作成する場合は「$GPGLL」のパケットであることを確認して処理を追加します。今回は公開していませんが、GPGLLフォーマットで位置情報を取得して座標をMAP上で入力すると少し誤差がありました(約100m)が位置情報が取れていました。
GPSの情報の時間を補正する
void GpsTimeRead(void){
tm t;
time_t tim;
tm* ltim;
year = strtol((char*)&date[0],NULL,10); //文字を10進数に置き換え
mon = strtol((char*)&date[5],NULL,10);
hour = strtol((char*)&date[11],NULL,10);
t.tm_year = year - 1900;
t.tm_mon = mon - 1; //0からカウントするので-1
t.tm_hour = hour + 9; //日本:世界標準時から9時間ずれ
tim = mktime(&t); //UNIX時間からの経過
ltim = localtime(&tim); //ローカル時間に置き換え
year = ltim->tm_year + 1900;
month = ltim->tm_mon + 1;
Serial.print(year); //年を表示
}
GPSモジュールの時刻と日時は世界標準時が基準であるため日本での時間は世界標準時+9時間となります。世界標準時で15:00を超えたとき日本時間に換算すると24時を超えることになり日付も更新する必要がありますが、うるう年が絡んだ時など日本時間に換算しようとしたとき少し面倒なのが欠点です。
日本時間に換算にする際にtime.hのmktime()やlocaltime()を使用すると煩雑な計算をしなくても時間の管理ができます。
date[]にGPSモジュールから取得した時刻データの文字列が格納していますが10進数に変換してtmの型の変数に格納します。年・月・日・時・分・秒をそれぞれをtmの型の変数に格納した後はmktime()関数で時刻の補正を行います。
mktime()はUNIX時間(1970年1月1日0時0分0秒)からの経過時間を時刻の桁上がりなどを考慮しての補正を行いtime_tの型で戻り値として格納できる関数です。
localtime()はtime_tの型のUNIX時間をtmの型(年・月・日・時・分・秒に分けてtmの型に変換する関数です。localtime()で変換した時刻データをLCDやシリアルモニタ表示用のデータとして加工すると日本時間に換算した結果が表示されます。
動作確認
GPSモジュールからの受信データをLCDに表示するための回路図です。初期画面はGPS-TESTとVER1.00を表示しておき、GPSモジュールの時刻データが確定した後は時刻を表示するようにしています。すでに確定している場合は最初で2秒の表示としています。
GPS受信機キット:AE-GYSFDMAXBのデータシートを確認すると330 PMTK_API_SET_DATUMを発行してTOKYO-Mに合わせると時刻調整できると考えていましたが、発行してもうまく時刻が補正されませんでした。他のAPIで補正することができるかもしれません。
時刻補正がなかったためtime.hを使用して時刻の補正を行いました。
時刻が表示されると日本時間に換算した時刻が表示されていることを確認しました。シリアルモニタでも同様の時刻が表示されていました。
ソースコード全体
以下のソースコードはコンパイルして動作確認をしております。コメントなど細かな部分で間違っていたりやライブラリの更新などにより動作しなくなったりする可能性があります。参考としてお使いいただければと思います。
#include <SoftwareSerial.h>
#include <LiquidCrystal.h>
#include <MsTimer2.h>
#include <time.h>
#define TIME_UP 0
#define TIME_OFF -1
#define BASE_CNT 10 //10msがベースタイマとなる
#define LCD_MAX 50
#define GPS_WAIT 500
#define TIM_RX_WAIT 50
#define LCD_RS 2
#define LCD_EN 3
#define LCD_D4 4
#define LCD_D5 5
#define LCD_D6 6
#define LCD_D7 7
#define PIN_DI1 13
#define RING_SZ 256
const char buf101[]= "$PMTK101*32";
const char buf314[]= "PMTK314,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0";
const char buf330[]= "$PMTK330,1*2F";
const char buf351[]= "$PMTK351,1*28";
struct RING_MNG{
uint16_t wp;
uint16_t rp;
uint8_t buf[RING_SZ];
};
struct TYP314{
uint8_t header;
uint8_t dat[sizeof(buf314)];
uint8_t endchar;
uint8_t sum_h;
uint8_t sum_l;
uint8_t cr;
uint8_t lf;
};
// application use
SoftwareSerial mySerial(10, 11); // RX, TX
LiquidCrystal lcd(LCD_RS, LCD_EN, LCD_D4,LCD_D5, LCD_D6, LCD_D7);
uint8_t RxData[RING_SZ/2];
int16_t timRxWait = TIME_OFF;
RING_MNG rxgps;
TYP314 Tx314;
int16_t cnt10ms;
int16_t timlcd = TIME_OFF;
int16_t timgpswait = TIME_OFF;
bool gpsok;
String lcd_1;
String lcd_2;
/*** Local function prototypes */
void TimerCnt();
void mainTimer();
uint8_t dectoask(uint8_t dec);
void RxDataChk();
uint8_t sumbcc( uint8_t *adrs, uint8_t sz );
void GpsMain();
void ControlSet();
void setup(){
uint8_t i;
uint8_t *adrs;
uint8_t buf[3];
uint8_t sum;
pinMode( PIN_DI1, INPUT_PULLUP ); //1pps用のDI
Serial.begin(115200);
mySerial.begin(9600);
lcd.begin(16,2); //16×2を表示領域
lcd.print("GPS-TEST");
lcd.setCursor(0, 1); //2段目の左端にカーソル
lcd.print("Ver1.00 ");
delay(1500); //初期表示を1.5秒間行う
//ホットスタートによるリスタートを実施(101)
for( i=0; i < sizeof(buf101);i++ ){
mySerial.write(buf101[i]);
}
mySerial.write(0x0D);
mySerial.write(0x0A);
//ホットスタートによるリスタートを実施(101)
for( i=0; i < sizeof(buf330);i++ ){
mySerial.write(buf330[i]);
}
mySerial.write(0x0D);
mySerial.write(0x0A);
//GPSからの出力を変更(314)
Tx314.header = '$';
for( i=0; i < sizeof(buf314);i++ ){
Tx314.dat[i] = buf314[i];
}
Tx314.endchar = '*';
sum = sumbcc(&Tx314.dat[0],sizeof(Tx314.dat)); //チェックサムの計算
sprintf((char*)&buf[0],"%x", sum);
Tx314.sum_h = buf[0];
Tx314.sum_l = buf[1];
mySerial.write( &Tx314.header, sizeof(Tx314)); //314フォーマット送出
Serial.write( &Tx314.header, sizeof(Tx314));
MsTimer2::set(1,TimerCnt); //1msごとに関数へ遷移
MsTimer2::start();
timlcd = LCD_MAX;
}
void loop(){
mainTimer();
GpsMain();
if( digitalRead(PIN_DI1) == 0 ){
gpsok = true;
timgpswait = GPS_WAIT;
}
if( timgpswait == TIME_UP ){ //1ppsが規定時間途絶えた
timgpswait = TIME_OFF;
gpsok = false;
}
while( mySerial.available()){
rxgps.buf[rxgps.wp] = mySerial.read();
if( ++rxgps.wp >= RING_SZ ){ //次にデータを入れる場所を更新
rxgps.wp = 0;
}
}
RxDataChk();
}
/* callback function add */
void TimerCnt(){
++cnt10ms;
}
/* Timer Management function add */
void mainTimer(){
if( cnt10ms >= BASE_CNT ){ //10msごとにここに遷移する
cnt10ms -=BASE_CNT;
if( timlcd > TIME_UP ){
timlcd--;
}
if( timgpswait > TIME_UP ){
timgpswait--;
}
if( timRxWait > TIME_UP ){
timRxWait--;
}
}
}
/* RX function add */
void RxDataChk(){
int rxsz;
uint16_t sz;
uint16_t allsz;
uint8_t *adrs;
uint16_t rp = rxgps.rp;
uint8_t sum;
uint8_t sum_h;
uint8_t sum_l;
uint8_t buf[3];
bool flg;
if( timRxWait == TIME_UP ){
timRxWait = TIME_OFF;
if(++rxgps.rp >= RING_SZ ){
rxgps.rp = 0;
}
}
rxsz = rxgps.wp - rxgps.rp; //受信データ数の算出
if( rxsz < 0 ){
rxsz = rxsz + RING_SZ;
}
if( rxsz == 0 ){
timRxWait = TIME_OFF;
}
else{
if( timRxWait == TIME_OFF ){
timRxWait = TIM_RX_WAIT;
}
if( rxgps.buf[rp] == '$' ){
flg = false;
for(uint16_t i=0; i< rxsz; i++ ){
if( rxgps.buf[rp] == '*' ){
flg = true;
sz = i+1;
allsz = sz + 4;
}
if(++rp >= RING_SZ ){
rp = 0;
}
}
if( rxsz >= allsz && flg){
timRxWait = TIME_OFF;
adrs = &RxData[0];
for(uint16_t i=0; i< allsz; i++ ){
*adrs = rxgps.buf[rxgps.rp];
adrs++;
if(++rxgps.rp >= RING_SZ ){
rxgps.rp = 0;
}
}
sum = sumbcc(&RxData[1], sz-2);
sprintf((char*)&buf[0],"%X", sum);
sum_h = buf[0];
sum_l = buf[1];
if( RxData[sz] == sum_h && RxData[sz+1] == sum_l ){
//チェックサムで受け入れた後の処理
ControlSet();
for(uint16_t i=0; i< allsz; i++ ){
//Serial.write(RxData[i]);
}
}else{
Serial.write("NG");
}
}
}else{
if(++rxgps.rp >= RING_SZ ){
rxgps.rp = 0;
}
}
}
}
/* GPZDA data-lcdset function add */
void ControlSet(){
uint8_t i;
tm t;
time_t tim;
tm* ltim;
uint16_t year;
uint8_t mon;
uint8_t day;
uint8_t hour;
uint8_t minute;
uint8_t sec;
uint8_t week;
uint8_t date[20]; //文字変換したときにNULL文字が入る分を考慮したサイズ
if( RxData[0] =='$' && RxData[1] =='G'
&& RxData[2] =='P' && RxData[3] =='Z'
&& RxData[4] =='D' && RxData[5] =='A' ){
date[0] = RxData[24];
date[1] = RxData[25];
date[2] = RxData[26];
date[3] = RxData[27];
date[4] = '/';
date[5] = RxData[21];
date[6] = RxData[22];
date[7] = '/';
date[8] = RxData[18];
date[9] = RxData[19];
date[10] = '-';
date[11]= RxData[7];
date[12]= RxData[8];
date[13]= ':';
date[14]= RxData[9];
date[15]= RxData[10];
date[16]= ':';
date[17]= RxData[11];
date[18]= RxData[12];
date[19]=0; //NULL文字
year = strtol((char*)&date[0],NULL,10);
mon = strtol((char*)&date[5],NULL,10);
day = strtol((char*)&date[8],NULL,10);
hour = strtol((char*)&date[11],NULL,10);
minute = strtol((char*)&date[14],NULL,10);
sec = strtol((char*)&date[17],NULL,10);
t.tm_year = year - 1900;
t.tm_mon = mon - 1; //0からカウントするので-1
t.tm_mday = day;
t.tm_hour = hour + 9; //日本:世界標準時から9時間ずれ
t.tm_min = minute;
t.tm_sec = sec;
tim = mktime(&t); //UNIX時間からの経過
ltim = localtime(&tim); //ローカル時間に置き換え(経過時間をtm構造体に置き換え)
year = ltim->tm_year + 1900;
mon = ltim->tm_mon + 1;
day = ltim->tm_mday;
hour = ltim->tm_hour;
minute = ltim->tm_min;
sec = ltim->tm_sec;
week = ltim->tm_wday;
lcd_1 ="";
lcd_2 ="";
Serial.print(year);
Serial.print("/");
lcd_1 += year;
lcd_1 += "/";
if( mon > 9 ){
Serial.print(mon);
lcd_1 += mon;
}
else{
Serial.print("0"); Serial.print(mon);
lcd_1 += "0"; lcd_1 += mon;
}
Serial.print("/");
lcd_1 += "/";
if( day > 9 ){
Serial.print(day);
lcd_1 += day;
}
else{
Serial.print("0"); Serial.print(day);
lcd_1 += "0"; lcd_1 += day;
}
Serial.print("/");
if( hour > 9 ){
Serial.print(hour);
lcd_2 += hour;
}
else{
Serial.print("0"); Serial.print(hour);
lcd_2 += "0"; lcd_2 += hour;
}
Serial.print(":");
lcd_2 += ":";
if( minute > 9 ){
Serial.print(minute);
lcd_2 += minute;
}
else{
Serial.print("0"); Serial.print(minute);
lcd_2 += "0"; lcd_2 += minute;
}
Serial.print(":");
lcd_2 += ":";
if( sec > 9 ){
Serial.print(sec);
lcd_2 += sec;
}
else{
Serial.print("0"); Serial.print(sec);
lcd_2 += "0"; lcd_2 += sec;
}
Serial.print("-");
switch(week){
case 0:
Serial.println("SUN");
lcd_1 +=" SUN";
break;
case 1:
Serial.println("MON");
lcd_1 +=" MON";
break;
case 2:
Serial.println("TUE");
lcd_1 +=" SUN";
break;
case 3:
Serial.println("WED");
lcd_1 +=" WED";
break;
case 4:
Serial.println("THU");
lcd_1 +=" THU";
break;
case 5:
Serial.println("FRI");
lcd_1 +=" FRI";
break;
case 6:
Serial.println("SAT");
lcd_1 +=" SAT";
break;
}
}else{ //ZPA以外であれば314電文が受け付けられていないので受け付けるまで繰り返す
if( RxData[5] =='0' && RxData[6] =='0'
&& RxData[7] =='1' && RxData[9] =='3'
&& RxData[10] =='1' && RxData[11] == '4'
&& RxData[13] == '3'){
Serial.println("314 ok");
}else{
Serial.println("314 retry");
mySerial.write( &Tx314.header, sizeof(Tx314)); //314フォーマット送出
for( i=0; i < sizeof(buf330);i++ ){
mySerial.write(buf330[i]);
}
mySerial.write(0x0D);
mySerial.write(0x0A);
for( i=0; i < sizeof(buf351);i++ ){
mySerial.write(buf351[i]);
}
mySerial.write(0x0D);
mySerial.write(0x0A);
}
}
}
/* checksum(EOR) function add */
uint8_t sumbcc( uint8_t *adrs, uint8_t sz ){
uint8_t bcc=0;
uint8_t i;
for( i=0; i < sz;i++ ){
bcc ^= *adrs;
++adrs;
}
return bcc;
}
/*** RTCメイン処理 ***/
void GpsMain(){
if( gpsok ){
if( timlcd == TIME_UP ){
timlcd = LCD_MAX;
lcd.setCursor(0, 0); //1段目の左端にカーソル
lcd.print(lcd_1);
lcd.setCursor(0, 1); //2段目の左端にカーソル
lcd.print(lcd_2);
}
}
}
本記事はGPSモジュールから受信したシリアル通信のデータを判断して時刻表示を行っていますが、GPSモジュール用のライブラリを追加して時刻情報を表示する方法もあります。
ArduinoのライブラリでGPSモジュールで時刻を取得する
関連リンク
Arduinoのライブラリを使って動作確認を行ったことを下記リンクにまとめています。
Arduinoで学べるマイコンのソフト開発と標準ライブラリの使い方
Seeeduino XIAOで学べるソフト開発と標準ライブラリの使い方
ESP32-WROOM-32Eで学べるソフト開発と標準ライブラリの使い方
最後まで、読んでいただきありがとうございました。
外部機器をマイコンから制御する場合、マイコン起動時に外部機器に対して初期化を行ってから動作させることが経験上多いです。