14주차 ‐ 토양 센서값 전송(카카오톡 활용) - movie-01/SmartDevice GitHub Wiki

14주차 - 토양 센서값 전송(카카오톡 활용)


개요

  • OAuth 2.0 인증 체계 정리
  • 토양 습도 센서
  • 실습

🌐 OAuth 2.0 인증 체계 정리

🔐 Authorization Code Grant 방식

1. 개요

OAuth 2.0의 Authorization Code Grant는 보안성이 높은 인증 방식으로, 주로 웹 애플리케이션에서 사용된다. 사용자의 권한을 서버가 직접 획득하지 않고, **인가 코드(authorization code)**를 발급받아 **액세스 토큰(access token)**으로 교환하는 구조이다.

2. 인증 흐름 예시

클라이언트 앱 → 리소스 소유자(사용자) → 인가 서버 → 리소스 서버 순으로 흐름이 전개된다.

  • 인가 코드 요청
    • 클라이언트가 인가 서버에 /authorize 엔드포인트로 요청
GET https://kauth.kakao.com/oauth/authorize?
client_id=YOUR_CLIENT_ID&
redirect_uri=YOUR_REDIRECT_URI&
response_type=code&
scope=talk_message

  • 리디렉션 수신

    • 사용자가 권한을 승인하면, 브라우저가 redirect_uri로 리디렉션되며, URL 파라미터에 ?code=AUTH_CODE를 포함.
  • 토큰 교환

    • 클라이언트가 /token 엔드포인트로 POST 요청을 보내 인가 코드를 액세스 토큰으로 교환
POST https://kauth.kakao.com/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&
client_id=YOUR_CLIENT_ID&
redirect_uri=YOUR_REDIRECT_URI&
code=AUTH_CODE

3. 주요 파라미터

파라미터 역할 설명
client_id 애플리케이션의 식별자
redirect_uri 인가 코드 수신을 위한 리디렉션 주소
grant_type 인증 방식 (authorization_code 등)
scope 요청할 사용자 권한 범위 (예: profile, talk_message)

⚠️ 발생 가능한 오류

오류 코드 설명
invalid_grant 유효하지 않거나 만료된 인가 코드 사용
invalid_scope 지원되지 않거나 잘못된 scope 요청
redirect_uri mismatch 등록된 redirect_uri와 다를 경우
insufficient_scopes 리소스 접근 권한 부족

📌 카카오 로그인 예외 사항

  • talk_message 등 민감한 권한은 scope 파라미터로 명시적으로 요청해야 하며, 카카오 디벨로퍼스 콘솔에서도 별도로 동의 항목 설정 필요.

🔐 보안 요소

  • HTTPS 필수 사용: 인가 코드 및 토큰 노출 방지
  • 토큰 보안: 액세스 토큰을 안전하게 저장하고, Refresh Token으로 갱신 처리 필요
  • CSRF 보호: state 파라미터로 공격 방지

🌱 토양 습도 센서 (Soil Moisture Sensor)

1. 저항식 센서 원리

센서는 토양 내 수분 함량에 따라 전기 저항이 달라지는 원리를 이용한다. 수분이 많을수록 전류가 잘 흐르고, 저항이 작아진다.


2. 회로 구성 예시

  • VCC — 센서 — A0(아날로그 입력) — GND
  • ESP32, Arduino 등에 연결 가능

3. 아두이노/ESP32 코드 예시

int sensorValue = analogRead(A0); // 0~4095 범위 (ESP32)
Serial.println(sensorValue);

4. 센서 보정

상태 센서 출력값 예시
완전 건조 약 0~300
물에 담금 약 3000~4095
  • map() 함수를 사용해 비율 변환 가능
int moisturePercent = map(sensorValue, 0, 4095, 0, 100);

5. 응용 사례

  • 🌿 자동 급수 시스템: 일정 수치 이하로 떨어지면 펌프 작동
  • 📡 IoT 연동 원격 모니터링: 측정값을 앱이나 API로 전송해 실시간 확인

실습

🔍 실습을 위한 준비물

  • ESP32
  • ESP32 확장 실드
  • 토양 습도 센서
  • ArduinoJson by Benoit Blanchon 라이브러리 설치

image

코드 작성

#include <ArduinoJson.h>
void setup() {
  // Initialize serial port
  Serial.begin(115200);
  while (!Serial) continue;
  delay(3000);
  // JSON 문서 할당
  //
  // 괄호 안의 200은 메모리 풀의 용량(바이트)입니다.
  // JSON 문서와 일치하도록 이 값을 변경하는 것을 잊지 마십시오.
  // https://arduinojson.org/v6/assistant 를 사용하여 용량을 계산합니다.
  StaticJsonDocument<96> doc;
  // StaticJsonDocument<N>은 스택에 메모리를 할당합니다.
  // 힙에 할당하는 DynamicJsonDocument로 대체할 수 있습니다.
  // DynamicJsonDocument doc(200);
  // JSON 입력 문자열.
  char json[] = R"rawliteral({
    "token_type":"bearer",
    "access_token":"c281d73b097",
    "expires_in":43199,
    "refresh_token":"0a0c90af08f",
    "refresh_token_expires_in":5184000,
    "scope":"account_email profile"
  })rawliteral";
  Serial.println(json);
  // Deserialize the JSON document
  DeserializationError error = deserializeJson(doc, json);
  // Test if parsing succeeds.
  if (error) {
    Serial.print(F("deserializeJson() failed: "));
    Serial.println(error.f_str());
    return;
  }
  // Fetch values.
  const char* access_token = doc["access_token"];
  const char* refresh_token = doc["refresh_token"];
  long expires_in = doc["expires_in"];
  // Print values.
  Serial.print("Access token : ");
  Serial.println(access_token);
  Serial.print("Refresh token : ");
  Serial.println(refresh_token);
  Serial.print("Expire time : ");
  Serial.println(expires_in);
}
void loop() {
  // not used in this example
}

결과

image


카카오톡으로 실시간 정보 전달 받기

  1. 카카오톡 개발자 사이트 회원 가입 및 로그인

  2. 앱 등록 및 정보 설정

  • 애플리케이션 추가
  • 좌측 메뉴의 "앱 키"에서 REST API키 복사
  • web플랫폼 사이트 도메인 https://www.example.com 등록

image

image


  1. 앱 키 - REST API 키 복사

image


  1. 플랫폼 - 사이트 도메인 -> "http://localhost/" 입력

image


  1. 카카오 로그인 - 활성화 설정 ON, Redirect URL: "https://example.com/oauth" 입력

image


  1. 동의항목 - 카카오톡 메시지 전송(선택동의 설정)

image


  1. Access 토근 발급
  • 지급 받은 [REST API키] 수정하기(대괄호 지우고 수정하기)
https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=[REST API키]&redirect_uri=https://www.example.com/oauth

  1. 동의 후, URL에 code= 이후의 값 복사 image image

  1. Access 토큰 발급 받기
  • 위에서 얻은 REST_API 키 값과 Redirection_URL, Authorization Code 값을 이용
  • Windows의 cmd창 명령 프롬프트에 다음과 같이 조합하여 입력
curl -v -X POST "https://kauth.kakao.com/oauth/token" -H "Content-Type: application/x-wwwform-urlencoded" -d "grant_type=authorization_code" -d "client_id=${REST_API_KEY}" --dataurlencode "redirect_uri=${REDIRECT_URI}" -d "code=${AUTHORIZE_CODE}"

코드 작성

#include <WiFi.h>
#include <HTTPClient.h>

const char *ssid = "Your_SSID";          // 사용하는 WiFi 네트워크 이름 (SSID)
const char *password = "Your_Password";  // 사용하는 WiFi 네트워크 비밀번호

const String rest_api_key = "REST API KEY"; 
String access_token = "Access token";
String refresh_token = "Refresh token";

#define MsgSendInterval 3600 // 60 * 60 초, 즉 한시간 간격으로 전송
long timeout = 3600;  //시간을 초로 나타냄
int sensorValue = 0;
int sensorPin = 34; // 토양 습도 센서 핀

void setup() {
  Serial.begin(115200);

  WiFi.begin(ssid, password);
  Serial.println("Connecting");
  while (WiFi.status() != WL_CONNECTED) {
    Serial.print(".");
    delay(1000);
  }
  Serial.print("\nConnected to WiFi : ");
  Serial.println(WiFi.localIP());
}
void loop() {
  if (timeout++ > MsgSendInterval)  // 1시간(60 * 60)에 1번씩 전송
  {
    if (isAccessTokenExpired() == true) { //access token 만료 여부 확인
      if (update_access_token() == false) { // access token 재발급
        Serial.println("Access token update failed");
      }
    }
    sensorValue = analogRead(sensorPin);//토양 센서값 읽기
    send_message();
    timeout = 0;
  }

  delay(1000);
}


// str문자열에서 start_string와 end_string사이의 문자열을 추출하는 함수
String extract_string(String str, String start_string, String end_string) {
  int index1 = str.indexOf(start_string) + start_string.length();
  int index2 = str.indexOf(end_string, index1);
  String value = str.substring(index1, index2);
  return value;
}

bool isAccessTokenExpired() {
  HTTPClient http;
  bool returnVal = true;
/*
curl -v -X GET "https://kapi.kakao.com/v1/user/access_token_info" \
  -H "Authorization: Bearer ${ACCESS_TOKEN}"
*/
  if (!http.begin("https://kapi.kakao.com/v1/user/access_token_info")) {
    Serial.println("\nfailed to begin http\n");
  }
  http.addHeader("Authorization", "Bearer " + access_token);

  int httpCode = http.GET();

  // httpCode will be negative on error
  if (httpCode > 0) {
    // file found at server
    if (httpCode == HTTP_CODE_OK) {
      String payload = http.getString();
      Serial.println(payload);
      String expireT = extract_string(payload, "\"expires_in\":", ",");
      Serial.println(expireT.toInt());
      if (expireT.toInt() > 0) {
        returnVal = false;
      } else {
        returnVal = true;
      }
    }
  } else {
    Serial.printf("[HTTP] GET... failed, error: %s\n", http.errorToString(httpCode).c_str());
  }
  http.end();
  return returnVal;
}

void send_message() {
  HTTPClient http;
  String url = "https://kapi.kakao.com/v2/api/talk/memo/default/send";
  if (!http.begin(url)) {
    Serial.println("\nfailed to begin http\n");
  }
  http.addHeader("Authorization", "Bearer " + access_token);
  http.addHeader("Content-Type", "application/x-www-form-urlencoded");

  int http_code;
  /*
   template_object={
        "object_type": "text",
        "text": "텍스트 영역입니다. 최대 200자 표시 가능합니다.",
        "link": {
            "web_url": "https://developers.kakao.com",
            "mobile_web_url": "https://developers.kakao.com"
        },
        "button_title": "바로 확인"
    }
    */
  String data = String("template_object={") + 
                String("\"object_type\": \"text\",") + 
                String("\"text\": \"") + String("토양 센서 값 :") + 
                String(sensorValue) +  //토양 센서 값
                String("\",\"link\": {}}"); //link가 없으면 오류메세지 받음
  Serial.println(data);
  http_code = http.POST(data);
  Serial.print("HTTP Response code: ");
  Serial.println(http_code);

  String response;
  if (http_code > 0) {
    response = http.getString();
    Serial.println(response);
  }

  http.end();
}

/*
curl -v -X POST "https://kauth.kakao.com/oauth/token" \
 -H "Content-Type: application/x-www-form-urlencoded" \
 -d "grant_type=refresh_token" \
 -d "client_id=${REST_API_KEY}" \
 -d "refresh_token=${USER_REFRESH_TOKEN}"
*/
bool update_access_token() {
  HTTPClient http;
  bool retVal = false;

  String url = "https://kauth.kakao.com/oauth/token";
  String new_refresh_token = "";
  if (!http.begin(url)) {
    Serial.println("\nfailed to begin http\n");
  }
  http.addHeader("Content-Type", "application/x-www-form-urlencoded");
  int http_code;
  String data = "grant_type=refresh_token&client_id=" + rest_api_key + "&refresh_token=" + refresh_token;
  Serial.println(data);
  http_code = http.POST(data);
  Serial.print("HTTP Response code: ");
  Serial.println(http_code);

  String response;
  if (http_code > 0) {
    response = http.getString();
    Serial.println(response);
    access_token = extract_string(response, "{\"access_token\":\"", "\"");
    new_refresh_token = extract_string(response, "\"refresh_token\":\"", "\"");
    //만료 1개월전부터 갱신되므로 data가 없을 수도 있음
    if (new_refresh_token != "") {
      refresh_token = new_refresh_token;
    }
    retVal = true;
  } else {
    retVal = false;
  }
  http.end();
  return retVal;
}

실습 결과

image

⚠️ **GitHub.com Fallback** ⚠️