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?

RON-V #15 — RON-V統合後編 — 自動配送を仕上げて再起動後の復帰を検証する

0
Last updated at Posted at 2026-10-18

RON-V 連載リンク
← 前の記事:#14 — 統合前編:L0/L1/L2と自動配送
→ 次の記事:#16 — Google Sheets・Looker Studioの準備

L0〜L4の定義追記(2026-10-25)

本文中のコード・試験結果は執筆当時の記録として残しています。画像AI・音声を関連付ける現在の定義は次のとおりです。

レベル 残す内容
L0 Meshtastic向けの23バイト短報。画像AIの候補通知は任意設定
L1 個別の観測・撮影/録音/認識の状態と、詳細への参照
L2 一定時間の観測件数・分類別候補などの集計
L3 画像AIの詳細、録音情報、文字起こし全文
L4 元画像JPEG、元音声WAV、LTEのraw logなど

L0の警報欄は1バイト=8ビットです。新しいversion=2の定義は、倒木 0x01、冠水 0x02、煙 0x04、道路上の障害物 0x08、路面の穴 0x10。残り3ビットは未割り当てです。0x03は倒木と冠水の組み合わせで、煙の番号ではありません。0x00はL0上では未判定で、安全を示しません。 候補ありでは元イベントの時刻・位置を使い、現在地と混同しません。

発信元はnode_uid、根拠は画像・音声などです。「処理成功」「災害の候補」「人の確認済み」を分けます。初期の確認状態はunreviewed。画像AIの検出0件を「危険なし」とせず、音声の否定も全文へ残します。人が補足・訂正する画面と、音声からの災害自動分類は今後の実装です。

10月25日にL1の関連付け・L2の集計・音声L3/L4の転送処理をコードへ追加しました。新連携の実機確認はこれからです。音声転送とL0候補通知は初期設定では無効で、個別に有効化します。従来のL0 version=1へ、この分類の意味を遡って付けません。

Looker Studioで使用中のSheets summaryはRF測定から直接出力する既存の10列で、L2とは別です。今回の追加で列名・順序・値の意味、既存Grafanaの参照項目は変えません。L1のMesh送信、写真・音声のMesh転送、自動公開は実装していません。

詳細と設定手順:データレベルの定義(リポジトリの閲覧権限が必要です)。

[!NOTE]
2026-09-23 現行GitHub同期メモ
#15執筆時点ではL3/L4を将来拡張としていましたが、現在のGitHub main では実装済みです。LTEは app/integration/lte_evidence.py により L3=解析済みJSON / L4=cell_search raw log、Vehicle Cameraは vehicle_evidence.py により L3=manifest JSON / L4=JPEG evidence を生成します。rclone_delivery_worker.py がL3/L4を sync_deliveries 経由で非同期転送します。したがって本文中の「L3/L4は未実装」は当時の開発段階を示す記録として読み、現在仕様はこの注記とGitHub main を優先してください。

はじめに

前編では、RON-Vの観測データを、

L0
L1
L2

へ整理し、

sync_objects
sync_deliveries
sync_cursors

を用いた同期・配送管理を実装しました。

さらに、

ronv-controller.timer
        ↓
Integration Cycle
        ↓
L0 / L1 / L2
        ↓
Delivery登録
        ↓
ronv-delivery.timer
        ↓
Meshtastic
        ↓
920MHz
        ↓
real ACK

までを自動化しました。

前編終了時点では、一晩の継続運転とControllerの約600秒周期化まで確認できています。

後編では、ここからRON-V統合の最終仕上げを行います。

今回確認するのは、

統合状態確認コマンド
        ↓
L3 / L4の設計境界
        ↓
最終バックアップ
        ↓
reboot前検査
        ↓
Raspberry Pi reboot
        ↓
service / timer自動復帰
        ↓
reboot後の新しいL0
        ↓
Delivery自動処理
        ↓
Meshtastic real ACK

です。

最終的に、

電源投入
   ↓
RON-V自動起動
   ↓
観測
   ↓
構造化
   ↓
配送
   ↓
920MHz
   ↓
real ACK

まで人手を介さず復帰できれば、RON-V #15の統合完成とします。


現行statusの追加点

現在の app/integration/status.py / bin/ronv-status は、当時のVehicle/Web/Mesh/Controller/Deliveryに加え、RF測定の最新状態と ronv-rf-measure.timer も確認対象に含みます。

154. 統合状態確認コマンドを作る

これまでRON-Vの確認では、

systemctl
sqlite3
curl
journalctl

を個別に実行していました。

しかし、統合後は確認対象が増えています。

例えば、

Vehicle
Web
Mesh
gpsd
Grafana

Controller
Delivery

L0
L1
L2

sync_deliveries
mesh_messages

SQLite integrity

があります。

そこで、RON-V全体を一度に確認する、

ronv-status

を作ります。

このコマンドは、

read only

とします。

つまり、

DB更新しない
USBを開かない
serviceをrestartしない

状態確認専用コマンドです。


155. status.pyを作る

ファイル:

app/integration/status.py

を作ります。

既存ファイルがある場合に備えて、先にバックアップします。

cd /home/ronv/ronv

STAMP=$(date +%Y%m%d_%H%M%S)

if [ -f app/integration/status.py ]; then
  cp -a \
    app/integration/status.py \
    "app/integration/status.py.bak_${STAMP}"
fi

echo "STAMP=$STAMP"

156. status.py

cd /home/ronv/ronv

cat > app/integration/status.py <<'PY'
from __future__ import annotations

import json
import sqlite3
import subprocess
import urllib.error
import urllib.request

from app.common.config import load_config
from app.integration.l0_packet import unpack_l0


def systemctl_value(
    unit: str,
    command: str,
) -> str:

    try:

        result = subprocess.run(
            [
                "systemctl",
                command,
                unit,
            ],
            capture_output=True,
            text=True,
            timeout=3,
            check=False,
        )

        value = (
            result.stdout.strip()
            or result.stderr.strip()
            or "unknown"
        )

        return value

    except subprocess.TimeoutExpired:

        return "timeout"

    except Exception as exc:

        return (
            "error:"
            + type(exc).__name__
        )


def systemd_result(
    unit: str,
) -> str:

    try:

        result = subprocess.run(
            [
                "systemctl",
                "show",
                unit,
                "--property=Result",
                "--value",
            ],
            capture_output=True,
            text=True,
            timeout=3,
            check=False,
        )

        return (
            result.stdout.strip()
            or "unknown"
        )

    except subprocess.TimeoutExpired:

        return "timeout"

    except Exception as exc:

        return (
            "error:"
            + type(exc).__name__
        )


def web_health(
    host: str,
    port: int,
) -> str:

    if host in (
        "0.0.0.0",
        "::",
    ):
        host = "127.0.0.1"

    url = (
        f"http://{host}:{port}/health"
    )

    try:

        with urllib.request.urlopen(
            url,
            timeout=3,
        ) as response:

            body = (
                response
                .read(512)
                .decode(
                    "utf-8",
                    errors="replace",
                )
                .strip()
            )

            return (
                f"HTTP {response.status} "
                f"{body}"
            )

    except urllib.error.URLError as exc:

        return (
            "ERROR "
            + str(exc.reason)
        )

    except Exception as exc:

        return (
            "ERROR "
            + type(exc).__name__
        )


def mask_node_id(
    value: str | None,
) -> str:

    if not value:
        return "-"

    value = str(value)

    if len(value) <= 5:
        return "***"

    return (
        value[:3]
        + "***"
        + value[-2:]
    )


def open_database_ro(
    db_path: str,
) -> sqlite3.Connection:

    con = sqlite3.connect(
        f"file:{db_path}?mode=ro",
        uri=True,
        timeout=5,
    )

    con.row_factory = sqlite3.Row

    return con


def print_system_status(
    config: dict,
) -> None:

    print(
        "===== SYSTEM ====="
    )

    continuous_units = (
        "ronv-vehicle.service",
        "ronv-web.service",
        "ronv-mesh.service",
        "gpsd.socket",
        "grafana-server.service",
    )

    for unit in continuous_units:

        active = systemctl_value(
            unit,
            "is-active",
        )

        enabled = systemctl_value(
            unit,
            "is-enabled",
        )

        print(
            f"{unit:28s}"
            f" active={active:10s}"
            f" enabled={enabled}"
        )

    print()

    timer_units = (
        "ronv-controller.timer",
        "ronv-delivery.timer",
    )

    for unit in timer_units:

        active = systemctl_value(
            unit,
            "is-active",
        )

        enabled = systemctl_value(
            unit,
            "is-enabled",
        )

        print(
            f"{unit:28s}"
            f" active={active:10s}"
            f" enabled={enabled}"
        )

    print()

    oneshot_units = (
        "ronv-controller.service",
        "ronv-delivery.service",
    )

    for unit in oneshot_units:

        active = systemctl_value(
            unit,
            "is-active",
        )

        result = systemd_result(
            unit
        )

        print(
            f"{unit:28s}"
            f" active={active:10s}"
            f" last_result={result}"
        )


def print_config(
    config: dict,
) -> None:

    print(
        "===== CONFIG ====="
    )

    print(
        "project             :",
        config[
            "project"
        ][
            "name"
        ],
    )

    print(
        "version             :",
        config[
            "project"
        ][
            "version"
        ],
    )

    print(
        "cycle_sec           :",
        config[
            "controller"
        ][
            "cycle_sec"
        ],
    )

    print(
        "integration.node_id :",
        config.get(
            "integration",
            {},
        ).get(
            "node_id",
            "-",
        ),
    )

    print(
        "mesh_destination    :",
        mask_node_id(
            config.get(
                "integration",
                {},
            ).get(
                "mesh_destination"
            )
        ),
    )

    print(
        "sqlite_db           :",
        config[
            "paths"
        ][
            "sqlite_db"
        ],
    )


def print_database_status(
    config: dict,
) -> None:

    db_path = (
        config[
            "paths"
        ][
            "sqlite_db"
        ]
    )

    con = open_database_ro(
        db_path
    )

    try:

        print(
            "===== DATABASE ====="
        )

        integrity = con.execute(
            "PRAGMA integrity_check;"
        ).fetchone()[0]

        print(
            "integrity_check     :",
            integrity,
        )

        print()

        print(
            "===== L0 LATEST ====="
        )

        row = con.execute(
            """
            SELECT
                id,
                object_uid,
                created_at_jst,
                sequence_u16,
                latitude_deg,
                longitude_deg,
                network_flags_u8,
                packet_bytes,
                packet_hex
            FROM l0_beacons
            ORDER BY id DESC
            LIMIT 1
            """
        ).fetchone()

        if row is None:

            print(
                "no L0 beacon"
            )

        else:

            crc_ok = False

            try:

                packet = bytes.fromhex(
                    row[
                        "packet_hex"
                    ]
                )

                decoded = unpack_l0(
                    packet
                )

                crc_ok = bool(
                    decoded[
                        "crc_ok"
                    ]
                )

            except Exception:

                crc_ok = False

            position = (
                "yes"
                if (
                    row[
                        "latitude_deg"
                    ]
                    is not None
                    and
                    row[
                        "longitude_deg"
                    ]
                    is not None
                )
                else "no"
            )

            print(
                "id                  :",
                row["id"],
            )

            print(
                "object_uid          :",
                row["object_uid"],
            )

            print(
                "created_at_jst      :",
                row["created_at_jst"],
            )

            print(
                "sequence            :",
                row["sequence_u16"],
            )

            print(
                "position_available  :",
                position,
            )

            print(
                "network_flags       :",
                row[
                    "network_flags_u8"
                ],
            )

            print(
                "packet_bytes        :",
                row[
                    "packet_bytes"
                ],
            )

            print(
                "crc_ok              :",
                crc_ok,
            )

        print()

        print(
            "===== L1 COUNTS ====="
        )

        rows = con.execute(
            """
            SELECT
                data_type,
                COUNT(*) AS rows
            FROM sync_objects
            WHERE level=1
            GROUP BY data_type
            ORDER BY data_type
            """
        ).fetchall()

        if not rows:

            print(
                "no L1 objects"
            )

        else:

            for item in rows:

                print(
                    f"{item['data_type']:24s}:",
                    item["rows"],
                )

        print()

        print(
            "===== L2 LATEST ====="
        )

        row = con.execute(
            """
            SELECT
                object_uid,
                created_at_jst,
                bytes,
                payload_json
            FROM sync_objects
            WHERE level=2
              AND data_type='cycle_summary'
            ORDER BY created_at_jst DESC
            LIMIT 1
            """
        ).fetchone()

        if row is None:

            print(
                "no L2 summary"
            )

        else:

            print(
                "object_uid          :",
                row["object_uid"],
            )

            print(
                "created_at_jst      :",
                row["created_at_jst"],
            )

            print(
                "bytes               :",
                row["bytes"],
            )

            try:

                payload = json.loads(
                    row[
                        "payload_json"
                    ]
                )

                window = payload.get(
                    "window",
                    {},
                )

                vehicle = payload.get(
                    "vehicle",
                    {},
                )

                lte = payload.get(
                    "lte",
                    {},
                )

                mesh = payload.get(
                    "mesh",
                    {},
                )

                print(
                    "cycle_sec           :",
                    window.get(
                        "cycle_sec",
                        "-",
                    ),
                )

                print(
                    "vehicle.count       :",
                    vehicle.get(
                        "count",
                        "-",
                    ),
                )

                print(
                    "vehicle.shock       :",
                    vehicle.get(
                        "shock_count",
                        "-",
                    ),
                )

                print(
                    "lte.count           :",
                    lte.get(
                        "count",
                        "-",
                    ),
                )

                print(
                    "lte.unique_pci      :",
                    lte.get(
                        "unique_pci_count",
                        "-",
                    ),
                )

                print(
                    "mesh.tx_count       :",
                    mesh.get(
                        "tx_count",
                        "-",
                    ),
                )

                print(
                    "mesh.tx_acked       :",
                    mesh.get(
                        "tx_acked",
                        "-",
                    ),
                )

            except Exception as exc:

                print(
                    "payload_parse       :",
                    "ERROR",
                    type(exc).__name__,
                )

        print()

        print(
            "===== DELIVERY LATEST ====="
        )

        row = con.execute(
            """
            SELECT
                id,
                object_uid,
                transport,
                state,
                attempt_count,
                created_at_jst,
                acked_at_jst,
                remote_id,
                last_latency_ms,
                last_error
            FROM sync_deliveries
            ORDER BY id DESC
            LIMIT 1
            """
        ).fetchone()

        if row is None:

            print(
                "no delivery"
            )

        else:

            print(
                "id                  :",
                row["id"],
            )

            print(
                "object_uid          :",
                row["object_uid"],
            )

            print(
                "transport           :",
                row["transport"],
            )

            print(
                "state               :",
                row["state"],
            )

            print(
                "attempt_count       :",
                row["attempt_count"],
            )

            print(
                "created_at_jst      :",
                row["created_at_jst"],
            )

            print(
                "acked_at_jst        :",
                row["acked_at_jst"],
            )

            print(
                "remote_id           :",
                row["remote_id"],
            )

            print(
                "last_latency_ms     :",
                row["last_latency_ms"],
            )

            print(
                "last_error          :",
                row["last_error"],
            )

        print()

        print(
            "===== DELIVERY STATES ====="
        )

        rows = con.execute(
            """
            SELECT
                state,
                COUNT(*) AS rows
            FROM sync_deliveries
            GROUP BY state
            ORDER BY state
            """
        ).fetchall()

        for item in rows:

            print(
                f"{item['state']:12s}:",
                item["rows"],
            )

        print()

        print(
            "===== MESH TX LATEST ====="
        )

        row = con.execute(
            """
            SELECT
                id,
                timestamp_jst,
                state,
                packet_id,
                ack_type,
                ack_error,
                last_error
            FROM mesh_messages
            WHERE direction='TX'
            ORDER BY id DESC
            LIMIT 1
            """
        ).fetchone()

        if row is None:

            print(
                "no Mesh TX"
            )

        else:

            print(
                "id                  :",
                row["id"],
            )

            print(
                "timestamp_jst       :",
                row["timestamp_jst"],
            )

            print(
                "state               :",
                row["state"],
            )

            print(
                "packet_id           :",
                row["packet_id"],
            )

            print(
                "ack_type            :",
                row["ack_type"],
            )

            print(
                "ack_error           :",
                row["ack_error"],
            )

            print(
                "last_error          :",
                row["last_error"],
            )

    finally:

        con.close()


def main() -> None:

    config = load_config()

    print(
        "========================================"
    )

    print(
        "RON-V INTEGRATION STATUS"
    )

    print(
        "========================================"
    )

    print()

    print_config(
        config
    )

    print()

    print_system_status(
        config
    )

    print()

    print(
        "===== WEB HEALTH ====="
    )

    print(
        web_health(
            str(
                config[
                    "web"
                ][
                    "host"
                ]
            ),
            int(
                config[
                    "web"
                ][
                    "port"
                ]
            ),
        )
    )

    print()

    print_database_status(
        config
    )

    print()

    print(
        "========================================"
    )

    print(
        "RON-V STATUS END"
    )

    print(
        "========================================"
    )


if __name__ == "__main__":

    main()
PY

157. 構文確認

cd /home/ronv/ronv

/home/ronv/.venvs/ronv/bin/python \
  -m py_compile \
  app/integration/status.py

何も表示されなければOKです。


158. status.pyを実行する

cd /home/ronv/ronv

RONV_CONFIG=/home/ronv/ronv/config/ronv.toml \
/home/ronv/.venvs/ronv/bin/python \
  -m app.integration.status

実機では、

===== SYSTEM =====
ronv-vehicle.service         active=active     enabled=enabled
ronv-web.service             active=active     enabled=enabled
ronv-mesh.service            active=active     enabled=enabled
gpsd.socket                  active=active     enabled=enabled
grafana-server.service       active=active     enabled=enabled

ronv-controller.timer        active=active     enabled=enabled
ronv-delivery.timer          active=active     enabled=enabled

ronv-controller.service      active=inactive   last_result=success
ronv-delivery.service        active=inactive   last_result=success

となりました。

ControllerとDeliveryは、

Type=oneshot

なので、

active=inactive

でも正常です。

重要なのは、

last_result=success

です。


159. Web / SQLite / L0も同時確認する

実機:

===== WEB HEALTH =====
HTTP 200 {"status":"ok"}

===== DATABASE =====
integrity_check     : ok

最新L0:

sequence            : 76
position_available  : no
network_flags       : 4
packet_bytes        : 23
crc_ok              : True

室内だったためGNSSは、

position_available : no

でした。

これは異常ではありません。


160. L1 / L2も確認する

L1:

lte_cell_observation    : 1
vehicle_event           : 7

L2:

cycle_sec           : 600
vehicle.count       : 0
vehicle.shock       : 0
lte.count           : 0
lte.unique_pci      : 0
mesh.tx_count       : 1
mesh.tx_acked       : 1

L1 COUNTSは累積です。

一方L2は、

直近600秒

なので、値の意味は異なります。

例えば、

vehicle_event累積 = 7

直近600秒
vehicle.count = 0

という状態は正常です。


161. Delivery状態も確認する

実機:

===== DELIVERY LATEST =====
state               : acked
attempt_count       : 1
last_error          : None

さらに、

===== DELIVERY STATES =====
acked       : 64

でした。

この時点でDBに存在していた64配送はすべて、

acked

でした。


162. Mesh real ACKも確認する

===== MESH TX LATEST =====
state               : acked
ack_type            : real
ack_error           : NONE
last_error          : None

つまり、

Delivery
  ↓
Meshtastic
  ↓
real ACK

までstatusコマンドだけで確認できます。


163. ronv-statusコマンドを作る

毎回、

python -m app.integration.status

を打つのは面倒です。

そこで、

ronv-status

だけで実行できるようにします。


164. 起動スクリプトを作る

cd /home/ronv/ronv

STAMP=$(date +%Y%m%d_%H%M%S)

mkdir -p bin

if [ -f bin/ronv-status ]; then
  cp -a \
    bin/ronv-status \
    "bin/ronv-status.bak_${STAMP}"
fi

cat > bin/ronv-status <<'SH'
#!/bin/bash

set -e

export RONV_CONFIG=/home/ronv/ronv/config/ronv.toml

cd /home/ronv/ronv

exec \
  /home/ronv/.venvs/ronv/bin/python \
  -m app.integration.status
SH

chmod +x bin/ronv-status

165. /usr/local/binへリンクする

sudo ln -sfn \
  /home/ronv/ronv/bin/ronv-status \
  /usr/local/bin/ronv-status

確認:

command -v ronv-status

結果:

/usr/local/bin/ronv-status

166. ronv-statusを実行する

ronv-status

これだけで、

service
timer
Web
SQLite
L0
L1
L2
Delivery
Mesh

をまとめて確認できます。

このコマンドは、この後のreboot試験でも使用します。


167. 現在のL3 / L4実装

#15の初期段階ではL3/L4を将来拡張としていましたが、現在のGitHub main ではEvidence層として実装済みです。

168. LTE Evidence

lte_measurements
      │
      ├─ L3 /var/lib/ronv/l3/lte/*.json
      │      解析済み cell_search evidence
      │
      └─ L4 /var/lib/ronv/l4/lte/*.log
             raw cell_search log

実装は、

app/integration/lte_evidence.py

です。L3/L4はSHA-256を保持して sync_objects へ登録されます。

169. Vehicle / Camera Evidence

Vehicle EventとCameraは、

app/integration/vehicle_evidence.py

でEvidence化します。

L3 = Vehicle Event / Camera manifest JSON
L4 = captured JPEG evidence

RingCamのSHOCK Eventでは現在、イベント時刻を中心に選択した複数画像をL4として扱える実装へ発展しています。

170. L3 / L4の非同期配送

L3/L4ファイルは sync_deliveries に transport=rclone_drive として登録し、

app/integration/rclone_delivery_worker.py

がGoogle Drive等へ非同期転送します。外部通信が失敗しても測定処理そのものを止めない設計です。

したがって現在のレベル構成は、

L0   Node Beacon
L1   Sensor Fact (Vehicle / LTE / RF)
L2   Cycle Summary
L3   Structured Evidence / Manifest
L4   Raw Evidence File / Raw Log

となります。

171. reboot前の最終バックアップ

ここからRaspberry Piの再起動試験へ進みます。

その前に、現在のRON-Vを保存します。

今回保存するのは、

integration source
config
systemd
ronv-status
SQLite DB

です。


172. pre_rebootバックアップを作る

cd /home/ronv/ronv

STAMP=$(date +%Y%m%d_%H%M%S)

mkdir -p backups

FINAL_DIR="backups/pre_reboot_${STAMP}"

mkdir -p "$FINAL_DIR"

echo "FINAL_DIR=$FINAL_DIR"

cp -a \
  app/integration \
  "$FINAL_DIR/integration"

cp -a \
  config/ronv.toml \
  "$FINAL_DIR/ronv.toml"

cp -a \
  systemd \
  "$FINAL_DIR/systemd"

cp -a \
  bin/ronv-status \
  "$FINAL_DIR/ronv-status"

sqlite3 /var/lib/ronv/ronv.db \
  ".backup '$FINAL_DIR/ronv.db'"

SQLiteはファイルコピーではなく、

SQLite .backup

を使います。


173. バックアップDBを検査する

sqlite3 \
  "$FINAL_DIR/ronv.db" \
  'PRAGMA integrity_check;'

実機:

ok

174. バックアップ内容を確認する

find "$FINAL_DIR" \
  -maxdepth 3 \
  -type f \
  -printf '%p %s bytes\n' \
  | sort

du -sh "$FINAL_DIR"

実機では、

ronv.db
integration/
ronv.toml
systemd/
ronv-status

などが保存されました。

バックアップサイズは、

684K

でした。


175. SHA-256 manifestを作る

バックアップ後の改変検出用に、

SHA256SUMS

を作ります。

cd /home/ronv/ronv

FINAL_DIR="backups/pre_reboot_<TIMESTAMP>"

cd "$FINAL_DIR"

find . \
  -type f \
  ! -name SHA256SUMS \
  -print0 \
  | sort -z \
  | xargs -0 sha256sum \
  > SHA256SUMS

確認:

wc -l SHA256SUMS

実機:

36 SHA256SUMS

176. SHA-256を検証する

sha256sum -c SHA256SUMS

実機では36ファイルすべて、

OK

となりました。

例えば、

./integration/cycle.py: OK
./integration/delivery_worker.py: OK
./integration/status.py: OK
./integration/sync_store.py: OK

./ronv.db: OK
./ronv-status: OK
./ronv.toml: OK

./systemd/ronv-controller.service: OK
./systemd/ronv-controller.timer: OK
./systemd/ronv-delivery.service: OK
./systemd/ronv-delivery.timer: OK

です。


177. バックアップDB integrityを再確認

sqlite3 \
  /home/ronv/ronv/backups/pre_reboot_<TIMESTAMP>/ronv.db \
  'PRAGMA integrity_check;'

結果:

ok

これで、

source
config
systemd
SQLite
hash

まで保存できました。


178. reboot前にPythonを全検査する

cd /home/ronv/ronv

/home/ronv/.venvs/ronv/bin/python \
  -m py_compile \
  app/integration/sync_store.py \
  app/integration/l0_packet.py \
  app/integration/l0_store.py \
  app/integration/l1_store.py \
  app/integration/l2_summary.py \
  app/integration/delivery_worker.py \
  app/integration/cycle.py \
  app/integration/status.py

echo "Python compile: OK"

実機:

Python compile: OK

179. systemd unitもverifyする

systemd-analyze verify \
  systemd/ronv-vehicle.service \
  systemd/ronv-web.service \
  systemd/ronv-mesh.service \
  systemd/ronv-lte-measure.service \
  systemd/ronv-controller.service \
  systemd/ronv-controller.timer \
  systemd/ronv-delivery.service \
  systemd/ronv-delivery.timer

echo "systemd verify: OK"

実機:

systemd verify: OK

180. project版とinstalled版のunitを比較する

for UNIT in \
  ronv-vehicle.service \
  ronv-web.service \
  ronv-mesh.service \
  ronv-controller.service \
  ronv-controller.timer \
  ronv-delivery.service \
  ronv-delivery.timer
do
  if cmp -s \
    "systemd/$UNIT" \
    "/etc/systemd/system/$UNIT"
  then
    echo "$UNIT : MATCH"
  else
    echo "$UNIT : DIFFER"
  fi
done

実機:

ronv-vehicle.service : MATCH
ronv-web.service : MATCH
ronv-mesh.service : MATCH
ronv-controller.service : MATCH
ronv-controller.timer : MATCH
ronv-delivery.service : MATCH
ronv-delivery.timer : MATCH

つまり、

repository側のunit

と、

実際にsystemdへインストールされているunit

が一致しています。


181. reboot前baselineを取る

再起動前に、比較用の状態を記録します。

まずboot IDです。

echo -n "boot_id    : "
cat /proc/sys/kernel/random/boot_id

echo -n "boot_since : "
uptime -s

公開記事では値そのものは伏せます。

boot_id    : <PRE_REBOOT_BOOT_ID>
boot_since : 2026-09-04 21:13:37

182. timer状態

systemctl is-enabled \
  ronv-controller.timer \
  ronv-delivery.timer

systemctl is-active \
  ronv-controller.timer \
  ronv-delivery.timer

実機:

enabled
enabled
active
active

183. ronv-statusでreboot前確認

ronv-status

主要service:

ronv-vehicle.service         active=active     enabled=enabled
ronv-web.service             active=active     enabled=enabled
ronv-mesh.service            active=active     enabled=enabled
gpsd.socket                  active=active     enabled=enabled
grafana-server.service       active=active     enabled=enabled

timer:

ronv-controller.timer        active=active     enabled=enabled
ronv-delivery.timer          active=active     enabled=enabled

Web:

HTTP 200 {"status":"ok"}

SQLite:

integrity_check : ok

184. reboot前の最終L0

実機:

id                  : 82
sequence            : 82
created_at_jst      : 2026-09-05T10:59:32+09:00
packet_bytes        : 23
crc_ok              : True

つまりreboot前baselineは、

L0 sequence = 82

です。


185. reboot前のDelivery

実機:

id                  : 70
state               : acked
attempt_count       : 1
last_error          : None

この時点では、

Delivery id = 70

まで完了していました。


186. reboot前のMesh

実機:

id                  : 77
state               : acked
ack_type            : real
ack_error           : NONE

reboot前baseline:

L0 sequence   82
Delivery id   70
Mesh id       77

とします。


187. SQLite integrity

sqlite3 /var/lib/ronv/ronv.db \
  'PRAGMA integrity_check;'

結果:

ok

これでreboot前の条件が揃いました。


188. Raspberry Piを再起動する

最後に、

sync

を実行します。

sync

その後、

sudo reboot

します。

SSH接続が切れますが正常です。


189. reboot後のboot IDを確認する

再接続後、何もserviceを手動startせず確認します。

echo "===== POST-REBOOT BOOT ====="

echo -n "boot_id    : "
cat /proc/sys/kernel/random/boot_id

echo -n "boot_since : "
uptime -s

echo
echo "===== UPTIME ====="

uptime

実機:

boot_id    : <POST_REBOOT_BOOT_ID>
boot_since : 2026-09-05 11:10:29

reboot前とは異なるboot IDになりました。

つまり、

実際に新しいbootへ移行

したことを確認できます。


190. service / timerが自動復帰したか確認する

重要なのは、

systemctl start

を一度も実行しないことです。

確認:

systemctl is-enabled \
  ronv-vehicle.service \
  ronv-web.service \
  ronv-mesh.service \
  ronv-controller.timer \
  ronv-delivery.timer \
  gpsd.socket \
  grafana-server.service

実機:

enabled
enabled
enabled
enabled
enabled
enabled
enabled

191. active状態

systemctl is-active \
  ronv-vehicle.service \
  ronv-web.service \
  ronv-mesh.service \
  ronv-controller.timer \
  ronv-delivery.timer \
  gpsd.socket \
  grafana-server.service

実機:

active
active
active
active
active
active
active

つまり、

Vehicle
Web
Mesh
Controller timer
Delivery timer
gpsd
Grafana

がすべて自動復帰しました。


192. Controllerはまだ動いていない

reboot直後、

systemctl list-timers \
  ronv-controller.timer \
  ronv-delivery.timer \
  --all \
  --no-pager

を確認しました。

Controllerは、

LAST -

でした。

つまり、

reboot後まだControllerは一度も動いていない

状態です。

これは狙いどおりです。

Controller timerは、

OnActiveSec=10min

なので、boot後約10分で最初の自動実行になります。


193. reboot直後に興味深い状態が発生した

ここでronv-statusを確認すると、

L0 sequence = 83

になっていました。

しかし時刻を見ると、

created_at_jst
2026-09-05T11:09:33+09:00

でした。

一方rebootは、

11:10:29

です。

つまり、

sequence 83

はreboot前に生成されたL0です。


194. sequence 83はreboot前に生成されていた

時系列:

11:09:33
L0 sequence 83生成
        ↓
Delivery登録
        ↓
11:10:29
Raspberry Pi reboot

つまりreboot時点で、

未配送Delivery

がSQLiteに残っていました。

これは偶然ですが、非常に良い試験条件になりました。


195. reboot後に未配送データが復元された

reboot後のronv-status:

===== DELIVERY LATEST =====
id                  : 71
state               : acked
attempt_count       : 1
acked_at_jst        : 2026-09-05T11:11:43+09:00
last_error          : None

つまりreboot後、

Delivery timer

が自動復帰し、reboot前に残っていたDeliveryを処理しました。


196. Mesh real ACK

Mesh側:

===== MESH TX LATEST =====
id                  : 78
timestamp_jst       : 2026-09-05T11:11:43+09:00
state               : acked
ack_type            : real
ack_error           : NONE
last_error          : None

つまり、

reboot前
L0生成
Delivery pending
        ↓
reboot
        ↓
Delivery timer復帰
        ↓
Mesh Gateway復帰
        ↓
920MHz送信
        ↓
real ACK

が成立しました。


197. SQLiteを配送queueとして使った効果

今回のsequence 83は、RON-Vの設計上かなり重要な実証です。

もしDelivery queueがRAMだけに存在していた場合、

reboot

で失われます。

しかしRON-Vでは、

sync_deliveries
mesh_messages

をSQLiteへ保存しています。

そのため、

reboot前の未配送データ

も残ります。

今回、

seq83

が実際にreboot後へ持ち越され、

real ACK

まで到達しました。


198. Delivery全件もackedになった

この時点のstatus:

===== DELIVERY STATES =====
acked       : 71

つまりDBに存在した71配送はすべて、

acked

でした。


199. 次はController自身の自動復帰を確認する

ここまでで、

service自動復帰
Delivery自動復帰
未配送データ再開
real ACK

は確認できました。

残る条件は、

reboot後にController自身が
新しいL0を生成する

ことです。

reboot直後、

journalctl -b \
  -u ronv-controller.service \
  --no-pager

を確認すると、

-- No entries --

でした。

つまりまだ一度も動いていません。


200. Controller timerの次回実行を待つ

systemctl list-timers \
  ronv-controller.timer \
  ronv-delivery.timer \
  --all \
  --no-pager

実機では、

ronv-controller.timer
LEFT 約2分
LAST -

でした。

ここでは手動で、

systemctl start ronv-controller.service

を実行してはいけません。

完全自動復帰を確認するため、そのまま待ちます。


201. reboot後のController初回自動実行

数分後、

journalctl -b \
  -u ronv-controller.service \
  --no-pager

を確認しました。

実機:

Sep 05 11:20:37
Starting ronv-controller.service

続いて、

Sep 05 11:20:42
===== RON-V INTEGRATION CYCLE =====

が出ました。


202. reboot後の新しいL0

実機:

l0_object_uid       : ronv-l0-0001-...-00084
l0_sequence         : 84
l0_gps_status       : no_fix
l0_crc_ok           : True
l0_delivery_id      : 72

vehicle_l1_count    : 0
lte_l1_count        : 0

cycle_sec           : 600
elapsed_ms          : 5349

つまり、

reboot
   ↓
約10分
   ↓
Controller timer
   ↓
L0 sequence 84

が人手なしで生成されました。


203. GNSS no_fixでも正常

今回も室内だったため、

l0_gps_status : no_fix

です。

しかし、

l0_crc_ok : True

なのでL0 packet自体は正常です。

RON-VではGNSSが取得できない場合も、

GPS unknown

としてpacketを生成できる設計にしています。


204. L2も自動生成された

同じCycleで、

l2_object_uid

も新しく生成されました。

つまりreboot後、

L0
L1処理
L2
Delivery登録

までControllerが正常復帰しています。


205. Delivery id 72が自動登録された

sequence 84に対応する、

l0_delivery_id : 72

が作られました。

つまり、

Controller
    ↓
L0
    ↓
sync_objects
    ↓
sync_deliveries

まで自動で進みました。


206. Delivery timerがsequence 84を処理した

その後ronv-statusを確認しました。

===== DELIVERY LATEST =====
id                  : 72
state               : acked
attempt_count       : 1
created_at_jst      : 2026-09-05T11:20:42+09:00
acked_at_jst        : 2026-09-05T11:21:26+09:00
last_error          : None

sequence 84のDeliveryは、

attempt_count = 1

で成功しています。


207. sequence 84もreal ACK

Mesh:

===== MESH TX LATEST =====
id                  : 79
timestamp_jst       : 2026-09-05T11:21:26+09:00
state               : acked
ack_type            : real
ack_error           : NONE
last_error          : None

つまり、

reboot
  ↓
Controller timer
  ↓
L0 seq84
  ↓
Delivery id72
  ↓
Delivery timer
  ↓
Mesh id79
  ↓
920MHz
  ↓
real ACK

まで完全自動で成立しました。


208. SQLite integrityも正常

同じronv-statusで、

===== DATABASE =====
integrity_check : ok

でした。

つまり、

reboot
SQLite reopen
service復帰
Delivery再開
新Cycle

を経てもDB integrityに問題はありません。


209. Webも自動復帰

===== WEB HEALTH =====
HTTP 200 {"status":"ok"}

なので、

ronv-web.service

もreboot後に自動復帰しています。


210. reboot後の最終service状態

最終状態:

ronv-vehicle.service         active / enabled
ronv-web.service             active / enabled
ronv-mesh.service            active / enabled
gpsd.socket                  active / enabled
grafana-server.service       active / enabled

ronv-controller.timer        active / enabled
ronv-delivery.timer          active / enabled

oneshot:

ronv-controller.service
    inactive
    last_result=success

ronv-delivery.service
    inactive
    last_result=success

です。


211. reboot試験で確認できた2種類の復旧

今回のreboot試験では、偶然ではありますが、非常に良い2種類の実証ができました。

1. reboot前の未配送データ

L0 seq83
  ↓
Delivery登録
  ↓
reboot
  ↓
Delivery timer復帰
  ↓
Mesh
  ↓
real ACK

2. reboot後に新規生成されたデータ

reboot
  ↓
Controller timer復帰
  ↓
L0 seq84
  ↓
Delivery id72
  ↓
Mesh id79
  ↓
real ACK

です。


212. RON-Vのreboot耐性

今回確認できたことは、

rebootしても
queueが消えない

だけではありません。

reboot
  ↓
service復帰
  ↓
timer復帰
  ↓
未配送再開
  ↓
新規観測開始
  ↓
無線配送
  ↓
ACK確認

まで成立しています。


213. 最終的なRON-V構成

                       Raspberry Pi 5
                             │
         ┌───────────────────┼──────────────────┐
         │                   │                  │
         ▼                   ▼                  ▼
     AtomS3R              RTL-SDR             gpsd
         │                   │                  │
         ▼                   ▼                  │
 vehicle_events       lte_measurements          │
         │                   │                  │
         ▼                   ▼                  ▼
      L1 Fact             L1 Fact            L0 Beacon
         │                   │                  │
         └─────────┬─────────┘                  │
                   │                            │
                   ▼                            │
               L2 Summary                      │
                   │                            │
                   └─────────────┬──────────────┘
                                 │
                                 ▼
                           sync_objects
                                 │
                                 ▼
                          sync_deliveries
                                 │
                                 ▼
                       ronv-delivery.timer
                                 │
                                 ▼
                           mesh_messages
                                 │
                                 ▼
                        ronv-mesh.service
                                 │
                                 ▼
                       Meshtastic 920MHz
                                 │
                                 ▼
                             real ACK

表示系:

SQLite
  ├─ ronv-status
  ├─ RON-V Web UI
  └─ Grafana

です。


214. systemd構成

常駐service:

ronv-vehicle.service
ronv-web.service
ronv-mesh.service
gpsd.socket
grafana-server.service

oneshot:

ronv-controller.service
ronv-delivery.service

timer:

ronv-controller.timer
ronv-delivery.timer

です。


215. ControllerとDeliveryを分離した理由

Controllerが、

観測
集約
無線送信
ACK待ち
retry

を全部行う設計にはしませんでした。

RON-Vでは、

Controller
    L0 / L1 / L2
    Delivery登録

Delivery Worker
    配送管理

Mesh Gateway
    USB / 無線

へ分離しています。

その結果、

Meshtasticが一時的に送れない

場合でも、

観測処理

まで止める必要がありません。


216. SQLite中心の構成

RON-VではSQLiteを、

履歴DB

としてだけでなく、

永続queue

としても使っています。

今回のreboot試験で、

seq83

が再起動をまたいで配送されたことで、この設計が実際に機能することを確認できました。


217. 最終確認一覧

[OK] ronv-status

[OK] Web health
[OK] SQLite integrity

[OK] Vehicle service
[OK] Web service
[OK] Mesh service
[OK] gpsd
[OK] Grafana

[OK] Controller timer
[OK] Delivery timer

[OK] L0
[OK] CRC16
[OK] L1 Vehicle
[OK] L1 LTE
[OK] L2 Summary

[OK] sync_objects
[OK] sync_deliveries
[OK] sync_cursors

[OK] Delivery retry設計
[OK] 二重送信抑止設計

[OK] Meshtastic real ACK

[OK] 最終バックアップ
[OK] SHA-256検証
[OK] backup DB integrity

[OK] Python compile
[OK] systemd verify
[OK] installed unit MATCH

[OK] Raspberry Pi reboot

[OK] service自動復帰
[OK] timer自動復帰

[OK] reboot前未配送データの再開
[OK] seq83 real ACK

[OK] reboot後Controller自動起動
[OK] seq84生成
[OK] seq84 Delivery
[OK] seq84 real ACK

[OK] reboot後SQLite integrity
[OK] reboot後Web HTTP 200

218. RON-V #15完成判定

今回の完成条件は、

reboot
  ↓
service / timer自動復帰
  ↓
Controller自動実行
  ↓
新規L0生成
  ↓
Delivery自動処理
  ↓
Meshtastic 920MHz
  ↓
real ACK

でした。

今回、

seq84

でこの全経路を確認できました。

したがって、

RON-V #15 Integration

は完成とします。


219. #15でRON-Vがどう変わったか

#15開始前のRON-Vは、

Vehicle
Camera
LTE
GNSS
Mesh
Web
Grafana

という個別機能が動作するRaspberry Piでした。

#15では、それらを、

観測
  ↓
構造化
  ↓
同期管理
  ↓
配送管理
  ↓
無線
  ↓
ACK

という1つのシステムへ接続しました。


220. RON-Vの現在地

現在のRON-Vは、

Radio Observation Node – Vehicle

という名前のとおり、

車載観測
通信状態観測
GNSS
イベント取得
無線配送
可視化

を行うノードになりました。

さらに、

rebootしても自動復帰

できるところまで確認しています。


221. ただし、まだ「製品」ではない

今回完成したのは、

RON-V software integration

です。

今後、車載装置として使うには、

電源
電圧降下
ACC連動
安全shutdown
筐体
温度
SDカード寿命
アンテナ配置
LTE観測周期
カメラEvidence

なども検討する必要があります。

つまり、

Software Integration

は完成しましたが、

Vehicle Hardware Integration

は今後も続きます。


222. 次の課題

今回のPiでは、ここまで多くの作業を手動で行いました。

例えば、

apt install
Python venv
pip install
directory作成
config作成
SQLite初期化
systemd登録
Meshtastic設定
Web設定
Grafana設定

などです。

次に同じRON-Vをもう1台作るとき、これを最初から繰り返すのは現実的ではありません。

そこで次は、

完成したRON-V
       ↓
GitHub
       ↓
install.sh
       ↓
新しいRaspberry Pi
       ↓
RON-V構築

へ進みます。


223. 次回

次回は、

RON-V #16 — GitHubからRON-Vを一括インストールする

を行います。

最初に、

GitHubへ入れるもの
GitHubへ入れないもの

を整理します。

例えば、

入れる

app/
systemd/
config example
install script
requirements
README


入れない

実DB
実GPS位置
実Mesh Node ID
SSID
runtime log
JPEG
backup
__pycache__

という整理が必要です。

その後、

git clone
   ↓
install.sh
   ↓
venv
   ↓
dependencies
   ↓
config
   ↓
SQLite
   ↓
systemd
   ↓
ronv-status

まで自動化します。


まとめ

#15前編・後編を通して、RON-Vは、

複数の観測機能を持つRaspberry Pi

から、

自律して観測し、
情報を構造化し、
配送状態を永続管理し、
920MHz Meshへ送信し、
real ACKを確認し、
reboot後も自動復帰する
車載観測ノード

になりました。

今回特に重要だったのは、

SQLiteによる永続状態管理

Controller / Delivery / Meshの責務分離

Meshtastic USBの単一所有

systemdによる自動復帰

です。

reboot試験では、

reboot前の未配送L0

と、

reboot後に新しく生成されたL0

の両方がreal ACKまで到達しました。

これで、

RON-V #15 — RON-V統合

を完了とします。

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?