우리는 한다, 개발을.

arduino

⌛ 13 mins

사건의 시작

2주 전 대표님과 이야기를 나누면서 대표님들(저희는 대표님이 공동 대표로 2분이십니다)이 사무실 상주여부를

특정 인원(대부분 팀장)들이 알 수 있는 방법이 있냐는 질문을 받았습니다.

직원들이 대표님들이 자리에 안계실 때 헛걸음 하는 일이 많았던 것 같습니다.

noty는 MS Teams webhook으로 간단하게 메세지를 전달하면 되는 거였고,

사무실(특정 공간)에 존재하는 지 여부를 어떻게 알 수 있을까가 가장 큰 고민이었습니다.


android native app

몇 년 전 학원을 다니면서 출석을 스마트폰 앱을 통해 “비콘(beacon)”으로 인식하는 시스템을 경험한 게 불현 듯 생각났습니다. (이 생각을 하지 말았어야 했습니다)

20240612-01.png

아까운 2만원..

비콘을 이용하면 사무실에 비콘을 두고, 스마트폰에 앱이 비콘 근처에 있을 때 webhook을 통해 메세지를 전달하면 되는 간단한(?) 문제처럼 보였습니다.

무슨 자신감이였는지 안드로이드 앱 개발을 시작하기로 했습니다.


foreground 에서의 어려움

안드로이드 앱 개발 이야기는 너무 길어지기 때문에 생략하겠습니다…

결론은 안드로이드 앱을 foreground 에서 실행하면서 주기적으로 비콘과 거리를 측정하는 것은 실패했습니다.

  • 안드로이드 버전에 따른 API 제약
  • 원인을 알 수 없는 crash
  • 앱을 화면에서 실행할 때와 background에서 실행할 때의 큰 제약

거의 1주일을 투자했지만, 원하는 결과를 얻지 못했습니다.


다시 생각해보자

대표님께 앱은 어려울 것 같다고 말씀드리고, 다시 생각해보기로 했습니다.

방에 있다는 걸 가장 직관적으로 알 수 있는 방법은 무엇일까.

“전등” 이었습니다.

출근하거나 퇴근할 때 “전등”은 절대 빼먹지 않고 on / off를 하신다는 거였습니다.

어쩌면 당연한 거였습니다.

라즈베리파이와 아두이노와 같은 SBC(Single Board Computer)에 조도 센서를 연결해서 주기적으로 API를 호출하면 되는 간단한 상황입니다.

20240612-02.png

이번에 썼던 bh1750 조도 센서입니다. 3000원 정도에 구매할 수 있었습니다.


Raspberry Pi

라즈베리 파이(Raspberry Pi)는 영국의 Raspberry Pi Foundation에서 만든 저가형 단일 기판 컴퓨터(Single-Board Computer; SBC) 제품군으로, 교육용 프로젝트의 일환으로 개발되었다.

1980년대 BBC의 컴퓨터 교육 프로젝트였던 BBC Micro에서 영감을 받았다고 하는데, 예를 들어 영국에서 개발되었고, 다양한 I/O 포트가 지원되고, 높은 hackability와 그 시절 8비트 컴퓨터처럼 모니터가 없어도 (아날로그) TV에 끼워 쓸 수 있도록 컴포지트 출력이 지원된다는 점 등이 닮아 있다.

라즈베리파이는 간단하게 말해 아주 저렴하고 작은 컴퓨터입니다.

Debian 계열의 리눅스를 사용하며, 다양한 I/O 포트를 지원합니다.

SD카드에 OS를 설치하여 우리가 늘 하듯이 사용하면 됩니다.

라즈베리파이 zero 2W 제품을 구매해서 아래와 같이 코딩해봤습니다.


import smbus2
import time
import requests
import os
from datetime import datetime
import RPi.GPIO as GPIO

# BH1750 주소
BH1750_ADDRESS = 0x23
# 명령어 (조도 측정 명령어)
BH1750_CMD = 0x10

# 임계값 설정
LUX_THRESHOLD = 200  # todo: 장소마다 다르게 셋팅 해야함 LED를 켜기 위한 조도 임계값 (단위: lux)
API_ENDPOINT = "https://example.api.com"

# GPIO 핀 번호 설정 (BCM 모드)
LED_PIN = 18

# GPIO 설정
GPIO.setmode(GPIO.BCM)
GPIO.setup(LED_PIN, GPIO.OUT)

def read_light():
    bus = smbus2.SMBus(1)  # I2C 버스 1 사용
    data = bus.read_i2c_block_data(BH1750_ADDRESS, BH1750_CMD, 2)
    bus.close()

    # 조도 계산
    light_level = (data[0] << 8) | data[1]
    light_level /= 1.2  # 센서 해상도 보정
    return light_level

def send_alert(light_level, status):
    try:
        username = os.getenv('USER') or os.getenv('LOGNAME') or os.getenv('USERNAME')  # 현재 사용자 이름 가져오기
        if not username:
            username = "unknown_user"
        payload = {
            "lux": light_level,
            "deviceId": username,
            "status": status
        }
        response = requests.post(API_ENDPOINT, json=payload)
        if response.status_code == 201:
            print(f"Alert sent successfully: {response.json()}")
        else:
            print(f"Failed to send alert: {response.status_code}")
    except Exception as e:
        print(f"Error sending alert: {e}")

def control_led(light_level):
    if light_level <= LUX_THRESHOLD:
        GPIO.output(LED_PIN, GPIO.HIGH)  # LED 켜기
        print("LED ON")
        return "NOT_EXISTS"
    else:
        GPIO.output(LED_PIN, GPIO.LOW)  # LED 끄기
        print("LED OFF")
        return "EXISTS"

def get_sleep_duration():
    now = datetime.now()
    # 주말 확인 (토요일: 5, 일요일: 6)
    if now.weekday() in [5, 6]:
        return 1800  # 주말에는 30분 (1800초)
    # 저녁 22시부터 다음 날 오전 8시까지는 5분 주기
    elif (now.hour >= 22) or (now.hour < 8):
        return 300  # 5분 (300초)
    else:
        return 10  # 10초

if __name__ == "__main__":
    try:
        while True:
            light_level = read_light()
            status = control_led(light_level)  # 조도에 따라 LED 제어
            send_alert(light_level, status)  # 무조건 보내고 비즈니스 로직은 API에서 처리
            sleep_duration = get_sleep_duration()
            time.sleep(sleep_duration)
    except KeyboardInterrupt:
        print("Program interrupted by user")
    finally:
        GPIO.cleanup()  # GPIO 핀 정리

I2C로 조도센서를 연결하여 특정 주기동안 조도를 측정하고, 임계값을 넘으면 LED를 켜고 API를 호출하는 코드입니다.

해당 코드를 systemd로 등록하여 부팅 시 자동 실행하면 됩니다.


라즈베리파이까지 필요할까?

굉장히 간단한 기능이기 때문에 라즈베리파이까지 필요할까라는 생각이 들었습니다.(아두이노를 몇 일 더 빨리 알았더라면..)

조금 찾아보니 라즈베리파이 대신 아두이노를 사용하면 더 간단하게 구현할 수 있을 것 처럼 보였습니다.

특정 IDE(arduino IDE, Arduino Studio)만 사용할 수 있는 점부터 뭔가 거부감이 들었지만

교육용으로 쓸만큼 간단한 코드를 작성하는 데는 무리가 없을 것 같았습니다.


Arduino

Arduino is an open-source electronics platform based on easy-to-use hardware and software.
아두이노는 사용하기 쉬운 하드웨어 및 소프트웨어를 기반으로 하는 오픈 소스 전자 플랫폼입니다

아두이노는 아두이노 개발환경을 통해 cpp로 코드를 작성하고 컴파일 후 usb로 연결하여 바로 업로드 하는 형식입니다.

라즈베리파이와는 다르게 리눅스 os같은 건 없고, flash memory라는 영역에 코드를 업로드하여

전원이 연결되면 바로 실행되게 됩니다.

전력도 매우 적게 먹기 때문에(발열도 훨씬 적음) 이번과 같이 간단한 기능을 구현할 때는 아두이노가 훨씬 적합합니다.

#include <Wire.h>
#include <BH1750.h>
#include <WiFiS3.h>
#include <time.h>
#include <LiquidCrystal_I2C.h>

// 기기 ID 설정
const char* deviceId = "arduino-002";

// WiFi 설정
const char* ssid = "와이파이 아이디";
const char* password = "와이파이 비밀번호";

// API 엔드포인트
const char* API_ENDPOINT = "엔드포인트";
const char* HOST = "호스트";

// 임계값 설정
const int LUX_THRESHOLD = 200;

BH1750 lightMeter;

unsigned long previousMillis = 0;
const long interval = 10000; // 10초
unsigned long wifiCheckMillis = 0;
const long wifiCheckInterval = 30000; // 30초 간격으로 WiFi 상태 확인


// LCD 핀 설정
LiquidCrystal_I2C lcd(0x27, 16, 2);  // I2C 주소는 0x27로 가정, LCD는 16x2

void setup() {
  Serial.begin(9600);
  Wire.begin();

  if (!lightMeter.begin()) {
    Serial.println(F("BH1750 initialization failed"));
    while (1);
  }
  
  Serial.println(F("BH1750 Test"));

  // WiFi 연결 설정
  connectToWiFi();

  // LCD
  lcd.init();
  lcd.backlight();  // 백라이트 켜기  
  // LCD 초기화
  lcd.begin(16, 2);  // LCD의 열과 행 수를 설정
}

void loop() {
  unsigned long currentMillis = millis();

  // 10초마다 조도값 읽고 서버로 전송
  if (currentMillis - previousMillis >= interval) {
    previousMillis = currentMillis;

    float lux = lightMeter.readLightLevel();
    Serial.print("Light: ");
    Serial.print(lux);
    Serial.println(" lx");

    String status = (lux <= LUX_THRESHOLD) ? "NOT_EXISTS" : "EXISTS";
    sendAlert(lux, status);  // HTTP 요청 전송
  }

  // 30초마다 WiFi 상태 확인
  if (currentMillis - wifiCheckMillis >= wifiCheckInterval) {
    wifiCheckMillis = currentMillis;
    checkWiFiConnection();
  }
}

void connectToWiFi() {
  // WiFi 모듈 체크
  if (WiFi.status() == WL_NO_MODULE) {
    Serial.println("Communication with WiFi module failed!");
    while (true);
  }

  // 이미 연결되어 있다면 반환
  if (WiFi.status() == WL_CONNECTED) {
    return;
  }

  String fv = WiFi.firmwareVersion();
  if (fv < WIFI_FIRMWARE_LATEST_VERSION) {
    Serial.println("Please upgrade the firmware");
  }

  lcd.setCursor(0, 0);  // 첫 번째 줄 첫 번째 칸으로 커서 이동    
  lcd.print("Connectiong to WIFI..");  // 첫 줄 출력  

  // WiFi 네트워크에 연결 시도
  Serial.print("Attempting to connect to WPA SSID: ");
  Serial.println(ssid);
  WiFi.begin(ssid, password);

  // 연결 대기
  int retries = 10;
  while (WiFi.status() != WL_CONNECTED && retries-- > 0) {
    delay(1000);
    Serial.print(".");
  }
  Serial.println("");

  if (WiFi.status() == WL_CONNECTED) {
    Serial.println("You're connected to the network");
  } else {
    Serial.println("Failed to connect to WiFi");
  }
}

void checkWiFiConnection() {
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("WiFi not connected. Attempting to reconnect...");
    connectToWiFi();
  } else {
    Serial.println("WiFi is connected");
  }
}

void sendAlert(float lightLevel, String status) {
  connectToWiFi();

  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("WiFi not connected. Cannot send alert.");
    return;
  }

  WiFiClient client;
  
  if (!client.connect(HOST, 80)) {
    Serial.println("Connection to server failed");
    return;
  }

  String payload = "{\"lux\": " + String(lightLevel) + ", \"deviceId\": \"" + deviceId + "\", \"status\": \"" + status + "\"}";
  
  client.println("POST " + String(API_ENDPOINT) + " HTTP/1.1");
  client.println("Host: " + String(HOST));
  client.println("User-Agent: ESP32");
  client.println("Content-Type: application/json");
  client.print("Content-Length: ");
  client.println(payload.length());
  client.println();
  client.println(payload);

  lcd.clear(); // LCD 초기화
  lcd.setCursor(0, 0);  // 첫 번째 줄 첫 번째 칸으로 커서 이동    
  lcd.print("lux: " + String(lightLevel));  // 첫 줄 출력  

  lcd.setCursor(0, 1);  // 두 번째 줄 첫 번째 칸으로 커서 이동
  lcd.print("status: " + status);  // 첫 줄 출력    

  // 응답 확인
  while (client.connected()) {
    String line = client.readStringUntil('\n');
    if (line == "\r") {
      Serial.println("Headers received");
      break;
    }
  }

  String response = client.readString();
  Serial.println("Response: " + response);

  client.stop();
}

위 코드는 라즈베리파이의 파이썬 코드와 크게 다르지 않습니다.

LED 대신 LCD로 글씨를 찍어주는 정도네요.

아두이노는 라즈베리파이와 다르게 wifi 연결부터 직접 해줘야 합니다.

특별한 설명이 없어도 코드를 보면 어떻게 동작하는지 알 수 있을 것 같습니다.

20240612-03.jpg

이번에 해당 프로젝트를 경험하면서 알게 된 것들을 정리해보면..

  • I2C(SDA, SCL)
  • UART(TX, RX)
  • jump wire와 색깔이 의미하는 것들
  • VCC(+)와 GND(-)
  • 브레드보드
  • 저항(resistor)의 역할과 종류들

위와 같이 개발자로써 컴퓨터 공학 경험만 하다가 전자공학 영역의 경험을 하게된 재밌는 경험이었습니다.


마치며

이번 일을 겪으며 느낀 건 해결을 위해 가장 간단한 방법을 선택하는 것이 중요하다는 것입니다.

안드로이드 앱을 개발할 만한 중요한 이슈도 아니였고,

조금 더 정보를 찾고 해결방법을 모색했더라면 훨씬 더 쉽게 아두이노로 해결할 수 있었을 문제였는데 말이죠..

시간과 자원을 고려해 타협점을 찾는 것도 우리 개발자의 큰 능력이라는 걸 다시 한 번 느꼈습니다.

여러분도 비슷한 상황에 놓이게 되면, 가장 간단하고 빠른 방법을 선택해보세요.

  • 간단하고 빠른 방법인지 아는 게 힘듬