RUMlogNG ADIF > ハムサポcsv変換ツール

移動運用時にログ情報(交信記録)を持ち運べるようiPhone用のログアプリ「HAM交信サポート」(以下ハムサポ)を利用するようになりました。又、ホームでFT8の交信を行うようになってからパソコン用ログアプリはファイルメーカーPro(テンプレート利用)からRUMlogNGへ変更しました。(この時は他の局長さんが作成されたPerlのスクリプトでデータ移行は助かりました。)ログ用アプリが2種類になれば同期をどうするか?が課題になってきます。交信局数が少ないのでiPhoneへ手入力していましたが、忘れていると入力する局数が溜まったり、入力ミス(年齢が増えると集中力は反比例で下がってしまう。)も発生してきました。

ということでRUMlogNGのログ書き出し形式はadif、ハムサポのインポート用ファイルの形式がcsv、それらの形式と諸々を整合する変換スクリプトをChatGPTに問合せてキャッチボール(やりとり)しながら変換ツールを作成してもらいました。忘れたらここを見てなるほどと思えればいいなと思い記しました。

機能

RUMlogNGで書き出したログファイルADIF形式がハムサポのインポートファイル形式csvへ変換されハムサポへ読込が出来ること。

・RUMlogNGのログ(交信記録)

4個あるユーザーフィールドも含む赤枠を対象にしました。
(サンプルなのでコールサインは実在しません。)

ADIF形式で書き出し

ADIF Export from RumLogNG by DL2RUM
For further info visit: https://www.dl2rum.de
<ADIF_VER:5>3.1.6
<CREATED_TIMESTAMP:15>20260406 072628
<PROGRAMID:8>RUMlogNG
<PROGRAMVERSION:3>6.2
<EOH>
<call:6>JC1BBB <qso_date:8>20260326 <time_on:6>101400 <qso_date_off:8>20260326 <time_off:6>101400 <band:2>2m <freq:10>144.205000 <mode:3>SSB <submode:3>USB <rst_sent:2>55 <rst_rcvd:2>55 <app_rumlog_qsl:1>- <qsl_rcvd:1>N <qsl_sent:1>I <dxcc:3>339 <cont:2>AS <name:3>かきく <qth:3>埼玉県 <comment:27>宇都宮市JCC#1501TG-109寅巳山PM96vq <gridsquare:6>PM96VQ <tx_pwr:2>10 <app_rumlog_power:2>10 <eqsl_qsl_rcvd:1>N <eqsl_qsl_sent:1>N <lotw_qsl_rcvd:1>N <lotw_qsl_sent:1>N <pfx:3>JC1 <clublog_qso_upload_status:1>M <app_rumlog_userfield_1:15>IC-705/2mHRH770 <app_rumlog_userfield_2:21>JA/TG-109寅巳山:JC1CCC/1 <app_rumlog_userfield_3:6>PM96pe <app_rumlog_colorcode:1>0 <eor>
<call:8>JC1DEF/1 <qso_date:8>20260326 <time_on:6>101100 <qso_date_off:8>20260326 <time_off:6>101100 <band:2>2m <freq:10>144.205000 <mode:3>SSB <submode:3>USB <rst_sent:2>59 <rst_rcvd:2>59 <app_rumlog_qsl:1>- <qsl_rcvd:1>N <qsl_sent:1>I <dxcc:3>339 <cont:2>AS <name:3>あいう <qth:3>茨城県 <comment:27>宇都宮市JCC#1501TG-109寅巳山PM96vq <gridsquare:6>PM96VQ <tx_pwr:2>10 <app_rumlog_power:2>10 <eqsl_qsl_rcvd:1>N <eqsl_qsl_sent:1>N <lotw_qsl_rcvd:1>N <lotw_qsl_sent:1>N <pfx:3>JC1 <clublog_qso_upload_status:1>M <app_rumlog_userfield_1:15>IC-705/2mHRH770 <app_rumlog_userfield_2:21>JA/TG-109寅巳山:JC1CCC/1 <app_rumlog_userfield_3:6>QM06bg <app_rumlog_userfield_4:12>JA/IB-006足尾山 <app_rumlog_colorcode:1>0 <eor>

ADIF形式から変換したハムサポ用csvファイル(表計算ソフトで開いた状態)

CallsignPortableTimeTime EndRST SentRST ReceivedWho CalledGrid ZoneMy CallsignMy QTHOther QTHOther LatitudeOther LongitudeDistanceOther NameBandFrequencyModeQSL CardQSL WayQSL FlagNR ReceivedNR SentRig ModelAntennaTXPowerLatitudeLongitudeJCC/JGCAltitude above sea levelWeatherOther InformationQSL CommentContest NameContest Points
JC1BBB2026-03-26 10:14:00 +09002026-03-26 10:14:00 +09005555PM96VQJC1CCC/1宇都宮市JCC#1501 TG-109寅巳山PM96vq埼玉県 PM96peかきく2m144.205000SSBIC-7052mHRH77010JA/TG-109寅巳山:JC1CCC/1
JC1DEF12026-03-26 10:11:00 +09002026-03-26 10:11:00 +09005959PM96VQJC1CCC/1宇都宮市JCC#1501 TG-109寅巳山PM96vq茨城県 QM06bg JA/IB-006足尾山あいう2m144.205000SSBIC-7052mHRH77010JA/TG-109寅巳山:JC1CCC/1

・iPhoneのハムサポへ読込み

左は交信ログの一覧(2交信がサンプル)、JC1DEF/1をタップして中央・右(画面スクロール)が詳細画面。
交信終了時刻は開始時刻と同じ。
相手局QTHはRUMlogNGのQTH + Your Locater + Your SOTA Ref.を連結
無線機種類とアンテナはRUMlogNGのRIG/ANTを分割して変換しています。

スクリプト作成時の環境

・OS :macOS 15.7.3
・Hard:Mac Book Air M4 2025
pythonがインストールしてあること

スクリプトの動作手順

1.準備するファイル(拡張子を間違えなければファイル名は任意と思います。)
 RUMlogNGからADIF形式で書き出したファイル名:input.adi
 スクリプトのファイル名           :adi_to_csv_script.py

2.ターミナルから

~(ホームディレクトリ)からスクリプトを保存したディレクトリ(フォルダ)へcdコマンドで移動します。
(例:cd Desktop はデスクトップへ移動)
スクリプトを動かすため、python3 + スペース + ファイル名を入力してエンターします。
スクリプトが起動して、ADIF → HAM交信サポート変換ツールダイアログが表示されます。
(間違った場合はNo Such…見つからないよ みたいなメッセージがターミナルに出ます。コピペしてChatGPTに質問すると回答が得られます。)
ADIF → HAM交信サポート変換ツールダイアログの参照をクリックすると
ファイルを選ぶダイアログが表示されます。該当ファイルを選択してOpenをクリックします。
変換実行をクリックします。
error messagingが出る場合も気にせず進めます。(ChatGPTによると重大なエラーではないそうです。)
変換が完了しました! ダイアログが出るのでOKをクリックします。
変換後のファイル名入力と保存先を指定するダイアログが出るので記述します。
(例:output260326-3 )Format:CSV files(.csv)を確認してSaveをクリックします。
ADIF → HAM交信サポート変換ツールダイアログを閉じるとターミナルは次のコマンド待ちになります。
exitを入力してエンターで終了。

スクリプト(adi_to_csv_script.py)

ChatGPTで出来たコードをコピー&Visual Studio Code(Mac用アプリ)へペースト後、ファイル名をadi_to_csv_script.pyで保存しました。

import re
import csv
import tkinter as tk
from tkinter import filedialog, messagebox

# -----------------------------
# ADIF解析
# -----------------------------
def parse_adif(file_path):
    with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
        content = f.read()

    if '<EOH>' in content:
        content = content.split('<EOH>')[1]

    content = content.replace('\n', ' ').replace('\r', ' ')
    records = re.split(r'<eor>', content, flags=re.IGNORECASE)

    parsed_data = []

    for record in records:
        fields = re.findall(r'<(.*?):(\d+).*?>([^<]*)', record)
        record_dict = {}

        for field_name, length, value in fields:
            record_dict[field_name.upper()] = value.strip()

        if record_dict:
            parsed_data.append(record_dict)

    return parsed_data


# -----------------------------
# 日時整形
# -----------------------------
def format_datetime(date, time):
    if date and time and len(date) == 8 and len(time) == 6:
        d = f"{date[0:4]}-{date[4:6]}-{date[6:8]}"
        t = f"{time[0:2]}:{time[2:4]}:{time[4:6]}"
        return f"{d} {t} +0900"
    return ""


# -----------------------------
# Callsign解析
# -----------------------------
def parse_callsign(call):
    base = call
    portable = ""
    info = ""

    if "/" in call:
        parts = call.split("/")
        base = parts[0]
        suffix = parts[1].upper()

        if suffix.isdigit():
            portable = suffix
        elif suffix == "P":
            portable = "P"
        elif suffix == "MM":
            info = "Maritime Mobile"
        elif suffix == "QRP":
            info = "QRP"
        else:
            info = suffix

    return base, portable, info


# -----------------------------
# CSV出力
# -----------------------------
def write_log_csv(data, output_file):

    output_order = [
        'Callsign', 'Portable', 'Time', 'Time End', 'RST Sent', 'RST Received',
        'Who Called', 'Grid Zone', 'My Callsign', 'My QTH', 'Other QTH',
        'Other Latitude', 'Other Longitude', 'Distance', 'Other Name',
        'Band', 'Frequency', 'Mode', 'QSL Card', 'QSL Way', 'QSL Flag',
        'NR Received', 'NR Sent', 'Rig Model', 'Antenna', 'TXPower',
        'Latitude', 'Longitude', 'JCC/JGC', 'Altitude above sea level',
        'Weather', 'Other Information', 'QSL Comment',
        'Contest Name', 'Contest Points'
    ]

    mapping = {
        "RST_SENT": "RST Sent",
        "RST_RCVD": "RST Received",
        "GRIDSQUARE": "Grid Zone",
        "NAME": "Other Name",
        "QTH": "Other QTH",
        "BAND": "Band",
        "FREQ": "Frequency",
        "MODE": "Mode",
        "TX_PWR": "TXPower"
    }

    with open(output_file, "w", newline="", encoding="utf-8-sig") as f:
        writer = csv.writer(f)
        writer.writerow(output_order)

        for row in data:
            new_row = []

            raw_call = row.get("CALL", "")
            callsign, portable, extra_info = parse_callsign(raw_call)

            # -----------------------------
            # ★ USERFIELD取得
            # -----------------------------
            uf1 = row.get("APP_RUMLOG_USERFIELD_1", "")
            uf2 = row.get("APP_RUMLOG_USERFIELD_2", "")
            uf3 = row.get("APP_RUMLOG_USERFIELD_3", "")
            uf4 = row.get("APP_RUMLOG_USERFIELD_4", "")

            # -----------------------------
            # ★追加:My Callsign抽出
            # 例:JA/TG-109寅巳山:JC1CCC/1 → JC1CCC/1
            # -----------------------------
            my_call = ""
            if uf2 and ":" in uf2:
                my_call = uf2.split(":", 1)[1].strip()

            for field in output_order:
                value = ""

                if field == "Callsign":
                    value = callsign

                elif field == "Portable":
                    value = portable

                elif field == "Time":
                    value = format_datetime(row.get("QSO_DATE", ""), row.get("TIME_ON", ""))

                elif field == "Time End":
                    value = format_datetime(row.get("QSO_DATE", ""), row.get("TIME_OFF", ""))

                # -----------------------------
                # ★ Grid Zone(Locator優先)
                # -----------------------------
                elif field == "Grid Zone":
                    value = row.get("APP_RUMLOG_LOCATOR", "") or row.get("GRIDSQUARE", "")

                # -----------------------------
                # ★ My Callsign(追加機能)
                # -----------------------------
                elif field == "My Callsign":
                    value = my_call

                # -----------------------------
                # ★ My QTH(COMMENT)
                # -----------------------------
                elif field == "My QTH":
                    value = row.get("COMMENT", "").strip()

                # -----------------------------
                # ★ Other QTH(USERFIELD_3・4追記)
                # -----------------------------
                elif field == "Other QTH":
                    base_qth = row.get("QTH", "")
                    additions = " ".join(filter(None, [uf3, uf4]))
                    value = f"{base_qth} {additions}".strip()

                # -----------------------------
                # ★ Rig Model(USERFIELD_1 前)
                # -----------------------------
                elif field == "Rig Model":
                    if uf1:
                        parts = uf1.split("/", 1)
                        value = parts[0]

                # -----------------------------
                # ★ Antenna(USERFIELD_1 後)
                # -----------------------------
                elif field == "Antenna":
                    if uf1 and "/" in uf1:
                        parts = uf1.split("/", 1)
                        value = parts[1]

                # -----------------------------
                # ★ Other Information(USERFIELD_2)
                # -----------------------------
                elif field == "Other Information":
                    value = uf2 if uf2 else extra_info

                else:
                    adif_key = next((k for k, v in mapping.items() if v == field), None)
                    value = row.get(adif_key, "") if adif_key else ""

                new_row.append(value)

            writer.writerow(new_row)


# -----------------------------
# GUI処理
# -----------------------------
def select_file():
    file_path = filedialog.askopenfilename(filetypes=[("ADIF files", "*.adi")])
    entry_input.delete(0, tk.END)
    entry_input.insert(0, file_path)


def convert():
    input_file = entry_input.get()

    if not input_file:
        messagebox.showerror("エラー", "ファイルを選択してください")
        return

    output_file = filedialog.asksaveasfilename(defaultextension=".csv",
                                               filetypes=[("CSV files", "*.csv")])

    if not output_file:
        return

    try:
        data = parse_adif(input_file)
        write_log_csv(data, output_file)
        messagebox.showinfo("完了", "変換が完了しました!")
    except Exception as e:
        messagebox.showerror("エラー", str(e))


# -----------------------------
# GUI画面
# -----------------------------
root = tk.Tk()
root.title("ADIF → HAM交信サポート変換ツール")

frame = tk.Frame(root, padx=10, pady=10)
frame.pack()

tk.Label(frame, text="ADIFファイル:").grid(row=0, column=0)

entry_input = tk.Entry(frame, width=40)
entry_input.grid(row=0, column=1)

tk.Button(frame, text="参照", command=select_file).grid(row=0, column=2)

tk.Button(frame, text="変換実行", command=convert, bg="lightblue").grid(row=1, column=1, pady=10)

root.mainloop()

応用編としてSOTAデータベースアップロード用も出来るのか?気になるところです。