Showing posts with label Python. Show all posts
Showing posts with label Python. Show all posts

Friday, October 16, 2026

Sniffing CAN Bus CubeSat (libcsp) dengan Korlan USB2CAN dan GUI Python

Assalamu'alaikum warohmatullohi wabarakatuh,

Pada tutorial sebelumnya, Komunikasi CubeSat Space Protocol (libcsp) lewat CAN Bus FDCAN1 pada STM32H743 dengan FreeRTOS, kita membuat dua board STM32H743 berperan sebagai OBC (On-Board Computer) dan EPS (Electrical Power System) yang saling bertukar paket CSP lewat CAN bus. Masalahnya, kalau komunikasi gagal, kita hanya bisa menebak-nebak dari log UART masing-masing board.

Kali ini kita membuat "telinga" ketiga di bus: Korlan USB2CAN dari 8devices yang dicolokkan ke laptop, ditambah program Python dengan GUI untuk sniffing (menyadap) bus CAN. Program ini tidak hanya menampilkan frame CAN mentah, tetapi juga menyusun ulang frame menjadi paket CSP, menampilkan data housekeeping EPS, dan bisa mengirim perintah (ping, payload ON/OFF) langsung dari laptop.

Apa itu sniffing CAN bus?

Sniffing berarti ikut mendengarkan semua lalu lintas di bus tanpa mengganggu node lain. Di CAN, semua node berbagi dua kabel (CANH dan CANL), jadi adapter yang tersambung ke bus otomatis "mendengar" setiap frame. Dengan sniffer kita bisa:

  • memastikan sebuah node benar-benar mengirim (misalnya OBC sudah mengirim ping atau belum),
  • melihat isi data sebenarnya di kabel, bukan hanya yang dilaporkan firmware,
  • mendeteksi masalah fisik: terminator, kabel tertukar, bitrate salah, dan
  • menguji node satu per satu dengan mengirim perintah dari laptop.

Perangkat yang dibutuhkan

PerangkatKeterangan
Korlan USB2CAN (8devices)adapter USB ke CAN, konektor DB9
2 board STM32H743 (DevEBox) + modul MCP2551OBC dan EPS dari tutorial sebelumnya
2 resistor 120 Ωterminator di kedua ujung bus
Konektor DB9 female / adaptor terminal sekrupuntuk menyambung kabel ke Korlan
Laptop Windows + Python 3 (64-bit)python-can, pywin32, Tkinter
Korlan USB2CAN dan pinout DB9

Korlan USB2CAN dan pinout konektor DB9: pin 7 = CAN_H, pin 2 = CAN_L, pin 3 = GND.

Langkah 1 – Instal driver dan library

Semua file resmi ada di halaman produk 8devices Korlan USB2CAN. Gulir ke bawah tabel Specifications sampai bagian Resources (file disimpan di Google Drive). Unduh tiga file berikut:

  1. Windows Driver (WinUSB) → jalankan usb2can_winusb.msi (butuh izin Administrator). Setelah selesai, Korlan muncul sebagai USB2CAN di Device Manager tanpa tanda segitiga kuning.
  2. USB2CAN CANAL DLL v2.0.0 → di dalam zip ada x64/Release/usb2can.dll dan x86/Release/usb2can.dll.
  3. Windows python examples → contoh resmi 8devices (berguna untuk perbandingan).

Salin usb2can.dll versi x64 ke folder program kita. Versi DLL harus sama dengan Python: Python 64-bit memakai DLL x64. Lalu pasang library Python:

pip install python-can pywin32

pywin32 dipakai python-can untuk mendeteksi serial number Korlan secara otomatis lewat WMI.

Langkah 2 – Menyambung Korlan ke bus CAN

Korlan dipasang paralel (sebagai cabang) di tengah bus, seperti node ketiga. Bus tetap memakai dua resistor 120 Ω di kedua ujung (di modul OBC dan modul EPS). Korlan sendiri tidak perlu resistor.

Wiring Korlan USB2CAN ke bus CAN OBC dan EPS

Wiring: CANH–CANH, CANL–CANL, GND bersama, terminator 120 Ω di kedua ujung, Korlan menyadap di tengah.

DB9 KorlanBus CAN
pin 7CANH
pin 2CANL
pin 3GND board

Cek terminator dengan multimeter saat semua board mati: hambatan CANH–CANL seharusnya sekitar 60 Ω (dua resistor 120 Ω paralel).

Langkah 3 – Tes koneksi dengan Python

Sebelum membuat GUI, pastikan Python bisa membuka Korlan. Skrip pendek ini mencari adapter lalu menampilkan frame selama 5 detik:

import can

# 1) cari Korlan yang terpasang
print(can.detect_available_configs("usb2can"))
# contoh hasil: [{'interface': 'usb2can', 'channel': '04EF18C4'}]

# 2) buka bus 1 Mbit/s dan tampilkan frame selama 5 detik
bus = can.Bus(interface="usb2can", channel="04EF18C4",
              bitrate=1000000, dll="./usb2can.dll")
import time
t0 = time.time()
while time.time() - t0 < 5:
    msg = bus.recv(timeout=0.5)
    if msg:
        print(msg)
bus.shutdown()

Serial number (contoh 04EF18C4) adalah 8 karakter yang juga tertera di stiker Korlan. Kecepatan bitrate harus sama dengan firmware, di proyek ini 1 Mbit/s.

Langkah 4 – Membaca ID CAN sebagai header CFP

libcsp mengirim paket CSP lewat CAN memakai CFP (CAN Fragmentation Protocol). Semua informasi alamat ada di ID extended 29-bit:

Membaca ID CAN sebagai header CFP

Contoh ID 0x0110645D: dari OBC (1) ke EPS (2), frame BEGIN, masih 25 frame lagi, paket nomor 93.

Fungsi Python untuk memecah ID dan header CSP 32-bit (dikirim big-endian di frame BEGIN):

def cfp_fields(can_id):
    return {
        "src": (can_id >> 24) & 0x1F,
        "dst": (can_id >> 19) & 0x1F,
        "type": (can_id >> 18) & 0x01,
        "remain": (can_id >> 10) & 0xFF,
        "id": can_id & 0x3FF,
    }


def csp_header_unpack(raw4):
    h = struct.unpack(">I", raw4)[0]
    return {
        "prio": (h >> 30) & 0x3,
        "src": (h >> 25) & 0x1F,
        "dst": (h >> 20) & 0x1F,
        "dport": (h >> 14) & 0x3F,
        "sport": (h >> 8) & 0x3F,
        "flags": h & 0xFF,
    }

Langkah 5 – Menyusun ulang frame menjadi paket CSP

Satu paket CSP bisa terdiri dari banyak frame. Kelas CfpReassembler meniru fungsi csp_can_rx() di libcsp: frame dikelompokkan berdasarkan (SRC, DST, ID), frame BEGIN membuka buffer baru, frame MORE ditambahkan selama nilai REMAIN turun berurutan, dan paket dianggap lengkap ketika jumlah byte sudah sama dengan panjang di header.

class CfpReassembler:
    """Menyusun ulang frame CFP menjadi paket CSP (meniru csp_can_rx())."""

    def __init__(self, timeout=1.0):
        self.buffers = {}
        self.timeout = timeout

    def push(self, can_id, data):
        f = cfp_fields(can_id)
        key = (f["src"], f["dst"], f["id"])
        now = time.time()
        for k in [k for k, b in self.buffers.items() if now - b["t"] > self.timeout]:
            del self.buffers[k]

        if f["type"] == CFP_BEGIN:
            if len(data) < CFP_OVERHEAD:
                return None, "BEGIN terlalu pendek"
            hdr = csp_header_unpack(bytes(data[:4]))
            length = struct.unpack(">H", bytes(data[4:6]))[0]
            buf = {"hdr": hdr, "len": length, "data": bytearray(data[6:]),
                   "remain": f["remain"], "t": now}
            self.buffers[key] = buf
        else:
            buf = self.buffers.get(key)
            if buf is None:
                return None, "frame MORE tanpa BEGIN"
            if f["remain"] != buf["remain"] - 1:
                del self.buffers[key]
                return None, "frame hilang (remain tidak urut)"
            buf["remain"] = f["remain"]
            buf["data"] += bytes(data)

        if len(buf["data"]) >= buf["len"]:
            del self.buffers[key]
            return {"hdr": buf["hdr"], "data": bytes(buf["data"][:buf["len"]])}, None
        return None, None

Langkah 6 – Arsitektur program

Arsitektur software sniffer

Alur data dari Korlan sampai GUI.

Kunci agar GUI tidak macet adalah memisahkan pembacaan bus dari tampilan. Thread penerima membaca bus.recv() terus-menerus dan memasukkan hasilnya ke antrean:

    def _rx_loop(self):
        while not self._stop.is_set():
            msg = self.bus.recv(timeout=0.1)
            if msg is None:
                continue
            if msg.is_error_frame:
                self.stats["errors"] += 1
                self.inbox.put(("error", msg))
                continue
            if not msg.is_extended_id:
                self.inbox.put(("std", msg))
                continue
            self.stats["frames"] += 1
            pkt, err = self.rx.push(msg.arbitration_id, msg.data)
            self.inbox.put(("frame", msg, err))
            if pkt:
                pkt["t"] = time.time()
                self.stats["packets"] += 1
                self.inbox.put(("packet", pkt))

GUI (Tkinter) mengambil isi antrean setiap 40 ms dengan after(), paling banyak 500 item sekali ambil, sehingga jendela tetap responsif meskipun bus sangat ramai:

    def _poll(self):
        if self.node:
            n = 0
            while n < 500:
                try:
                    item = self.node.inbox.get_nowait()
                except queue.Empty:
                    break
                n += 1
                self._handle_item(item)
            self._check_timeouts()
            self._update_stats()
        self.after(40, self._poll)

Koneksi ke Korlan cukup satu baris can.Bus(interface="usb2can", ...). Fungsi berikut otomatis memakai usb2can.dll yang ada di folder program:

def open_bus(args):
    if args.interface == "virtual":
        return can.Bus(interface="virtual", channel=args.channel or "csp")
    kw = {"interface": args.interface, "bitrate": args.bitrate}
    if args.channel:
        kw["channel"] = args.channel
    if args.interface == "usb2can":
        here = os.path.dirname(os.path.abspath(__file__))
        dll = os.path.join(here, "usb2can.dll")
        if os.path.exists(dll):
            kw["dll"] = dll
    return can.Bus(**kw)

Serial number Korlan dicari otomatis saat GUI dibuka (tombol Cari untuk mencari ulang):

    def _scan_serial(self, quiet=False):
        """Cari Korlan USB2CAN yang terpasang dan isi kolom Serial."""
        try:
            cfgs = can.detect_available_configs("usb2can")
        except Exception as e:  # noqa: BLE001
            cfgs = []
            if not quiet:
                self._log(f"Gagal mencari adapter: {e}")
        serials = [str(c.get("channel")) for c in cfgs if c.get("channel")]
        self.cb_ch.configure(values=serials)
        if serials:
            if self.var_ch.get().strip() not in serials:
                self.var_ch.set(serials[0])
            self._log("Korlan USB2CAN terdeteksi: S/N " + ", ".join(serials))
        elif not quiet:
            self._log("Korlan USB2CAN tidak terdeteksi (cek kabel USB / driver 8devices / pywin32).")
        return serials

Langkah 7 – Menjalankan GUI

Simpan csp_can_tool.py, csp_can_gui.py dan usb2can.dll dalam satu folder, lalu jalankan:

python csp_can_gui.py

Pilih Adapter: usb2can untuk Korlan sungguhan, atau simulasi untuk mencoba tampilan tanpa hardware (program membuat OBC dan EPS tiruan yang perilakunya sama dengan firmware). Klik Hubungkan.

GUI CubeSat CAN Monitor tab Paket CSP dengan Korlan asli

Tab Paket CSP saat terhubung ke Korlan asli (S/N 04EF18C4): biru = OBC, merah = EPS. Panel kiri menampilkan housekeeping EPS (suhu chip 32,8 °C, VDDA 3338 mV, Payload ON, uptime) dan hasil Ping EPS 1,2 ms.

Yang terlihat di tab Paket CSP:

  • OBC:30 → EPS:1 PING lalu EPS:1 → OBC:30 re:PING: OBC mem-ping EPS dan EPS membalas.
  • OBC → EPS:12 EPS_HK dan balasan 20 byte berisi uptime, suhu chip, VDDA dan status payload, yang langsung diterjemahkan.
  • OBC → EPS:11 TLM: paket telemetri 200 byte (26 frame CAN) dengan checksum.
  • Baris dari/ke PC: perintah yang kita kirim dari tombol Ping, Payload ON/OFF, dan Kirim pesan.
GUI CubeSat CAN Monitor tab Frame CAN dengan Korlan asli

Tab Frame CAN saat terhubung ke Korlan asli (S/N 04EF18C4): setiap frame dengan ID yang sudah diuraikan (Src, Dst, BEGIN/MORE, Remain, CFP id). Terlihat paket telemetri 200 byte dari OBC (1) ke EPS (2) dengan CFP id 952: BEGIN remain=25 lalu MORE 24…0, serta frame balasan EPS (src 2) ke OBC (dst 1) dan ke PC (dst 10).

Hasil uji nyata: OBC dan EPS terbaca bersih

Setelah dua resistor 120 Ω terpasang di ujung bus (modul OBC dan modul EPS), semua stuff error hilang. Korlan langsung menerima frame valid dan GUI menampilkan seluruh lalu lintas CubeSat:

GUI CubeSat CAN Monitor membaca OBC dan EPS lewat Korlan, payload ON

Hasil nyata dengan Korlan asli: kedua node aktif, Kondisi bus OK, error frame 0. Tombol Payload ON dari laptop dijawab EPS "status=OK rail=ON" dalam 10 ms, panel housekeeping menampilkan Payload ON, suhu chip 33,2 °C dan VDDA 3336 mV.

Yang terlihat pada pengujian dengan hardware asli (Korlan S/N 04EF18C4, 1 Mbit/s):

  • Kondisi bus OK, error frame 0 dari 1.133 frame CAN (±48 frame/detik), status node OBC dan EPS sama-sama aktif.
  • OBC → EPS PING dan EPS → OBC re:PING: OBC mem-ping EPS setiap 2 detik dan EPS membalas.
  • OBC → EPS:10 ECHO berisi "Halo EPS, pesan ke-127", dibalas EPS "ACK dari node 2 (EPS)".
  • OBC → EPS:12 EPS_HK dan balasan 20 byte berisi uptime, status payload, serta jumlah frame CAN yang diterima/dikirim EPS.
  • OBC → EPS:11 TLM: telemetri 200 byte (26 frame) dengan checksum, dan EPS_CMD setiap 10 detik untuk membalik status payload.

Perintah langsung dari laptop lewat Korlan juga berhasil:

Tombol di GUIHasil
Ping EPS2,0 ms
Ping OBCOBC membalas (OBC:1 → PC re:PING)
Payload ONstatus OK dalam 9,7 ms, LED PA1 di board EPS menyala
Kirim pesan ke EPSbalasan "ACK dari node 2 (EPS)" dalam 6,8 ms

Update – suhu dan VDDA EPS sudah terbaca. Pada pengujian pertama nilai suhu chip dan VDDA EPS masih 0. Penyebabnya ada di firmware EPS: kalibrasi linearitas ADC3 STM32H743 butuh waktu lebih lama dari batas tunggu 10 ms di kode awal, sehingga inisialisasi ADC dianggap gagal. Setelah batas tunggu kalibrasi dinaikkan menjadi 200 ms, EPS mengirim nilai sensor yang benar:

Housekeeping EPS suhu dan VDDA terbaca lewat Korlan

Setelah perbaikan firmware: panel EPS housekeeping menampilkan suhu chip 33,1 °C dan VDDA 3337 mV.

  • Suhu chip STM32H743 (EPS): ±32,7–33,2 °C, dari sensor suhu internal ADC3 channel 18 dengan kalibrasi pabrik TS_CAL1/TS_CAL2.
  • VDDA: ±3337 mV, dihitung dari VREFINT (ADC3 channel 19) dan VREFINT_CAL.
  • Ping EPS dari laptop: 2,3 ms; error frame tetap 0 dan Kondisi bus OK.

Log serial EPS. Dari sisi EPS, log UART (USART1, PA9/PA10, 115200 baud) dibaca lewat Mini Debugger di COM6. Log ini memastikan apa yang terlihat di Korlan memang diterima dan diproses oleh EPS:

Log serial EPS di console STM32CubeIDE COM6

Log serial EPS (COM6) di console STM32CubeIDE: permintaan housekeeping dari OBC dan laptop, pesan dan telemetri dari OBC, perintah payload, serta status CAN TEC=0 REC=0.

  • [EPS-HK] permintaan housekeeping dari node 10 (laptop lewat Korlan) dan node 1 (OBC), masing-masing tiap 2 detik, lengkap dengan suhu chip ±31 °C dan VDDA ±3338 mV.
  • [SERVER] pesan "Halo EPS, pesan ke-…" di port 10 dan paket telemetri 200 byte di port 11 beserta checksum.
  • [EPS-CMD] perintah payload dari OBC: ON (LED PA1 menyala) lalu OFF (LED PA1 mati) setiap 10 detik.
  • [STAT] kesehatan CAN di EPS: TEC=0 REC=0 OK, 0 kiriman gagal, buffer CSP bebas 19–20 dari 20.

Troubleshooting: membaca status error dari Korlan

Kalau sinyal di bus rusak, Korlan tidak menerima frame data, melainkan pesan status/error. GUI meringkasnya per detik di tab Log dan menampilkannya di baris Kondisi bus. Berikut contoh nyata saat bus OBC–EPS saya belum dipasangi terminator:

GUI menampilkan stuff error dari Korlan

Koneksi nyata ke Korlan (S/N 04EF18C4): Frame CAN 0, ribuan error frame, Kondisi bus "ERROR: stuff error".

Arti kode status yang dikirim adapter (byte data pertama):

# Kode status/error yang dikirim adapter 8devices USB2CAN (byte data pertama)
USB2CAN_STATUS = {
    0x00: "OK", 0x01: "overrun", 0x02: "bus warning", 0x03: "error passive",
    0x04: "BUS-OFF", 0x20: "stuff error", 0x21: "form error", 0x23: "ACK error",
    0x24: "bit0 error", 0x25: "bit1 error", 0x27: "CRC error", -1: "error",
}
GejalaKemungkinan penyebabSolusi
Frame CAN = 0, tidak ada errorKorlan belum tersambung ke bus / node tidak mengirimcek kabel DB9 (pin 7, 2, 3) dan pastikan board menyala
Banyak stuff error, bus warning, error passivetanpa terminator 120 Ω, CANH/CANL tertukar, atau bitrate berbedapasang 2×120 Ω (ukur ±60 Ω), cek CANH–CANH, samakan bitrate
Hanya ACK errortidak ada node lain yang membalasnyalakan node lain; Korlan sendiri juga memberi ACK
Error terus walau kabel benarMCP2551 dicatu 5 V butuh sinyal TXD ≥ 3,5 V, STM32 hanya 3,3 Vpakai transceiver 3,3 V (SN65HVD230) atau pull-up 2,2 kΩ CTX ke 5 V + PA12 open-drain
"DLL failed to load"usb2can.dll tidak ada / beda arsitektursalin DLL x64 ke folder program, Python 64-bit

Catatan: Korlan ikut memberi ACK di bus. Jadi kalau hanya satu board yang terpasang bersama Korlan, frame board itu tetap terbaca dengan benar. Ini cara cepat menguji OBC dan EPS satu per satu.

Kode lengkap

Dua file berikut disimpan dalam satu folder bersama usb2can.dll. Klik judul untuk membuka kodenya.

csp_can_tool.py – library CFP/CSP + versi command line (436 baris)
#!/usr/bin/env python3
"""
csp_can_tool.py - membaca dan menguji bus CAN CubeSat (libcsp 1.6 / CFP)
memakai Korlan USB2CAN (8devices) dan python-can.

Bisa dipakai untuk:
  monitor  : menampilkan semua frame CAN + paket CSP yang sudah disusun ulang
  ping     : ping node CSP (OBC = 1, EPS = 2)
  hk       : minta data housekeeping EPS (port 12)
  payload  : perintah ON/OFF rail payload EPS (port 13)
  echo     : kirim pesan teks ke port 10 dan tampilkan balasannya

Contoh (Windows):
  python csp_can_tool.py list
  python csp_can_tool.py monitor
  python csp_can_tool.py ping 2
  python csp_can_tool.py hk
  python csp_can_tool.py payload on
  python csp_can_tool.py echo 1 "Halo OBC"

Kebutuhan:
  pip install python-can pywin32
  usb2can.dll (64-bit) dari driver 8devices diletakkan di folder yang sama
  dengan script ini, atau di folder yang ada di PATH.
"""
import argparse
import os
import queue
import struct
import sys
import threading
import time

import can

# ---------------------------------------------------------------------------
# Konstanta sistem (harus sama dengan firmware: cubesat_proto.h)
# ---------------------------------------------------------------------------
ADDR_OBC = 1
ADDR_EPS = 2
ADDR_PC = 10            # alamat CSP untuk PC/Korlan (bebas, asal unik 0..30)

PORT_PING = 1
PORT_ECHO = 10
PORT_TLM = 11
PORT_EPS_HK = 12
PORT_EPS_CMD = 13

NODE_NAMES = {ADDR_OBC: "OBC", ADDR_EPS: "EPS", ADDR_PC: "PC", 31: "BCAST"}
PORT_NAMES = {0: "CMP", 1: "PING", 2: "PS", 3: "MEMFREE", 4: "REBOOT",
              5: "BUF_FREE", 6: "UPTIME", PORT_ECHO: "ECHO",
              PORT_TLM: "TLM", PORT_EPS_HK: "EPS_HK", PORT_EPS_CMD: "EPS_CMD"}

# eps_hk_t (20 byte, little endian, packed)
EPS_HK_FMT = "<IhHBBHII"
EPS_HK_SIZE = struct.calcsize(EPS_HK_FMT)

# ---------------------------------------------------------------------------
# CFP: CAN Fragmentation Protocol (ID extended 29 bit)
#   [SRC 5][DST 5][TYPE 1][REMAIN 8][ID 10]
# ---------------------------------------------------------------------------
CFP_BEGIN, CFP_MORE = 0, 1
CFP_OVERHEAD = 6                        # 4 byte header CSP + 2 byte panjang


def cfp_fields(can_id):
    return {
        "src": (can_id >> 24) & 0x1F,
        "dst": (can_id >> 19) & 0x1F,
        "type": (can_id >> 18) & 0x01,
        "remain": (can_id >> 10) & 0xFF,
        "id": can_id & 0x3FF,
    }


def cfp_make_id(src, dst, typ, remain, ident):
    return ((src & 0x1F) << 24) | ((dst & 0x1F) << 19) | ((typ & 1) << 18) \
        | ((remain & 0xFF) << 10) | (ident & 0x3FF)


# ---------------------------------------------------------------------------
# Header CSP 1.6 (32 bit, dikirim big endian)
#   [PRIO 2][SRC 5][DST 5][DPORT 6][SPORT 6][FLAGS 8]
# ---------------------------------------------------------------------------
def csp_header_unpack(raw4):
    h = struct.unpack(">I", raw4)[0]
    return {
        "prio": (h >> 30) & 0x3,
        "src": (h >> 25) & 0x1F,
        "dst": (h >> 20) & 0x1F,
        "dport": (h >> 14) & 0x3F,
        "sport": (h >> 8) & 0x3F,
        "flags": h & 0xFF,
    }


def csp_header_pack(prio, src, dst, dport, sport, flags=0):
    h = ((prio & 3) << 30) | ((src & 0x1F) << 25) | ((dst & 0x1F) << 20) \
        | ((dport & 0x3F) << 14) | ((sport & 0x3F) << 8) | (flags & 0xFF)
    return struct.pack(">I", h)


def cfp_fragment(src, dst, payload, header4, ident):
    """Pecah satu paket CSP menjadi daftar (can_id, data) sesuai csp_can_tx()."""
    length = len(payload)
    frames = []
    first = header4 + struct.pack(">H", length) + payload[:2]
    remain = (length + CFP_OVERHEAD - 1) // 8
    frames.append((cfp_make_id(src, dst, CFP_BEGIN, remain, ident), first))
    sent = min(length, 2)
    while sent < length:
        chunk = payload[sent:sent + 8]
        remain = (length - sent - len(chunk) + 7) // 8
        frames.append((cfp_make_id(src, dst, CFP_MORE, remain, ident), chunk))
        sent += len(chunk)
    return frames


class CfpReassembler:
    """Menyusun ulang frame CFP menjadi paket CSP (meniru csp_can_rx())."""

    def __init__(self, timeout=1.0):
        self.buffers = {}
        self.timeout = timeout

    def push(self, can_id, data):
        f = cfp_fields(can_id)
        key = (f["src"], f["dst"], f["id"])
        now = time.time()
        for k in [k for k, b in self.buffers.items() if now - b["t"] > self.timeout]:
            del self.buffers[k]

        if f["type"] == CFP_BEGIN:
            if len(data) < CFP_OVERHEAD:
                return None, "BEGIN terlalu pendek"
            hdr = csp_header_unpack(bytes(data[:4]))
            length = struct.unpack(">H", bytes(data[4:6]))[0]
            buf = {"hdr": hdr, "len": length, "data": bytearray(data[6:]),
                   "remain": f["remain"], "t": now}
            self.buffers[key] = buf
        else:
            buf = self.buffers.get(key)
            if buf is None:
                return None, "frame MORE tanpa BEGIN"
            if f["remain"] != buf["remain"] - 1:
                del self.buffers[key]
                return None, "frame hilang (remain tidak urut)"
            buf["remain"] = f["remain"]
            buf["data"] += bytes(data)

        if len(buf["data"]) >= buf["len"]:
            del self.buffers[key]
            return {"hdr": buf["hdr"], "data": bytes(buf["data"][:buf["len"]])}, None
        return None, None


# ---------------------------------------------------------------------------
# Node CSP sederhana di PC
# ---------------------------------------------------------------------------
class CspNode:
    def __init__(self, bus, addr=ADDR_PC, verbose=False):
        self.bus = bus
        self.addr = addr
        self.verbose = verbose
        self.rx = CfpReassembler()
        self.ident = 0
        self.sport = 20
        self.inbox = queue.Queue()
        self.stats = {"frames": 0, "packets": 0, "errors": 0}
        self._stop = threading.Event()
        self._thread = threading.Thread(target=self._rx_loop, daemon=True)
        self._thread.start()

    def close(self):
        self._stop.set()
        self._thread.join(timeout=1)

    def _rx_loop(self):
        while not self._stop.is_set():
            msg = self.bus.recv(timeout=0.1)
            if msg is None:
                continue
            if msg.is_error_frame:
                self.stats["errors"] += 1
                self.inbox.put(("error", msg))
                continue
            if not msg.is_extended_id:
                self.inbox.put(("std", msg))
                continue
            self.stats["frames"] += 1
            pkt, err = self.rx.push(msg.arbitration_id, msg.data)
            self.inbox.put(("frame", msg, err))
            if pkt:
                pkt["t"] = time.time()
                self.stats["packets"] += 1
                self.inbox.put(("packet", pkt))

    def alloc_sport(self):
        self.sport = 17 + (self.sport - 16) % 40   # port sementara 17..56
        return self.sport

    def send(self, dst, dport, payload, sport=None, prio=2):
        if sport is None:
            sport = self.alloc_sport()
        hdr = csp_header_pack(prio, self.addr, dst, dport, sport)
        self.ident = (self.ident + 1) & 0x3FF
        for can_id, data in cfp_fragment(self.addr, dst, payload, hdr, self.ident):
            self.bus.send(can.Message(arbitration_id=can_id, data=data,
                                      is_extended_id=True), timeout=0.2)
        return sport

    def wait_reply(self, src, sport, timeout):
        """Tunggu paket dari node 'src' yang ditujukan ke port 'sport' kita."""
        end = time.time() + timeout
        while time.time() < end:
            try:
                item = self.inbox.get(timeout=max(0.0, end - time.time()))
            except queue.Empty:
                break
            if item[0] == "packet":
                h = item[1]["hdr"]
                if h["src"] == src and h["dst"] == self.addr and h["dport"] == sport:
                    return item[1]
        return None

    def transaction(self, dst, dport, payload, timeout=1.0):
        t0 = time.time()
        sport = self.send(dst, dport, payload)
        reply = self.wait_reply(dst, sport, timeout)
        return reply, (time.time() - t0) * 1000.0


# ---------------------------------------------------------------------------
# Tampilan
# ---------------------------------------------------------------------------
def node_name(a):
    return f"{NODE_NAMES.get(a, '?')}({a})"


def describe_packet(pkt):
    h, d = pkt["hdr"], pkt["data"]
    port = PORT_NAMES.get(h["dport"]) or (PORT_NAMES.get(h["sport"], str(h["dport"])) + " balasan")
    line = (f"CSP {node_name(h['src'])}:{h['sport']} -> {node_name(h['dst'])}:{h['dport']} "
            f"[{port}] prio={h['prio']} len={len(d)}")
    extra = ""
    if h["dport"] == PORT_PING or h["sport"] == PORT_PING:
        extra = f"ping {len(d)} byte"
    elif h["dport"] == PORT_ECHO or h["sport"] == PORT_ECHO:
        extra = f'teks="{d.decode(errors="replace")}"'
    elif h["dport"] == PORT_TLM:
        extra = f"checksum={sum(d)}"
    elif h["src"] == ADDR_EPS and h["sport"] == PORT_EPS_HK and len(d) == EPS_HK_SIZE:
        extra = format_hk(d)
    elif h["dport"] == PORT_EPS_CMD and len(d) == 2:
        extra = f"perintah rail={d[0]} -> {'ON' if d[1] else 'OFF'}"
    elif h["sport"] == PORT_EPS_CMD and len(d) == 2:
        extra = f"status={'OK' if d[0] == 0 else 'DITOLAK'} rail={'ON' if d[1] else 'OFF'}"
    elif d:
        extra = "data=" + d[:16].hex(" ") + (" ..." if len(d) > 16 else "")
    return line + ("  " + extra if extra else "")


def format_hk(d):
    up, t10, vdda, rail, _, cnt, rxf, txf = struct.unpack(EPS_HK_FMT, d)
    return (f"uptime={up}s suhu={t10 / 10:.1f}C VDDA={vdda}mV payload={'ON' if rail else 'OFF'} "
            f"HK#{cnt} CAN rx={rxf} tx={txf}")


# ---------------------------------------------------------------------------
# Koneksi ke Korlan USB2CAN
# ---------------------------------------------------------------------------
def open_bus(args):
    if args.interface == "virtual":
        return can.Bus(interface="virtual", channel=args.channel or "csp")
    kw = {"interface": args.interface, "bitrate": args.bitrate}
    if args.channel:
        kw["channel"] = args.channel
    if args.interface == "usb2can":
        here = os.path.dirname(os.path.abspath(__file__))
        dll = os.path.join(here, "usb2can.dll")
        if os.path.exists(dll):
            kw["dll"] = dll
    return can.Bus(**kw)


# ---------------------------------------------------------------------------
# Perintah
# ---------------------------------------------------------------------------
def cmd_list(args):
    cfgs = can.detect_available_configs(args.interface)
    if not cfgs:
        print("Tidak ada adapter terdeteksi. Cek driver 8devices, usb2can.dll dan pywin32.")
    for c in cfgs:
        print(c)


def cmd_monitor(args):
    bus = open_bus(args)
    node = CspNode(bus)
    print(f"Monitor CAN {args.bitrate} bit/s. Tekan Ctrl+C untuk berhenti.\n")
    t_last = time.time()
    try:
        while True:
            try:
                item = node.inbox.get(timeout=1.0)
            except queue.Empty:
                item = None
            ts = time.strftime("%H:%M:%S")
            if item:
                kind = item[0]
                if kind == "frame" and args.frames:
                    msg, err = item[1], item[2]
                    f = cfp_fields(msg.arbitration_id)
                    print(f"{ts}  frame 0x{msg.arbitration_id:08X} "
                          f"src={f['src']} dst={f['dst']} {'BEGIN' if f['type'] == 0 else 'MORE '} "
                          f"remain={f['remain']:3d} id={f['id']:4d} | {msg.data.hex(' ')}"
                          + (f"  !! {err}" if err else ""))
                elif kind == "packet":
                    print(f"{ts}  {describe_packet(item[1])}")
                elif kind == "error":
                    print(f"{ts}  ERROR FRAME {item[1]}")
                elif kind == "std":
                    m = item[1]
                    print(f"{ts}  frame standar 0x{m.arbitration_id:03X} {m.data.hex(' ')} (bukan CFP)")
            if time.time() - t_last >= 10:
                t_last = time.time()
                s = node.stats
                print(f"--- statistik: frame={s['frames']} paket CSP={s['packets']} error={s['errors']}")
    except KeyboardInterrupt:
        pass
    finally:
        node.close()
        bus.shutdown()


def cmd_ping(args):
    bus = open_bus(args)
    node = CspNode(bus)
    try:
        for i in range(args.count):
            payload = bytes(range(args.size))
            reply, ms = node.transaction(args.addr, PORT_PING, payload, args.timeout)
            if reply and reply["data"] == payload:
                print(f"ping {node_name(args.addr)} #{i}: {ms:.1f} ms ({args.size} byte)")
            elif reply:
                print(f"ping {node_name(args.addr)} #{i}: balasan berbeda ({len(reply['data'])} byte)")
            else:
                print(f"ping {node_name(args.addr)} #{i}: TIMEOUT")
            time.sleep(0.5)
    finally:
        node.close()
        bus.shutdown()


def cmd_hk(args):
    bus = open_bus(args)
    node = CspNode(bus)
    try:
        reply, ms = node.transaction(ADDR_EPS, PORT_EPS_HK, b"\x00", args.timeout)
        if reply and len(reply["data"]) == EPS_HK_SIZE:
            print(f"EPS housekeeping ({ms:.1f} ms): {format_hk(reply['data'])}")
        else:
            print("Housekeeping EPS: TIMEOUT / format salah")
    finally:
        node.close()
        bus.shutdown()


def cmd_payload(args):
    bus = open_bus(args)
    node = CspNode(bus)
    try:
        on = 1 if args.state == "on" else 0
        reply, ms = node.transaction(ADDR_EPS, PORT_EPS_CMD, bytes([0, on]), args.timeout)
        if reply and len(reply["data"]) == 2:
            st, rail = reply["data"]
            print(f"Perintah payload {args.state.upper()}: {'OK' if st == 0 else 'DITOLAK'}, "
                  f"rail sekarang {'ON' if rail else 'OFF'} ({ms:.1f} ms)")
        else:
            print("Perintah payload: TIMEOUT")
    finally:
        node.close()
        bus.shutdown()


def cmd_echo(args):
    bus = open_bus(args)
    node = CspNode(bus)
    try:
        reply, ms = node.transaction(args.addr, PORT_ECHO, args.text.encode(), args.timeout)
        if reply:
            print(f'Balasan {node_name(args.addr)} ({ms:.1f} ms): "{reply["data"].decode(errors="replace")}"')
        else:
            print("Echo: TIMEOUT")
    finally:
        node.close()
        bus.shutdown()


def main():
    p = argparse.ArgumentParser(description="Alat uji CSP lewat CAN (Korlan USB2CAN)")
    p.add_argument("--interface", default="usb2can", help="usb2can (default), virtual, dll")
    p.add_argument("--channel", default=None, help="serial number Korlan, mis. ED000200 (opsional)")
    p.add_argument("--bitrate", type=int, default=1000000, help="default 1000000 (1 Mbit/s)")
    p.add_argument("--timeout", type=float, default=1.0)
    sub = p.add_subparsers(dest="cmd", required=True)

    sub.add_parser("list", help="cari adapter yang terpasang").set_defaults(fn=cmd_list)

    m = sub.add_parser("monitor", help="tampilkan lalu lintas bus")
    m.add_argument("--frames", action="store_true", help="tampilkan juga setiap frame CAN")
    m.set_defaults(fn=cmd_monitor)

    pg = sub.add_parser("ping", help="ping node CSP")
    pg.add_argument("addr", type=int, help="1 = OBC, 2 = EPS")
    pg.add_argument("--count", type=int, default=4)
    pg.add_argument("--size", type=int, default=16)
    pg.set_defaults(fn=cmd_ping)

    sub.add_parser("hk", help="minta housekeeping EPS").set_defaults(fn=cmd_hk)

    pl = sub.add_parser("payload", help="ON/OFF rail payload EPS")
    pl.add_argument("state", choices=["on", "off"])
    pl.set_defaults(fn=cmd_payload)

    e = sub.add_parser("echo", help="kirim teks ke port 10")
    e.add_argument("addr", type=int)
    e.add_argument("text")
    e.set_defaults(fn=cmd_echo)

    args = p.parse_args()
    args.fn(args)


if __name__ == "__main__":
    main()
csp_can_gui.py – GUI Tkinter (622 baris)
#!/usr/bin/env python3
"""
csp_can_gui.py - GUI pemantau bus CAN CubeSat (libcsp 1.6 / CFP)
untuk Korlan USB2CAN (8devices) + python-can.

Jalankan:   python csp_can_gui.py
Butuh   :   pip install python-can pywin32   (+ usb2can.dll di folder ini)

Fitur:
  - Hubungkan ke Korlan USB2CAN (atau mode Simulasi tanpa hardware)
  - Tabel paket CSP yang sudah disusun ulang (warna per node)
  - Tabel frame CAN mentah (ID CFP diuraikan)
  - Panel EPS: suhu, VDDA, status payload, uptime (auto-refresh)
  - Tombol Ping OBC / Ping EPS, Minta HK, Payload ON/OFF, kirim pesan
  - Statistik bus + simpan log ke CSV
"""
import csv
import queue
import struct
import threading
import time
import tkinter as tk
from tkinter import ttk, filedialog, messagebox

import can

import csp_can_tool as T

APP_TITLE = "CubeSat CAN Monitor - Korlan USB2CAN"
MAX_ROWS = 2000

# Kode status/error yang dikirim adapter 8devices USB2CAN (byte data pertama)
USB2CAN_STATUS = {
    0x00: "OK", 0x01: "overrun", 0x02: "bus warning", 0x03: "error passive",
    0x04: "BUS-OFF", 0x20: "stuff error", 0x21: "form error", 0x23: "ACK error",
    0x24: "bit0 error", 0x25: "bit1 error", 0x27: "CRC error", -1: "error",
}

NODE_COLORS = {T.ADDR_OBC: "#1f5fd6", T.ADDR_EPS: "#c0392b", T.ADDR_PC: "#1a8a3a"}


# ---------------------------------------------------------------------------
# Simulator OBC + EPS (mode tanpa hardware, memakai bus 'virtual' python-can)
# ---------------------------------------------------------------------------
class Simulator:
    def __init__(self, channel):
        self.bus = can.Bus(interface="virtual", channel=channel)
        self.rx = T.CfpReassembler()
        self.ident = 0
        self.rail = 0
        self.hk_count = 0
        self.t0 = time.time()
        self.stop = threading.Event()
        threading.Thread(target=self._rx, daemon=True).start()
        threading.Thread(target=self._obc, daemon=True).start()

    def close(self):
        self.stop.set()
        time.sleep(0.15)
        self.bus.shutdown()

    def _send(self, src, dst, dport, sport, data):
        self.ident = (self.ident + 1) & 0x3FF
        hdr = T.csp_header_pack(2, src, dst, dport, sport)
        for cid, d in T.cfp_fragment(src, dst, data, hdr, self.ident):
            self.bus.send(can.Message(arbitration_id=cid, data=d, is_extended_id=True))
            time.sleep(0.0002)

    def _hk(self):
        temp = int(380 + 15 * ((time.time() * 0.2) % 1))
        return struct.pack(T.EPS_HK_FMT, int(time.time() - self.t0), temp, 3290,
                           self.rail, 0, self.hk_count, 0, 0)

    def _reply(self, node, pkt):
        h, d = pkt["hdr"], pkt["data"]
        if h["dport"] == T.PORT_PING:
            out = d
        elif h["dport"] == T.PORT_ECHO:
            out = f"ACK dari node {node} ({T.NODE_NAMES[node]})".encode()
        elif h["dport"] == T.PORT_TLM:
            return
        elif node == T.ADDR_EPS and h["dport"] == T.PORT_EPS_HK:
            self.hk_count += 1
            out = self._hk()
        elif node == T.ADDR_EPS and h["dport"] == T.PORT_EPS_CMD and len(d) == 2:
            self.rail = 1 if d[1] else 0
            out = bytes([0, self.rail])
        else:
            return
        self._send(node, h["src"], h["sport"], h["dport"], out)

    def _rx(self):
        while not self.stop.is_set():
            m = self.bus.recv(0.05)
            if m is None or not m.is_extended_id:
                continue
            pkt, _ = self.rx.push(m.arbitration_id, m.data)
            if pkt and pkt["hdr"]["dst"] in (T.ADDR_OBC, T.ADDR_EPS):
                self._reply(pkt["hdr"]["dst"], pkt)

    def _obc(self):
        """OBC tiruan: tiap 2 detik ping, HK dan paket 200 byte ke EPS."""
        n = 0
        while not self.stop.wait(2.0):
            self._send(T.ADDR_OBC, T.ADDR_EPS, T.PORT_PING, 30, bytes(range(32)))
            self._send(T.ADDR_EPS, T.ADDR_OBC, 30, T.PORT_PING, bytes(range(32)))
            self._send(T.ADDR_OBC, T.ADDR_EPS, T.PORT_EPS_HK, 31, b"\x00")
            self.hk_count += 1
            self._send(T.ADDR_EPS, T.ADDR_OBC, 31, T.PORT_EPS_HK, self._hk())
            self._send(T.ADDR_OBC, T.ADDR_EPS, T.PORT_TLM, 32, bytes((i + n) & 0xFF for i in range(200)))
            n += 1


# ---------------------------------------------------------------------------
# GUI
# ---------------------------------------------------------------------------
class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title(APP_TITLE)
        self.geometry("1280x800")
        self.minsize(1000, 640)

        self.bus = None
        self.node = None
        self.sim = None
        self.pending = {}            # sport -> (kind, info, t0, deadline)
        self.log_rows = []           # untuk simpan CSV
        self.node_seen = {T.ADDR_OBC: 0.0, T.ADDR_EPS: 0.0}
        self.rate_count = 0
        self.rate_t = time.time()
        self.err_counts = {}
        self.err_total = 0
        self.last_err = None
        self.err_log_t = 0.0
        self.send_errors = []

        self._style()
        self._build()
        self.after(40, self._poll)
        self.after(2000, self._auto_hk)
        self.after(300, lambda: self._scan_serial(quiet=True))
        self.protocol("WM_DELETE_WINDOW", self._on_close)

    # ---------------------------------------------------------------- style
    def _style(self):
        import tkinter.font as tkfont
        fams = set(tkfont.families(self))
        ui = next((f for f in ("Segoe UI", "Helvetica", "DejaVu Sans", "Arial") if f in fams), "TkDefaultFont")
        mono = next((f for f in ("Consolas", "DejaVu Sans Mono", "Courier New") if f in fams), "TkFixedFont")
        self.ui_font, self.mono_font = ui, mono
        s = ttk.Style(self)
        try:
            s.theme_use("clam")
        except tk.TclError:
            pass
        self.configure(bg="#eef1f5")
        s.configure(".", font=(ui, 10), background="#eef1f5")
        s.configure("Card.TFrame", background="white", relief="flat")
        s.configure("Card.TLabel", background="white")
        s.configure("Title.TLabel", font=(ui, 11, "bold"), background="white")
        s.configure("Big.TLabel", font=(ui, 22, "bold"), background="white")
        s.configure("Unit.TLabel", font=(ui, 9), foreground="#666", background="white")
        s.configure("Treeview", rowheight=22, font=(mono, 10))
        s.configure("Treeview.Heading", font=(ui, 10, "bold"))
        s.configure("Accent.TButton", font=(ui, 10, "bold"))

    def _card(self, parent, title):
        f = ttk.Frame(parent, style="Card.TFrame", padding=10)
        ttk.Label(f, text=title, style="Title.TLabel").pack(anchor="w", pady=(0, 6))
        return f

    # ---------------------------------------------------------------- layout
    def _build(self):
        # ---- baris koneksi
        top = ttk.Frame(self, padding=(10, 8))
        top.pack(fill="x")
        ttk.Label(top, text="Adapter:").pack(side="left")
        self.var_if = tk.StringVar(value="usb2can")
        ttk.Combobox(top, textvariable=self.var_if, width=10, state="readonly",
                     values=["usb2can", "simulasi"]).pack(side="left", padx=(4, 12))
        ttk.Label(top, text="Serial:").pack(side="left")
        self.var_ch = tk.StringVar()
        self.cb_ch = ttk.Combobox(top, textvariable=self.var_ch, width=11)
        self.cb_ch.pack(side="left", padx=(4, 2))
        ttk.Button(top, text="Cari", width=5, command=self._scan_serial).pack(side="left", padx=(0, 12))
        ttk.Label(top, text="Bitrate:").pack(side="left")
        self.var_br = tk.StringVar(value="1000000")
        ttk.Combobox(top, textvariable=self.var_br, width=9,
                     values=["1000000", "500000", "250000", "125000"]).pack(side="left", padx=(4, 12))
        self.btn_conn = ttk.Button(top, text="Hubungkan", style="Accent.TButton", command=self._toggle)
        self.btn_conn.pack(side="left")
        self.lbl_status = tk.Label(top, text="  TIDAK TERHUBUNG  ", bg="#999", fg="white",
                                   font=(self.ui_font, 10, "bold"))
        self.lbl_status.pack(side="left", padx=12)
        ttk.Button(top, text="Simpan log CSV", command=self._save_csv).pack(side="right")
        ttk.Button(top, text="Bersihkan", command=self._clear).pack(side="right", padx=6)

        body = ttk.Frame(self, padding=(10, 0, 10, 10))
        body.pack(fill="both", expand=True)

        # ---- panel kiri
        left = ttk.Frame(body, width=330)
        left.pack(side="left", fill="y", padx=(0, 10))
        left.pack_propagate(False)

        # node status
        c = self._card(left, "Status node")
        c.pack(fill="x", pady=(0, 8))
        self.node_lbl = {}
        for addr in (T.ADDR_OBC, T.ADDR_EPS):
            row = ttk.Frame(c, style="Card.TFrame")
            row.pack(fill="x", pady=2)
            dot = tk.Label(row, text="●", fg="#bbb", bg="white", font=(self.ui_font, 14))
            dot.pack(side="left")
            ttk.Label(row, text=f"{T.NODE_NAMES[addr]} (alamat {addr})", style="Card.TLabel",
                      width=16).pack(side="left")
            info = ttk.Label(row, text="belum terlihat", style="Card.TLabel", foreground="#777")
            info.pack(side="left")
            self.node_lbl[addr] = (dot, info)

        # EPS housekeeping
        c = self._card(left, "EPS housekeeping")
        c.pack(fill="x", pady=(0, 8))
        grid = ttk.Frame(c, style="Card.TFrame")
        grid.pack(fill="x")
        self.hk = {}
        for i, (key, label, unit) in enumerate([("temp", "Suhu chip", "°C"), ("vdda", "VDDA", "mV"),
                                                ("rail", "Payload", ""), ("uptime", "Uptime", "detik")]):
            f = ttk.Frame(grid, style="Card.TFrame", padding=(0, 2))
            f.grid(row=i // 2, column=i % 2, sticky="w", padx=(0, 18), pady=2)
            ttk.Label(f, text=label, style="Unit.TLabel").pack(anchor="w")
            v = ttk.Label(f, text="–", style="Big.TLabel")
            v.pack(anchor="w")
            ttk.Label(f, text=unit, style="Unit.TLabel").pack(anchor="w")
            self.hk[key] = v
        self.lbl_hk_time = ttk.Label(c, text="belum ada data", style="Unit.TLabel")
        self.lbl_hk_time.pack(anchor="w", pady=(4, 4))
        r = ttk.Frame(c, style="Card.TFrame")
        r.pack(fill="x")
        ttk.Button(r, text="Minta HK", command=self._req_hk).pack(side="left")
        self.var_auto = tk.BooleanVar(value=True)
        ttk.Checkbutton(r, text="otomatis tiap 2 s", variable=self.var_auto).pack(side="left", padx=8)

        # perintah
        c = self._card(left, "Perintah")
        c.pack(fill="x", pady=(0, 8))
        r = ttk.Frame(c, style="Card.TFrame")
        r.pack(fill="x", pady=2)
        ttk.Button(r, text="Ping OBC", command=lambda: self._ping(T.ADDR_OBC)).pack(side="left", expand=True, fill="x")
        ttk.Button(r, text="Ping EPS", command=lambda: self._ping(T.ADDR_EPS)).pack(side="left", expand=True, fill="x", padx=(6, 0))
        r = ttk.Frame(c, style="Card.TFrame")
        r.pack(fill="x", pady=2)
        ttk.Button(r, text="Payload ON", command=lambda: self._payload(1)).pack(side="left", expand=True, fill="x")
        ttk.Button(r, text="Payload OFF", command=lambda: self._payload(0)).pack(side="left", expand=True, fill="x", padx=(6, 0))
        r = ttk.Frame(c, style="Card.TFrame")
        r.pack(fill="x", pady=(6, 2))
        self.var_dst = tk.StringVar(value="EPS (2)")
        ttk.Combobox(r, textvariable=self.var_dst, width=8, state="readonly",
                     values=["OBC (1)", "EPS (2)"]).pack(side="left")
        self.var_msg = tk.StringVar(value="Halo dari PC")
        e = ttk.Entry(r, textvariable=self.var_msg)
        e.pack(side="left", fill="x", expand=True, padx=6)
        e.bind("<Return>", lambda _: self._echo())
        ttk.Button(r, text="Kirim", command=self._echo).pack(side="left")
        self.lbl_result = tk.Label(c, text="", bg="white", anchor="w", justify="left",
                                   font=(self.ui_font, 10), wraplength=300)
        self.lbl_result.pack(fill="x", pady=(6, 0))

        # statistik
        c = self._card(left, "Statistik bus")
        c.pack(fill="both", expand=True)
        self.stat_lbl = {}
        for key, label in [("frames", "Frame CAN"), ("packets", "Paket CSP"), ("rate", "Frame/detik"),
                           ("errors", "Error frame"), ("cfp_err", "Error susun CFP")]:
            row = ttk.Frame(c, style="Card.TFrame")
            row.pack(fill="x")
            ttk.Label(row, text=label, style="Card.TLabel").pack(side="left")
            v = ttk.Label(row, text="0", style="Card.TLabel", font=(self.ui_font, 10, "bold"))
            v.pack(side="right")
            self.stat_lbl[key] = v
        self.cfp_err = 0
        row = ttk.Frame(c, style="Card.TFrame")
        row.pack(fill="x", pady=(6, 0))
        ttk.Label(row, text="Kondisi bus", style="Card.TLabel").pack(side="left")
        self.lbl_bus = tk.Label(row, text="–", bg="white", font=(self.ui_font, 10, "bold"))
        self.lbl_bus.pack(side="right")

        # ---- panel kanan: tab
        nb = ttk.Notebook(body)
        nb.pack(side="left", fill="both", expand=True)

        t1 = ttk.Frame(nb)
        nb.add(t1, text="  Paket CSP  ")
        cols = ("time", "src", "dst", "port", "len", "info")
        self.tv_pkt = self._tree(t1, cols, {"time": ("Waktu", 95), "src": ("Dari", 90), "dst": ("Ke", 90),
                                           "port": ("Port", 110), "len": ("Len", 50), "info": ("Isi", 520)})
        for addr, col in NODE_COLORS.items():
            self.tv_pkt.tag_configure(f"n{addr}", foreground=col)

        t2 = ttk.Frame(nb)
        nb.add(t2, text="  Frame CAN  ")
        self.var_frames = tk.BooleanVar(value=True)
        ttk.Checkbutton(t2, text="Tampilkan frame (matikan jika bus sangat ramai)",
                        variable=self.var_frames).pack(anchor="w", padx=4, pady=2)
        cols = ("time", "id", "src", "dst", "type", "remain", "cfp", "dlc", "data")
        self.tv_frm = self._tree(t2, cols, {"time": ("Waktu", 95), "id": ("CAN ID", 95), "src": ("Src", 45),
                                           "dst": ("Dst", 45), "type": ("Tipe", 60), "remain": ("Remain", 60),
                                           "cfp": ("CFP id", 60), "dlc": ("DLC", 40), "data": ("Data", 300)})
        self.tv_frm.tag_configure("err", foreground="#c0392b")

        t3 = ttk.Frame(nb)
        nb.add(t3, text="  Log  ")
        self.txt_log = tk.Text(t3, font=(self.mono_font, 10), wrap="word", bg="#fbfbfb")
        self.txt_log.pack(fill="both", expand=True)

        self._log("Siap. Pilih adapter lalu klik Hubungkan. Pilih 'simulasi' untuk mencoba tanpa hardware.")

    def _tree(self, parent, cols, spec):
        frame = ttk.Frame(parent)
        frame.pack(fill="both", expand=True)
        tv = ttk.Treeview(frame, columns=cols, show="headings")
        for c in cols:
            title, w = spec[c]
            tv.heading(c, text=title)
            tv.column(c, width=w, anchor="w", stretch=(c in ("info", "data")))
        sb = ttk.Scrollbar(frame, orient="vertical", command=tv.yview)
        tv.configure(yscrollcommand=sb.set)
        tv.pack(side="left", fill="both", expand=True)
        sb.pack(side="right", fill="y")
        return tv

    # ---------------------------------------------------------------- koneksi
    def _toggle(self):
        if self.bus:
            self._disconnect()
        else:
            self._connect()

    def _connect(self):
        try:
            br = int(self.var_br.get())
            if self.var_if.get() == "simulasi":
                self.sim = Simulator("csp_sim")
                self.bus = can.Bus(interface="virtual", channel="csp_sim")
            else:
                if not self.var_ch.get().strip():
                    self._scan_serial(quiet=True)
                class A:  # argumen untuk open_bus()
                    pass
                a = A()
                a.interface, a.channel, a.bitrate = "usb2can", (self.var_ch.get().strip() or None), br
                self.bus = T.open_bus(a)
            self.node = T.CspNode(self.bus)
        except Exception as e:  # noqa: BLE001
            self.bus = None
            if self.sim:
                self.sim.close()
                self.sim = None
            messagebox.showerror("Gagal terhubung",
                                 f"{e}\n\nCek: driver 8devices terpasang, usb2can.dll (64-bit) ada di "
                                 "folder script, pywin32 terpasang, dan serial number benar.")
            return
        self.btn_conn.configure(text="Putuskan")
        sn = "" if self.var_if.get() == "simulasi" else (self.var_ch.get().strip() or "?")
        self.lbl_status.configure(text=f"  TERHUBUNG {int(self.var_br.get()) // 1000} kbit/s"
                                       + (f" · S/N {sn}" if sn else "") + "  ", bg="#1a8a3a")
        self.title(f"{APP_TITLE}" + (f"  [S/N {sn}]" if sn else ""))
        self._log(f"Terhubung ({self.var_if.get()}{', S/N ' + sn if sn else ''}), alamat CSP PC = {T.ADDR_PC}")

    def _scan_serial(self, quiet=False):
        """Cari Korlan USB2CAN yang terpasang dan isi kolom Serial."""
        try:
            cfgs = can.detect_available_configs("usb2can")
        except Exception as e:  # noqa: BLE001
            cfgs = []
            if not quiet:
                self._log(f"Gagal mencari adapter: {e}")
        serials = [str(c.get("channel")) for c in cfgs if c.get("channel")]
        self.cb_ch.configure(values=serials)
        if serials:
            if self.var_ch.get().strip() not in serials:
                self.var_ch.set(serials[0])
            self._log("Korlan USB2CAN terdeteksi: S/N " + ", ".join(serials))
        elif not quiet:
            self._log("Korlan USB2CAN tidak terdeteksi (cek kabel USB / driver 8devices / pywin32).")
        return serials

    def _disconnect(self):
        try:
            if self.node:
                self.node.close()
            if self.bus:
                self.bus.shutdown()
            if self.sim:
                self.sim.close()
        finally:
            self.node = self.bus = self.sim = None
            self.pending.clear()
        self.btn_conn.configure(text="Hubungkan")
        self.lbl_status.configure(text="  TIDAK TERHUBUNG  ", bg="#999")
        self._log("Koneksi diputus")

    def _on_close(self):
        if self.bus:
            self._disconnect()
        self.destroy()

    # ---------------------------------------------------------------- perintah
    def _request(self, kind, dst, dport, payload, info=None, timeout=1.0):
        if not self.node:
            self._result("Belum terhubung ke adapter.", "#c0392b")
            return
        node = self.node
        sport = node.alloc_sport()
        self.pending[sport] = (kind, dst, info, time.time(), time.time() + timeout)

        def worker():
            # dikirim di thread terpisah agar GUI tidak macet bila adapter lambat
            try:
                node.send(dst, dport, payload, sport=sport)
            except Exception as e:  # noqa: BLE001
                self.send_errors.append(str(e))
        threading.Thread(target=worker, daemon=True).start()

    def _ping(self, addr):
        self._request("ping", addr, T.PORT_PING, bytes(range(16)), bytes(range(16)))

    def _req_hk(self):
        self._request("hk", T.ADDR_EPS, T.PORT_EPS_HK, b"\x00")

    def _payload(self, on):
        self._request("cmd", T.ADDR_EPS, T.PORT_EPS_CMD, bytes([0, on]), on)

    def _echo(self):
        dst = T.ADDR_OBC if self.var_dst.get().startswith("OBC") else T.ADDR_EPS
        self._request("echo", dst, T.PORT_ECHO, self.var_msg.get().encode())

    def _auto_hk(self):
        if self.node and self.var_auto.get():
            if not any(p[0] == "hk" for p in self.pending.values()):
                self._req_hk()
        self.after(2000, self._auto_hk)

    def _handle_reply(self, pkt):
        h = pkt["hdr"]
        p = self.pending.get(h["dport"])
        if not p or h["src"] != p[1] or h["dst"] != T.ADDR_PC:
            return
        del self.pending[h["dport"]]
        kind, dst, info, t0, _ = p
        ms = (pkt.get("t", time.time()) - t0) * 1000
        name = f"{T.NODE_NAMES[dst]} ({dst})"
        d = pkt["data"]
        if kind == "ping":
            ok = d == info
            self._result(f"Ping {name}: {ms:.1f} ms" + ("" if ok else " (data berbeda!)"),
                         "#1a8a3a" if ok else "#c0392b")
        elif kind == "hk" and len(d) == T.EPS_HK_SIZE:
            self._show_hk(d, ms)
        elif kind == "cmd" and len(d) == 2:
            ok = d[0] == 0
            self._result(f"Payload {'ON' if d[1] else 'OFF'}: {'OK' if ok else 'DITOLAK'} ({ms:.1f} ms)",
                         "#1a8a3a" if ok else "#c0392b")
        elif kind == "echo":
            self._result(f'Balasan {name} ({ms:.1f} ms):\n"{d.decode(errors="replace")}"', "#1f5fd6")

    def _show_hk(self, d, ms=None):
        up, t10, vdda, rail, _, cnt, rxf, txf = struct.unpack(T.EPS_HK_FMT, d)
        self.hk["temp"].configure(text=f"{t10 / 10:.1f}")
        self.hk["vdda"].configure(text=f"{vdda}")
        self.hk["rail"].configure(text="ON" if rail else "OFF", foreground="#1a8a3a" if rail else "#777")
        self.hk["uptime"].configure(text=f"{up}")
        extra = f", {ms:.0f} ms" if ms is not None else ""
        self.lbl_hk_time.configure(text=f"diperbarui {time.strftime('%H:%M:%S')}{extra} · HK #{cnt}")

    def _check_timeouts(self):
        now = time.time()
        for sport, p in list(self.pending.items()):
            if now > p[4]:
                del self.pending[sport]
                kind, dst = p[0], p[1]
                label = {"ping": "Ping", "hk": "Housekeeping", "cmd": "Perintah payload",
                         "echo": "Pesan"}[kind]
                self._result(f"{label} ke {T.NODE_NAMES[dst]} ({dst}): TIMEOUT", "#c0392b")
                self._log(f"{label} ke node {dst}: timeout")

    # ---------------------------------------------------------------- loop GUI
    def _poll(self):
        if self.node:
            n = 0
            while n < 500:
                try:
                    item = self.node.inbox.get_nowait()
                except queue.Empty:
                    break
                n += 1
                self._handle_item(item)
            self._check_timeouts()
            self._update_stats()
        self.after(40, self._poll)

    def _handle_item(self, item):
        ts = time.strftime("%H:%M:%S") + f".{int(time.time() * 1000) % 1000:03d}"
        kind = item[0]
        if kind == "frame":
            msg, err = item[1], item[2]
            self.rate_count += 1
            f = T.cfp_fields(msg.arbitration_id)
            if f["src"] in self.node_seen:
                self.node_seen[f["src"]] = time.time()
            if err:
                self.cfp_err += 1
                self._log(f"CFP: {err} (ID 0x{msg.arbitration_id:08X})")
            if self.var_frames.get():
                self._insert(self.tv_frm, (ts, f"0x{msg.arbitration_id:08X}", f["src"], f["dst"],
                                           "BEGIN" if f["type"] == 0 else "MORE", f["remain"], f["id"],
                                           len(msg.data), msg.data.hex(" ")), ("err",) if err else ())
        elif kind == "packet":
            pkt = item[1]
            h = pkt["hdr"]
            desc = T.describe_packet(pkt)
            info = desc.split("  ", 1)[1] if "  " in desc else ""
            if h["dport"] in T.PORT_NAMES:
                port = f"{h['dport']} {T.PORT_NAMES[h['dport']]}"
            elif h["sport"] in T.PORT_NAMES:
                port = f"{h['dport']} re:{T.PORT_NAMES[h['sport']]}"
            else:
                port = str(h["dport"])
            self._insert(self.tv_pkt, (ts, f"{T.NODE_NAMES.get(h['src'], '?')}:{h['sport']}",
                                       f"{T.NODE_NAMES.get(h['dst'], '?')}:{h['dport']}", port,
                                       len(pkt["data"]), info), (f"n{h['src']}",))
            self.log_rows.append([ts, h["src"], h["sport"], h["dst"], h["dport"], len(pkt["data"]),
                                  pkt["data"].hex(), info])
            # HK EPS (dari siapa pun yang meminta, termasuk OBC) -> perbarui panel
            if h["src"] == T.ADDR_EPS and h["sport"] == T.PORT_EPS_HK and len(pkt["data"]) == T.EPS_HK_SIZE:
                self._show_hk(pkt["data"])
            self._handle_reply(pkt)
        elif kind == "error":
            data = bytes(item[1].data)
            code = data[0] if data else -1
            self.err_counts[code] = self.err_counts.get(code, 0) + 1
            self.err_total += 1
            self.last_err = code
        elif kind == "std":
            m = item[1]
            self._log(f"Frame standar 0x{m.arbitration_id:03X} {m.data.hex(' ')} (bukan CFP)")

    def _insert(self, tv, values, tags=()):
        tv.insert("", "end", values=values, tags=tags)
        kids = tv.get_children()
        if len(kids) > MAX_ROWS:
            tv.delete(*kids[:len(kids) - MAX_ROWS])
        tv.yview_moveto(1.0)

    def _update_stats(self):
        now_t = time.time()
        while self.send_errors:
            self._result(f"Gagal kirim: {self.send_errors.pop()}", "#c0392b")
        if self.err_counts and now_t - self.err_log_t >= 1.0:
            self.err_log_t = now_t
            txt = ", ".join(f"{USB2CAN_STATUS.get(c, hex(c))} x{n}" for c, n in sorted(self.err_counts.items()))
            self._log(f"Status/error dari adapter (1 detik terakhir): {txt}")
            self.lbl_bus.configure(text="ERROR: " + USB2CAN_STATUS.get(self.last_err, hex(self.last_err)),
                                   fg="#c0392b")
            self.err_counts.clear()
            self.bus_err_t = now_t
        elif now_t - getattr(self, "bus_err_t", 0) > 3 and self.node.stats["frames"]:
            self.lbl_bus.configure(text="OK", fg="#1a8a3a")
        s = self.node.stats
        self.stat_lbl["frames"].configure(text=str(s["frames"]))
        self.stat_lbl["packets"].configure(text=str(s["packets"]))
        self.stat_lbl["errors"].configure(text=str(s["errors"]))
        self.stat_lbl["cfp_err"].configure(text=str(self.cfp_err))
        now = time.time()
        if now - self.rate_t >= 1.0:
            self.stat_lbl["rate"].configure(text=f"{self.rate_count / (now - self.rate_t):.0f}")
            self.rate_count, self.rate_t = 0, now
        for addr, (dot, info) in self.node_lbl.items():
            seen = self.node_seen[addr]
            if seen == 0:
                continue
            age = now - seen
            if age < 5:
                dot.configure(fg="#1a8a3a")
                info.configure(text="aktif", foreground="#1a8a3a")
            else:
                dot.configure(fg="#e67e22")
                info.configure(text=f"diam {age:.0f} s", foreground="#e67e22")

    # ---------------------------------------------------------------- util
    def _result(self, text, color="#333"):
        self.lbl_result.configure(text=text, fg=color)

    def _log(self, text):
        self.txt_log.insert("end", f"[{time.strftime('%H:%M:%S')}] {text}\n")
        self.txt_log.see("end")

    def _clear(self):
        for tv in (self.tv_pkt, self.tv_frm):
            tv.delete(*tv.get_children())
        self.log_rows.clear()
        self.txt_log.delete("1.0", "end")

    def _save_csv(self):
        if not self.log_rows:
            messagebox.showinfo("Simpan log", "Belum ada paket CSP untuk disimpan.")
            return
        path = filedialog.asksaveasfilename(defaultextension=".csv",
                                            initialfile=time.strftime("csp_log_%Y%m%d_%H%M%S.csv"),
                                            filetypes=[("CSV", "*.csv")])
        if not path:
            return
        with open(path, "w", newline="", encoding="utf-8") as f:
            w = csv.writer(f)
            w.writerow(["waktu", "src", "sport", "dst", "dport", "len", "data_hex", "keterangan"])
            w.writerows(self.log_rows)
        self._log(f"Log disimpan: {path}")


if __name__ == "__main__":
    App().mainloop()

csp_can_tool.py juga bisa dipakai tanpa GUI, misalnya python csp_can_tool.py monitor, python csp_can_tool.py ping 2 atau python csp_can_tool.py hk.

Penutup

Dengan Korlan USB2CAN dan sedikit Python, kita punya alat diagnosis bus CAN yang memahami protokol CubeSat Space Protocol: frame mentah, paket CSP, data housekeeping EPS, sampai status error fisik bus. Hasil uji dengan dua board sungguhan menunjukkan OBC dan EPS berkomunikasi tanpa error setelah terminator 120 Ω terpasang. Semoga bermanfaat.

Wassalamu'alaikum warohmatullohi wabarakatuh.