From 970680570ed9705179950c7ae7681320fd901f7a Mon Sep 17 00:00:00 2001 From: choibk Date: Sat, 8 Nov 2025 22:39:20 +0900 Subject: [PATCH] First --- .gitignore | 4 + lib/README | 46 ++++ platformio.ini | 14 ++ src/Receiver/main.cpp | 484 ++++++++++++++++++++++++++++++++++++++++++ src/sender/main.cpp | 253 ++++++++++++++++++++++ 5 files changed, 801 insertions(+) create mode 100644 .gitignore create mode 100644 lib/README create mode 100644 platformio.ini create mode 100644 src/Receiver/main.cpp create mode 100644 src/sender/main.cpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b1a8bb --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.pio +.vscode +include/README +test/README \ No newline at end of file diff --git a/lib/README b/lib/README new file mode 100644 index 0000000..9379397 --- /dev/null +++ b/lib/README @@ -0,0 +1,46 @@ + +This directory is intended for project specific (private) libraries. +PlatformIO will compile them to static libraries and link into the executable file. + +The source code of each library should be placed in a separate directory +("lib/your_library_name/[Code]"). + +For example, see the structure of the following example libraries `Foo` and `Bar`: + +|--lib +| | +| |--Bar +| | |--docs +| | |--examples +| | |--src +| | |- Bar.c +| | |- Bar.h +| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html +| | +| |--Foo +| | |- Foo.c +| | |- Foo.h +| | +| |- README --> THIS FILE +| +|- platformio.ini +|--src + |- main.c + +Example contents of `src/main.c` using Foo and Bar: +``` +#include +#include + +int main (void) +{ + ... +} + +``` + +The PlatformIO Library Dependency Finder will find automatically dependent +libraries by scanning project source files. + +More information about PlatformIO Library Dependency Finder +- https://docs.platformio.org/page/librarymanager/ldf.html diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..27c3edc --- /dev/null +++ b/platformio.ini @@ -0,0 +1,14 @@ +; PlatformIO Project Configuration File +; +; Build options: build flags, source filter +; Upload options: custom upload port, speed and extra flags +; Library options: dependencies, extra library storages +; Advanced options: extra scripting +; +; Please visit documentation for the other options and examples +; https://docs.platformio.org/page/projectconf.html + +[env:heltec_wireless_tracker_v12] +platform = espressif32 +board = heltec_wireless_tracker_v12 +framework = arduino diff --git a/src/Receiver/main.cpp b/src/Receiver/main.cpp new file mode 100644 index 0000000..e1d6cd7 --- /dev/null +++ b/src/Receiver/main.cpp @@ -0,0 +1,484 @@ +/* +프로젝트명: GNSS + LoRa + MQTT 실시간 위치 및 환경 데이터 수신 시스템 +보드 종류: ESP32 (예: HELTEC Wireless Tracker 또는 유사 보드) +REV 2.0 + +주요 기능: + - GNSS(GPS) 수신 (TinyGPS++) + - LoRa 데이터 수신 및 JSON 파싱 (RadioLib, ArduinoJson) + - WiFi 신호 세기 기반 자동 연결 및 후보 관리 (Preferences EEPROM 저장) + - MQTT 브로커로 SELF 및 NODE1 데이터 번갈아 전송 (PubSubClient) + - OLED TFT 디스플레이 출력 (HT_st7735) + - 시리얼 명령어를 통한 WiFi 및 MQTT 설정 관리 + - 설정 모드에서 WiFi/MQTT 정보 저장 및 복원 + - TFT 출력 시 고정 길이 문자열 포맷 적용으로 잔여 문자 문제 방지 + +시리얼 명령어: + - CONFIG : 설정 모드 진입 (시스템 주요 기능 일시 중지) + - LIST : 저장된 WiFi 후보 목록 출력 + - ADD ssid,pass : WiFi 후보 추가 (최대 후보 개수 제한) + - EDIT idx ssid,pass : 지정 인덱스 WiFi 후보 수정 + - DELETE idx : 지정 인덱스 WiFi 후보 삭제 + - MQTTSET server,user,pass,topic : MQTT 설정 저장 + - MQTTINFO : 현재 MQTT 설정 출력 + - EXIT : 설정 모드 종료 및 시스템 재시작 + +TFT 디스플레이 정보: + - SELF 위치 정보 및 GPS 위성 상태 녹색 표시 + - NODE1 수신 데이터 (TVOC, eCO2 포함) 노란색 표시 + - CONFIG 모드 진입 시 안내 메시지 표시 + +하드웨어 연결: + - GNSS_TX (GPS 송신) : GPIO 33 + - GNSS_RX (GPS 수신) : GPIO 34 + - VGNSS_CTRL (GPS 전원 제어) : GPIO 3 + - LoRa SPI 핀 (SX1262) : NSS=8, DIO1=14, RESET=12, BUSY=13 + +LoRa 설정: + - 주파수: 923.0 MHz + - Spreading Factor: 7 + - Bandwidth: 125 kHz + - Coding Rate: 4/5 + - Sync Word: 0x12 + +저장소: + - WiFi 설정: Preferences 네임스페이스 "wifi" + - MQTT 설정: Preferences 네임스페이스 "mqtt" + +개발 및 테스트 환경: + - Arduino IDE 및 ESP32 환경 + - 안정적인 GPS 신호 확보를 위한 별도 초기화 및 수신 처리 + - 주기적 WiFi 및 MQTT 연결 상태 점검 및 자동 복구 + - JSON 파싱 오류 및 TFT 출력 잔여 문자 문제 방지를 위한 고정 길이 문자열 포맷 적용 + +작성일: 2025-07-29 +작성자: BK Choi +*/ + +#include "Arduino.h" +#include "HT_st7735.h" +#include "HT_TinyGPS++.h" +#include +#include +#include +#include +#include +#include + +#define GNSS_TX 33 +#define GNSS_RX 34 +#define VGNSS_CTRL 3 +#define MAX_WIFI_CANDIDATES 10 + +HT_st7735 tft; +TinyGPSPlus GPS; +SX1262 radio = new Module(8, 14, 12, 13); + +struct WiFiCandidate { + String ssid; + String password; +}; +WiFiCandidate wifiList[MAX_WIFI_CANDIDATES]; +int wifiCount = 0; + +String mqtt_server = "selimcns.synology.me"; +String mqtt_user = ""; +String mqtt_password = ""; +String mqtt_topic = "odor/monitor"; +Preferences prefs; +WiFiClient espClient; +PubSubClient mqttClient(espClient); + +volatile bool receivedFlag = false; +String latestSELF = ""; +String latestNODE1 = ""; + +bool configMode = false; + +enum State { S_SELF, + S_NODE1 }; +State publishState = S_SELF; +unsigned long lastNode1Time = 0; +const unsigned long NODE1_TIMEOUT = 10000; + +void setFlag() { + receivedFlag = true; +} + +void saveWiFiListToEEPROM() { + char key[16]; + prefs.begin("wifi", false); + prefs.putUInt("count", wifiCount); + for (int i = 0; i < wifiCount; i++) { + snprintf(key, sizeof(key), "ssid%d", i); + prefs.putString(key, wifiList[i].ssid); + snprintf(key, sizeof(key), "pass%d", i); + prefs.putString(key, wifiList[i].password); + } + prefs.end(); +} + +void loadWiFiListFromEEPROM() { + char key[16]; + prefs.begin("wifi", true); + wifiCount = prefs.getUInt("count", 0); + for (int i = 0; i < wifiCount && i < MAX_WIFI_CANDIDATES; i++) { + snprintf(key, sizeof(key), "ssid%d", i); + wifiList[i].ssid = prefs.getString(key, ""); + snprintf(key, sizeof(key), "pass%d", i); + wifiList[i].password = prefs.getString(key, ""); + } + prefs.end(); +} + +void saveMQTTToEEPROM() { + prefs.begin("mqtt", false); + prefs.putString("server", mqtt_server); + prefs.putString("user", mqtt_user); + prefs.putString("pass", mqtt_password); + prefs.putString("topic", mqtt_topic); + prefs.end(); +} + +void loadMQTTFromEEPROM() { + prefs.begin("mqtt", true); + mqtt_server = prefs.getString("server", mqtt_server); + mqtt_user = prefs.getString("user", mqtt_user); + mqtt_password = prefs.getString("pass", mqtt_password); + mqtt_topic = prefs.getString("topic", mqtt_topic); + prefs.end(); +} + +void handleSerialCommand() { + if (Serial.available()) { + String cmd = Serial.readStringUntil('\n'); + cmd.trim(); + + if (cmd == "CONFIG") { + configMode = true; + Serial.println("[설정 모드 진입: 기능 일시 중지됨]"); + Serial.println("명령어: LIST, ADD ssid,pass, EDIT idx ssid,pass, DELETE idx"); + Serial.println("MQTTSET server,user,pass,topic / MQTTINFO / EXIT"); + + // TFT 출력 추가 + tft.st7735_fill_screen(ST7735_BLACK); + tft.st7735_write_str(0, 0, "CONFIG MODE", Font_7x10, ST7735_RED, ST7735_BLACK); + tft.st7735_write_str(0, 12, "Use serial cmd", Font_7x10, ST7735_YELLOW, ST7735_BLACK); + } else if (configMode) { + if (cmd == "LIST") { + Serial.println("[WiFi 목록]"); + for (int i = 0; i < wifiCount; i++) { + Serial.printf("%d. %s / %s\n", i, wifiList[i].ssid.c_str(), wifiList[i].password.c_str()); + } + } else if (cmd.startsWith("ADD ")) { + int sep = cmd.indexOf(','); + if (sep == -1 || wifiCount >= MAX_WIFI_CANDIDATES) { + Serial.println("형식: ADD SSID,PASSWORD"); + return; + } + String ssid = cmd.substring(4, sep); + String pass = cmd.substring(sep + 1); + wifiList[wifiCount++] = { ssid, pass }; + Serial.println("[WiFi 추가됨]"); + saveWiFiListToEEPROM(); + } else if (cmd.startsWith("EDIT ")) { + int sep1 = cmd.indexOf(' ', 5); + int sep2 = cmd.indexOf(',', sep1); + if (sep1 == -1 || sep2 == -1) { + Serial.println("형식: EDIT INDEX SSID,PASSWORD"); + return; + } + int idx = cmd.substring(5, sep1).toInt(); + if (idx < 0 || idx >= wifiCount) { + Serial.println("잘못된 인덱스"); + return; + } + String ssid = cmd.substring(sep1 + 1, sep2); + String pass = cmd.substring(sep2 + 1); + wifiList[idx] = { ssid, pass }; + Serial.println("[WiFi 수정됨]"); + saveWiFiListToEEPROM(); + } else if (cmd.startsWith("DELETE ")) { + int idx = cmd.substring(7).toInt(); + if (idx < 0 || idx >= wifiCount) { + Serial.println("잘못된 인덱스"); + return; + } + for (int i = idx; i < wifiCount - 1; i++) wifiList[i] = wifiList[i + 1]; + wifiCount--; + Serial.println("[WiFi 삭제됨]"); + saveWiFiListToEEPROM(); + } else if (cmd.startsWith("MQTTSET ")) { + int sep1 = cmd.indexOf(',', 8); + int sep2 = cmd.indexOf(',', sep1 + 1); + int sep3 = cmd.indexOf(',', sep2 + 1); + if (sep1 == -1 || sep2 == -1 || sep3 == -1) { + Serial.println("형식: MQTTSET server,user,pass,topic"); + return; + } + mqtt_server = cmd.substring(8, sep1); + mqtt_user = cmd.substring(sep1 + 1, sep2); + mqtt_password = cmd.substring(sep2 + 1, sep3); + mqtt_topic = cmd.substring(sep3 + 1); + saveMQTTToEEPROM(); + Serial.println("[MQTT 설정 저장됨]"); + } else if (cmd == "MQTTINFO") { + Serial.println("[MQTT 설정 정보]"); + Serial.println("Server: " + mqtt_server); + Serial.println("User: " + mqtt_user); + Serial.println("Password: " + mqtt_password); + Serial.println("Topic: " + mqtt_topic); + } else if (cmd == "EXIT") { + configMode = false; + Serial.println("[설정 모드 종료: 시스템 재시작 중...]"); + tft.st7735_fill_screen(ST7735_BLACK); // 화면 초기화 + ESP.restart(); + } else { + Serial.println("지원 명령어: LIST, ADD, EDIT, DELETE, MQTTSET, MQTTINFO, EXIT"); + } + } + } +} + +void connectToBestWiFi() { + int bestRSSI = -1000, bestIndex = -1; + bool openWiFiAttempted = false; + for (int attempt = 0; attempt < 3; attempt++) { + int n = WiFi.scanNetworks(); + if (n == 0) { + Serial.println("[WiFi 검색 실패] - CONFIG 입력으로 설정 가능"); + return; + } + for (int i = 0; i < n; i++) { + for (int j = 0; j < wifiCount; j++) { + if (WiFi.SSID(i) == wifiList[j].ssid && WiFi.RSSI(i) > bestRSSI) { + bestRSSI = WiFi.RSSI(i); + bestIndex = j; + } + } + } + if (bestIndex != -1) { + WiFi.begin(wifiList[bestIndex].ssid.c_str(), wifiList[bestIndex].password.c_str()); + unsigned long start = millis(); + while (WiFi.status() != WL_CONNECTED && millis() - start < 10000) delay(500); + if (WiFi.status() == WL_CONNECTED) return; + } else { + for (int i = 0; i < n; i++) { + if (WiFi.encryptionType(i) == WIFI_AUTH_OPEN && !openWiFiAttempted) { + WiFi.begin(WiFi.SSID(i).c_str()); + unsigned long start = millis(); + while (WiFi.status() != WL_CONNECTED && millis() - start < 10000) delay(500); + if (WiFi.status() == WL_CONNECTED) return; + openWiFiAttempted = true; + } + } + } + delay(3000); + } + Serial.println("[WiFi 연결 실패] 설정을 확인해주세요"); +} + +String generateClientID() { + uint8_t mac[6]; + esp_read_mac(mac, ESP_MAC_WIFI_STA); + char macStr[13]; + for (int i = 0; i < 6; i++) { + sprintf(&macStr[i * 2], "%02X", mac[i]); + } + return "DEVICE" + String(macStr) + String(random(1000, 9999)); +} + +void connectToMQTT() { + mqttClient.setServer(mqtt_server.c_str(), 1883); + String clientId = generateClientID(); + unsigned long start = millis(); + while (!mqttClient.connected() && millis() - start < 10000) { + mqttClient.connect(clientId.c_str(), mqtt_user.c_str(), mqtt_password.c_str()); + delay(500); + } +} + +void ensureWiFiAndMQTTConnected() { + static unsigned long lastCheck = 0; + if (millis() - lastCheck < 5000) return; + lastCheck = millis(); + if (WiFi.status() != WL_CONNECTED) connectToBestWiFi(); + if (!mqttClient.connected()) connectToMQTT(); + mqttClient.loop(); +} + +void handleGPSAndSelfJson() { + while (!configMode && Serial1.available()) GPS.encode(Serial1.read()); + static unsigned long lastSelfTime = 0; + if (!configMode && millis() - lastSelfTime > 1000) { + lastSelfTime = millis(); + StaticJsonDocument<256> doc; + char timeStr[10]; + sprintf(timeStr, "%02d:%02d:%02d", (GPS.time.hour() + 9) % 24, GPS.time.minute(), GPS.time.second()); + doc["id"] = "SELF"; + doc["time"] = timeStr; + doc["lat"] = GPS.location.lat(); + doc["lon"] = GPS.location.lng(); + doc["alt"] = GPS.altitude.meters(); + doc["spd"] = GPS.speed.kmph(); + doc["sat"] = GPS.satellites.value(); + doc["hdop"] = GPS.hdop.hdop(); + char buffer[256]; + serializeJson(doc, buffer); + latestSELF = String(buffer); + } +} + +void handleLoRaReceive() { + if (!configMode && receivedFlag) { + receivedFlag = false; + String recv; + if (radio.readData(recv) == RADIOLIB_ERR_NONE) { + if (recv.indexOf("\"id\":\"NODE-001\"") != -1) { + latestNODE1 = recv; + lastNode1Time = millis(); + Serial.println("[LoRa 수신]: " + recv); + } + } + radio.startReceive(); + } +} + +void handleMQTTPublish() { + static unsigned long lastPub = 0; + if (!configMode && millis() - lastPub > 1000) { + lastPub = millis(); + if (millis() - lastNode1Time > NODE1_TIMEOUT) latestNODE1 = ""; + String toSend = ""; + if (publishState == S_SELF && latestSELF != "") { + toSend = latestSELF; + publishState = S_NODE1; + } else if (publishState == S_NODE1 && latestNODE1 != "") { + toSend = latestNODE1; + publishState = S_SELF; + } else { + publishState = (State)(((int)publishState + 1) % 2); + } + if (toSend != "") { + mqttClient.publish(mqtt_topic.c_str(), toSend.c_str()); + Serial.println("[MQTT 발행]: " + toSend); + } + } +} + +void handleTFTDisplay() { + if (configMode) return; + + // SELF 정보 출력 + StaticJsonDocument<256> doc; + deserializeJson(doc, latestSELF); + char buf[40]; + sprintf(buf, "SELF %s", doc["time"] | "-"); + tft.st7735_write_str(0, 0, buf, Font_7x10, ST7735_GREEN, ST7735_BLACK); + sprintf(buf, "LAT:%.4f LON:%.4f", doc["lat"] | 0.0, doc["lon"] | 0.0); + tft.st7735_write_str(0, 12, buf, Font_7x10, ST7735_GREEN, ST7735_BLACK); + sprintf(buf, "SAT:%d HDOP:%.1f", doc["sat"] | 0, doc["hdop"] | 0.0); + tft.st7735_write_str(0, 24, buf, Font_7x10, ST7735_GREEN, ST7735_BLACK); + + // NODE1 정보 출력 + if (latestNODE1 != "") { + StaticJsonDocument<256> ndoc; + DeserializationError err = deserializeJson(ndoc, latestNODE1); + + if (!err) { + sprintf(buf, "NODE1 %s", ndoc["time"] | "-"); + tft.st7735_write_str(0, 36, buf, Font_7x10, ST7735_YELLOW, ST7735_BLACK); + + sprintf(buf, "LAT:%.4f LON:%.4f", ndoc["lat"] | 0.0, ndoc["lon"] | 0.0); + tft.st7735_write_str(0, 48, buf, Font_7x10, ST7735_YELLOW, ST7735_BLACK); + + // 안전한 타입 검사 및 고정 길이 포맷 적용 + int tvoc = ndoc["tvoc"].is() ? ndoc["tvoc"].as() : -1; + int eco2 = ndoc["eco2"].is() ? ndoc["eco2"].as() : -1; + + if (eco2 < 0 || eco2 > 4000) { + // 'ERR' 출력 뒤 공백 2칸 추가해 잔여 문자 덮기 + sprintf(buf, "TVOC:%-5d eCO2:ERR ", tvoc); + } else { + // 숫자 5자리 확보, 빈 자리 공백으로 채움 + sprintf(buf, "TVOC:%-5d eCO2:%-5d", tvoc, eco2); + } + tft.st7735_write_str(0, 60, buf, Font_7x10, ST7735_YELLOW, ST7735_BLACK); + } else { + tft.st7735_write_str(0, 36, "NODE1 JSON ERR ", Font_7x10, ST7735_RED, ST7735_BLACK); + } + } +} + +void setup() { + Serial.begin(115200); + Serial.println("\n[시스템 시작]"); + + pinMode(VGNSS_CTRL, OUTPUT); + digitalWrite(VGNSS_CTRL, HIGH); + Serial.println("[GNSS 전원 ON]"); + + tft.st7735_init(); + tft.st7735_fill_screen(ST7735_BLACK); + tft.st7735_write_str(0, 0, "Starting...", Font_7x10, ST7735_WHITE, ST7735_BLACK); + + Serial1.begin(115200, SERIAL_8N1, GNSS_TX, GNSS_RX); + Serial.println("[GNSS 시리얼 시작됨]"); + + loadWiFiListFromEEPROM(); + Serial.printf("[WiFi 목록 %d개 로드됨]\n", wifiCount); + + loadMQTTFromEEPROM(); + Serial.println("[MQTT 설정 로드됨]"); + + Serial.println("[LoRa 초기화 중...]"); + if (radio.begin(923.0) == RADIOLIB_ERR_NONE) { + radio.setSpreadingFactor(7); + radio.setBandwidth(125.0); + radio.setCodingRate(5); + radio.setSyncWord(0x12); + radio.setDio1Action(setFlag); + radio.startReceive(); + Serial.println("[LoRa 시작됨]"); + tft.st7735_write_str(0, 0, "LoRa OK", Font_7x10, ST7735_GREEN, ST7735_BLACK); + } else { + Serial.println("[LoRa 시작 실패]"); + tft.st7735_write_str(0, 0, "LoRa Fail", Font_7x10, ST7735_RED, ST7735_BLACK); + while (true) + ; + } + + Serial.println("[WiFi 연결 시도 중]"); + tft.st7735_write_str(0, 12, "Connecting WiFi...", Font_7x10, ST7735_WHITE, ST7735_BLACK); + connectToBestWiFi(); + if (WiFi.status() == WL_CONNECTED) { + Serial.print("[WiFi 연결됨]: "); + Serial.println(WiFi.localIP()); + tft.st7735_write_str(0, 24, "WiFi OK", Font_7x10, ST7735_GREEN, ST7735_BLACK); + } else { + tft.st7735_write_str(0, 24, "WiFi Fail", Font_7x10, ST7735_RED, ST7735_BLACK); + } + + Serial.println("[MQTT 연결 시도 중]"); + tft.st7735_write_str(0, 36, "Connecting MQTT...", Font_7x10, ST7735_WHITE, ST7735_BLACK); + connectToMQTT(); + if (mqttClient.connected()) { + Serial.println("[MQTT 연결됨]"); + tft.st7735_write_str(0, 48, "MQTT OK", Font_7x10, ST7735_GREEN, ST7735_BLACK); + } else { + Serial.println("[MQTT 연결 실패]"); + tft.st7735_write_str(0, 48, "MQTT Fail", Font_7x10, ST7735_RED, ST7735_BLACK); + } +} + + +void loop() { + handleSerialCommand(); + if (!configMode) { + ensureWiFiAndMQTTConnected(); + handleGPSAndSelfJson(); + handleLoRaReceive(); + handleMQTTPublish(); + handleTFTDisplay(); + } +} diff --git a/src/sender/main.cpp b/src/sender/main.cpp new file mode 100644 index 0000000..f56df7b --- /dev/null +++ b/src/sender/main.cpp @@ -0,0 +1,253 @@ +/* +프로젝트명: GNSS + LoRa + MQTT 실시간 위치 및 환경 데이터 송신 시스템 +보드 종류: ESP32 (예: HELTEC Wireless Tracker 또는 유사 보드) +REV 2.0 + +주요 기능: + - GNSS(GPS) 모듈을 통해 시간, 위성, 위치, 속도, 고도 정보 수집 + - SGP30 공기질 센서로 TVOC 및 eCO2 농도 측정 + - DFRobot H2S 및 NH3 가스 센서 데이터 측정 + - OLED TFT 디스플레이에 실시간 환경 및 GPS 정보 출력 + - LoRa SX1262 모듈을 통해 JSON 형식으로 환경 데이터 송신 + - 센서 및 통신 모듈 초기화, 상태 점검 및 안정성 확보 + - TFT 출력 시 고정 길이 문자열 포맷을 적용하여 잔여 문자 문제 해결 + +출력 방식 및 개선점: + - TFT 출력은 문자열 덮어쓰기 방식을 사용하여 화면 깜박임 최소화 + - 숫자 출력 시 "%-5d" 같은 고정 길이 포맷으로 출력하여 이전 출력 잔여 문자 방지 + +LoRa 통신 설정: + - 주파수: 923.0 MHz + - Spreading Factor: 7 + - Bandwidth: 125 kHz + - Coding Rate: 4/5 + - Sync Word: 0x12 + +하드웨어 연결: + - GNSS_TX (GPS 송신): GPIO 33 + - GNSS_RX (GPS 수신): GPIO 34 + - VGNSS_CTRL (GPS 전원 제어): GPIO 3 + - LoRa SPI 핀: NSS=8, DIO1=14, RESET=12, BUSY=13 + +사용 라이브러리: + - HT_st7735 : OLED TFT 디스플레이 제어 + - HT_TinyGPS++ : GPS 데이터 처리 + - RadioLib : SX1262 LoRa 제어 + - DFRobot_MultiGasSensor : H2S, NH3 센서 제어 + - Seeed_Arduino_SGP30 : SGP30 센서 제어 + - ArduinoJson : JSON 데이터 직렬화 + - Wire : I2C 통신 + +개발 및 테스트 환경: + - Arduino IDE 및 ESP32 환경 + - 센서 초기 안정화 시간 150초 설정 + - 시리얼 모니터를 통한 상태 및 디버깅 출력 지원 + +향후 개선 가능 사항: + - TFT 부분 클리어 기능 추가 또는 라이브러리 변경으로 깜박임 개선 + - LoRa 통신 오류 감지 및 재전송 로직 강화 + - 데이터 송수신 간 동기화 및 신뢰성 강화 + +작성일: 2025-07-29 +작성자: BK Choi +*/ + +#include "Arduino.h" +#include "HT_st7735.h" +#include "HT_TinyGPS++.h" +#include +#include +#include +#include +#include "DFRobot_MultiGasSensor.h" +#include "sensirion_common.h" +#include "sgp30.h" + +#define SDA_PIN 5 +#define SCL_PIN 4 +#define GNSS_TX 33 +#define GNSS_RX 34 +#define VGNSS_CTRL 3 + +#define LORA_FREQ 923.0 +#define LORA_SPREADING_FACTOR 7 +#define LORA_BW 125.0 +#define LORA_CR 5 +#define LORA_SYNCWORD 0x12 + +HT_st7735 tft; +TinyGPSPlus GPS; +SX1262 radio = new Module(8, 14, 12, 13); +DFRobot_GAS_I2C h2s(&Wire, 0x74); +DFRobot_GAS_I2C nh3(&Wire, 0x75); + +unsigned long lastLoRaTime = 0; +const unsigned long loraInterval = 1000; + +uint16_t tvoc_ppb = 0, co2_eq_ppm = 0; +float h2s_val = 0, nh3_val = 0; + +void setupTFT() { + tft.st7735_init(); + tft.st7735_fill_screen(ST7735_BLACK); + tft.st7735_write_str(0, 0, "Initializing...", Font_7x10, ST7735_GREEN, ST7735_BLACK); +} + +void setupGPS() { + Serial1.begin(115200, SERIAL_8N1, GNSS_TX, GNSS_RX); +} + +void setupGasSensors() { + while (!h2s.begin()) { + Serial.println("No H2S sensor found"); + delay(1000); + } + while (!nh3.begin()) { + Serial.println("No NH3 sensor found"); + delay(1000); + } + h2s.changeAcquireMode(h2s.INITIATIVE); + nh3.changeAcquireMode(nh3.INITIATIVE); + + while (sgp_probe() != STATUS_OK) { + Serial.println("SGP30 failed to start"); + delay(1000); + } + sgp_iaq_init(); + sgp_set_absolute_humidity(1030); + + for (int sec = 150; sec >= 0; sec--) { + char buf[32]; + sprintf(buf, "Stabilize %d sec", sec); + tft.st7735_write_str(0, 12, buf, Font_7x10, ST7735_YELLOW, ST7735_BLACK); + delay(1000); + } +} + +void setupLoRa() { + if (radio.begin(LORA_FREQ) == RADIOLIB_ERR_NONE) { + radio.setSpreadingFactor(LORA_SPREADING_FACTOR); + radio.setBandwidth(LORA_BW); + radio.setCodingRate(LORA_CR); + radio.setSyncWord(LORA_SYNCWORD); + Serial.println("LoRa initialized successfully."); + } else { + tft.st7735_write_str(0, 0, "LoRa Fail", Font_7x10, ST7735_RED, ST7735_BLACK); + Serial.println("LoRa initialization failed!"); + while (true); + } + tft.st7735_fill_screen(ST7735_BLACK); +} + +void updateSensors() { + while (Serial1.available()) GPS.encode(Serial1.read()); + + uint16_t tvoc_tmp = 0, co2_tmp = 0; + s16 ret = sgp_measure_iaq_blocking_read(&tvoc_tmp, &co2_tmp); + if (ret == STATUS_OK) { + tvoc_ppb = tvoc_tmp; + co2_eq_ppm = co2_tmp; + } + + h2s_val = nh3_val = 0; + if (h2s.dataIsAvailable()) h2s_val = AllDataAnalysis.gasconcentration; + if (nh3.dataIsAvailable()) nh3_val = AllDataAnalysis.gasconcentration; +} + +void updateDisplay() { + char buf[64]; + + sprintf(buf, "NODE-001 %02d:%02d:%02d", + (GPS.time.hour() + 9) % 24, + GPS.time.minute(), + GPS.time.second()); + tft.st7735_write_str(0, 0, buf, Font_7x10, ST7735_GREEN, ST7735_BLACK); + + sprintf(buf, "SAT:%d HDOP:%.1f", + GPS.satellites.value(), + GPS.hdop.hdop()); + tft.st7735_write_str(0, 12, buf, Font_7x10, ST7735_GREEN, ST7735_BLACK); + + sprintf(buf, "LAT:%.4f LON:%.4f", + GPS.location.lat(), + GPS.location.lng()); + tft.st7735_write_str(0, 24, buf, Font_7x10, ST7735_YELLOW, ST7735_BLACK); + + char spdStr[8], altStr[8]; + dtostrf(GPS.speed.kmph(), 5, 1, spdStr); + dtostrf(GPS.altitude.meters(), 5, 1, altStr); + sprintf(buf, "SPD:%s ALT:%s", spdStr, altStr); + tft.st7735_write_str(0, 36, buf, Font_7x10, ST7735_CYAN, ST7735_BLACK); + + dtostrf(nh3_val, 4, 1, spdStr); + dtostrf(h2s_val, 4, 1, altStr); + sprintf(buf, "NH3:%s H2S:%s", spdStr, altStr); + tft.st7735_write_str(0, 48, buf, Font_7x10, ST7735_MAGENTA, ST7735_BLACK); + + int tvoc_val = (tvoc_ppb <= 60000) ? tvoc_ppb : 0; + int eco2_val = (co2_eq_ppm >= 400 && co2_eq_ppm <= 10000) ? co2_eq_ppm : 400; + + // 고정 길이 문자열 출력 (깜박임 없이 덮어쓰기) + sprintf(buf, "TVOC:%-5d eCO2:%-5d", tvoc_val, eco2_val); + tft.st7735_write_str(0, 60, buf, Font_7x10, ST7735_RED, ST7735_BLACK); +} + +void sendLoRaData() { + unsigned long now = millis(); + if (now - lastLoRaTime >= loraInterval) { + lastLoRaTime = now; + + StaticJsonDocument<256> doc; + char timeStr[10]; + sprintf(timeStr, "%02d:%02d:%02d", + (GPS.time.hour() + 9) % 24, + GPS.time.minute(), + GPS.time.second()); + + doc["id"] = "NODE-001"; + doc["time"] = timeStr; + doc["lat"] = GPS.location.lat(); + doc["lon"] = GPS.location.lng(); + doc["alt"] = GPS.altitude.meters(); + doc["spd"] = GPS.speed.kmph(); + doc["sat"] = GPS.satellites.value(); + doc["hdop"] = GPS.hdop.hdop(); + doc["nh3"] = nh3_val; + doc["h2s"] = h2s_val; + + int tvoc_val = (tvoc_ppb <= 60000) ? tvoc_ppb : 0; + int eco2_val = (co2_eq_ppm >= 400 && co2_eq_ppm <= 10000) ? co2_eq_ppm : 400; + + doc["tvoc"] = tvoc_val; + doc["eco2"] = eco2_val; + + char buffer[256]; + serializeJson(doc, buffer); + + if (radio.transmit(buffer) == RADIOLIB_ERR_NONE) { + Serial.print("LoRa Sent: "); + Serial.println(buffer); + } else { + Serial.println("LoRa transmission failed."); + } + } +} + +void setup() { + Serial.begin(115200); + Wire.begin(SDA_PIN, SCL_PIN); + + pinMode(VGNSS_CTRL, OUTPUT); + digitalWrite(VGNSS_CTRL, HIGH); + + setupTFT(); + setupGPS(); + setupGasSensors(); + setupLoRa(); +} + +void loop() { + updateSensors(); + updateDisplay(); + sendLoRaData(); +}