본문 바로가기

개인공부

[MQTT / C++ / Python / IoT] Khadas VIM4 환경 온습도 센서 연결

이번 시간에는 원래 계획한 알고리즘 작성에서 조금 방향을 틀어보았다.

 

아무래도 조잡하게 난수 데이터를 사용하여 구현을 진행하는게 영 마음에 걸려서

실제 온습도 센서 데이터를 가져올수 있도록 센서 장치를 달아보기로 마음먹었다.

 


개발 환경

개발 보드 : Khadas VIM 4 (ubuntu 24.04)

센서 장치 : DHT 11 온습도 센서


Khadas 환경에서의 센서 연결의 특징 (vs 라즈베리파이)

라즈베리파이에서의 센서 연결은 WiringPi를 이용한 구현 예제가 곳곳에 널려있어 손쉽게 가능하다.

 

기본적인 WiringPi와 GPIO에 대한 설명은 이 블로그를 참고하자

 

https://remnant24c1.tistory.com/109

 

Raspberry Pi 4(Bookworm)에서 WiringPi 설치해서 사용하기

안녕하세요. Raspberry Pi에서 GPIO를 쉽게 사용하기 위해서 WiringPi 라이브러리를 이용했습니다. 그런데 이제는 메인 개발자분이 지원(support) 않고, 홈페이지도 중단시켜 놓았습니다. 최신 bookworm 64bit

remnant24c1.tistory.com

 

하지만 Khadas VIM4 (정보가 많이 없는...)에서는 이 방법을 그대로 쓰지 못하고, 약간의 (?) 특징을 고려해야한다.

 

1. khadas전용 WiringPi 오픈소스 라이브러리 설치해야함

2. GPIO 핀 번호가 다름

3. CPU 사양이 훨씬 높아서, 같은 코드로 동작이 거의 불가능 (처리속도를 못따라감)

 

이 난관을 극복하기위한 정보가 상당히 부족하여, 여차저차 애를 좀 먹었다.

아래부터는 차근차근 단계별로 센서 연결 방법을 설명하겠다.


1. Khadas 전용 WiringPi 오픈소스 라이브러리 설치

khadas 공식 문서

우선 khadas 공식문서를 찾아보았을때, wiringPi 자체는 포팅되어 지원한다고 되어있는데 git링크는 달아놓지 않았기에(...)

직접 khadas 깃허브를 들어가서 찾아냈다.

 

git clone https://github.com/khadas/WiringPi.git
cd WiringPi
./build

 

- 각 명령어를 차례로 수행한다.

 

* 해당 링크를 통해 받은 WiringPi만 동작한다. 라즈베리파이에서 사용하는 라이브러리로는 불가능

 

이후 아래 명령어를 수행하면 사진과 같이 GPIO테이블이 출력된다.

gpio readall

GPIO 테이블

*** 물리 번호가 라즈베리파이와 달리 세로로 번호가 이어져있음에 유의. 기판에 써져있는 번호를 잘 확인하자.

 

우리는 여기서 5V, GND, DOUT의 기능을 하는 핀을 찾아서 각각 포트를 연결해야한다.

 

5V와 GND는 쉽게 찾을 수 있지만, (1번, 5번)

DOUT은 본래 라즈베리파이에서는 GPIO.(번호) 형태로 되어있고, 모드가 IN인 핀에 꽃아야했다.

Khadas에서는 표현 방식이 달라, 여기서 난관에 부딪혔다.

 

일단 반신반의 하며 GPIO번호가 있고, mode가 IN인 22번 핀에 DOUT을 연결해놓고, 코드부터 작성하였다.

후술 하겠지만, 결국 22번핀으로 정상 동작하였다.

아래는 실제 연결 사진이다

물리 1번, 5번, 22번에 연결


2. WiringPi 코드 작성

기존 사용하던 라즈베리파이 코드 (DHT_PI.cc) - 사용 불가

#include <iostream>
#include <wiringPi.h>
#include <cstdint>
#include <thread>

#define MAXTIMINGS 85
#define DHTPIN 1

int dht11_dat[5] = {0, 0, 0, 0, 0};
int tempDat;

void read_dht11_dat() {
    uint8_t laststate = HIGH;
    uint8_t counter = 0;
    uint8_t j = 0, i;
    float f;

    dht11_dat[0] = dht11_dat[1] = dht11_dat[2] = dht11_dat[3] = dht11_dat[4] = 0;

    pinMode(DHTPIN, OUTPUT);
    digitalWrite(DHTPIN, LOW);
    delay(18);
    digitalWrite(DHTPIN, HIGH);
    delayMicroseconds(40);
    pinMode(DHTPIN, INPUT);

    for (int i = 0; i < MAXTIMINGS; i++) {
        counter = 0;
        while (digitalRead(DHTPIN) == laststate) {
            counter++;
            delayMicroseconds(1);
            if (counter == 255)
                break;
        }

        laststate = digitalRead(DHTPIN);

        if (counter == 255)
            break;

        if ((i >= 4) && (i % 2 == 0)) {
            dht11_dat[j / 8] <<= 1;
            if (counter > 50)
                dht11_dat[j / 8] |= 1;
            j++;
        }
    }

    if ((j >= 40) && (dht11_dat[4] == (dht11_dat[0] + dht11_dat[1] + dht11_dat[2] + dht11_dat[3]) & 0xFF)) {
        if (dht11_dat[4] != tempDat) {
            f = dht11_dat[2] * 9. / 5. + 32;
            std::cout << "Humidity = " << dht11_dat[0] << "." << dht11_dat[1]
                      << " % Temperature = " << dht11_dat[2] << "." << dht11_dat[3]
                      << " C (" << f << " F)\n";
            tempDat = dht11_dat[4];
        }
    } else {
    }
}

int main(void) {
    std::cout << "Raspberry Pi Humidity And Temperature\n";

    if (wiringPiSetup() == -1)
        return 1;

    while (true) {
        read_dht11_dat();
        std::this_thread::sleep_for(std::chrono::milliseconds(250));
    }

    return 0;
}

 

우선 기존 사용하던 라즈베리파이 코드를 그대로 사용해보려고 하였으나, 센서 감지에 대한 동작이 전혀 이루어지지 않았다.

 

아무래도 상술한 특징3 (라즈베리파이 대비 고사양) 때문에,

count++로 타이밍을 체크하는 방식의 처리 속도가 너무 빨라서 센싱 주기와 맞지 않는 것이라 추측했다.

 

따라서 count 변수가 아닌, ms단위로 실제 시간을 측정하여

CPU의 처리속도에 영향을 받지 않고 센서 신호를 감지할 수 있도록 코드를 변경했다.

 

또한 마이크로초 단위로 변경되었으니, 0과 1비트를 구분하는 기준(신호의 길이)을 다시 잡아야했다.

여러번의 테스트 디버깅 끝에, 적절한 us단위를 찾게 되었다.

 

아래는 변경된 Khadas VIM4 전용 코드 전문이다.

DHT_KHADAS.cc

#include <iostream>
#include <wiringPi.h>
#include <cstdint>
#include <thread>

/* --------------------------------------------------------
온습도 센서 (DHT11) 데이터 읽기 함수

기존 라즈베리파이에서 구동되는 WiringPi 라이브러리를 사용한 코드를 VIM4에서 구동되도록 수정한 코드

VIM4의 CPU성능이 라즈베리파이보다 좋기 때문에 코드를 그대로 사용하면 동작하지 않음

count++을 사용하지 않고 마이크로초 단위로 시간을 측정하여 제대로 동작하도록 변경

대기 시간을 걸어두어 신호를 정상적으로 포착할수 있도록 구현
-------------------------------------------------------- */

#define MAXTIMINGS 85
#define DHTPIN 6

int dht11_dat[5] = {0, 0, 0, 0, 0};

bool read_dht11_dat() {
    uint8_t laststate = HIGH;
    uint8_t j = 0;
    long startTime;
    long duration;

    dht11_dat[0] = dht11_dat[1] = dht11_dat[2] = dht11_dat[3] = dht11_dat[4] = 0;

    pinMode(DHTPIN, OUTPUT);
    digitalWrite(DHTPIN, LOW);
    delay(20); 
    digitalWrite(DHTPIN, HIGH);
    delayMicroseconds(20); // 대기 시간을 20us로 설정
    pinMode(DHTPIN, INPUT);

    for (int i = 0; i < MAXTIMINGS; i++) {
        startTime = micros();

        while (digitalRead(DHTPIN) == laststate) {
            if (micros() - startTime > 150) break;
        }

        duration = micros() - startTime;
        laststate = digitalRead(DHTPIN);

        if (duration > 150) break;

        // 0번(응답), 1번(응답 대기), 2번(응답 종료) 등 앞 신호를 건너뛰고 4번부터 데이터
        if ((i >= 4) && (i % 2 == 0)) {
            dht11_dat[j / 8] <<= 1;
            // 디버깅 로그를 통해 알아낸 사실 : 대기 기간이 65us 이상인 경우는 1, 이하인 경우는 0으로 판단하는것이 적절
            if (duration > 65) 
                dht11_dat[j / 8] |= 1;
            j++;
        }
    }

    if ((j >= 40) && (dht11_dat[4] == ((dht11_dat[0] + dht11_dat[1] + dht11_dat[2] + dht11_dat[3]) & 0xFF))) {
        return true;
    } else {
        // 실패 시 비트 개수와 체크섬 에러를 확인하기 위한 디버깅 출력 (필요시 제거 가능)
        // std::cout << " (Bits: " << (int)j << ") ";
        return false;
    }
}

int main(void) {
    if (wiringPiSetup() == -1) return 1;
    
    // VIM4의 다른 프로세스 방해를 최소화하기 위한 우선순위 설정
    piHiPri(99); 

    while (true) {
        if (read_dht11_dat()) {
            // python에서 타입 캐스팅 편의를 위해 단순 문자열 형식으로만 출력
            std::cout << dht11_dat[0] << "." << dht11_dat[1] << " "
                      << dht11_dat[2] << "." << dht11_dat[3] << std::endl;
        } else {
            // 실패 시 별도 표시 없이 다음 루프 시도
        }
        // 센서 복구를 위한 2초 대기시간
        std::this_thread::sleep_for(std::chrono::seconds(2));
    }
    return 0;
}

 

이후 아래 코드를 통해 컴파일 하고, 실행하게 되면 사진처럼 출력이 정상적으로 되는 것을 확인할 수 있다.

g++ -o DHT DHT.cc -lwiringPi -lcrypt -lpthread
./DHT

DHT 온습도 정상 출력


3. MQTT와의 연동

이제 우리는 실제 수집한 센서 데이터를 MQTT프로토콜로 전송해야한다..

센서 데이터를 DB에 저장한 후, DB에 접근하여 작업하는 것이 일반적이다.

 

하지만 우리는 간단한 (가벼운) 프로젝트이기 때문에, 단순히 센서 데이터를 바로 MQTT에 연동할 셈이다.

C++으로 작성된 코드를 어떻게 파이썬으로 작성된 MQTT 프로토콜 코드로 옮길 수 있을까?

 

- python의 subprocess 모듈을 사용했다.

 

subprocess 모듈은 코드를 실행할 때, 다른 프로세스를 실행시키고

추가적인 작업을 수행할 수 있게 해주는 모듈이다. 자세한 사용법과 예제는 아래 블로그를 참고하자.

 

https://hbase.tistory.com/341

 

[Python] subprocess 모듈 사용법 및 예제

subprocess는 파이썬 스크립트에서 쉘 명령 등 다른 프로세스를 실행하고 출력 결과를 가져올 수 있게 해주는 라이브러리다. subprocess 모듈은 os.system, os.spawn* 등을 대체하기 위해 만들어졌다. subproce

hbase.tistory.com

 

여기서 우리는 C++ 프로세스가 지속적으로 온습도를 센싱하고 출력하기 때문에,

Popen() 함수를 통해 파이프라인의 출력값을 주기적으로 갖고 오도록 코드를 작성하였다.

 

추가적으로 이후 적용할 AoI 알고리즘을 위해, 센서 데이터 이상치에 대한 처리 로직을 구현하였다.

아래는 코드 전문이다.

sensor_publisher.py

import paho.mqtt.client as mqtt
import time
import json
import subprocess

BROKER = "브로커 주소"
PORT = "포트번호"
TOPIC = "aoi/data"

def main():
    mqttc = mqtt.Client()
    mqttc.connect(BROKER, PORT, 60)
    mqttc.loop_start()
    SEQ = 0

    try:
        proc = subprocess.Popen(['sudo', './DHT'], stdout=subprocess.PIPE, text=True, bufsize=1)
        
        while True:
            last_line = None
            
            # 버퍼에 쌓인 모든 줄을 빠르게 읽어서 마지막 줄만 가져옴(최신 센싱 데이터)
            while True:
                # 읽을 데이터가 있는지 확인 (0초 대기)
                if select.select([proc.stdout], [], [], 0)[0]:
                    line = proc.stdout.readline()
                    if line:
                        last_line = line
                    else:
                        break
                else:
                    break # 더 이상 읽을 데이터가 없음

            # 읽은 데이터가 있다면 처리
            if last_line:
                try:
                    data = last_line.strip().split()
                    humidity = float(data[0])
                    temperature = float(data[1])
                    
                    message_type = "sensor"
                    if temperature > 30 or temperature < 20 or humidity > 40 or humidity < 15:
                        message_type = "alarm"

                    payload = {
                        "device_id": "s2",
                        "timestamp": time.time(),
                        "seq_number" : SEQ,
                        "message_type" : message_type,
                        "data": {"temperature": temperature, "humidity": humidity},
                    }
                    
                    SEQ += 1
                    mqttc.publish(TOPIC, json.dumps(payload), qos=0)
                    
                    if message_type == "alarm":
                        print(f"Alarm Data published: {payload}")
                        # 알람일 때는 딜레이 없이 즉시 다음 최신 데이터 확인
                        continue

                    print(f"Sensor Data published: {payload}")
                    time.sleep(3) # 일반 상황에서는 3초 대기

                except (ValueError, IndexError):
                    continue
            else:
                # 버퍼에 데이터가 하나도 없으면 아주 짧게 쉬고 다시 확인
                time.sleep(0.1)

    except KeyboardInterrupt:
        print("\n사용자 종료")
    finally:
        mqttc.loop_stop()
        mqttc.disconnect()

if __name__ == "__main__":
    main()

 

코드를 실행하면, 드디어 실제 센서 데이터와 연동된 MQTT가 그럴싸하게 구동한다.

센서 데이터 전송 (평상 시)
알람 데이터 전송 (이상치 탐지 시)

 

다음 시간에는, 다른 센서를 연동하여 다양한 센서 데이터를 수집하는 환경을 구축해보겠다.