track #9(Issue #9、outcome: adopted)で確定した採用構成、および Issue #2 に対する CODEX からの handover 合意事項を踏まえた実装案。実装はまだ着手しておらず、本文書は着手前の設計整理。
Issue #9のCODEX設計レビュー(無効Fixの扱い・接続状態モデル・replay速度の呼称・NMEA再利用境界・軽微な指摘の計5点)を反映済み。反映箇所は各節に記載。
P1で確立したNMEA GGA/RMC入力を地図表示へ接続する最小プロトタイプについて、track #9で決めた構成を実装可能な粒度まで具体化し、着手前にレビューできる形にする。
track #9 の判断(Issue #9)と CODEX handover の合意事項をあわせると以下の通り。
| 項目 | 決定内容 |
|---|---|
| 地図描画ライブラリ | MapLibre GL JS |
| タイル提供元 | オンラインサービスを暫定採用。具体名は OpenFreeMap。スタイル設定として分離し後から交換可能にする |
| 配信方式 | file:// 直開きではなくローカルHTTPサーバーから配信(CORS/ブラウザ制約回避) |
| サーバー実装 | Python標準ライブラリ http.server ベース。外部フレームワーク不使用 |
| 位置更新方式 | まずJSONポーリング。更新頻度・遅延が問題化した場合にSSE/WebSocketへ切替 |
| 新規配置先 | tools/map/ |
| 検証順序 | 保存済みNMEAログ再生 → P1のライブTCPストリーム。P2完了前にライブ受信経路の確認を必須とする |
| 手動検証ログ | track #8 の1回目 artifacts/raw-logs/drogger_20260711_152445.nmea(docs/decisions/p1-position-verification.md参照) |
| 自動テスト方針 | Git管理外の実測ログに依存せず、少量のGGA/RMCフィクスチャをリポジトリ内(samples/)に用意する |
| ログ再生速度 | 平均レート(1x)・高速(Nx)を選択可能にする。元ログの正確な時系列再現ではない点をREADME/APIで明記する(CODEXレビュー指摘3) |
| オフライン対応 | 本trackでは決定せず、P2進行中に要否と理由を判断・記録する |
[NMEAソース] [tools/map/ サーバープロセス] [ブラウザ]
保存済み.nmea (replay) ---+ +-- fix_state.py (共有状態 + Lock) index.html
P1ライブTCP 127.0.0.1:2947 -+--------> | reader が更新 --HTTP--> + map.js (MapLibre GL JS)
(将来、receive.py/serial_bridge.ps1系) +-- server.py (http.server) 定期ポーリングで
GET / -> index.html/static /api/fix を取得し
GET /api/fix -> 最新Fix + 接続状態 マーカー・状態表示を更新
fix_state.py の共有状態を介して疎結合にするtools/parse/gnss_stream_stats.py の decode_gga / decode_rmc / nmea_checksum をそのまま再利用するtools/map/
server.py # http.server ベースの配信 + /api/fix ハンドラ
fix_state.py # FixState dataclass、Lock付き共有状態、GGA/RMC反映ロジック
replay_source.py # 保存済み.nmeaログを速度指定で読み進めるreader
live_source.py # P1のライブTCPストリームを読み進めるreader(P2完了条件のため必須実装、後続で追加)
static/
index.html # 最小限のページ。MapLibre GL JS本体はバージョン固定CDN(8章参照)
map.js # MapLibre初期化、/api/fixへのポーリング、マーカー・状態更新
map-config.json # タイルスタイルURLなど、差し替え対象の設定を分離
line_assembler.py # TCP/ファイル入力を1NMEAセンテンス単位に組み立てるadapter(6.3節)
tests/
test_fix_state.py
test_replay_source.py
test_line_assembler.py
test_live_source.py
README.md
samples/
map-fixtures/
minimal.nmea # 自動テスト用の小さなGGA/RMCフィクスチャ(Git管理する)
docs/workflow.md のリポジトリ内配置基準に沿い、確認用の補助ツールとして tools/map/ に置き、自動テスト用の小さな再現ログは samples/ に置く。
fix_state.py)FixState:
# --- 直近の「有効な」Fix(座標として採用してよい値) ---
lat: float | None
lon: float | None
altitude: float | None # GGA第9フィールド(標高、単位M)。API境界で数値型に正規化する(CODEXレビュー指摘5)
fix_quality: int | None # GGAのfix_quality。数値型に正規化する(CODEXレビュー指摘5)
num_satellites: int | None
rmc_status: str | None # RMCのstatus(A=有効/V=無効)。直近の有効Fixに紐づく値
last_sentence_type: str | None # 直近「有効Fixとして」反映したセンテンス種別(GGA/RMC)
last_valid_fix_at: str | None # 直近の有効Fixを反映した時刻(ISO8601、壁時計)
# --- 有効性・受信全般の状態(無効/エラーを含む全センテンスが対象) ---
fix_valid: bool # 直近処理したセンテンスが「有効なFix」を更新したか
invalid_fix_count: int # fix_quality=0 または status=V などで座標を採用しなかった件数
last_invalid_reason: str | None # 例: "gga_fix_quality_0" / "rmc_status_v" / "missing_lat_lon"
last_received_at: str | None # 有効/無効を問わず、何らかのセンテンスを受信・処理した時刻
sentence_count: int # 受信・処理したGGA+RMC件数の累計(有効/無効問わず)
checksum_fail_count: int
last_error: str | None # 直近のパース/接続エラー(累積件数は別カウンタで持つ)
error_count: int # last_errorとは別に、エラー発生件数の累積(CODEXレビュー指摘4)
# --- 接続状態(replay/liveで共通のenumとして扱う) ---
input_mode: "replay" | "live"
connection_state: "starting" | "connected" | "disconnected" | "reconnecting" | "eof" | "error"
connected_at: str | None
eof: bool # replay時、入力ログを読み切ったか(connection_state="eof"と併用)
fix_quality != 0 かつ lat/lon が取得できた場合のみ「有効な最新Fix」として lat/lon/altitude/fix_quality/num_satellites/last_sentence_type/last_valid_fix_at を更新する。それ以外(fix_quality == 0、または座標欠損)は fix_valid=False、invalid_fix_count を加算、last_invalid_reason="gga_fix_quality_0" 等を設定するのみで座標は更新しないstatus == "A" かつ lat/lon が取得できた場合のみ「有効な最新Fix」として更新する(GGAの位置と食い違う場合はより新しい有効センテンスを優先)。status == "V" または座標欠損は同様に座標を更新せず理由のみ記録するlast_received_at と sentence_count を更新する(接続やデータ受信自体は継続していることを示すため)checksum_fail_count / last_error / error_count のみ加算するfix_valid で明確に区別できるようにする(CODEXレビュー指摘1)connection_state は starting(reader開始直後・未接続)/ connected(replay: ファイルを開けた、live: TCP接続確立)/ disconnected(liveで接続が切れた)/ reconnecting(liveで再接続を試行中)/ eof(replayで読み切った)/ error(回復不能なエラー)を持つdisconnected → 有限バックオフで reconnecting に遷移する設計とする(具体的な再試行回数・間隔は6.2節参照)last_received_at が一定時間更新されない場合、connection_state に関わらず「stale」表示を行う(閾値は設定可能。8章参照)threading.Lock でreaderスレッドとHTTPハンドラスレッド間の読み書きを保護するreplay_source.py、最初に実装)artifacts/raw-logs/drogger_20260711_152445.nmea、自動テストは samples/map-fixtures/minimal.nmeacapture_session.pyが生成する.meta.jsonサイドカーのduration_actual_sとnmea.sentence_countから平均間隔を算出し、それを基準間隔とする(メタデータが無い入力向けに固定値のフォールバックも用意する)duration_actual_s / sentence_countから算出した平均間隔での再生であり、元ログの各センテンスの実際の間隔やGGA/RMCの同一epochを再現するものではない。README/APIでは「等速」ではなくこの呼称を使う(CODEXレビュー指摘3)FixState.eof = Trueとし、サーバー側は最後の状態を保持したまま更新を止める(成功条件の「EOF確認」に対応)live_source.py、P2完了前に必須)127.0.0.1:2947(tools/windows-serial-bridge/serial_bridge.ps1 → WSL2側TCP、docs/decisions/p1-position-verification.mdの接続経路と同一)tools/windows-serial-bridge/receive.pyのreceive()は「ファイルへ書き切って終了」する設計のため、そのままは使えない。ライブ表示にはソケットから逐次読み取り、6.3節のadapterで改行単位に組み立てたNMEAセンテンスをfix_stateへ反映する専用readerが必要(receive()とはネットワーク接続確立部分のみ考え方を流用し、読み取りループは新規実装する)connection_state="connected"、connected_atを設定する。接続失敗・切断(ConnectionError/EOF相当のsocket切断)を検知したらconnection_state="disconnected"にし、有限バックオフ(例: 1秒→2秒→5秒→10秒の固定段階、上限を設ける)で再接続を試行しながらconnection_state="reconnecting"にする。上限試行後も失敗する場合はconnection_state="error"としlast_errorに理由を記録する。P1側のserial_bridge.ps1はクライアント再接続を受け付ける前提(docs/procedures/windows-bluetooth-receive.md参照)のため、再接続自体は妥当な設計fix_state.update(...)を呼ぶ」形の共通関数に寄せ、server.pyからはinput_mode以外の分岐が出ないようにするtools/parse/gnss_stream_stats.pyのtry_parse_nmea()はオフセット走査型(bytes全体とposを受け取りdata.find(NMEA_END, pos)で次の\r\nを探す)で、ファイル全体を一括デコードするparse_stream()向けの設計。TCPは任意のバイト境界で分割されて届くため、そのまま呼ぶだけでは「センテンスがちょうど1チャンクに収まる」場合しか正しく動かないtools/map/側に、decode_gga/decode_rmc/nmea_checksumをそのまま再利用しつつ、受信バイト列を1センテンス単位に組み立てる薄いadapter(例: line_assembler.py)を置く
\r\n(またはNMEA機器によっては\nのみの場合がある点も考慮)が見つかるまで蓄積する\r\nが見つかったら、バッファ先頭から$〜チェックサムまでを取り出し、try_parse_nmea相当のchecksum検証・decode_gga/decode_rmc呼び出しへ渡し、消費した分をバッファから捨てるerror_countを加算してlast_errorに記録する(不正な入力でバッファが無限に増え続けることを防ぐ)\nのみで終端される」「非ASCIIバイトが混入する」「終端に達しない未完了データのまま入力が終わる」を最低限カバーする(6.3節・9.1節)last_errorの扱い: 直近1件のメッセージをlast_errorに保持し、後続の正常なセンテンス処理で上書き・クリアする一方、発生件数の累積はerror_countとして別に保持する(「直近のエラー」と「これまでの累積」を区別する。CODEXレビュー指摘4)server.py)http.server.ThreadingHTTPServer + 自作BaseHTTPRequestHandlerサブクラスGET / → static/index.htmlを返すGET /static/* → static/配下を静的配信。リクエストパスを正規化し、static/の外へ出るパス(../等)を拒否する(CODEXレビュー指摘5)GET /api/fix → FixStateをJSONで返す(Fixと接続状態を1エンドポイントにまとめ、成功条件の「最新Fixと状態をJSON APIで取得できる」を満たす。分割が必要になった場合はここで再検討する)。altitude/fix_quality/num_satellitesはAPI境界で数値型(float/int)に正規化して返す(CODEXレビュー指摘5)fix_stateのスナップショットを読むだけにするstatic/map.js)map-config.jsonから取得したスタイルURL(OpenFreeMapのホスト済みstyle.json、例: https://tiles.openfreemap.org/styles/liberty)で地図を初期化するlatestは使わない。P2はオンライン前提の暫定構成であり、CDN/vendor同梱の判断はオフライン対応の要否が決まってから確定する。CODEXレビュー指摘5)map.js本体に埋め込まずmap-config.jsonからfetchして読む(成功条件の「タイルスタイルURLをコード本体から分離して変更できる」に対応)/api/fixをポーリングし、
fix_valid: trueかつlat/lonがあればマーカー位置を更新する。fix_valid: falseの場合はマーカーを直近の有効位置のまま維持し、「現在無効なFixを受信中」であることを別途表示する(CODEXレビュー指摘1)last_received_atを「最終受信時刻」として画面表示するlast_received_atが設定可能な閾値(初期値: 例えば5秒程度、要調整)を超えて更新されない場合はconnection_stateに関わらず「stale」表示にする(CODEXレビュー指摘2)connection_state(connected/disconnected/reconnecting/eof/error)を接続状態表示に反映するchecksum_fail_count > 0やlast_error/error_countがあれば画面上にエラー表示するconnection_state: "eof"なら再生終了を表示するsamples/map-fixtures/minimal.nmeaに、有効なGGA/RMC数行、checksum不正1行、パース不能な壊れた行に加えて、以下をカバーするケースを用意する(CODEXレビュー指摘1・4)
fix_quality=0(座標を採用してはいけないケース)status=V(座標を採用してはいけないケース)test_fix_state.py: 有効GGA/RMCの反映、fix_quality=0/status=V/座標欠損時に座標を更新せずinvalid_fix_count・last_invalid_reasonのみ更新すること、checksum不正時の非更新、error_countとエラーカウントの区別を検証(CODEXレビュー指摘1・4)test_replay_source.py: フィクスチャ全体を高速再生し、EOF検知・件数・平均間隔算出ロジック(duration_actual_sが0以下、sentence_countが0/欠損、metadataと実ファイル件数が不一致の場合のfallbackを含む)を検証(CODEXレビュー指摘3)。実時間待ちを避けるため、sleep相当の待機はテストから注入・差し替え可能にするline_assembler(6.3節)向けのテスト: 複数センテンスが1チャンクに同時に届く、1センテンスが複数チャンクに分割される、\nのみで終端される、非ASCIIバイトが混入する、最大行長を超えても終端が見つからない、をそれぞれ検証する(CODEXレビュー指摘4)live_source.py向けのテスト: 接続失敗、途中切断、再接続(バックオフを含む)を検証する(CODEXレビュー指摘2)artifacts/raw-logs/drogger_20260711_152445.nmeaを再生ソースとしてserver.pyを起動するhttp://localhost:<port>/を開き、OpenFreeMapの地図上にマーカーが表示され、再生に応じて位置と最終受信時刻が更新されることを確認する/api/fixを直接取得し、フィールドが仕様通りであること、altitude/fix_quality/num_satellitesが数値型であることを確認するfix_quality=0/status=V)を混在させた入力で、マーカーが直近の有効位置を維持し「無効Fix受信中」の表示が出ることを確認する(CODEXレビュー指摘1)live_source.py実装後、P1と同じライブTCP経路(Windows中継稼働中)で同様の確認を行う。加えて、Windows中継プロセスを一時停止・再開し、connection_stateがdisconnected→reconnecting→connectedと遷移すること、last_received_at停止中は画面がstale表示になることを確認する(CODEXレビュー指摘2)| CODEX案の成功条件 | 対応する設計 |
|---|---|
| ローカルHTTPサーバーから画面を表示できる | 3章・7章 |
| OpenFreeMap上に最新Fixマーカーを表示できる | 4・8章 |
| NMEAログ再生に合わせてマーカーと受信時刻が更新される | 6.1・8章 |
/api/fix等のJSON APIで最新Fixと状態を取得できる |
5・7章 |
| EOF、無効Fix、解析エラーを画面またはログで確認できる | 5・6.1・6.3・8章 |
| タイルスタイルURLをコード本体から分離して変更できる | 8章(map-config.json) |
| 自動テストと実測ログによる手動確認手順がある | 9章 |
live_source.pyの具体的な読み取り実装(ソケット直読みか、receive.pyのファイル書き込みを裏で回してtailするか)は未確定。設計原則(ネットワーク接続確立のみ流用、読み取りループは新規、line_assemblerで行組み立て)は決めたが、詳細実装は着手時に判断するtrack_startし、track #9とは分離して実装に着手する