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?

M5Stack GPS unit v1.1 から読み込んだGPS情報をPCで描画してみた

0
Last updated at Posted at 2026-08-29

2年程前に書いた記事に掲載したPythonコードは、そのままでは M5Stack GPS unit v1.1 の NMEA を正しくエンコードできませんでした。NMEA 4.10 に対応していないからです。別途、NMEA 4.10 対応にアップデートします。

今回は、M5Stack GPS unit v1.1 が マルチGNSS対応であるため、衛星配置図を 衛星別にカラーで描いてみました。眺めていると、衛星の移動が面白いです。

引数でシリアルポート(COMポート)を指定します。

$ python sky-slot.py /dev/cu.usbserial-0001

skyplot.png

sky-slot.py
import sys
import math
import threading
import serial
import tkinter as tk
from datetime import datetime, timedelta

if len(sys.argv) < 2:
    print(f"Usage: {sys.argv[0]} com-port")
    sys.exit()

SERIAL_PORT = sys.argv[1]

# --- 設定 ---
BAUDRATE = 115200

# 共有オブジェクト(時刻、DOP、衛星データ)
satellites = {}
dop_data = {'hdop': 0.0, 'vdop': 0.0}
time_data = {'jst_str': '----/--/-- --:--:--'}
data_lock = threading.Lock()

def parse_nmea(line):
    """NMEA 4.10のGSV文、GSA文、RMC文をパースする"""
    if not (line.startswith('$') and '*' in line):
        return

    try:
        raw_sentence, _ = line.split('*', 1)
        parts = raw_sentence.split(',')
    except Exception:
        return

    if len(parts) == 0:
        return

    sentence_type = parts[0]
    if not isinstance(sentence_type, str):
        return

    # --- 1. RMC文からUTCの時刻と日付を取得し、JST(+9時間)に変換 ---
    if sentence_type.endswith('RMC'):
        try:
            if len(parts) >= 10:
                time_str = parts[1].strip()  # hhmmss.ss
                date_str = parts[9].strip()  # ddmmyy

                if time_str and date_str:
                    hh, mm, ss = int(time_str[0:2]), int(time_str[2:4]), int(time_str[4:6])
                    dd, mn, yy = int(date_str[0:2]), int(date_str[2:4]), int(date_str[4:6]) + 2000

                    # UTCからJSTへ変換
                    utc_time = datetime(yy, mn, dd, hh, mm, ss)
                    jst_time = utc_time + timedelta(hours=9)

                    with data_lock:
                        time_data['jst_str'] = jst_time.strftime("%Y/%m/%d %H:%M:%S (JST)")
        except Exception:
            pass
        return

    # --- 2. GSA文から全体DOPを抽出 ---
    if sentence_type.endswith('GSA'):
        try:
            if len(parts) >= 18:
                hdop_str = parts[16].strip()
                vdop_str = parts[17].strip()
                with data_lock:
                    dop_data['hdop'] = float(hdop_str) if hdop_str else 0.0
                    dop_data['vdop'] = float(vdop_str) if vdop_str else 0.0
        except Exception:
            pass
        return

    # --- 3. GSV文から衛星位置と個別SNR情報を抽出 ---
    if not sentence_type.endswith('GSV'):
        return

    if len(sentence_type) < 3:
        return
    talker = sentence_type[1:3]

    if talker == 'GP': sys_id = 'GPS'
    elif talker == 'GL': sys_id = 'GLO'
    elif talker == 'GA': sys_id = 'GAL'
    elif talker in ['GQ', 'QZ']: sys_id = 'QZS'
    elif talker in ['GB', 'BD']: sys_id = 'BDS'
    else: return

    try:
        num_fields = len(parts)
        for i in range(4, num_fields - 3, 4):
            if i + 3 >= num_fields: break
            prn_str = parts[i].strip()
            elev_str = parts[i+1].strip()
            azim_str = parts[i+2].strip()
            snr_str = parts[i+3].strip()

            if not prn_str: continue
            prn = int(prn_str)
            elev = float(elev_str) if elev_str else 0.0
            azim = float(azim_str) if azim_str else 0.0
            snr = float(snr_str) if snr_str else 0.0

            if sys_id == 'QZS' and (1 <= prn <= 10):
                prn = prn + 192

            with data_lock:
                if elev == 0.0 and azim == 0.0 and snr == 0.0:
                    satellites.pop((sys_id, prn), None)
                else:
                    satellites[(sys_id, prn)] = {'elevation': elev, 'azimuth': azim, 'snr': snr}
    except Exception:
        pass

def serial_reader():
    """シリアルポートからデータを読み込むスレッド関数"""
    try:
        with serial.Serial(SERIAL_PORT, BAUDRATE, timeout=1) as ser:
            while True:
                line = ser.readline().decode('ascii', errors='ignore').strip()
                if line: parse_nmea(line)
    except Exception as e:
        print(f"シリアルエラー: {e}", file=sys.stderr)

class SkyPlotApp(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("GNSS SkyPlot")
        self.geometry("600x650")

        self.canvas = tk.Canvas(self, bg="#000000", highlightthickness=0)
        self.canvas.pack(fill=tk.BOTH, expand=True)

        self.system_styles = {
            'GPS': ('#00bfff', '', False, 'GPS 1 ~ 64'),
            'GLO': ('#00ff00', '', False, 'GLO 65 ~ 96'),
            'GAL': ('#4d4dff', 'E', True, 'GAL E01 ~ E36'),
            'QZS': ('#ff66cc', '', False, 'QZS 193 ~ 201'),
            'BDS': ('#ff9933', 'C', True, 'BDS C01 ~ C62')
        }

        self.selected_satellite = None

        self.canvas.bind("<Configure>", self.on_resize)
        self.canvas.bind("<Button-1>", self.on_canvas_click)

        self.update_loop()

    def on_resize(self, event):
        self.draw_all()

    def on_canvas_click(self, event):
        click_x = event.x
        click_y = event.y
        nearest_sat = None
        min_dist = 15.0

        with data_lock:
            current_sats = list(satellites.items())

        for (sys_id, prn), info in current_sats:
            elev = info['elevation']
            azim = info['azimuth']
            r = self.max_r * (90.0 - elev) / 90.0
            theta = math.radians(90.0 - azim)
            x = self.cx + r * math.cos(theta)
            y = self.cy - r * math.sin(theta)

            dist = math.sqrt((click_x - x)**2 + (click_y - y)**2)
            if dist < min_dist:
                min_dist = dist
                nearest_sat = (sys_id, prn)

        if self.selected_satellite == nearest_sat:
            self.selected_satellite = None
        else:
            self.selected_satellite = nearest_sat
        self.draw_all()

    def draw_all(self):
        """画面全体の再描画処理"""
        self.canvas.delete("all")

        w = self.canvas.winfo_width()
        h = self.canvas.winfo_height()
        self.cx = w / 2
        self.cy = h / 2 - max(10, int(h * 0.03))
        self.max_r = min(self.cx, self.cy) * 0.82

        if self.max_r <= 0: return

        # 1. 背景のグリッド(同心円・十字線)の描画
        angles = list(range(15, 90, 15))
        for ang in angles:
            r = self.max_r * (90 - ang) / 90
            self.canvas.create_oval(self.cx - r, self.cy - r, self.cx + r, self.cy + r, outline="#333333", width=1)
            self.canvas.create_text(self.cx, self.cy - r, text=f"{ang}°", fill="#aaaaaa", font=("Arial", max(7, int(self.max_r * 0.025))), anchor="s")

        self.canvas.create_oval(self.cx - self.max_r, self.cy - self.max_r, self.cx + self.max_r, self.cy + self.max_r, outline="#555555", width=2)
        self.canvas.create_line(self.cx, self.cy - self.max_r, self.cx, self.cy + self.max_r, fill="#444444")
        self.canvas.create_line(self.cx - self.max_r, self.cy, self.cx + self.max_r, self.cy, fill="#444444")

        # 方位ラベル
        pad = max(10, int(self.max_r * 0.05))
        lbl_font = ("Arial", max(10, int(self.max_r * 0.05)), "bold")
        self.canvas.create_text(self.cx, self.cy - self.max_r - pad, text="N", fill="#ffffff", font=lbl_font)
        self.canvas.create_text(self.cx + self.max_r + pad, self.cy, text="E", fill="#ffffff", font=lbl_font)
        self.canvas.create_text(self.cx, self.cy + self.max_r + pad, text="S", fill="#ffffff", font=lbl_font)
        self.canvas.create_text(self.cx - self.max_r - pad, self.cy, text="W", fill="#ffffff", font=lbl_font)

        # 2. 画面左上へ RMC から取得した JST をリアルタイム表示
        with data_lock:
            jst_text_str = time_data['jst_str']
            hdop_val = dop_data['hdop']
            vdop_val = dop_data['vdop']
            current_sats = list(satellites.items())

        top_left_x = max(15, int(w * 0.03))
        top_y = max(15, int(h * 0.03))
        panel_font = ("Arial", max(10, int(self.max_r * 0.038)), "bold")
        self.canvas.create_text(top_left_x, top_y, text=jst_text_str, fill="#ffffff", font=panel_font, anchor="nw")

        # 3. 画面右上へ現在の総衛星数と HDOP / VDOP をリアルタイム表示
        top_right_x = w - max(15, int(w * 0.03))
        sat_count = len(current_sats) # 現在保持している衛星のトータル数をカウント
        dop_text = f"Satellites: {sat_count:02d}   HDOP: {hdop_val:.2f}   VDOP: {vdop_val:.2f}"
        self.canvas.create_text(top_right_x, top_y, text=dop_text, fill="#ffffff", font=panel_font, anchor="ne")

        # 4. 凡例(左下)の描画
        legend_x = max(15, int(w * 0.03))
        legend_step = max(13, int(self.max_r * 0.042))
        legend_font_size = max(8, int(self.max_r * 0.028))
        legend_y_start = h - max(15, int(h * 0.03))

        for idx, (sys_name, style_tuple) in enumerate(reversed(list(self.system_styles.items()))):
            color = style_tuple[0]
            text_str = style_tuple[3]

            y_pos = legend_y_start - (idx * legend_step)
            marker_size = max(4, int(legend_font_size * 0.4))

            self.canvas.create_oval(legend_x, y_pos - marker_size, legend_x + marker_size*2, y_pos + marker_size, fill=color, outline="#ffffff", width=1)
            self.canvas.create_text(legend_x + (marker_size * 3), y_pos, text=text_str, fill="#888888", font=("Arial", legend_font_size), anchor="w")

        # 5. 選択された個別衛星の SNR 表示(右下)
        info_x, info_y = w - max(15, int(w * 0.03)), h - max(15, int(h * 0.03))
        if self.selected_satellite:
            sys_name, prn = self.selected_satellite
            sat_info = next((info for (s_id, p_num), info in current_sats if s_id == sys_name and p_num == prn), None)
            style_data = self.system_styles.get(sys_name, ('#888888', '', False, ''))
            prefix, zero_pad = style_data[1], style_data[2]
            sat_name = f"{prefix}{prn:02d}" if zero_pad else f"{prefix}{prn}"
            info_text = f"Selected: {sat_name}  |  SNR: {sat_info['snr']:.1f} dB-Hz" if sat_info else f"Selected: {sat_name}  |  SNR: ---"
            self.canvas.create_text(info_x, info_y, text=info_text, fill="#00ffff", font=panel_font, anchor="e")
        else:
            self.canvas.create_text(info_x, info_y, text="Click a satellite to view SNR", fill="#555555", font=panel_font, anchor="e")

        # 6. 衛星プロットの描画
        for (sys_id, prn), info in current_sats:
            r = self.max_r * (90.0 - info['elevation']) / 90.0
            theta = math.radians(90.0 - info['azimuth'])
            x, y = self.cx + r * math.cos(theta), self.cy - r * math.sin(theta)
            color, prefix, zero_pad, _ = self.system_styles.get(sys_id, ('#888888', '', False, ''))
            sat_size = max(10, min(16, self.max_r * 0.045))

            if self.selected_satellite == (sys_id, prn):
                self.canvas.create_oval(x - sat_size - 4, y - sat_size - 4, x + sat_size + 4, y + sat_size + 4, outline="#ffffff", width=2)
            self.canvas.create_oval(x - sat_size, y - sat_size, x + sat_size, y + sat_size, fill=color, outline="#ffffff", width=1)
            label_text = f"{prefix}{prn:02d}" if zero_pad else f"{prefix}{prn}"
            self.canvas.create_text(x, y, text=label_text, fill="#ffffff", font=("Arial", max(6, int(sat_size * 0.75)), "bold"))

    def update_loop(self):
        self.draw_all()
        self.after(100, self.update_loop)

if __name__ == "__main__":
    reader_thread = threading.Thread(target=serial_reader, daemon=True)
    reader_thread.start()
    app = SkyPlotApp()
    app.mainloop()

M5Stack GPS unit v1.1 は、多くの衛星を捕捉できます。


以上

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?