0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GT-502MGG-N を 9600 bps のまま使うと、1秒の NMEA が回線に収まりきらない話(Raspberry Pi + gpsd + chrony)

0
Posted at

GT-502MGG-N を 9600bps のまま使うと、1秒の NMEA が回線に収まりきらない話(Raspberry Pi + gpsd + chrony)

はじめに

秋月電子で手に入る GNSS 受信機 GT-502MGG-N を Raspberry Pi 3 につないで、gpsd と chrony で stratum 1 の NTP サーバーとして運用しています。PPS は GPIO から pps-gpio で入れて、NMEA は gpsd 経由で chrony の SHM refclock に渡す、よくある構成です。

自作の NMEA モニタ(gnomon)で受信機の出力を眺めていたところ、工場出荷時の 9600 bps のままだと、1エポック分の NMEA が1秒にほぼ収まりきっていないことに気づきました。この記事では、その状況と、115200 bps に上げる方法(起動時の自動設定まで)をまとめます。

環境

項目 内容
ホスト Raspberry Pi 3 Model B
OS Debian 11 (bullseye)
GNSS 受信機 GT-502MGG-N(UART 接続、PPS は GPIO12)
gpsd 3.22
chrony 4.0

何が起きていたか

GT-502MGG-N は GPS、GLONASS、Galileo、QZSS を受信するので、衛星が多く見えると GSV(衛星の一覧)の行が増えます。私の環境では、1エポック(1秒)ごとに次の文が出ていました。

  • GGA、GLL、RMC、VTG(各1行)
  • GSA(GPS、GLONASS、Galileo で3行)
  • GSV(GPS 4行、GLONASS 2〜3行、Galileo 2行)

合計で 約 950〜1,000 文字 です。9600 bps(8N1)で送れるのは 毎秒約 960 文字 なので、回線の容量をほぼ使い切っています。

gnomon でホストへの到着時刻を見ると、1エポックの出力は次の順に届いていました(時刻は秒の頭からの経過時間)。

GGA   +0.497 s
GLL   +0.548 s
GSA   +0.605 s ...
GSV   +0.752 s ... +1.215 s
RMC   +1.253 s   ← 前の秒の RMC が、次の秒に届く
VTG   +1.293 s

GGA から VTG まで約 0.8 秒かかっていて、RMC はその秒の 1.25 秒後に届いていました。衛星がもう少し増えれば、1秒に収まらなくなって文が欠けたり、エポックがずれたりしかねない状態です。

PPS を GPIO から別に入れている場合、PPS の精度には影響しません。ただし NMEA の到着時刻が遅れたり揺れたりするので、chrony の NMEA refclock の offset や、NMEA の時刻だけを使う構成には影響します。

GT-502MGG-N の設定方法

GT-502MGG-N は MediaTek 系のチップで、PMTK コマンドで設定します。ボーレートの変更は秋月電子の FAQ に載っています。

$PMTK251,115200*1F    115200 bps にする
$PMTK251,9600*17      9600 bps(工場出荷時)に戻す

注意点として、この受信機は設定を不揮発メモリに保存できません。電源が完全に切れて内蔵のバックアップが空になると、工場出荷時の 9600 bps に戻ります。そこで、起動のたびに gpsd より先に設定を送ることにしました。

スクリプト

Python の標準ライブラリだけで書いています。

  • 今のボーレートを自動で探す(115200 → 9600 → 38400 … の順)
  • 目的の速度でなければ PMTK251 を送って切り替える
  • 切り替えたあと、チェックサムの正しい NMEA が届くかを確認する
  • --lean を付けると、GLL と VTG を止め、GSV を5秒に1回にする(PMTK314)
gt502-setup.py
#!/usr/bin/env python3
"""Configure a GT-502MGG-N (MediaTek, PMTK commands) for timing use:
raise the serial speed and optionally thin out NMEA sentences.

The receiver keeps settings only while its backup supply lasts, so run this
at every boot, before gpsd opens the port (see gt502-setup.service).

  gt502-setup.py /dev/ttyAMA0                 # 115200 bps, all sentences
  gt502-setup.py --lean /dev/ttyAMA0          # also GLL/VTG off, GSV every 5 s
  gt502-setup.py --speed 38400 /dev/ttyAMA0
  gt502-setup.py --check /dev/ttyAMA0         # only report the current speed

Standard library only.
"""
import argparse
import os
import sys
import termios
import time
from functools import reduce

SPEEDS = {4800: termios.B4800, 9600: termios.B9600, 19200: termios.B19200,
          38400: termios.B38400, 57600: termios.B57600,
          115200: termios.B115200}
# PMTK314 fields: GLL RMC VTG GGA GSA GSV, then reserved ... (MTK order)
LEAN = "PMTK314,0,1,0,1,1,5,0,0,0,0,0,0,0,0,0,0,0,0,0"
DEFAULT_OUTPUT = "PMTK314,-1"


def nmea(body):
    """Wrap a sentence body with $ and *checksum, CRLF terminated."""
    cs = reduce(lambda a, c: a ^ ord(c), body, 0)
    return f"${body}*{cs:02X}\r\n".encode("ascii")


def valid(line):
    """True if line is a complete NMEA sentence with a correct checksum."""
    if not line.startswith("$") or "*" not in line:
        return False
    body, _, cs = line[1:].partition("*")
    try:
        return reduce(lambda a, c: a ^ ord(c), body, 0) == int(cs[:2], 16)
    except ValueError:
        return False


class Port:
    def __init__(self, path):
        self.fd = os.open(path, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
        self.saved = termios.tcgetattr(self.fd)

    def set_speed(self, bps):
        a = termios.tcgetattr(self.fd)
        # raw 8N1, no flow control, ignore modem lines
        a[0] = termios.IGNPAR                       # iflag
        a[1] = 0                                    # oflag
        a[2] = termios.CS8 | termios.CREAD | termios.CLOCAL  # cflag
        a[3] = 0                                    # lflag
        a[4] = a[5] = SPEEDS[bps]                   # ispeed, ospeed
        termios.tcsetattr(self.fd, termios.TCSANOW, a)
        termios.tcflush(self.fd, termios.TCIOFLUSH)

    def write(self, data):
        os.write(self.fd, data)
        termios.tcdrain(self.fd)

    def lines(self, seconds):
        """Yield decoded lines received within the given time."""
        buf = b""
        end = time.monotonic() + seconds
        while time.monotonic() < end:
            try:
                chunk = os.read(self.fd, 512)
            except BlockingIOError:
                time.sleep(0.02)
                continue
            buf += chunk
            while b"\n" in buf:
                raw, buf = buf.split(b"\n", 1)
                yield raw.strip().decode("ascii", "replace")

    def close(self):
        os.close(self.fd)


def probe(port, bps, seconds=2.5):
    """Return True if two valid NMEA sentences arrive at this speed."""
    port.set_speed(bps)
    good = 0
    for line in port.lines(seconds):
        if valid(line):
            good += 1
            if good >= 2:
                return True
    return False


def find_speed(port, first):
    order = [first] + [s for s in (9600, 115200, 38400, 57600, 19200, 4800)
                       if s != first]
    for bps in order:
        if probe(port, bps):
            return bps
    return None


def send_and_ack(port, body, seconds=2.0):
    """Send a PMTK command and wait for $PMTK001,<cmd>,3 (success)."""
    cmd = body.split(",")[0][4:]
    port.write(nmea(body))
    for line in port.lines(seconds):
        if line.startswith(f"$PMTK001,{cmd},"):
            return line.split(",")[2].split("*")[0] == "3"
    return False


def main():
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("device")
    ap.add_argument("--speed", type=int, default=115200, choices=sorted(SPEEDS))
    ap.add_argument("--lean", action="store_true",
                    help="GLL and VTG off, GSV every 5 s")
    ap.add_argument("--default-output", action="store_true",
                    help="restore the factory sentence set")
    ap.add_argument("--check", action="store_true",
                    help="only detect and print the current speed")
    a = ap.parse_args()

    port = Port(a.device)
    try:
        cur = find_speed(port, a.speed)
        if cur is None:
            print("no valid NMEA at any speed (gpsd still running?)",
                  file=sys.stderr)
            return 1
        print(f"receiver talks at {cur} bps")
        if a.check:
            return 0

        if a.lean or a.default_output:
            body = LEAN if a.lean else DEFAULT_OUTPUT
            ok = send_and_ack(port, body)
            print(f"output set {'ok' if ok else 'NOT acknowledged'}: {body}")

        if cur != a.speed:
            # the ack for PMTK251 may not arrive: the receiver switches at once
            port.write(nmea(f"PMTK251,{a.speed}"))
            time.sleep(0.3)
            if not probe(port, a.speed):
                print(f"receiver did not come up at {a.speed} bps",
                      file=sys.stderr)
                return 1
        print(f"receiver now at {a.speed} bps")
        return 0
    finally:
        port.close()


if __name__ == "__main__":
    sys.exit(main())

systemd の unit

gpsd より先に実行するよう、Before= で順番を指定しています。

gt502-setup.service
# Configure the GT-502MGG-N before gpsd opens it.
# The receiver forgets its settings once its backup supply runs out,
# so this runs at every boot.
[Unit]
Description=Configure GT-502MGG-N (115200 bps) before gpsd
Before=gpsd.service gpsd.socket

[Service]
Type=oneshot
# wait up to 30 s for the device node to appear
ExecStartPre=/bin/sh -c 'for i in $(seq 30); do [ -e /dev/gps0 ] && exit 0; sleep 1; done; exit 1'
ExecStart=/usr/local/sbin/gt502-setup.py --speed 115200 /dev/gps0
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target

/dev/gps0 は udev で作っているシンボリックリンクです。環境に合わせて /dev/ttyAMA0 などに読み替えてください。

導入手順

1. 配置

sudo install -m 755 gt502-setup.py /usr/local/sbin/
sudo install -m 644 gt502-setup.service /etc/systemd/system/

2. gpsd を止めて、手で試す

gpsd がポートを開いていると、受信機と通信できません。

sudo systemctl stop gpsd.socket gpsd
sudo /usr/local/sbin/gt502-setup.py --check /dev/gps0   # receiver talks at 9600 bps
sudo /usr/local/sbin/gt502-setup.py /dev/gps0           # receiver now at 115200 bps

3. gpsd のボーレートを合わせる

/etc/default/gpsd の GPSD_OPTIONS に -s 115200 を追加します。

/etc/default/gpsd
GPSD_OPTIONS="-n -s 115200"

4. 自動実行を有効にして gpsd を再開

sudo systemctl daemon-reload
sudo systemctl enable gt502-setup.service
sudo systemctl start gpsd

5. 再起動して確認

sudo reboot
systemctl status gt502-setup
gt502-setup.py[345]: receiver talks at 115200 bps
gt502-setup.py[345]: receiver now at 115200 bps

再起動の間もバックアップで設定が保持されていたので、スクリプトは何も変更せずに終わっています。gt502-setup が終わった 1 秒後に gpsd が起動していて、Before=gpsd.service の順番どおりです。電源が完全に落ちて 9600 bps に戻っていた場合は、スクリプトが 9600 bps を見つけて 115200 bps に切り替えます(最初に手で実行したときの流れです)。

結果

9600 bps 115200 bps
1エポックの送信時間(GGA〜VTG) 約 0.8 秒 約 77 ms
RMC の到着 秒の頭から 1.25 秒後(前の秒の RMC が次の秒に届く) 0.45 秒後(その秒のうちに届く)
GGA の到着(gnomon の Lag) +479 ms +371 ms
gpsd の TOFF −478.8 ms −370.7 ms

1エポック分が約 77 ms でまとめて届くようになり、RMC が次の秒にずれ込むこともなくなりました。

115200 bps にしても、GGA は秒の頭から約 0.37 秒後に出てきます。これは受信機が測位の計算を終えて出力を始めるまでの時間で、回線の速度とは関係ない、この受信機の固有の値です。

chrony の NMEA refclock の offset を確認する

NMEA の届くタイミングが変わるので、chrony の refclock SHM ... offset が合っているかを確認しました。

chronyc sourcestats はあてにならなかった

最初は chronyc sourcestats の Offset を見て合わせるつもりでした。

chronyc -n sourcestats | grep NMEA

ところが、見るたびに値が大きく変わります。

時刻 Offset Span
05:55 −98 ms 318 s
06:39 −36 ms 164 s
08:36 +25 ms 384 s

NMEA はばらつきが大きく(標準偏差で数十 ms)、chrony は回帰に使う区間を短く切り詰め続けるので、Span が数分にしかなりません。短い区間の回帰の推定値なので、NMEA の平均を知る用途には向いていませんでした。

refclocks のログで平均を取る

そこで、chrony が受け取った NMEA のサンプルを1つずつ記録して、平均を取ることにしました。Raspberry Pi の SD カードに書かないよう、ログは tmpfs の /run に出します。

/etc/chrony/chrony.conf(一時的に追加)
logdir /run/chrony
log refclocks
sudo systemctl restart chrony
# しばらく待ってから
awk '$3 == "NMEA" && $4 ~ /^[0-9]+$/ { s += $8; n++ }
     END { printf "n=%d mean=%+.6f s\n", n, s/n }' /run/chrony/refclocks.log

$8 は Cooked offset(設定済みの offset を反映したあとの残差)の列です。$4(DP)が数字の行だけを数えて、フィルタ後の要約の行(DP が - の行)は除いています。chrony のバージョンで列の並びが違う可能性があるので、先に head で確認してください。

約 6 分半(395 サンプル)の結果です。

平均 中央値 標準偏差
全サンプル −3.4 ms −10.1 ms 32 ms
下の「跳ね」を除いた 369 サンプル −10.7 ms −11.7 ms 17 ms

残差は約 −11 ms で、offset 0.373 のままでほぼ合っていました。 直すとしても 0.362 程度です。

PPS を lock なしで使っている構成では、NMEA は秒の番号を付けるためだけに使われます。必要な精度は ±0.5 秒以内なので、この程度の残差は気にしなくて大丈夫です。私は offset 0.373 のままにしました。

おまけ:26 秒間だけ NMEA が 110 ms 早く届いた

ログの途中に、約 26 秒間だけ残差が +0.10 s に跳ねた区間がありました。

23:42:25  raw -0.381  cooked -0.008
23:42:26  raw -0.272  cooked +0.101   ← ここから
  ...
23:42:51  raw -0.281  cooked +0.092
23:42:52  raw -0.394  cooked -0.021   ← ここまで

gpsd が chrony に渡すタイミングが、この間だけ約 110 ms 早くなっています。受信機の出力が一時的に早まったのか、gpsd がエポックの始まりを別の文で判定したのかは、まだ切り分けていません。sourcestats の値が大きく揺れていたのは、こうした跳ねが短い区間の回帰に効いていたためだと思います。

まとめ

  • GT-502MGG-N は、工場出荷時の 9600 bps だと、マルチ GNSS で文が増えたときに1エポックの NMEA がほぼ1秒かかる。
  • PMTK251 で 115200 bps にすれば、1エポックは約 77 ms で送り終わる。
  • 設定は保存されないので、起動のたびに gpsd より先に送る。
  • ボーレートを変えたら、chrony の NMEA refclock の offset も確認する。chronyc sourcestats は揺れるので、log refclocks で平均を取るのが確実。私の環境では offset 0.373 のままで残差は約 −11 ms だった。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?