This commit is contained in:
choibk 2025-11-08 22:39:20 +09:00
commit 970680570e
5 changed files with 801 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.pio
.vscode
include/README
test/README

46
lib/README Normal file
View File

@ -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 <Foo.h>
#include <Bar.h>
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

14
platformio.ini Normal file
View File

@ -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

484
src/Receiver/main.cpp Normal file
View File

@ -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 <RadioLib.h>
#include <ArduinoJson.h>
#include <WiFi.h>
#include <PubSubClient.h>
#include <esp_mac.h>
#include <Preferences.h>
#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<int>() ? ndoc["tvoc"].as<int>() : -1;
int eco2 = ndoc["eco2"].is<int>() ? ndoc["eco2"].as<int>() : -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();
}
}

253
src/sender/main.cpp Normal file
View File

@ -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 <RadioLib.h>
#include <Wire.h>
#include <EEPROM.h>
#include <ArduinoJson.h>
#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();
}