2021년 1월 29일 금요일

Android Bluetooth serial socket 통신

디바이스와 modbus통신을 위해서 소캣통신을 구현해야 했다.

구글링해서 github에서 구한 소스를 조금 고쳐서 사용하는 것으로 해결 했다.


먼저 SerialSocket.java

package com...;

import android.app.Activity;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothSocket;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;

import java.io.IOException;
import java.security.InvalidParameterException;
import java.util.Arrays;
import java.util.UUID;
import java.util.concurrent.Executors;

public class SerialSocket implements Runnable {

    private static final UUID BLUETOOTH_SPP = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB");

    private final BroadcastReceiver disconnectBroadcastReceiver;

    private Context context;
    private SerialListener listener;
    private BluetoothDevice device;
    private BluetoothSocket socket;
    private boolean connected;

    public SerialSocket(Context context, BluetoothDevice device) {
        if(context instanceof Activity)
            throw new InvalidParameterException("expected non UI context");
        this.context = context;
        this.device = device;
        disconnectBroadcastReceiver = new BroadcastReceiver() {
            @Override
            public void onReceive(Context context, Intent intent) {
                if(listener != null)
                    listener.onSerialIoError(new IOException("background disconnect"));
                disconnect(); // disconnect now, else would be queued until UI re-attached
            }
        };
    }

    String getName() {
        return device.getName() != null ? device.getName() : device.getAddress();
    }

    /**
     * connect-success and most connect-errors are returned asynchronously to listener
     */
    public void connect(SerialListener listener) throws IOException {
        this.listener = listener;
        context.registerReceiver(disconnectBroadcastReceiver, new IntentFilter(Constants.INTENT_ACTION_DISCONNECT));
        Executors.newSingleThreadExecutor().submit(this);
    }

    public void disconnect() {
        listener = null; // ignore remaining data and errors
        // connected = false; // run loop will reset connected
        if(socket != null) {
            try {
                socket.close();
            } catch (Exception ignored) {
            }
            socket = null;
        }
        try {
            context.unregisterReceiver(disconnectBroadcastReceiver);
        } catch (Exception ignored) {
        }
    }

    public void write(byte[] data) throws IOException {
        if (!connected)
            throw new IOException("not connected");
        socket.getOutputStream().write(data);
    }

    @Override
    public void run() { // connect & read
        try {
            socket = device.createRfcommSocketToServiceRecord(BLUETOOTH_SPP);
            socket.connect();
            if(listener != null)
                listener.onSerialConnect();
        } catch (Exception e) {
            if(listener != null)
                listener.onSerialConnectError(e);
            try {
                socket.close();
            } catch (Exception ignored) {
            }
            socket = null;
            return;
        }
        connected = true;
        try {
            byte[] buffer = new byte[1024];
            int len;
            //noinspection InfiniteLoopStatement
            while (true) {
                len = socket.getInputStream().read(buffer);
                byte[] data = Arrays.copyOf(buffer, len);
                if(listener != null)
                    listener.onSerialRead(data);
            }
        } catch (Exception e) {
            connected = false;
            if (listener != null)
                listener.onSerialIoError(e);
            try {
                socket.close();
            } catch (Exception ignored) {
            }
            socket = null;
        }
    }

}


SerialService.java

package com...;

import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.Binder;
import android.os.Build;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;

import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;

import java.io.IOException;
import java.util.LinkedList;
import java.util.Queue;

/**
 * create notification and queue serial data while activity is not in the foreground
 * use listener chain: SerialSocket -> SerialService -> UI fragment
 */
public class SerialService extends Service implements SerialListener {

    public class SerialBinder extends Binder {
        public SerialService getService(){
            return SerialService.this;
        }
    }

    private enum QueueType {Connect, ConnectError, Read, IoError}

    private class QueueItem {
        QueueType type;
        byte[] data;
        Exception e;

        QueueItem(QueueType type, byte[] data, Exception e) { this.type=type; this.data=data; this.e=e; }
    }

    private final Handler mainLooper;
    private final IBinder binder;
    private final Queue<QueueItem> queue1, queue2;

    private SerialSocket socket;
    private SerialListener listener;
    private boolean connected;

    /**
     * Lifecylce
     */
    public SerialService() {
        mainLooper = new Handler(Looper.getMainLooper());
        binder = new SerialBinder();
        queue1 = new LinkedList<>();
        queue2 = new LinkedList<>();
    }

    @Override
    public void onDestroy() {
        cancelNotification();
        disconnect();
        super.onDestroy();
    }

    @Nullable
    @Override
    public IBinder onBind(Intent intent) {
        return binder;
    }

    /**
     * Api
     */
    public void connect(SerialSocket socket) throws IOException {
        socket.connect(this);
        this.socket = socket;
        connected = true;
    }

    public void disconnect() {
        connected = false; // ignore data,errors while disconnecting
        cancelNotification();
        if(socket != null) {
            socket.disconnect();
            socket = null;
        }
    }

    public void write(byte[] data) throws IOException {
        if(!connected)
            throw new IOException("not connected");
        socket.write(data);
    }

    public void attach(SerialListener listener) {
        if(Looper.getMainLooper().getThread() != Thread.currentThread())
            throw new IllegalArgumentException("not in main thread");
        cancelNotification();
        // use synchronized() to prevent new items in queue2
        // new items will not be added to queue1 because mainLooper.post and attach() run in main thread
        synchronized (this) {
            this.listener = listener;
        }
        for(QueueItem item : queue1) {
            switch(item.type) {
                case Connect:       listener.onSerialConnect      (); break;
                case ConnectError:  listener.onSerialConnectError (item.e); break;
                case Read:          listener.onSerialRead         (item.data); break;
                case IoError:       listener.onSerialIoError      (item.e); break;
            }
        }
        for(QueueItem item : queue2) {
            switch(item.type) {
                case Connect:       listener.onSerialConnect      (); break;
                case ConnectError:  listener.onSerialConnectError (item.e); break;
                case Read:          listener.onSerialRead         (item.data); break;
                case IoError:       listener.onSerialIoError      (item.e); break;
            }
        }
        queue1.clear();
        queue2.clear();
    }

    public void detach() {
        if(connected)
            createNotification();
        // items already in event queue (posted before detach() to mainLooper) will end up in queue1
        // items occurring later, will be moved directly to queue2
        // detach() and mainLooper.post run in the main thread, so all items are caught
        listener = null;
    }

    private void createNotification() {
//        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
//            NotificationChannel nc = new NotificationChannel(Constants.NOTIFICATION_CHANNEL, "Background service", NotificationManager.IMPORTANCE_LOW);
//            nc.setShowBadge(false);
//            NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
//            nm.createNotificationChannel(nc);
//        }
//        Intent disconnectIntent = new Intent()
//                .setAction(Constants.INTENT_ACTION_DISCONNECT);
//        Intent restartIntent = new Intent()
//                .setClassName(this, Constants.INTENT_CLASS_MAIN_ACTIVITY)
//                .setAction(Intent.ACTION_MAIN)
//                .addCategory(Intent.CATEGORY_LAUNCHER);
//        PendingIntent disconnectPendingIntent = PendingIntent.getBroadcast(this, 1, disconnectIntent, PendingIntent.FLAG_UPDATE_CURRENT);
//        PendingIntent restartPendingIntent = PendingIntent.getActivity(this, 1, restartIntent,  PendingIntent.FLAG_UPDATE_CURRENT);
//        NotificationCompat.Builder builder = new NotificationCompat.Builder(this, Constants.NOTIFICATION_CHANNEL)
//                .setSmallIcon(R.drawable.ic_notification)
//                .setColor(getResources().getColor(R.color.colorPrimary))
//                .setContentTitle(getResources().getString(R.string.app_name))
//                .setContentText(socket != null ? "Connected to "+socket.getName() : "Background Service")
//                .setContentIntent(restartPendingIntent)
//                .setOngoing(true)
//                .addAction(new NotificationCompat.Action(R.drawable.ic_clear_white_24dp, "Disconnect", disconnectPendingIntent));
//        // @drawable/ic_notification created with Android Studio -> New -> Image Asset using @color/colorPrimaryDark as background color
//        // Android < API 21 does not support vectorDrawables in notifications, so both drawables used here, are created as .png instead of .xml
//        Notification notification = builder.build();
//        startForeground(Constants.NOTIFY_MANAGER_START_FOREGROUND_SERVICE, notification);
    }

    private void cancelNotification() {
        stopForeground(true);
    }

    /**
     * SerialListener
     */
    public void onSerialConnect() {
        if(connected) {
            synchronized (this) {
                if (listener != null) {
                    mainLooper.post(() -> {
                        if (listener != null) {
                            listener.onSerialConnect();
                        } else {
                            queue1.add(new QueueItem(QueueType.Connect, null, null));
                        }
                    });
                } else {
                    queue2.add(new QueueItem(QueueType.Connect, null, null));
                }
            }
        }
    }

    public void onSerialConnectError(Exception e) {
        if(connected) {
            synchronized (this) {
                if (listener != null) {
                    mainLooper.post(() -> {
                        if (listener != null) {
                            listener.onSerialConnectError(e);
                        } else {
                            queue1.add(new QueueItem(QueueType.ConnectError, null, e));
                            cancelNotification();
                            disconnect();
                        }
                    });
                } else {
                    queue2.add(new QueueItem(QueueType.ConnectError, null, e));
                    cancelNotification();
                    disconnect();
                }
            }
        }
    }

    public void onSerialRead(byte[] data) {
        if(connected) {
            synchronized (this) {
                if (listener != null) {
                    mainLooper.post(() -> {
                        if (listener != null) {
                            listener.onSerialRead(data);
                        } else {
                            queue1.add(new QueueItem(QueueType.Read, data, null));
                        }
                    });
                } else {
                    queue2.add(new QueueItem(QueueType.Read, data, null));
                }
            }
        }
    }

    public void onSerialIoError(Exception e) {
        if(connected) {
            synchronized (this) {
                if (listener != null) {
                    mainLooper.post(() -> {
                        if (listener != null) {
                            listener.onSerialIoError(e);
                        } else {
                            queue1.add(new QueueItem(QueueType.IoError, null, e));
                            cancelNotification();
                            disconnect();
                        }
                    });
                } else {
                    queue2.add(new QueueItem(QueueType.IoError, null, e));
                    cancelNotification();
                    disconnect();
                }
            }
        }
    }

}

SerialListener.java

package com....;

public interface SerialListener {
    void onSerialConnect      ();
    void onSerialConnectError (Exception e);
    void onSerialRead         (byte[] data);
    void onSerialIoError      (Exception e);
}



마지막으로 실제 사용 예시

package com...

import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle
import android.os.IBinder
import android.util.Log
import android.widget.Button
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com...SerialListener
import com...SerialService
import com...SerialSocket
import com...CRC16Modbus
import com...TextUtil

const val STATE_SETTING = 1
const val STATE_MEASURE = 2
const val STATE_SET_VALUE = 3

class DataRelayActivityV2 : AppCompatActivity(), ServiceConnection, SerialListener {

    enum class Connected { False, Pending, True }

    lateinit var device: BluetoothDevice
    lateinit var deviceAddress: String
    private var service: SerialService? = null //SerialService()
    private var connected: Connected = Connected.False
    var initialStart: Boolean = true

    private val newline = TextUtil.newline_crlf

    lateinit var btnSetting: Button
    lateinit var btnMeasure: Button
    lateinit var btnSendSetting: Button

    val requestSetting: String = "0103000000384418"
    val requestMeasure: String = "010400000014F005"
    var requestSetVaule: String = "0106"

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_data_relay_v2)

        device = intent.getParcelableExtra("device")!!
        deviceAddress = intent.getStringExtra("deviceAddress").toString()

        init()
    }

    private fun init() {
        btnSetting = findViewById(R.id.btn_setting)
        btnSetting.setOnClickListener {
            send(requestSetting)
        }

        btnMeasure = findViewById(R.id.btn_measure)
        btnMeasure.setOnClickListener {
            send(requestMeasure)
        }

        btnSendSetting = findViewById(R.id.btn_send_setting)
        btnSendSetting.setOnClickListener {
            val crcCRC16Modbus: CRC16Modbus = CRC16Modbus()
            var cmd: String = "010600000002"
            var nCmd: IntArray = intArrayOf(0x01, 0x06, 0x00, 0x00, 0x00, 0x02)
            var sCmd: String = ""
            for (i in (nCmd.indices)) {
                sCmd += String.format("%02X", nCmd[i].toByte())
            }
            Log.d("TEST", "sCmd = $sCmd")
            // Command value
            var crc: ByteArray = crcCRC16Modbus.getCRC16Modbus(nCmd)
            sCmd += String.format("%02X", crc[0])
            sCmd += String.format("%02X", crc[1])
            Log.d("TEST", "after sCmd = $sCmd")
            Log.d("TEST", "toHexString = " + TextUtil.toHexString(crc))
        }
//        connect()
    }

    private fun connect() {
        Log.d("TEST", "connect called !!!")
        try {
            val btAdapter: BluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
            val device: BluetoothDevice = btAdapter.getRemoteDevice(deviceAddress)
            Log.d("TEST", "device address = $deviceAddress")
            showToast("Connecting....")
            connected = Connected.Pending
            val socket = SerialSocket(this.applicationContext, device)
            service?.connect(socket)
        } catch (e: Exception) {
            onSerialConnectError(e)
        }
    }

    private fun disconnect() {
        connected = Connected.False
        service?.disconnect()
    }

    private fun send(str: String) {
        var data: ByteArray
        var msg: String
        if (connected == Connected.True) {
            var sb: StringBuilder = StringBuilder()
            TextUtil.toHexString(sb, TextUtil.fromHexString(str))
//            TextUtil.toHexString(sb, newline.toByteArray())
            msg = sb.toString()
            data = TextUtil.fromHexString(msg)
            Log.d("TEST", "send data = $msg")
            service?.write(data)
        } else {
            showToast("Not Connected")
        }
    }

    override fun onDestroy() {
        if (connected != Connected.False)
            disconnect()
        this.stopService(Intent(this, SerialService::class.java))
        super.onDestroy()
    }

    override fun onStart() {
        super.onStart()
        Log.d("TEST", "onStart Called !!!")
        if (service != null) {
            Log.d("TEST", "on start service is not null !!!")
            service?.attach(this)
        } else {
            Log.d("TEST", "on start service is null !!!")
//            startService(Intent( this, SerialService::class.java))
            Intent(this, SerialService::class.java).also { intent ->
                bindService(intent, this, Context.BIND_AUTO_CREATE)
            }
        }
    }

    override fun onStop() {
        if (service != null && !this.isChangingConfigurations) {
            service!!.detach()
        }
        super.onStop()
    }

    private fun receive(data: ByteArray): String {
        Log.d("TEST", "received called !!!")
        Log.d("TEST", "data = " + TextUtil.toHexString(data))
        return TextUtil.toHexString(data)
    }

//    fun onAttach(context: Context){
//        Log.d("TEST", "on Attach Called !!!")
//        super.onAttachedToWindow()
//        this.bindService(Intent(context, SerialService::class.java), this, Context.BIND_AUTO_CREATE)
//    }
//
//    fun onDetach(){
//        try{
//            this.unbindService(this)
//        } catch (e: Exception){
//            e.printStackTrace()
//        }
//        super.onDetachedFromWindow()
//    }

    override fun onResume() {
        super.onResume()
        if (initialStart && service != null) {
            initialStart = false
//            this.runOnUiThread(this::connect)
            runOnUiThread { connect() }
        }
    }

    override fun onServiceConnected(p0: ComponentName?, p1: IBinder?) {
        Log.d("TEST", "on Service Connected called !!!")
//        service =  (p1 as SerialService.SerialBinder).service
//        service = ((SerialService.SerialBinder) p1).getService()
        val binder = p1 as SerialService.SerialBinder
        service = binder.service
        service?.attach(this)
        if (initialStart) { // && isResumed()
            initialStart = false;
//            this.runOnUiThread(this::connect)
            runOnUiThread {
                connect()
            }
        }
    }

    override fun onServiceDisconnected(p0: ComponentName?) {
//        service = null
    }

    override fun onSerialConnect() {
        showToast("connected")
        connected = Connected.True
    }

    override fun onSerialConnectError(e: java.lang.Exception?) {
        if (e != null) {
            showToast("Connection failed! " + e.message.toString())
            e.printStackTrace()
        }
    }

    override fun onSerialRead(data: ByteArray?) {
        Log.d("TEST", "on Serial Read called !!!")
        if (data != null) {
            receive(data)
        }
    }

    override fun onSerialIoError(e: java.lang.Exception?) {
        if (e != null) {
            e.printStackTrace()
            showToast("Connection lost! " + e.message)
        }
    }

    private fun showToast(msg: String) {
        Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
    }
}

어쩌다가 역대급 긴 글이 된듯...

2021년 1월 14일 목요일

Java CRC16 Modbus

프로젝트 진행중에 필요하여 찾아보았더니 역시나 잘 구현된것들이 있었다.

간편하게 사용할 수 있도록 일부 수정 하였다.

 

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
import java.util.zip.Checksum;


public class CRC16Modbus implements Checksum {
    private static final int[] TABLE = {
            0x0000, 0xc0c1, 0xc181, 0x0140, 0xc301, 0x03c0, 0x0280, 0xc241,
            0xc601, 0x06c0, 0x0780, 0xc741, 0x0500, 0xc5c1, 0xc481, 0x0440,
            0xcc01, 0x0cc0, 0x0d80, 0xcd41, 0x0f00, 0xcfc1, 0xce81, 0x0e40,
            0x0a00, 0xcac1, 0xcb81, 0x0b40, 0xc901, 0x09c0, 0x0880, 0xc841,
            0xd801, 0x18c0, 0x1980, 0xd941, 0x1b00, 0xdbc1, 0xda81, 0x1a40,
            0x1e00, 0xdec1, 0xdf81, 0x1f40, 0xdd01, 0x1dc0, 0x1c80, 0xdc41,
            0x1400, 0xd4c1, 0xd581, 0x1540, 0xd701, 0x17c0, 0x1680, 0xd641,
            0xd201, 0x12c0, 0x1380, 0xd341, 0x1100, 0xd1c1, 0xd081, 0x1040,
            0xf001, 0x30c0, 0x3180, 0xf141, 0x3300, 0xf3c1, 0xf281, 0x3240,
            0x3600, 0xf6c1, 0xf781, 0x3740, 0xf501, 0x35c0, 0x3480, 0xf441,
            0x3c00, 0xfcc1, 0xfd81, 0x3d40, 0xff01, 0x3fc0, 0x3e80, 0xfe41,
            0xfa01, 0x3ac0, 0x3b80, 0xfb41, 0x3900, 0xf9c1, 0xf881, 0x3840,
            0x2800, 0xe8c1, 0xe981, 0x2940, 0xeb01, 0x2bc0, 0x2a80, 0xea41,
            0xee01, 0x2ec0, 0x2f80, 0xef41, 0x2d00, 0xedc1, 0xec81, 0x2c40,
            0xe401, 0x24c0, 0x2580, 0xe541, 0x2700, 0xe7c1, 0xe681, 0x2640,
            0x2200, 0xe2c1, 0xe381, 0x2340, 0xe101, 0x21c0, 0x2080, 0xe041,
            0xa001, 0x60c0, 0x6180, 0xa141, 0x6300, 0xa3c1, 0xa281, 0x6240,
            0x6600, 0xa6c1, 0xa781, 0x6740, 0xa501, 0x65c0, 0x6480, 0xa441,
            0x6c00, 0xacc1, 0xad81, 0x6d40, 0xaf01, 0x6fc0, 0x6e80, 0xae41,
            0xaa01, 0x6ac0, 0x6b80, 0xab41, 0x6900, 0xa9c1, 0xa881, 0x6840,
            0x7800, 0xb8c1, 0xb981, 0x7940, 0xbb01, 0x7bc0, 0x7a80, 0xba41,
            0xbe01, 0x7ec0, 0x7f80, 0xbf41, 0x7d00, 0xbdc1, 0xbc81, 0x7c40,
            0xb401, 0x74c0, 0x7580, 0xb541, 0x7700, 0xb7c1, 0xb681, 0x7640,
            0x7200, 0xb2c1, 0xb381, 0x7340, 0xb101, 0x71c0, 0x7080, 0xb041,
            0x5000, 0x90c1, 0x9181, 0x5140, 0x9301, 0x53c0, 0x5280, 0x9241,
            0x9601, 0x56c0, 0x5780, 0x9741, 0x5500, 0x95c1, 0x9481, 0x5440,
            0x9c01, 0x5cc0, 0x5d80, 0x9d41, 0x5f00, 0x9fc1, 0x9e81, 0x5e40,
            0x5a00, 0x9ac1, 0x9b81, 0x5b40, 0x9901, 0x59c0, 0x5880, 0x9841,
            0x8801, 0x48c0, 0x4980, 0x8941, 0x4b00, 0x8bc1, 0x8a81, 0x4a40,
            0x4e00, 0x8ec1, 0x8f81, 0x4f40, 0x8d01, 0x4dc0, 0x4c80, 0x8c41,
            0x4400, 0x84c1, 0x8581, 0x4540, 0x8701, 0x47c0, 0x4680, 0x8641,
            0x8201, 0x42c0, 0x4380, 0x8341, 0x4100, 0x81c1, 0x8081, 0x4040
    };


    private int sum = 0xFFFF;

    public long getValue() {
        return sum;
    }

    public void reset() {
        sum = 0xFFFF;
    }

    public void update(byte[] b, int off, int len) {
        for (int i = off; i < off + len; i++)
            update((int) b[i]);
    }

    public void update(int b) {
        sum = (sum >> 8) ^ TABLE[((sum) ^ (b & 0xff)) & 0xff];
    }

    public byte[] getCrcBytes() {
        long crc = (int) this.getValue();
        byte[] byteStr = new byte[2];
        byteStr[0] = (byte) ((crc & 0x000000ff));
        byteStr[1] = (byte) ((crc & 0x0000ff00) >>> 8);
        return byteStr;
    }
    
    public byte[] getCRC16Modbus(int[] data) {
//    	CRC16Modbus crc = new CRC16Modbus();
    	byte[] result = new byte[2];
        for (int d : data) {
            update(d);
        }
        result[0] = (byte) ((getValue() & 0x000000ff));
        result[1] = (byte) ((getValue() & 0x0000ff00) >>> 8);    	
    	return result;
    }

    public static void main(String[] args) {
    	byte[] crc = new byte[2];
    	CRC16Modbus crc16Modbus = new CRC16Modbus();

    	// Command value
    	int[] data = new int[] {0x01, 0x03, 0x00, 0x00, 0x00, 0x38};
    	
    	crc = crc16Modbus.getCRC16Modbus(data);
    	System.out.printf("byteStr[0] = %02X, byteStr[1] = %02X\n", crc[0], crc[1]);
    }
}

2024년 첫번째 도서 리뷰 GPT-4를 활용한 인공지능 앱 개발

 "한빛미디어 <나는 리뷰어다> 활동을 위해서 책을 제공받아 작성된 서평입니다." 24년 첫 도서 리뷰이다. 작년까지? 한참 핫 했던 인공지능 서비스 Chat GPT에 관한 책이다. 핑계지만 어쩌다보니 GPT에 대한 접근이...