This commit is contained in:
choibk 2025-11-08 21:45:54 +09:00
commit 4ef1c95e92
9 changed files with 2219 additions and 0 deletions

4
.gitignore vendored Normal file
View File

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

256
README Normal file
View File

@ -0,0 +1,256 @@
Smart Factory Monitoring System - 프로토콜 & 프로젝트 보고서 v1.0
작성일: 2025-08-15 / 대상: ESP32 기반 RS485 Modbus RTU 마스터–슬레이브 시스템
________________________________________
1. 시스템 개요
• 구성: RS485 버스(마스터 1대 ↔ 슬레이브 N대), Wi Fi + MQTT 백엔드 연동.
• 마스터(ESP32): RS485 폴링, 응답 파싱, MQTT JSON 발행, 설정 CLI(시리얼) 제공.
• 슬레이브(ESP32): 각 센서 수집 → 내부 레지스터 매핑 → Modbus RTU 응답.
• 확장성: 슬레이브별 센서/레지스터 구성이 달라도 공통 규약(섹션 3)을 따르면 마스터는 동일하게 동작.
________________________________________
2. 물리 계층 & 배선 가이드 (RS485)
• 전기적: RS485 2선식 차동(A/B), 공통 GND 권장.
• 속도/포맷: 115200 bps, 8N1 (모든 노드 동일)
• 종단/바이어스: 버스 양 끝 120 Ω 종단 1개씩, 라인 바이어스(풀업/풀다운) 구성 권장.
• 프레임 정숙 시간(t3.5): 115200 bps(8N1) 기준 ≥ 0.4 ms. 구현 상 8 ms 이상의 침묵 시간을 프레임 경계로 사용.
________________________________________
3. 응용 프로토콜 (Modbus RTU 프로파일)
3.1 요청 프레임 (마스터 → 슬레이브)
[슬레이브ID][FC=03][addr_hi=0x00][addr_lo=0x00][qty_hi=0x00][qty_lo=0x0A][CRC_lo][CRC_hi]
• 의미: Holding Registers 0x0000부터 10개 읽기.
• CRC: Modbus/CRC16 IBM(다항식 0xA001), 리틀 엔디언(Lo,Hi).
3.2 응답 프레임 (슬레이브 → 마스터)
[슬레이브ID][FC=03][byteCount=20][Data(20B)][CRC_lo][CRC_hi]
• Data(20B)= 레지스터 10개(각 2B)
• CRC: 위와 동일.
3.3 공통 레지스터 맵(10 regs)
인덱스 크기 의미
[0] 1 reg 데이터 영역 길이(16비트 레지스터 개수). 예) 6 → 3개의 float(각 2 regs).
[1..] 가변 센서 데이터 영역. float은 2 regs 사용. 엔디언은 3.4절 참조.
[7..8] 2 regs 예약(0)
[9] 1 reg Heartbeat(순증가, 16비트)
슬레이브는 실제 보유 센서 수에 맞춰 [0]을 설정(예: 3개 float라면 6). 슬레이브가 데이터 갱신에 실패한 항목은 NULL 표현(3.5절)로 응답.
3.4 부동소수점 엔디언
• 기본: LSW first (레지스터 순서: LSW, MSW → 32비트 float 구성)
• 필요 시 프로젝트 전역 스위치: FLOAT_WORD_ORDER_LSW_FIRST = 0(MSW first)로 변경 가능. 마스터/슬레이브 일치 필수.
3.5 NULL 표현
• float: 해당 2레지스터가 0xFFFF, 0xFFFF → NULL(유효 데이터 없음)
• uint16: 0xFFFF → NULL
• 마스터는 이를 JSON에서 null 로 발행.
3.6 타이밍 권장값
• 마스터 폴링 간격: ≥ 50 ms(≤ 20 Hz) 권장.
• 프레임 경계 판정: 침묵 시간 ≥ 8 ms.
• 슬레이브 내부: 센서 수집은 라운드로빈 + 프레임 선점(마스터 요청 시 즉시 응답) 설계 권장.
________________________________________
4. 슬레이브별 데이터 맵 (의미 레벨)
실제 센서 인터페이스(아날로그/I2C/UART 등)가 달라도, 아래 의미/단위로 Modbus 레지스터만 일관되게 제공합니다.
Slave #1 소음 & 조도
항목 단위 타입 레지스터
noise dB float [1..2]
illuminance lux float [3..4]
meta/예약/HB - - [0], [7..9]
• [0] = 4 (float 2개 * 2 regs)
Slave #2 온습도(이중 채널)
항목 단위 타입 레지스터
t1 °C float [1..2]
h1 % float [3..4]
t2 °C float [5..6]
h2 % float [7..8] ※(주의: 예약과 겹치지 않게 10regs 내 설계 필요)
HB - uint16 [9]
• [0] = 8 (float 4개 × 2 regs)
• 구현상 10레지스터 제한에 맞춰 예약([7..8])을 데이터로 활용. 공용 템플릿과 차이가 있으므로 슬레이브2는 예약 미사용.
Slave #3 환경(온/습/CO2/TVOC/PM1/PM2.5/PM10)
항목 단위 타입 레지스터
t °C float [1..2]
h % float [3..4]
co2 ppm float [5..6]
tvoc ppb float [7..8]
pm1 µg/m³ float [9..10]
pm25 µg/m³ float [11..12]
pm10 µg/m³ float [13..14]
• 확장형 슬레이브: 10 regs(기본)로 부족 → 이 노드는 확장 프로파일(예: qty 늘리기)을 사용할 수 있음. 본 보고서 기본 프로파일(10 regs)에서는 축약 구성을 권장하거나, 마스터 요청 수량을 노드별로 늘리는 옵션을 사용.
Slave #4 가스( CO / CO2 / HCHO )
항목 단위 타입 레지스터
co ppm float [1..2]
co2 ppm float [3..4]
hcho ppm float [5..6]
meta/예약/HB - - [0], [7..9]
• [0] = 6 (float 3개 × 2 regs)
슬레이브4는 아래 센서 통신 상세를 따릅니다(섹션 7.4).
________________________________________
5. 마스터 코드 개요
• 역할: RS485 폴링(FC=03/0x0000/0x000A) → 응답 CRC/길이 검증 → 매핑(slaveMap) 기반 JSON 작성 → MQTT 발행.
• 매핑 변경으로 MQTT 키명을 자유롭게 정의 가능 (예: co,co2,hcho).
• 응답 파서: 바이트간 타임아웃(15 ms)로 프레임 리셋, CRC 불일치 폐기.
• NULL 규칙: 0xFFFF → JSON null.
• 엔디안 스위치: FLOAT_WORD_ORDER_LSW_FIRST 매크로.
5.1 시리얼 명령어 (CONFIG 모드)
명령 형식 설명
CONFIG CONFIG 설정 모드 진입(기능 일시 중지)
EXIT EXIT 또는 CONFIG OFF 설정 모드 종료 및 재시작
LIST LIST 저장된 Wi Fi 목록 표시
ADD ADD ssid,password Wi Fi 후보 추가
EDIT EDIT index ssid,password Wi Fi 후보 수정
DELETE DELETE index Wi Fi 후보 삭제
CLEAR CLEAR Wi Fi 목록 전체 삭제
MQTTSET MQTTSET server,user,pass,topic MQTT 설정 저장 (server는 host 또는 host:port)
MQTTINFO MQTTINFO 현재 MQTT 설정 조회
REQID REQID start,end RS485 폴링 ID 범위 설정
REQTIME REQTIME ms RS485 폴링 간격(ms) 설정
예시: MQTTSET selimcns.synology.me:1883,,,"smartfactory/monitor"
5.2 MQTT 발행 요약
• 브로커: 설정된 server:port, user/pass(옵션)
• 토픽: mqtt_topic (예: smartfactory/monitor)
• 페이로드(JSON):
{
"id": 4,
"co": 0,
"co2": 461,
"hcho": 0.72
}
• 키 이름은 slaveMap 으로 정의. 값이 NULL이면 JSON null 로 전송.
• QoS: PubSubClient 기본(QoS0). 필요 시 라이브러리 교체/확장으로 QoS1/2 지원 가능.
________________________________________
6. 마스터–슬레이브 통신 체크리스트
• 마스터 폴링 간격 ≥ 50 ms, 타임아웃 30 ~ 100 ms.
• 버스 끝단 종단 120 Ω, 바이어스 확인. A/B 극성 일치.
• 슬레이브가 프레임 우선 응답(센서 수집 선점 중단) 가능한 구조 권장.
• float 엔디언 일치 확인(LSW first 기본). 불일치 시 값이 비정상.
• CRC mismatch 증가 시 노이즈/배선 점검.
________________________________________
7. 슬레이브 코드 - 센서 통신 & 프로토콜
슬레이브 공통: UART1=RS485(Modbus), UART2=센서 묶음(슬레이브 별 배치 상이 가능). 센서 무응답/무갱신 시 일정 시간 후 NULL(0xFFFF)로 표시.
7.1 Slave #1 (Noise/Lux)
• Noise(dB): 구현 의존(ADC/I2S). 레지스터 [1..2] float.
• Illuminance(lux): 구현 의존(I2C BH1750 등). 레지스터 [3..4] float.
• [0]=4, [9]=HB. 센서 알람/범위는 프로젝트 요구에 맞춰 확장.
7.2 Slave #2 (Temp/Humi Dual)
• t1/h1/t2/h2: 각 float → [1..8]. [9]=HB. 10regs 한계 내 사용.
• 센서 예시: SHT/DHT/RS 485 일체형 등. 폴링/필터 주기 현장에 맞춰 조정.
7.3 Slave #3 (Env T/H/CO2/TVOC/PM)
• 항목 수가 많아 10regs 한계를 초과할 수 있음 → 확장 프로파일 사용(마스터가 qty를 크게 요청) 또는 축약.
• 표준 의미/단위만 고정, 실제 인터페이스는 노드 구현에 따름.
7.4 Slave #4 (Gas CO / CO2 / HCHO)
CO (ELT CO S20 3V)
• UART: 38400 bps, 8N1, 센서 TX → ESP32 RX (단방향)
• 프레임: ASCII "____D1..D5 ppm\r\n"(예: " 0 ppm"), 약 1 s 주기.
• 파싱: ppm 앞 숫자만 추출 → float 저장.
• 주의: 보드 점퍼(ZERO/SPAN) 위치에 따라 주기적 동작(영점 보정) 영향 가능. 제조사 가이드 참조.
CO2 (HX 2000U 계열)
• UART: 9600 bps, 8N1 (TX/RX 모두 사용)
• 질의: 42 4D E3 00 00 01 72
• 응답(10B): 헤더 42 4D, 본문 00 08 ..., 합계검증(sum) 후 CO2 ppm 추출.
HCHO (Gravity 9B 프레임)
• UART: 9600 bps, 8N1 (단방향 RX)
• 프레임(9B): FF 17 04 00 [CH] [CL] 13 88 [CS]
• 해석: [CH][CL]=ppb → ppm = ppb / 1000
• 수신 안정화: 듣는 창(드웰) 1.01.5 s 권장, RS485 선점과 충돌 최소화.
슬레이브4 구현 팁
• 라운드로빈: CO → CO2 → HCHO 순서, 각 모드 dwell과 read budget 조정.
• Preempt on Modbus: 센서 읽기 중 RS485 요청 오면 즉시 중단하고 응답.
• STALE 창: CO5 s, HCHO~1012 s 권장(현장 튜닝).
________________________________________
8. 예제: 마스터 MQTT 페이로드 (슬레이브4)
{
"id": 4,
"co": 0,
"co2": 461,
"hcho": 0.72
}
• 센서 미갱신/오류 시 각 필드는 null 로 발행.
________________________________________
9. 버전/빌드 노트
• 2025 08 15 v1.0: 기본 통신규격 문서화, 마스터/슬레이브4 상세, MQTT 키 커스터마이즈(slaveMap).
• 향후: 슬레이브3 확장 프로파일(요청 qty 가변) 정식 문서화, QoS1 발행 옵션, OTA/보안 토큰 추가.
________________________________________
부록 A. 테스트 체크리스트
• RS485 A/B 극성, 120 Ω 종단, 바이어스 확인
• 모든 노드 115200 8N1, ID/주소 충돌 없음
• 마스터 폴링 ≥ 50 ms, CRC mismatch 없음
• 슬레이브4: CO/CO2/HCHO 각각 단독 테스트 후 통합 테스트
• MQTT 브로커 접속/발행 확인, 대시보드 스키마 업데이트
________________________________________
부록 B. 용어
• LSW first: 32비트 값의 하위 16비트 레지스터가 먼저 배치되는 방식.
• NULL 전파: 센서 무효값을 상위 계층(JSON)까지 null로 전달.
• Preempt on Modbus: RS485 요청 시 센서 읽기를 즉시 중단하고 응답 우선 처리하는 설계.
________________________________________
부록 C. 시리얼 CONFIG 명령어 상세
C.1 개요
마스터의 USB 시리얼 콘솔에서 설정을 변경/조회할 수 있습니다. CONFIG 모드에 들어가면 RS485 폴링과 MQTT 발행이 일시 중지됩니다.
C.2 명령 목록
• CONFIG : 설정 모드 진입
• EXIT 또는 CONFIG OFF : 설정 모드 종료 및 재시작
• LIST : 저장된 Wi Fi 목록 표시
• ADD <ssid>,<password> : Wi Fi 후보 추가
• EDIT <index> <ssid>,<password> : Wi Fi 후보 수정
• DELETE <index> : Wi Fi 후보 삭제
• CLEAR : Wi Fi 후보 전체 삭제
• MQTTSET <server[,port]>,<user>,<pass>,<topic> : MQTT 설정 저장 (server에 host:port 형태 허용)
• MQTTINFO : MQTT 설정 조회
• REQID <start>,<end> : RS485 폴링 슬레이브 ID 범위 지정
• REQTIME <ms> : RS485 폴링 간격(ms)
C.3 예시
CONFIG
ADD MyAP,MyPass1234
MQTTSET selimcns.synology.me:1883,,,"smartfactory/monitor"
REQID 1,4
REQTIME 200
EXIT
________________________________________
부록 D. MQTT 발행 스키마
D.1 공통 형식
• 토픽: smartfactory/monitor (변경 가능)
• QoS/Retain: QoS 0, Retain = false (기본 PubSubClient.publish())
• 페이로드(JSON): { "id": <slaveId>, <key1>: <value|null>, ... }
• NULL 규칙: 슬레이브가 0xFFFF(uint16) 또는 0xFFFF,0xFFFF(float)로 응답한 항목은 null 로 발행
D.2 슬레이브별 예시
• Slave #1
{ "id": 1, "noise": 42.3, "illuminance": 315.0 }
• Slave #2
{ "id": 2, "t1": 23.1, "h1": 45.0, "t2": 22.9, "h2": 44.8 }
• Slave #3 (확장 프로파일 사용 시 키는 동일, 필드 수만 증가)
{ "id": 3, "t": 25.0, "h": 40.0, "co2": 610, "tvoc": 120, "pm1": 4, "pm25": 7, "pm10": 9 }
• Slave #4
{ "id": 4, "co": 0, "co2": 461, "hcho": 0.72 }
D.3 키 매핑 전략
• 키 이름은 마스터의 slaveMap 으로 정의합니다. 슬레이브 변경 없이 필드명을 바꿀 수 있습니다.
• 레거시 호환이 필요하면 _legacy 객체로 병행 발행을 고려할 수 있습니다.
________________________________________
부록 E. 트러블슈팅 가이드
E.1 CRC mismatch가 자주 발생
• 배선/노이즈 확인(A/B 극성, 종단 120 Ω, 바이어스 저항)
• RS485 버스 길이/분기 최소화, 접지 공통화
• 마스터 폴링 간격을 50 ~ 200 ms 사이로 조정
E.2 특정 필드가 null 로 자주 발행
• 해당 슬레이브의 STALE 타임아웃 확인(슬레이브 코드)
• HCHO(9600 bps)처럼 프레임 주기가 긴 센서는 듣는 창(dwell) 확대 필요
• 센서 엔디안/프레임 체크(예: CO2 sum check 실패 등)
E.3 값이 비정상(이상한 큰 수/NaN)
• 부동소수 워드 순서 확인: 마스터/슬레이브의 FLOAT_WORD_ORDER_* 일치 필요
• 레지스터 수량/순서 확인([0] 길이와 매핑 개수 불일치 여부)
E.4 마스터 연결 시 슬레이브 수집이 끊김
• 슬레이브는 Preempt on Modbus 설계를 사용해야 함(요청 수신 즉시 센서 읽기 중단)
• 마스터 폴링 간격을 늘리거나 슬레이브의 센서 dwell/read budget 조정
E.5 MQTT 발행 실패
• 브로커 주소/포트/인증 확인(MQTTINFO), 네트워크 방화벽 점검
• Wi Fi 재연결 루틴 동작 로그 확인
________________________________________
부록 F. 파라미터 권장값 요약
F.1 마스터
• 폴링 간격: REQTIME 200 (현장 상황에 따라 50500 ms)
• 응답 바이트간 타임아웃: 15 ms
• MQTT: QoS0/Retain false(기본)
F.2 슬레이브 #4 (예시)
• 센서 라운드로빈: CO(900ms) → CO2(200ms) → HCHO(1200ms)
• Read budget: CO/CO2=60ms, HCHO=80ms
• STALE: CO=6s, CO2=5s, HCHO=12s
• 워드 순서: LSW first
________________________________________
부록 G. 운영/보안 메모
• Wi Fi/MQTT 자격증명은 ESP32 NVS(Preferences)에 저장됩니다. 기기 양도/폐기 시 NVS 초기화 필요.
• 브로커 인증/암호화를 강화하려면 TLS 지원 라이브러리(PubSubClient SSL 등)와 인증서 핀닝을 검토하세요.
• 시리얼 CONFIG 모드는 현장 접근으로 간주되므로 물리적 접근 통제를 권장합니다.
________________________________________
결론
본 문서는 Smart Factory Monitoring System의 마스터–슬레이브 통신규격과 운영 절차를 정리합니다. 현장 환경에 따라 제시한 튜닝 파라미터를 조정하면 안정성과 응답성을 동시에 확보할 수 있습니다. 추가 확장(슬레이브 수/레지스터 확장, QoS1/2, TLS)은 본 규격을 유지한 채 점진적으로 도입 가능합니다.

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

17
platformio.ini Normal file
View File

@ -0,0 +1,17 @@
; 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:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
lib_deps =
knolleary/PubSubClient@^2.8
bblanchon/ArduinoJson@^7.4.2

506
src/master/main.cpp Normal file
View File

@ -0,0 +1,506 @@
#include <Arduino.h>
#include <WiFi.h>
#include <PubSubClient.h>
#include <Preferences.h>
#include <HardwareSerial.h>
#include <ArduinoJson.h>
#include <map>
#include <vector>
/* =============== 튜닝/설정 =============== */
#define RX_PIN 16 // RS485 RX
#define TX_PIN 17 // RS485 TX
#define BAUD_RATE 115200
#define MAX_WIFI_CANDIDATES 5
#define RS485_REQ_INTERVAL_MS 120 // 다음 ID로 넘어갈 최소 간격
#define RESPONSE_TIMEOUT_MS 150 // 슬레이브 응답 타임아웃
#define MAX_RETRY_PER_ID 1 // 타임아웃 시 재시도 횟수
#define RESP_MAX_LEN 252
#define RESP_BYTE_GAP_TIMEOUT 15 // 프레임 바이트 간 최대 간격(ms)
#define WIFI_PER_AP_TIMEOUT_MS 8000
#define WIFI_RECONNECT_BACKOFF 10000
#define INTERNET_CHECK_HOST_FALLBACK "pool.ntp.org"
#define RS485_WATCHDOG_MS 8000 // 버스 전체 무응답 시 재초기화
// 슬레이브 float 워드 순서
#define FLOAT_WORD_ORDER_LSW_FIRST 1
/* =============== 전역 객체 =============== */
HardwareSerial& rs485 = Serial2;
WiFiClient espClient;
PubSubClient mqttClient(espClient);
Preferences prefs;
/* =============== Wi-Fi/MQTT 설정 =============== */
struct WiFiCandidate { String ssid, password; };
WiFiCandidate wifiList[MAX_WIFI_CANDIDATES];
int wifiCount = 0;
String mqtt_server = "selimcns.synology.me"; // "host" 또는 "host:port"
int mqtt_port = 1883;
String mqtt_user = "";
String mqtt_password = "";
String mqtt_topic = "smartfactory/monitor";
String mqtt_host_only = mqtt_server;
bool configMode = false;
/* =============== 폴링 범위/상태 =============== */
uint8_t startID = 1, endID = 4;
unsigned long requestInterval = RS485_REQ_INTERVAL_MS;
struct PollState {
uint8_t pendingID = 1; // 현재 응답을 기다리는 ID
bool awaiting = false;// 요청 보냈고 응답 대기 중?
uint8_t retry = 0; // 이 ID에서 재시도 카운트
unsigned long sentAtMs = 0; // 마지막 요청 보낸 시각
unsigned long lastCycleTick = 0; // 다음 ID로 넘어갈 최소 간격 보장용
} pstate;
/* =============== 프레임 파서 상태 =============== */
uint8_t rtuBuf[RESP_MAX_LEN];
int rtuPos = 0;
int rtuExpectedLen = 0;
unsigned long rtuLastByteMs = 0;
unsigned long lastGoodRtuMs = 0; // 마지막 정상 프레임 수신 시각
/* =============== 매핑/파싱 =============== */
struct DataInfo { const char* key; const char* unit; float scale; };
std::map<uint8_t, std::vector<DataInfo>> slaveMap = {
{ 1, { { "noise","dB",-1.0 }, { "illuminance","lux",-1.0 } } },
{ 2, { { "t1","C",-1.0 }, { "h1","%",-1.0 }, { "t2","C",-1.0 }, { "h2","%",-1.0 } } },
{ 3, { { "t","C",-1.0 }, { "h","%",-1.0 }, { "co2","ppm",-1.0 }, { "tvoc","ppb",-1.0 },
{ "pm1","ug/m3",-1.0 }, { "pm25","ug/m3",-1.0 }, { "pm10","ug/m3",-1.0 } } },
// 슬레이브4: CO, CO2, HCHO (float 3개)
{ 4, { { "co","ppm",-1.0 }, { "co2","ppm",-1.0 }, { "hcho","ppm",-1.0 } } }
};
/* =============== 저장소 로드/세이브 =============== */
void saveWiFiListToEEPROM() {
prefs.begin("wifi", false);
prefs.putUInt("count", wifiCount);
for (int i = 0; i < wifiCount; i++) {
prefs.putString(("ssid"+String(i)).c_str(), wifiList[i].ssid);
prefs.putString(("pass"+String(i)).c_str(), wifiList[i].password);
}
prefs.end();
}
void loadWiFiListFromEEPROM() {
prefs.begin("wifi", true);
wifiCount = prefs.getUInt("count", 0);
for (int i = 0; i < wifiCount; i++) {
wifiList[i].ssid = prefs.getString(("ssid"+String(i)).c_str(), "");
wifiList[i].password = prefs.getString(("pass"+String(i)).c_str(), "");
}
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();
}
/* =============== MQTT host:port 파서 =============== */
void splitMqttServerHostPort() {
mqtt_host_only = mqtt_server;
int colon = mqtt_server.indexOf(':');
if (colon > 0) {
mqtt_port = mqtt_server.substring(colon + 1).toInt();
mqtt_host_only = mqtt_server.substring(0, colon);
}
}
/* =============== CRC/유틸 =============== */
uint16_t calcCRC(const uint8_t* data, uint8_t length) {
uint16_t crc = 0xFFFF;
for (uint8_t i = 0; i < length; i++) {
crc ^= data[i];
for (uint8_t j = 0; j < 8; j++)
crc = (crc & 1) ? (crc >> 1) ^ 0xA001 : (crc >> 1);
}
return crc;
}
/* =============== MQTT 발행 헬퍼 =============== */
void publishError(uint8_t id, const char* msg) {
StaticJsonDocument<192> doc;
doc["id"] = id;
doc["error"] = msg;
char payload[192];
size_t n = serializeJson(doc, payload, sizeof(payload));
if (n) {
if (!mqttClient.publish(mqtt_topic.c_str(), payload)) {
Serial.println("[MQTT publish fail (error)]");
} else {
Serial.println(payload);
}
}
}
/* =============== RS485 요청/상태관리 =============== */
void sendRequest(uint8_t id) {
uint8_t frame[8] = { id, 0x03, 0x00, 0x00, 0x00, 0x0A };
uint16_t crc = calcCRC(frame, 6);
frame[6] = crc & 0xFF;
frame[7] = crc >> 8;
rs485.write(frame, 8);
pstate.awaiting = true;
pstate.sentAtMs = millis();
}
void advanceToNextID() {
pstate.pendingID = (pstate.pendingID < endID) ? (pstate.pendingID + 1) : startID;
pstate.retry = 0;
pstate.awaiting = false;
pstate.lastCycleTick = millis();
}
void handleTimeoutIfNeeded() {
if (!pstate.awaiting) return;
if (millis() - pstate.sentAtMs < RESPONSE_TIMEOUT_MS) return;
if (pstate.retry < MAX_RETRY_PER_ID) {
pstate.retry++;
sendRequest(pstate.pendingID); // 재시도
} else {
publishError(pstate.pendingID, "Serial Communication Failed");
advanceToNextID();
}
}
/* =============== 응답 파싱 + MQTT 발행 =============== */
void parseAndPublish(uint8_t id, const uint8_t* buf, int len) {
if (len < 5) return;
if (buf[1] != 0x03) return;
int byteCount = buf[2];
if (len != (3 + byteCount + 2)) return;
uint16_t crcRx = (uint16_t)buf[3 + byteCount] | ((uint16_t)buf[3 + byteCount + 1] << 8);
uint16_t crcCal = calcCRC(buf, 3 + byteCount);
if (crcRx != crcCal) return;
int regCount = byteCount / 2;
if (regCount <= 0 || regCount > 20) return;
uint16_t hregs[20];
for (int i = 0; i < regCount; i++) {
hregs[i] = ((uint16_t)buf[3 + i * 2] << 8) | buf[4 + i * 2];
}
auto it = slaveMap.find(id);
if (it == slaveMap.end()) return;
const auto& infoList = it->second;
StaticJsonDocument<512> doc;
doc["id"] = id;
int pos = 1; // 데이터 시작(레지스터 1)
for (size_t i = 0; i < infoList.size(); i++) {
const auto& info = infoList[i];
if (info.scale == -1.0f) {
if (pos + 1 >= regCount) {
doc[info.key] = nullptr;
} else if (hregs[pos] == 0xFFFF && hregs[pos + 1] == 0xFFFF) {
doc[info.key] = nullptr;
pos += 2;
} else {
uint32_t raw;
#if FLOAT_WORD_ORDER_LSW_FIRST
raw = ((uint32_t)hregs[pos + 1] << 16) | hregs[pos];
#else
raw = ((uint32_t)hregs[pos] << 16) | hregs[pos + 1];
#endif
float value;
memcpy(&value, &raw, sizeof(float));
doc[info.key] = value;
pos += 2;
}
} else if (info.scale == 0.0f) {
if (pos >= regCount) {
doc[info.key] = nullptr;
} else {
if (hregs[pos] == 0xFFFF) doc[info.key] = nullptr;
else doc[info.key] = (int)hregs[pos];
pos += 1;
}
} else {
if (pos >= regCount) {
doc[info.key] = nullptr;
} else {
if (hregs[pos] == 0xFFFF) doc[info.key] = nullptr;
else doc[info.key] = (float)hregs[pos] * info.scale;
pos += 1;
}
}
}
char payload[512];
size_t n = serializeJson(doc, payload, sizeof(payload));
if (!n) return;
if (!mqttClient.publish(mqtt_topic.c_str(), payload)) {
Serial.println("[MQTT publish fail]");
} else {
Serial.println(payload);
}
// 정상 프레임 → 워치독 갱신 & 다음 ID로
lastGoodRtuMs = millis();
advanceToNextID();
}
/* =============== Wi-Fi/MQTT =============== */
bool dnsWorks(const String& host) {
IPAddress ip;
return WiFi.hostByName(host.c_str(), ip) == 1;
}
bool tryConnectOneAP(const String& ssid, const String& pass, unsigned long perApTimeoutMs) {
Serial.printf("[WiFi 시도] %s\n", ssid.c_str());
WiFi.disconnect(true, false);
delay(100);
WiFi.begin(ssid.c_str(), pass.c_str());
unsigned long t0 = millis();
while (millis() - t0 < perApTimeoutMs) {
if (WiFi.status() == WL_CONNECTED) break;
delay(100);
}
if (WiFi.status() != WL_CONNECTED) { Serial.println(" ↳ 실패"); return false; }
String hostToCheck = mqtt_host_only.length() ? mqtt_host_only : INTERNET_CHECK_HOST_FALLBACK;
if (!dnsWorks(hostToCheck)) {
Serial.println(" ↳ DNS 실패 → 다음 AP");
WiFi.disconnect(true, false);
return false;
}
Serial.printf(" ↳ 연결됨: %s IP=%s\n", WiFi.SSID().c_str(), WiFi.localIP().toString().c_str());
return true;
}
bool connectAnyAP() {
if (wifiCount == 0) return false;
for (int i = 0; i < wifiCount; i++) if (tryConnectOneAP(wifiList[i].ssid, wifiList[i].password, WIFI_PER_AP_TIMEOUT_MS)) return true;
return false;
}
void checkWiFiReconnect() {
static unsigned long lastWiFiReconnect = 0;
if (WiFi.status() == WL_CONNECTED) return;
if (millis() - lastWiFiReconnect < WIFI_RECONNECT_BACKOFF) return;
Serial.println("[WiFi 끊김 → 재연결 시도]");
if (!connectAnyAP()) Serial.println("[WiFi 재연결 실패]");
lastWiFiReconnect = millis();
}
void checkMQTTReconnect() {
static unsigned long lastMQTTReconnect = 0;
if (mqttClient.connected() || WiFi.status() != WL_CONNECTED) return;
if (millis() - lastMQTTReconnect < 2000) return;
Serial.println("[MQTT 재연결 시도]");
if (mqttClient.connect("rs485master", mqtt_user.c_str(), mqtt_password.c_str()))
Serial.println("[MQTT 재연결 성공]");
else
Serial.printf("[MQTT 연결 실패] 코드: %d\n", mqttClient.state());
lastMQTTReconnect = millis();
}
/* =============== CONFIG CLI =============== */
void printHelp() {
Serial.println("[CONFIG] LIST / ADD ssid,password / EDIT idx ssid,password / DELETE idx / CLEAR");
Serial.println(" MQTTSET server,user,pass,topic (server: host[:port])");
Serial.println(" MQTTINFO");
Serial.println(" REQID start,end");
Serial.println(" REQTIME ms");
Serial.println(" EXIT");
}
void save485Range() {
prefs.begin("485", false);
prefs.putUInt("startID", startID);
prefs.putUInt("endID", endID);
prefs.putULong("interval", requestInterval);
prefs.end();
}
void load485Range() {
prefs.begin("485", true);
startID = prefs.getUInt("startID", startID);
endID = prefs.getUInt("endID", endID);
requestInterval = prefs.getULong("interval", requestInterval);
prefs.end();
}
void handleSerialCommand() {
if (!Serial.available()) return;
String cmd = Serial.readStringUntil('\n'); cmd.trim();
if (cmd.equalsIgnoreCase("CONFIG")) { configMode = true; Serial.println("[CONFIG ON]"); printHelp(); return; }
if (cmd.equalsIgnoreCase("CONFIG OFF") || cmd.equalsIgnoreCase("EXIT")) { configMode=false; Serial.println("[CONFIG OFF]"); ESP.restart(); return; }
if (!configMode) return;
if (cmd.equalsIgnoreCase("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.equalsIgnoreCase("CLEAR")) {
prefs.begin("wifi", false); prefs.clear(); prefs.end(); wifiCount = 0; Serial.println("[WiFi 목록 초기화]");
} else if (cmd.startsWith("ADD ")) {
int s = cmd.indexOf(',',4);
if (s==-1 || wifiCount>=MAX_WIFI_CANDIDATES) { Serial.println("형식: ADD SSID,PASSWORD"); return; }
wifiList[wifiCount++] = { cmd.substring(4,s), cmd.substring(s+1) };
saveWiFiListToEEPROM(); Serial.println("[WiFi 추가]");
} else if (cmd.startsWith("EDIT ")) {
int s1=cmd.indexOf(' ',5), s2=cmd.indexOf(',',s1);
if (s1==-1 || s2==-1) { Serial.println("형식: EDIT idx SSID,PASSWORD"); return; }
int idx = cmd.substring(5,s1).toInt();
if (idx<0 || idx>=wifiCount) { Serial.println("잘못된 인덱스"); return; }
wifiList[idx] = { cmd.substring(s1+1,s2), cmd.substring(s2+1) };
saveWiFiListToEEPROM(); Serial.println("[WiFi 수정]");
} 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--;
saveWiFiListToEEPROM(); Serial.println("[WiFi 삭제]");
} else if (cmd.startsWith("MQTTSET ")) {
int s1=cmd.indexOf(',',8), s2=cmd.indexOf(',',s1+1), s3=cmd.indexOf(',',s2+1);
if (s1==-1||s2==-1||s3==-1) { Serial.println("형식: MQTTSET server,user,pass,topic"); return; }
mqtt_server = cmd.substring(8, s1);
mqtt_user = cmd.substring(s1+1, s2);
mqtt_password = cmd.substring(s2+1, s3);
mqtt_topic = cmd.substring(s3+1);
splitMqttServerHostPort();
mqttClient.setServer(mqtt_host_only.c_str(), mqtt_port);
saveMQTTToEEPROM();
Serial.println("[MQTT 저장]");
} else if (cmd.equalsIgnoreCase("MQTTINFO")) {
Serial.println("[MQTT]");
Serial.println("Server: " + mqtt_server);
Serial.println("User: " + mqtt_user);
Serial.println("Password: " + mqtt_password);
Serial.println("Topic: " + mqtt_topic);
Serial.printf("Port: %d\n", mqtt_port);
} else if (cmd.startsWith("REQID ")) {
int s = cmd.indexOf(',',6); if (s==-1){ Serial.println("형식: REQID start,end"); return; }
int sID = cmd.substring(6,s).toInt(); int eID = cmd.substring(s+1).toInt();
if (sID<=0 || eID<sID) { Serial.println("[설정 오류]"); return; }
startID=sID; endID=eID; save485Range();
pstate.pendingID = startID; pstate.retry=0; pstate.awaiting=false;
Serial.printf("[요청범위] %d~%d\n", startID, endID);
} else if (cmd.startsWith("REQTIME ")) {
requestInterval = cmd.substring(8).toInt(); save485Range();
Serial.printf("[요청간격] %lu ms\n", requestInterval);
} else {
Serial.println("지원: LIST/ADD/EDIT/DELETE/CLEAR, MQTTSET, MQTTINFO, REQID, REQTIME, EXIT");
}
}
/* =============== RS485 초기화/워치독 =============== */
void rs485Reinit() {
Serial.println("[RS485] 재초기화");
rs485.end();
delay(10);
rs485.begin(BAUD_RATE, SERIAL_8N1, RX_PIN, TX_PIN);
// 파서 리셋 및 버퍼 비우기
rtuPos = 0; rtuExpectedLen = 0;
while (rs485.available()) rs485.read();
// 현재 ID를 다시 시도할 수 있게
pstate.awaiting = false;
pstate.retry = 0;
}
void rs485WatchdogTick() {
if (lastGoodRtuMs == 0) return;
if (millis() - lastGoodRtuMs > RS485_WATCHDOG_MS) {
rs485Reinit();
lastGoodRtuMs = millis(); // 재시작 기준
}
}
/* =============== SETUP/LOOP =============== */
void setup() {
Serial.begin(115200);
rs485.begin(BAUD_RATE, SERIAL_8N1, RX_PIN, TX_PIN);
rtuPos = 0; rtuExpectedLen = 0; rtuLastByteMs = millis();
loadWiFiListFromEEPROM();
loadMQTTFromEEPROM();
load485Range();
splitMqttServerHostPort();
WiFi.mode(WIFI_STA);
if (!connectAnyAP()) { Serial.println("[WiFi 실패] CONFIG 모드 필요"); configMode = true; }
mqttClient.setServer(mqtt_host_only.c_str(), mqtt_port);
// 시작 상태
pstate.pendingID = startID;
pstate.awaiting = false;
pstate.retry = 0;
pstate.lastCycleTick = millis();
}
void loop() {
handleSerialCommand();
if (configMode) return;
checkWiFiReconnect();
checkMQTTReconnect();
mqttClient.loop();
// 1) 요청 발사: 아직 응답 대기 중이 아니고, 최소 간격 지났으면 현재 ID 요청
if (!pstate.awaiting && (millis() - pstate.lastCycleTick >= requestInterval)) {
sendRequest(pstate.pendingID);
}
// 2) 수신 파서
while (rs485.available()) {
uint8_t b = rs485.read();
unsigned long now = millis();
if (rtuPos > 0 && (now - rtuLastByteMs) > RESP_BYTE_GAP_TIMEOUT) {
rtuPos = 0; rtuExpectedLen = 0;
}
rtuLastByteMs = now;
if (rtuPos == 0) {
rtuBuf[rtuPos++] = b; // id
} else if (rtuPos == 1) {
rtuBuf[rtuPos++] = b; // func
if (rtuBuf[1] != 0x03) { rtuPos = 0; rtuExpectedLen = 0; }
} else if (rtuPos == 2) {
rtuBuf[rtuPos++] = b; // byteCount
rtuExpectedLen = 3 + rtuBuf[2] + 2;
if (rtuExpectedLen > RESP_MAX_LEN || rtuExpectedLen < 5) { rtuPos = 0; rtuExpectedLen = 0; }
} else {
rtuBuf[rtuPos++] = b;
if (rtuPos == rtuExpectedLen) {
// 현재 pendingID의 응답만 처리
if (pstate.awaiting && rtuBuf[0] == pstate.pendingID) {
parseAndPublish(rtuBuf[0], rtuBuf, rtuPos);
}
// 어떤 경우든 프레임 소모 후 파서 리셋
rtuPos = 0; rtuExpectedLen = 0;
} else if (rtuPos >= RESP_MAX_LEN) {
rtuPos = 0; rtuExpectedLen = 0;
}
}
}
// 3) 타임아웃 처리(필요 시 재시도/에러 발행 + 다음 ID)
handleTimeoutIfNeeded();
// 4) 버스 워치독(전체 무응답 시에만 재초기화)
rs485WatchdogTick();
}

375
src/slave1/main.cpp Normal file
View File

@ -0,0 +1,375 @@
/*
=========================================================
RS485 Modbus RTU Slave + BH1750 + Sound(Modbus) // v1.3
- (RS485)
- : SOUND만
SOUND(UART) + BH1750(I2C)
- Data1(), Data2() / NA(0xFFFF)
=========================================================
*/
#include <Arduino.h>
#include <Wire.h>
#include <HardwareSerial.h>
// =========================[ 설정 ]========================
#define SLAVE_ID 1
#define NUM_REGS 10
// RS485 (Master <-> Slave)
#define RS485_RX 16
#define RS485_TX 17
#define RS485_BAUD 115200
// Sound Sensor (Modbus RTU on UART1)
#define SOUND_RX 26
#define SOUND_TX 27
#define SOUND_BAUD 9600
// BH1750 (I2C)
#define BH1750_ADDR 0x23
// HREGS 고정 매핑 (Data1=소음, Data2=조도)
#define IDX_D1_LSW 1
#define IDX_D1_MSW 2
#define IDX_D2_LSW 3
#define IDX_D2_MSW 4
#define HREGS_PAYLOAD_LEN 4
// 공통
#define NA_VALUE 0xFFFF
#define SENSOR_PERIOD_MS 1000
// -------------------- 워치독/재초기화 한계 --------------------
// RS485: 유효 요청 없을 때만 재초기화
#define RS485_IDLE_MS 8000
#define RS485_MAX_RESETS 5
#define RS485_RESET_COOLDOWN_MS 30000
// 센서: SOUND+BH1750 둘 다 실패가 연속 N회일 때만 리셋
#define ALL_SENSORS_FAIL_REINIT_STREAK 8 // 8초 정도(주기 1s 기준)
// 디버그 옵션 (1=ON, 0=OFF)
#define DEBUG_RS485 1
#define DEBUG_SENSOR 1
#define DEBUG_VERBOSE 0 // 상세 내부 상태
// =====================[ 전역 변수 ]=====================
HardwareSerial& rs485 = Serial2;
HardwareSerial soundSerial(1); // Serial1
uint16_t hregs[NUM_REGS] = {0};
uint8_t rxFrame[256];
uint8_t rxLen = 0;
unsigned long lastByteTime = 0;
unsigned long lastSensorRead = 0;
// 워치독 관련
unsigned long lastValidReqMs = 0; // 유효 Modbus 요청 수신 시각
unsigned long lastRs485ResetMs = 0;
uint8_t rs485ResetCount = 0;
// 센서 동시 실패 카운터
uint8_t allSensorsFailStreak = 0;
// =====================[ 공통 유틸 ]=====================
uint16_t calcCRC(uint8_t *data, uint8_t length) {
uint16_t crc = 0xFFFF;
for (int i = 0; i < length; i++) {
crc ^= data[i];
for (int j = 0; j < 8; j++) {
crc = (crc & 1) ? (crc >> 1) ^ 0xA001 : (crc >> 1);
}
}
return crc;
}
void writeFloatToHregs(uint16_t idxLSW, float f) {
uint32_t raw;
memcpy(&raw, &f, sizeof(float));
hregs[idxLSW] = (uint16_t)(raw & 0xFFFF); // LSW
hregs[idxLSW + 1] = (uint16_t)((raw >> 16) & 0xFFFF); // MSW
}
#if DEBUG_RS485
void printHexLine(const char* tag, const uint8_t* buf, uint8_t len) {
Serial.print(tag);
Serial.print(" ");
for (uint8_t i = 0; i < len; i++) {
Serial.printf("%02X ", buf[i]);
}
Serial.println();
}
#endif
// ===================[ 재초기화 유틸 ]===================
void clearSerialRx(HardwareSerial& s) {
while (s.available()) s.read();
}
void rs485_reinit() {
const unsigned long now = millis();
if (rs485ResetCount >= RS485_MAX_RESETS &&
(now - lastRs485ResetMs) < RS485_RESET_COOLDOWN_MS) {
#if DEBUG_RS485
Serial.println("[RS485] 쿨다운 구간 - 재초기화 스킵");
#endif
return;
}
#if DEBUG_RS485
Serial.println("[RS485] 재초기화");
#endif
rs485.end();
delay(10);
rs485.begin(RS485_BAUD, SERIAL_8N1, RS485_RX, RS485_TX);
clearSerialRx(rs485);
if ((now - lastRs485ResetMs) > RS485_RESET_COOLDOWN_MS) rs485ResetCount = 0;
else rs485ResetCount++;
lastRs485ResetMs = now;
// 프레임 버퍼 리셋
rxLen = 0;
lastByteTime = millis();
}
void sound_reinit() {
#if DEBUG_SENSOR
Serial.println("[SOUND] UART 재초기화");
#endif
soundSerial.end();
delay(5);
soundSerial.begin(SOUND_BAUD, SERIAL_8N1, SOUND_RX, SOUND_TX);
clearSerialRx(soundSerial);
}
void i2c_reinit() {
#if DEBUG_SENSOR
Serial.println("[BH1750] I2C 재초기화");
#endif
Wire.end();
delay(5);
Wire.begin();
// BH1750 연속모드 재설정
Wire.beginTransmission(BH1750_ADDR);
Wire.write(0x10);
Wire.endTransmission();
delay(100);
}
// ===============[ 센서 통신부: BH1750 ]==================
void bh1750_init() {
Wire.beginTransmission(BH1750_ADDR);
Wire.write(0x10); // Continuously H-Resolution Mode
Wire.endTransmission();
delay(100);
}
uint16_t bh1750_readRaw() {
uint16_t val = 0;
Wire.requestFrom(BH1750_ADDR, 2);
if (Wire.available() == 2) {
val = (Wire.read() << 8) | Wire.read();
}
return val; // 0이면 실패로 간주
}
// ==============[ 센서 통신부: SOUND (Modbus) ]============
void sound_request() {
// 01 03 00 00 00 01 CRC_L CRC_H
uint8_t req[] = {0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0, 0};
uint16_t crc = calcCRC(req, 6);
req[6] = crc & 0xFF;
req[7] = (crc >> 8) & 0xFF;
soundSerial.write(req, sizeof(req));
#if DEBUG_SENSOR && DEBUG_VERBOSE
printHexLine("[Sound-Tx]", req, sizeof(req));
#endif
}
bool sound_parse(float &out_dB) {
// 기대 응답: 01 03 02 HI LO CRC_L CRC_H (7바이트)
uint8_t resp[7]; int got = 0;
const uint32_t start = millis();
while (millis() - start < 20) {
while (soundSerial.available() && got < 7) {
resp[got++] = soundSerial.read();
}
if (got >= 7) break;
delayMicroseconds(300);
}
if (got != 7) return false;
// CRC 검증
uint16_t crc = calcCRC(resp, 5);
if (((crc & 0xFF) != resp[5]) || (((crc >> 8) & 0xFF) != resp[6])) return false;
if (resp[0] != 0x01 || resp[1] != 0x03 || resp[2] != 0x02) return false;
int dB10 = (resp[3] << 8) | resp[4];
out_dB = dB10 / 10.0f;
#if DEBUG_SENSOR && DEBUG_VERBOSE
printHexLine("[Sound-Rx]", resp, 7);
#endif
return true;
}
// ===============[ 센서 통신부: 업데이트 ]=================
void sensors_init() {
Wire.begin();
bh1750_init();
// 초기값: 고정 길이 및 NA
hregs[0] = HREGS_PAYLOAD_LEN;
hregs[IDX_D1_LSW] = NA_VALUE;
hregs[IDX_D1_MSW] = NA_VALUE;
hregs[IDX_D2_LSW] = NA_VALUE;
hregs[IDX_D2_MSW] = NA_VALUE;
allSensorsFailStreak = 0;
}
void sensors_update() {
// 고정 길이(두 개 float = 4레지스터)
hregs[0] = HREGS_PAYLOAD_LEN;
// 모두 NA로 초기화 (실패 시 NA 유지)
hregs[IDX_D1_LSW] = NA_VALUE;
hregs[IDX_D1_MSW] = NA_VALUE;
hregs[IDX_D2_LSW] = NA_VALUE;
hregs[IDX_D2_MSW] = NA_VALUE;
bool soundOK = false;
bool luxOK = false;
// Data1: 소음 (있으면 반영, 없으면 NA 유지)
sound_request();
float dB = 0.0f;
if (sound_parse(dB)) {
writeFloatToHregs(IDX_D1_LSW, dB);
soundOK = true;
#if DEBUG_SENSOR
Serial.printf("[센서] 소음: %.1f dB\n", dB);
#endif
} else {
#if DEBUG_SENSOR
Serial.println("[센서] 소음: NA (외부전원/미연결 가능)");
#endif
}
// Data2: BH1750 조도 (0이면 실패로 간주)
uint16_t luxRaw = bh1750_readRaw();
if (luxRaw > 0) {
float fLux = (float)luxRaw;
writeFloatToHregs(IDX_D2_LSW, fLux);
luxOK = true;
#if DEBUG_SENSOR
Serial.printf("[센서] 조도: %.0f Lux\n", fLux);
#endif
} else {
#if DEBUG_SENSOR
Serial.println("[센서] 조도: NA");
#endif
}
// --- 새로운 재초기화 정책 ---
// SOUND와 BH1750가 둘 다 "실패"한 경우에만 연속 카운트
if (!soundOK && !luxOK) {
if (++allSensorsFailStreak >= ALL_SENSORS_FAIL_REINIT_STREAK) {
#if DEBUG_SENSOR
Serial.println("[센서] 두 채널 모두 연속 실패 → 센서 인터페이스 재초기화");
#endif
sound_reinit();
i2c_reinit();
allSensorsFailStreak = 0;
}
} else {
allSensorsFailStreak = 0; // 둘 중 하나라도 OK면 정상
}
}
// ==============[ 마스터 통신부: RS485 RX/TX ]=============
void rs485_init() {
rs485.begin(RS485_BAUD, SERIAL_8N1, RS485_RX, RS485_TX);
clearSerialRx(rs485);
}
void rs485_sendResponse() {
uint8_t resp[256];
uint8_t len = 0;
resp[len++] = SLAVE_ID;
resp[len++] = 0x03;
resp[len++] = NUM_REGS * 2;
for (int i = 0; i < NUM_REGS; i++) {
resp[len++] = (uint8_t)(hregs[i] >> 8);
resp[len++] = (uint8_t)(hregs[i] & 0xFF);
}
uint16_t crc = calcCRC(resp, len);
resp[len++] = (uint8_t)(crc & 0xFF);
resp[len++] = (uint8_t)(crc >> 8);
#if DEBUG_RS485
printHexLine("[Tx]", resp, len);
#endif
rs485.write(resp, len);
}
void rs485_processRx() {
// 바이트 수신
while (rs485.available()) {
uint8_t b = rs485.read();
if (rxLen < sizeof(rxFrame)) rxFrame[rxLen++] = b;
lastByteTime = millis();
}
// 프레임 경계 판단(무정지 3ms 후 확정) 및 처리
if (rxLen > 0 && (millis() - lastByteTime) > 3) {
#if DEBUG_RS485 && DEBUG_VERBOSE
printHexLine("[Rx]", rxFrame, rxLen);
#endif
if (rxLen == 8) {
uint16_t crc = calcCRC(rxFrame, 6);
bool crcOK = ((crc & 0xFF) == rxFrame[6]) && ((crc >> 8) == rxFrame[7]);
if (crcOK && rxFrame[0] == SLAVE_ID && rxFrame[1] == 0x03) {
// 유효한 마스터 요청 수신 시각 갱신
lastValidReqMs = millis();
#if DEBUG_RS485 && !DEBUG_VERBOSE
printHexLine("[Rx]", rxFrame, rxLen);
#endif
rs485_sendResponse();
}
}
rxLen = 0; // 프레임 버퍼 클리어
}
// RS485 워치독: 유효 요청이 오래 없으면 재초기화
if (lastValidReqMs != 0 && (millis() - lastValidReqMs) > RS485_IDLE_MS) {
rs485_reinit();
lastValidReqMs = millis(); // 재초기화 직후 갱신(루프 방지)
}
}
// =====================[ 시스템 ]=========================
void setup() {
Serial.begin(115200);
rs485_init();
soundSerial.begin(SOUND_BAUD, SERIAL_8N1, SOUND_RX, SOUND_TX);
sensors_init();
Serial.printf("[SLAVE] ID:%d 시작\n", SLAVE_ID);
}
void loop() {
// 마스터 통신 처리
rs485_processRx();
// 센서 주기적 업데이트
if (millis() - lastSensorRead > SENSOR_PERIOD_MS) {
sensors_update();
lastSensorRead = millis();
}
}

355
src/slave2/main.cpp Normal file
View File

@ -0,0 +1,355 @@
#include <Arduino.h>
#include <HardwareSerial.h>
/* ========================= 설정 ========================= */
#define SLAVE_ID 2
#define NUM_REGS 10
// RS485 (Master <-> Slave)
#define RS485_RX 16
#define RS485_TX 17
#define RS485_BAUD 115200
// Temp/Hum Sensors (Modbus RTU, UART1)
#define TEMP_HUM_RX 26
#define TEMP_HUM_TX 27
#define TEMP_HUM_BAUD 9600
// 레지스터 매핑 (float LSW→MSW 고정)
#define IDX_T1_LSW 1
#define IDX_T1_MSW 2
#define IDX_H1_LSW 3
#define IDX_H1_MSW 4
#define IDX_T2_LSW 5
#define IDX_T2_MSW 6
#define IDX_H2_LSW 7
#define IDX_H2_MSW 8
#define HREGS_PAYLOAD_LEN 8 // T1,H1,T2,H2 = float 4개 = 워드 8개
// 공통
#define NA_VALUE 0xFFFF
#define SENSOR_PERIOD_MS 1000
// ----------------- 워치독 / 재초기화 정책 -----------------
#define RS485_IDLE_MS 8000 // 유효 요청 없으면 RS485 재초기화
#define RS485_MAX_RESETS 5
#define RS485_RESET_COOLDOWN_MS 30000
// 센서: 두 채널 모두 실패가 연속 N회일 때만 UART1 재초기화
#define ALL_SENSORS_FAIL_REINIT_STREAK 8
// 디버그 (1=ON, 0=OFF)
#define DEBUG_RS485 1
#define DEBUG_SENSOR 1
#define DEBUG_VERBOSE 0
// ==== 센서 타이밍 튜닝 ====
#define SENS_GUARD_SILENCE_MS 20 // 요청 뒤 무음 시간(턴어라운드)
#define SENS_RESP_TIMEOUT_MS 150 // 응답 대기 (기본 150ms, 센서2가 느리면 200~300 올려보세요)
#define SENS_INTER_REQ_GAP_MS 20 // 두 센서 요청 사이 간격
#define SENS_RETRY_ON_FAIL 1 // 실패 시 재시도 1회
/* ========================= 전역 ========================= */
HardwareSerial& rs485 = Serial2;
HardwareSerial tempHumSerial(1); // UART1
uint16_t hregs[NUM_REGS] = { 0 };
// 수신 프레임 버퍼(RS485)
uint8_t rxFrame[256];
uint8_t rxLen = 0;
unsigned long lastByteTime = 0;
// 주기 타이밍
unsigned long lastSensorRead = 0;
// 워치독
unsigned long lastValidReqMs = 0;
unsigned long lastRs485ResetMs = 0;
uint8_t rs485ResetCount = 0;
// 센서 동시 실패 카운트
uint8_t allSensorsFailStreak = 0;
/* ====================== 유틸/공용 ====================== */
uint16_t calcCRC(const uint8_t* data, uint8_t length) {
uint16_t crc = 0xFFFF;
for (uint8_t i = 0; i < length; i++) {
crc ^= data[i];
for (uint8_t j = 0; j < 8; j++) {
crc = (crc & 1) ? (crc >> 1) ^ 0xA001 : (crc >> 1);
}
}
return crc;
}
void writeFloatTo(uint16_t idxLSW, float f) {
uint32_t raw;
memcpy(&raw, &f, sizeof(float));
hregs[idxLSW] = (uint16_t)(raw & 0xFFFF); // LSW
hregs[idxLSW + 1] = (uint16_t)((raw >> 16) & 0xFFFF); // MSW
}
#if DEBUG_RS485
void printHexLine(const char* tag, const uint8_t* buf, uint8_t len) {
Serial.print(tag);
Serial.print(" ");
for (uint8_t i = 0; i < len; i++) Serial.printf("%02X ", buf[i]);
Serial.println();
}
#endif
void clearSerialRx(HardwareSerial& s) {
while (s.available()) s.read();
}
/* ====================== 재초기화 ====================== */
void rs485_reinit() {
const unsigned long now = millis();
if (rs485ResetCount >= RS485_MAX_RESETS && (now - lastRs485ResetMs) < RS485_RESET_COOLDOWN_MS) {
#if DEBUG_RS485
Serial.println("[RS485] 쿨다운 중 - 재초기화 스킵");
#endif
return;
}
#if DEBUG_RS485
Serial.println("[RS485] 재초기화");
#endif
rs485.end();
delay(10);
rs485.begin(RS485_BAUD, SERIAL_8N1, RS485_RX, RS485_TX);
clearSerialRx(rs485);
rxLen = 0;
lastByteTime = millis();
if ((now - lastRs485ResetMs) > RS485_RESET_COOLDOWN_MS) rs485ResetCount = 0;
else rs485ResetCount++;
lastRs485ResetMs = now;
}
void uart1_reinit() {
#if DEBUG_SENSOR
Serial.println("[SENSORS] UART1 재초기화");
#endif
tempHumSerial.end();
delay(5);
tempHumSerial.begin(TEMP_HUM_BAUD, SERIAL_8N1, TEMP_HUM_RX, TEMP_HUM_TX);
clearSerialRx(tempHumSerial);
}
/* =================== 센서(Modbus RTU) =================== */
void requestTempHum(uint8_t id) {
clearSerialRx(tempHumSerial); // 잔여 바이트 비우기
uint8_t req[8] = { id, 0x04, 0x00, 0x01, 0x00, 0x02, 0, 0 };
uint16_t crc = calcCRC(req, 6);
req[6] = (uint8_t)(crc & 0xFF);
req[7] = (uint8_t)(crc >> 8);
tempHumSerial.write(req, 8);
#if DEBUG_SENSOR && DEBUG_VERBOSE
printHexLine("[SENS-Tx]", req, 8);
#endif
delay(SENS_GUARD_SILENCE_MS); // 턴어라운드 무음시간
}
// expectID, timeout을 받아 ID/CRC/길이 모두 검증
bool parseTempHum(uint8_t expectID, float& temp, float& hum, uint16_t timeoutMs = SENS_RESP_TIMEOUT_MS) {
const unsigned long t0 = millis();
uint8_t buf[16];
int pos = 0;
// 최소 9바이트(id,fc,bc,4data,crc2)
while (millis() - t0 < timeoutMs) {
while (tempHumSerial.available() && pos < (int)sizeof(buf)) {
buf[pos++] = tempHumSerial.read();
// 빠르게 프레임 길이 도달 체크
if (pos >= 9) {
// 형식: id 04 04 T_hi T_lo H_hi H_lo CRC_L CRC_H
if (buf[0] == expectID && buf[1] == 0x04 && buf[2] == 0x04) {
uint16_t crc = calcCRC(buf, 7);
if (buf[7] == (crc & 0xFF) && buf[8] == (crc >> 8)) {
uint16_t tRaw = (buf[3] << 8) | buf[4];
uint16_t hRaw = (buf[5] << 8) | buf[6];
temp = (int16_t)tRaw / 10.0f;
hum = hRaw / 10.0f;
#if DEBUG_SENSOR && DEBUG_VERBOSE
printHexLine("[SENS-RxOK]", buf, 9);
#endif
return true;
}
}
// 9바이트 쌓였는데 조건 불일치 → 더 들어올 수도 있으니 5~6바이트 정도만 남기고 앞으로 민다
if (pos >= 12) { // 과도 데이터 보호
memmove(buf, buf + 3, pos - 3);
pos -= 3;
}
}
}
delayMicroseconds(300);
}
#if DEBUG_SENSOR
if (pos > 0) {
Serial.print("[SENS-RxRAW] ");
for (int i = 0; i < pos; i++) Serial.printf("%02X ", buf[i]);
Serial.println();
} else {
Serial.println("[SENS-Rx] timeout");
}
#endif
return false;
}
void sensors_init() {
// 고정 길이 + 모든 슬롯 NA 초기화
hregs[0] = HREGS_PAYLOAD_LEN;
hregs[IDX_T1_LSW] = hregs[IDX_T1_MSW] = NA_VALUE;
hregs[IDX_H1_LSW] = hregs[IDX_H1_MSW] = NA_VALUE;
hregs[IDX_T2_LSW] = hregs[IDX_T2_MSW] = NA_VALUE;
hregs[IDX_H2_LSW] = hregs[IDX_H2_MSW] = NA_VALUE;
allSensorsFailStreak = 0;
}
void sensors_update() {
// 고정 길이 + 기본 NA
hregs[0] = HREGS_PAYLOAD_LEN;
hregs[IDX_T1_LSW] = hregs[IDX_T1_MSW] = NA_VALUE;
hregs[IDX_H1_LSW] = hregs[IDX_H1_MSW] = NA_VALUE;
hregs[IDX_T2_LSW] = hregs[IDX_T2_MSW] = NA_VALUE;
hregs[IDX_H2_LSW] = hregs[IDX_H2_MSW] = NA_VALUE;
bool ok1 = false, ok2 = false;
float t, h;
// --- 센서 #1 (ID=0x01) ---
for (int tr = 0; tr <= SENS_RETRY_ON_FAIL && !ok1; tr++) {
requestTempHum(0x01);
ok1 = parseTempHum(0x01, t, h);
if (!ok1) delay(10);
}
if (ok1) {
writeFloatTo(IDX_T1_LSW, t);
writeFloatTo(IDX_H1_LSW, h);
#if DEBUG_SENSOR
Serial.printf("[센서1] T=%.1f℃ H=%.1f%%\n", t, h);
#endif
} else {
#if DEBUG_SENSOR
Serial.println("[센서1] NA");
#endif
}
delay(SENS_INTER_REQ_GAP_MS); // 두 요청 사이 간격
// --- 센서 #2 (ID=0x02) ---
for (int tr = 0; tr <= SENS_RETRY_ON_FAIL && !ok2; tr++) {
requestTempHum(0x02);
ok2 = parseTempHum(0x02, t, h);
if (!ok2) delay(10);
}
if (ok2) {
writeFloatTo(IDX_T2_LSW, t);
writeFloatTo(IDX_H2_LSW, h);
#if DEBUG_SENSOR
Serial.printf("[센서2] T=%.1f℃ H=%.1f%%\n", t, h);
#endif
} else {
#if DEBUG_SENSOR
Serial.println("[센서2] NA");
#endif
}
// “두 채널 모두 실패”일 때만 UART1 재초기화 카운트 증가 (기존 정책 유지)
if (!ok1 && !ok2) {
if (++allSensorsFailStreak >= ALL_SENSORS_FAIL_REINIT_STREAK) {
uart1_reinit();
allSensorsFailStreak = 0;
}
} else {
allSensorsFailStreak = 0;
}
}
/* =================== RS485 (Modbus RTU) =================== */
void rs485_init() {
rs485.begin(RS485_BAUD, SERIAL_8N1, RS485_RX, RS485_TX);
clearSerialRx(rs485);
}
void sendResponse() {
uint8_t resp[256];
uint8_t len = 0;
resp[len++] = SLAVE_ID;
resp[len++] = 0x03;
resp[len++] = NUM_REGS * 2;
for (int i = 0; i < NUM_REGS; i++) {
resp[len++] = (uint8_t)(hregs[i] >> 8);
resp[len++] = (uint8_t)(hregs[i] & 0xFF);
}
uint16_t crc = calcCRC(resp, len);
resp[len++] = (uint8_t)(crc & 0xFF);
resp[len++] = (uint8_t)(crc >> 8);
#if DEBUG_RS485
printHexLine("[Tx]", resp, len);
#endif
rs485.write(resp, len);
}
void rs485_processRx() {
// 수신 누적
while (rs485.available()) {
uint8_t b = rs485.read();
if (rxLen < sizeof(rxFrame)) rxFrame[rxLen++] = b;
lastByteTime = millis();
}
// 3ms 무정지 → 프레임 처리
if (rxLen > 0 && (millis() - lastByteTime) > 3) {
#if DEBUG_RS485 && DEBUG_VERBOSE
printHexLine("[Rx]", rxFrame, rxLen);
#endif
if (rxLen == 8) {
uint16_t crc = calcCRC(rxFrame, 6);
bool crcOK = ((crc & 0xFF) == rxFrame[6]) && ((crc >> 8) == rxFrame[7]);
if (crcOK && rxFrame[0] == SLAVE_ID && rxFrame[1] == 0x03) {
// 유효 요청 수신 시각 갱신
lastValidReqMs = millis();
#if DEBUG_RS485 && !DEBUG_VERBOSE
printHexLine("[Rx]", rxFrame, rxLen);
#endif
sendResponse();
}
}
rxLen = 0; // 버퍼 클리어
}
// RS485 워치독: 유효 요청 장시간 없음
if (lastValidReqMs != 0 && (millis() - lastValidReqMs) > RS485_IDLE_MS) {
rs485_reinit();
lastValidReqMs = millis(); // 루프 방지용
}
}
/* ========================= 시스템 ========================= */
void setup() {
Serial.begin(115200);
rs485_init();
tempHumSerial.begin(TEMP_HUM_BAUD, SERIAL_8N1, TEMP_HUM_RX, TEMP_HUM_TX);
clearSerialRx(tempHumSerial);
sensors_init();
Serial.printf("[SLAVE %d] 시작\n", SLAVE_ID);
}
void loop() {
rs485_processRx();
if (millis() - lastSensorRead > SENSOR_PERIOD_MS) {
sensors_update();
lastSensorRead = millis();
}
}

266
src/slave3/main.cpp Normal file
View File

@ -0,0 +1,266 @@
#include <Arduino.h>
#include <HardwareSerial.h>
/* ====================== 설정 ====================== */
#define SLAVE_ID 3
#define NUM_REGS 15 // [0]=개수, 7 floats x 2 = 14
// RS485
#define RS485_RX 16
#define RS485_TX 17
#define RS485_BAUD 115200
// CSV 센서(UART1) : "t,h,co2,tvoc,pm1,pm25,pm10\n"
#define SENSOR_RX 27
#define SENSOR_TX 26 // TX 미사용이어도 begin에 지정해 둠(미배선 OK)
#define SENSOR_BAUD 9600
// 타이밍
#define FRAME_GAP_MS 3 // RS485 프레임 경계(무정지)
#define SENSOR_READ_PERIOD_MS 200 // 센서 읽기 주기(폴링 간격)
#define SENSOR_LINE_TIMEOUT_MS 40 // readStringUntil('\n') 타임아웃
#define SENSOR_STALE_MS 5000 // 유효 라인 못 받으면 NA 처리 + UART1 재시도
#define SENSOR_REINIT_BACKOFF_MS 2000 // UART1 재초기화 최소 간격
// RS485 워치독(추가)
#define RS485_IDLE_RESET_MS 8000 // 이 시간 동안 바이트 무수신 시 재초기화
#define RS485_BAD_MAX 6 // 연속 불량 프레임 허용치
// 디버그
#define DEBUG_RS485 1
#define DEBUG_SENSOR 1
#define DEBUG_VERBOSE 0
/* ====================== 전역 ====================== */
HardwareSerial& rs485 = Serial2;
HardwareSerial sensorSerial(1); // UART1
uint16_t hregs[NUM_REGS] = {0};
uint8_t rxFrame[256];
uint8_t rxLen = 0;
unsigned long lastByteTime = 0;
unsigned long lastSensorTick = 0;
unsigned long lastGoodSensorMs = 0;
unsigned long lastSensorReinitTry = 0;
// RS485 워치독 상태(추가)
volatile uint8_t rs485BadCount = 0;
unsigned long lastRs485Byte = 0; // 최근 바이트 수신 시각
unsigned long lastRs485Good = 0; // 최근 정상 프레임 처리 시각
/* ====================== 유틸 ====================== */
uint16_t calcCRC(const uint8_t* data, uint8_t length) {
uint16_t crc = 0xFFFF;
for (uint8_t i = 0; i < length; i++) {
crc ^= data[i];
for (uint8_t j = 0; j < 8; j++) {
crc = (crc & 1) ? (crc >> 1) ^ 0xA001 : (crc >> 1);
}
}
return crc;
}
inline void writeFloatLSW(uint16_t idxLSW, float f) {
uint32_t raw; memcpy(&raw, &f, sizeof(float));
hregs[idxLSW] = (uint16_t)(raw & 0xFFFF); // LSW
hregs[idxLSW + 1] = (uint16_t)((raw >> 16) & 0xFFFF); // MSW
}
inline void markAllNA() {
hregs[0] = 14; // 7float=14워드
for (int i = 1; i < NUM_REGS; i++) hregs[i] = 0xFFFF;
}
#if DEBUG_RS485
void printHex(const char* tag, const uint8_t* buf, uint8_t len) {
Serial.print(tag); Serial.print(" ");
for (uint8_t i = 0; i < len; i++) Serial.printf("%02X ", buf[i]);
Serial.println();
}
#endif
/* ====================== RS485 재초기화/워치독 (추가) ====================== */
void rs485Reinit() {
Serial.println("[RS485] 재초기화"); // 요구 로그
rs485.end();
delay(5);
rs485.begin(RS485_BAUD, SERIAL_8N1, RS485_RX, RS485_TX);
while (rs485.available()) rs485.read(); // 버퍼 비우기
// 상태 리셋
rs485BadCount = 0;
lastRs485Byte = millis();
lastRs485Good = millis();
rxLen = 0;
}
void rs485WatchdogTick() {
const unsigned long now = millis();
if ((now - lastRs485Byte) > RS485_IDLE_RESET_MS) {
rs485Reinit();
} else if (rs485BadCount >= RS485_BAD_MAX) {
rs485Reinit();
}
}
/* ====================== RS485 응답 ====================== */
void rs485_sendResponse() {
uint8_t resp[2 + 1 + NUM_REGS * 2 + 2];
uint8_t len = 0;
resp[len++] = SLAVE_ID;
resp[len++] = 0x03;
resp[len++] = NUM_REGS * 2;
for (int i = 0; i < NUM_REGS; i++) {
resp[len++] = (uint8_t)(hregs[i] >> 8);
resp[len++] = (uint8_t)(hregs[i] & 0xFF);
}
uint16_t crc = calcCRC(resp, len);
resp[len++] = (uint8_t)(crc & 0xFF);
resp[len++] = (uint8_t)(crc >> 8);
#if DEBUG_RS485
printHex("[Tx]", resp, len);
#endif
rs485.write(resp, len);
}
/* ====================== RS485 수신 처리 ====================== */
void rs485_processRx() {
// 수신 바이트 누적
while (rs485.available()) {
uint8_t b = rs485.read();
if (rxLen < sizeof(rxFrame)) rxFrame[rxLen++] = b;
lastByteTime = millis();
lastRs485Byte = lastByteTime; // 워치독용 최근 바이트 시간 갱신
}
// 프레임 경계 판단 후 처리
if (rxLen > 0 && (millis() - lastByteTime) > FRAME_GAP_MS) {
#if DEBUG_RS485 && DEBUG_VERBOSE
printHex("[Rx]", rxFrame, rxLen);
#endif
if (rxLen == 8) { // Modbus RTU 요청 고정 8바이트
uint16_t crc = calcCRC(rxFrame, 6);
bool crcOK = ((crc & 0xFF) == rxFrame[6]) && ((crc >> 8) == rxFrame[7]);
if (crcOK && rxFrame[0] == SLAVE_ID && rxFrame[1] == 0x03) {
#if DEBUG_RS485 && !DEBUG_VERBOSE
printHex("[Rx]", rxFrame, rxLen);
#endif
rs485_sendResponse();
rs485BadCount = 0; // 정상 프레임 카운터 리셋
lastRs485Good = millis();
} else {
rs485BadCount++; // 불량 프레임 누적
}
} else {
// 길이 비정상 프레임은 무시(원하면 rs485BadCount++도 가능)
// rs485BadCount++;
}
rxLen = 0; // 버퍼 초기화
rs485WatchdogTick(); // 프레임 처리 후 워치독 체크
}
}
/* ====================== 센서 파트 ====================== */
void sensorUartRebegin() {
sensorSerial.end();
delay(10);
sensorSerial.begin(SENSOR_BAUD, SERIAL_8N1, SENSOR_RX, SENSOR_TX);
sensorSerial.setTimeout(SENSOR_LINE_TIMEOUT_MS);
while (sensorSerial.available()) sensorSerial.read();
#if DEBUG_SENSOR
Serial.println("[SENSORS] UART1 재초기화");
#endif
}
bool parseCSVLine(const String& line, float out[7]) {
// 기대: "v0,v1,v2,v3,v4,v5,v6"
int start = 0, idx = 0;
while (idx < 7) {
int comma = line.indexOf(',', start);
String tok = (comma == -1) ? line.substring(start) : line.substring(start, comma);
tok.trim();
if (tok.length() == 0) return false; // 빈 토큰은 무효
out[idx++] = tok.toFloat();
if (comma == -1) break;
start = comma + 1;
}
return idx == 7;
}
void sensors_update() {
// 라인 수신 시도(비차단성, 짧은 타임아웃)
String line = sensorSerial.readStringUntil('\n');
if (line.length()) {
line.replace("\r", "");
line.trim();
#if DEBUG_SENSOR
Serial.print("[센서 수신] ");
Serial.println(line);
#endif
float vals[7];
if (parseCSVLine(line, vals)) {
// 정상 파싱 → 레지스터 채우기
hregs[0] = 14;
uint16_t idx = 1;
for (int i = 0; i < 7; i++) {
writeFloatLSW(idx, vals[i]);
idx += 2;
}
#if DEBUG_SENSOR
Serial.printf("[CSV] T=%.2f H=%.2f CO2=%.0f TVOC=%.0f PM1=%.0f PM2.5=%.0f PM10=%.0f\n",
vals[0], vals[1], vals[2], vals[3], vals[4], vals[5], vals[6]);
#endif
lastGoodSensorMs = millis();
} else {
#if DEBUG_SENSOR
Serial.println("[CSV] 형식 불일치 → 무시");
#endif
}
}
// 오래 유효 라인이 없으면 NA로 바꾸고 UART1 재시도
unsigned long now = millis();
if ((lastGoodSensorMs == 0 || now - lastGoodSensorMs > SENSOR_STALE_MS) &&
(now - lastSensorReinitTry > SENSOR_REINIT_BACKOFF_MS)) {
markAllNA(); // 데이터는 NA 유지
sensorUartRebegin(); // UART1만 재초기화(외부전원 센서 대비)
lastSensorReinitTry = now;
}
}
/* ====================== 시스템 ====================== */
void setup() {
Serial.begin(115200);
rs485.begin(RS485_BAUD, SERIAL_8N1, RS485_RX, RS485_TX);
lastRs485Byte = millis(); // 워치독 초기화
lastRs485Good = 0;
sensorSerial.begin(SENSOR_BAUD, SERIAL_8N1, SENSOR_RX, SENSOR_TX);
sensorSerial.setTimeout(SENSOR_LINE_TIMEOUT_MS);
while (sensorSerial.available()) sensorSerial.read();
markAllNA(); // 초기값은 NA
Serial.printf("[SLAVE %d] 시작\n", SLAVE_ID);
}
void loop() {
// 1) RS485 마스터 요청 처리
rs485_processRx();
// 2) 센서 주기적 갱신(비차단)
if (millis() - lastSensorTick > SENSOR_READ_PERIOD_MS) {
sensors_update();
lastSensorTick = millis();
}
// 3) 워치독 주기 호출(보강)
rs485WatchdogTick();
}

394
src/slave4/main.cpp Normal file
View File

@ -0,0 +1,394 @@
#include <Arduino.h>
/* ===================== 핀/보드 설정 ===================== */
// RS485(UART1)
#define RS485 Serial1
#define RS485_RX 16
#define RS485_TX 17
#define RS485_BAUD 115200
// Sensors(UART2)
#define SEN Serial2
// CO (ASCII stream)
#define CO_RX 33
#define CO_BAUD 38400
// CO2 (BM 헤더)
#define CO2_RX 35
#define CO2_TX 27
#define CO2_BAUD 9600
// HCHO (9B frame)
#define HCHO_RX 34
#define HCHO_BAUD 9600
// Modbus/RTU
#define SLAVE_ID 4
#define MODBUS_TX_TURNAROUND_US 300
#define RS485_IDLE_RESET_MS 6000 // 유효 요청 장시간 無 → 재초기화
// 센서 스케줄
#define DWELL_CO_MS 900
#define DWELL_CO2_MS 200
#define DWELL_HCHO_MS 1200
// 비차단 슬라이스(센서 파싱 가용 시간)
#define SLICE_CO_MS 6
#define SLICE_CO2_MS 4
#define SLICE_HCHO_MS 6
// STALE + UART2 재시작
#define STALE_CO_MS 6000
#define STALE_CO2_MS 5000
#define STALE_HCHO_MS 12000
#define SEN_REINIT_BACKOFF_MS 2000
// 워드 순서
#define FLOAT_WORD_ORDER_LSW_FIRST 1
// 디버그
#define DEBUG_RS485 1
#define DEBUG_SENSOR 1
#define DEBUG_MODE 1
#define DEBUG_HB 1
/* ===================== 타입/전방 선언 ===================== */
struct S4_GasData {
float co; unsigned long t_co;
float co2; unsigned long t_co2;
float hcho; unsigned long t_hcho;
};
enum S4_Mode { S4_CO, S4_CO2, S4_HCHO };
// 전방 선언
void S4_taskRS485(void*);
void S4_taskSensors(void*);
void S4_rs485Begin();
void S4_rs485Reinit(const char* why);
void S4_buildHregsFromShared();
void S4_rs485Reply();
void S4_sensorSwitchMode(S4_Mode m);
S4_Mode S4_nextMode(S4_Mode m);
uint32_t S4_modeDwellMs(S4_Mode m);
void S4_sliceCO();
void S4_sliceCO2();
void S4_sliceHCHO();
void S4_sensorsWatchdog();
/* ===================== 전역/공유 ===================== */
S4_GasData gData;
SemaphoreHandle_t gDataMtx;
uint16_t hregs[10] = {0};
/* ===================== 유틸/CRC/워드 ===================== */
static inline uint16_t S4_crc16(const uint8_t* d, uint8_t n){
uint16_t c=0xFFFF; while(n--){ c^=*d++; for(int i=0;i<8;i++) c=(c&1)?(c>>1)^0xA001:(c>>1); } return c;
}
static inline void S4_writeNA(uint16_t i){ hregs[i]=0xFFFF; hregs[i+1]=0xFFFF; }
static inline void S4_writeF(uint16_t i, float f){
union{ float f; uint32_t u; }u; u.f=f; uint16_t MSW=u.u>>16, LSW=u.u;
#if FLOAT_WORD_ORDER_LSW_FIRST
hregs[i]=LSW; hregs[i+1]=MSW;
#else
hregs[i]=MSW; hregs[i+1]=LSW;
#endif
}
#if DEBUG_RS485
static inline void S4_dumpHex(const char* tag,const uint8_t* b,uint8_t n){
Serial.print(tag); Serial.print(" "); for(uint8_t i=0;i<n;i++) Serial.printf("%02X ", b[i]); Serial.println();
}
#endif
/* ===================== RS485 스트리밍 파서 ===================== */
// 바이트 스트림 큐(슬라이딩 윈도우 탐색)
static uint8_t s4_q[128];
static uint8_t s4_qlen = 0;
static unsigned long s4_lastGoodReqMs = 0; // 마지막 정상 요청 처리 시각
void S4_rs485Begin(){
RS485.begin(RS485_BAUD, SERIAL_8N1, RS485_RX, RS485_TX);
while(RS485.available()) RS485.read();
s4_qlen = 0;
s4_lastGoodReqMs = millis();
}
void S4_rs485Reinit(const char* why){
Serial.printf("[RS485] 재초기화 (이유: %s)\n", why);
RS485.end(); delay(2);
S4_rs485Begin();
}
// 큐에 8B 요청(04 03 00 00 00 0A + CRC) 있으면 1건 처리하고 true 반환
static bool S4_tryConsumeOneRequestFromQueue() {
if (s4_qlen < 8) return false;
for (uint8_t i = 0; i <= s4_qlen - 8; ++i) {
const uint8_t *p = &s4_q[i];
if (p[0] != SLAVE_ID || p[1] != 0x03 ||
p[2] != 0x00 || p[3] != 0x00 || p[4] != 0x00 || p[5] != 0x0A)
continue;
uint16_t crc = S4_crc16(p, 6);
if (((crc & 0xFF) != p[6]) || ((crc >> 8) != p[7]))
continue;
// 정상 요청 발견 → 큐에서 제거
#if DEBUG_RS485
S4_dumpHex("[Rx]", p, 8);
#endif
memmove(s4_q, p + 8, s4_qlen - (i + 8));
s4_qlen -= (i + 8);
S4_buildHregsFromShared();
S4_rs485Reply();
s4_lastGoodReqMs = millis();
return true;
}
// 유효 프레임 없으면 1바이트 버리고 재동기화
memmove(s4_q, s4_q + 1, --s4_qlen);
return false;
}
void S4_buildHregsFromShared(){
S4_GasData snap;
if (xSemaphoreTake(gDataMtx, 5/portTICK_PERIOD_MS) == pdTRUE) {
snap = gData;
xSemaphoreGive(gDataMtx);
} else {
snap = gData;
}
hregs[0]=6; // 3 floats * 2 words
unsigned long now = millis();
if (snap.t_co && (now - snap.t_co) <= STALE_CO_MS) S4_writeF(1, snap.co); else S4_writeNA(1);
if (snap.t_co2 && (now - snap.t_co2) <= STALE_CO2_MS) S4_writeF(3, snap.co2); else S4_writeNA(3);
if (snap.t_hcho && (now - snap.t_hcho)<= STALE_HCHO_MS) S4_writeF(5, snap.hcho); else S4_writeNA(5);
hregs[7]=0; hregs[8]=0; hregs[9]++; // 여유/하트비트
}
void S4_rs485Reply(){
uint8_t out[3+20+2]; uint8_t l=0;
out[l++]=SLAVE_ID; out[l++]=0x03; out[l++]=20;
for(int i=0;i<10;i++){ out[l++]=hregs[i]>>8; out[l++]=hregs[i]&0xFF; }
uint16_t c=S4_crc16(out,l); out[l++]=c&0xFF; out[l++]=c>>8;
delayMicroseconds(MODBUS_TX_TURNAROUND_US);
#if DEBUG_RS485
S4_dumpHex("[Tx]", out, l);
#endif
RS485.write(out, l);
}
void S4_taskRS485(void*){
S4_rs485Begin();
for(;;){
// 1) 수신 바이트를 큐로 축적
while (RS485.available()) {
uint8_t b = RS485.read();
if (s4_qlen < sizeof(s4_q)) {
s4_q[s4_qlen++] = b;
} else {
// 꽉 차면 앞에서 1바이트 버리고 밀어넣음(에코/노이즈 방지)
memmove(s4_q, s4_q + 1, --s4_qlen);
s4_q[s4_qlen++] = b;
}
}
// 2) 큐에서 유효 요청을 가능한 만큼 처리
bool any=false; do { any = S4_tryConsumeOneRequestFromQueue(); } while (any);
// 3) 워치독: 유효 요청이 오랫동안 없으면 리셋
if (millis() - s4_lastGoodReqMs > RS485_IDLE_RESET_MS) {
S4_rs485Reinit("idle-no-valid-requests");
s4_qlen = 0;
s4_lastGoodReqMs = millis();
}
vTaskDelay(1);
}
}
/* ===================== 센서 태스크(저우선/Core0) ===================== */
S4_Mode s4_curMode = S4_CO;
unsigned long s4_modeStart=0;
// CO 파서 상태
char s4_coBuf[48]; uint8_t s4_coW=0;
// CO2 파서 상태
uint8_t s4_bmSync=0; uint8_t s4_bmBuf[10]; uint8_t s4_bmW=0;
// HCHO 파서 상태
uint8_t s4_hchoSync=0; uint8_t s4_hchoBuf[9]; uint8_t s4_hchoW=0;
// STALE 재시작 타이머
unsigned long s4_lastRe_CO=0, s4_lastRe_CO2=0, s4_lastRe_HCHO=0;
static inline void S4_senBeginCO(){ SEN.begin(CO_BAUD, SERIAL_8N1, CO_RX, -1); while(SEN.available()) SEN.read(); }
static inline void S4_senBeginCO2(){ SEN.begin(CO2_BAUD, SERIAL_8N1, CO2_RX, CO2_TX); while(SEN.available()) SEN.read(); }
static inline void S4_senBeginHCHO(){ SEN.begin(HCHO_BAUD,SERIAL_8N1, HCHO_RX, -1); while(SEN.available()) SEN.read(); }
void S4_sensorSwitchMode(S4_Mode m){
SEN.end(); delay(2);
if (m==S4_CO) S4_senBeginCO();
if (m==S4_CO2) { S4_senBeginCO2(); const uint8_t cmd[7]={0x42,0x4D,0xE3,0x00,0x00,0x01,0x72}; SEN.write(cmd,sizeof(cmd)); }
if (m==S4_HCHO) S4_senBeginHCHO();
s4_curMode=m; s4_modeStart=millis();
#if DEBUG_MODE
Serial.printf("[MODE] -> %s\n", (m==S4_CO)?"CO":(m==S4_CO2)?"CO2":"HCHO");
#endif
}
S4_Mode S4_nextMode(S4_Mode m){ return (m==S4_CO)?S4_CO2:(m==S4_CO2)?S4_HCHO:S4_CO; }
uint32_t S4_modeDwellMs(S4_Mode m){ return (m==S4_CO)?DWELL_CO_MS:(m==S4_CO2)?DWELL_CO2_MS:DWELL_HCHO_MS; }
void S4_sliceCO(){
unsigned long t0=millis();
while(millis()-t0 < SLICE_CO_MS){
if(!SEN.available()){ vTaskDelay(1); continue; }
char c=(char)SEN.read();
if(c=='\r'||c=='\n'){
if(s4_coW){
s4_coBuf[s4_coW]=0; s4_coW=0;
int ppm=0; for(uint8_t i=0;i<47 && s4_coBuf[i];i++) if(isdigit((unsigned char)s4_coBuf[i])) ppm=ppm*10+(s4_coBuf[i]-'0');
if (xSemaphoreTake(gDataMtx, 1)==pdTRUE){ gData.co=(float)ppm; gData.t_co=millis(); xSemaphoreGive(gDataMtx); }
#if DEBUG_SENSOR
Serial.printf("[CO ] %d ppm (raw:\"%s\")\n", ppm, s4_coBuf);
#endif
}
}else{
if (isprint((unsigned char)c) && s4_coW<sizeof(s4_coBuf)-1) s4_coBuf[s4_coW++]=c;
}
}
}
void S4_sliceCO2(){
unsigned long t0=millis();
while(millis()-t0 < SLICE_CO2_MS){
if(!SEN.available()){ vTaskDelay(1); continue; }
int b=SEN.read();
if(s4_bmSync==0){ if(b==0x42) s4_bmSync=1; }
else if(s4_bmSync==1){
if(b==0x4D){ s4_bmSync=2; s4_bmW=0; }
else s4_bmSync=0;
}else{
s4_bmBuf[s4_bmW++]=(uint8_t)b;
if(s4_bmW==10){
s4_bmSync=0; s4_bmW=0;
if(s4_bmBuf[0]==0x00 && s4_bmBuf[1]==0x08){
uint32_t sum=0x42+0x4D; for(int i=0;i<8;i++) sum += s4_bmBuf[i];
if(((sum>>8)&0xFF)==s4_bmBuf[8] && (sum&0xFF)==s4_bmBuf[9]){
uint16_t v=((uint16_t)s4_bmBuf[2]<<8)|s4_bmBuf[3];
if (xSemaphoreTake(gDataMtx, 1)==pdTRUE){ gData.co2=(float)v; gData.t_co2=millis(); xSemaphoreGive(gDataMtx); }
#if DEBUG_SENSOR
Serial.printf("[CO2] %u ppm\n", v);
#endif
}
}
}
}
}
}
void S4_sliceHCHO(){
unsigned long t0=millis();
while(millis()-t0 < SLICE_HCHO_MS){
if(!SEN.available()){ vTaskDelay(1); continue; }
int b=SEN.read();
if(s4_hchoSync==0){ if(b==0xFF){ s4_hchoSync=1; s4_hchoW=0; s4_hchoBuf[0]=0xFF; } }
else {
s4_hchoBuf[++s4_hchoW]=(uint8_t)b;
if(s4_hchoW==8){ // 총 9바이트(0..8)
s4_hchoSync=0; s4_hchoW=0;
if(s4_hchoBuf[1]==0x17 && s4_hchoBuf[2]==0x04){
uint16_t ppb=((uint16_t)s4_hchoBuf[3]<<8)|s4_hchoBuf[4];
float ppm=ppb/1000.0f;
if (ppm>=0.0f && ppm<=5.0f){
if (xSemaphoreTake(gDataMtx, 1)==pdTRUE){ gData.hcho=ppm; gData.t_hcho=millis(); xSemaphoreGive(gDataMtx); }
#if DEBUG_SENSOR
Serial.printf("[HCH] %.3f ppm\n", ppm);
#endif
}
}
}
}
}
}
void S4_sensorsWatchdog(){
unsigned long now=millis();
if(s4_curMode==S4_CO){
if((gData.t_co==0 || now-gData.t_co>STALE_CO_MS) && now-s4_lastRe_CO>SEN_REINIT_BACKOFF_MS){
Serial.println("[SENS] UART2 reinit (CO)");
S4_sensorSwitchMode(S4_CO);
s4_lastRe_CO=now;
}
}else if(s4_curMode==S4_CO2){
if((gData.t_co2==0 || now-gData.t_co2>STALE_CO2_MS) && now-s4_lastRe_CO2>SEN_REINIT_BACKOFF_MS){
Serial.println("[SENS] UART2 reinit (CO2)");
S4_sensorSwitchMode(S4_CO2);
s4_lastRe_CO2=now;
}
}else{
if((gData.t_hcho==0 || now-gData.t_hcho>STALE_HCHO_MS) && now-s4_lastRe_HCHO>SEN_REINIT_BACKOFF_MS){
Serial.println("[SENS] UART2 reinit (HCHO)");
S4_sensorSwitchMode(S4_HCHO);
s4_lastRe_HCHO=now;
}
}
}
void S4_taskSensors(void*){
memset(&gData, 0, sizeof(gData));
S4_sensorSwitchMode(S4_CO);
unsigned long lastHB=0;
for(;;){
// 모드 전환
if (millis()-s4_modeStart >= S4_modeDwellMs(s4_curMode)) {
S4_sensorSwitchMode( S4_nextMode(s4_curMode) );
}
// 비차단 파싱
if (s4_curMode==S4_CO) S4_sliceCO();
else if (s4_curMode==S4_CO2) S4_sliceCO2();
else S4_sliceHCHO();
// STALE 감시
S4_sensorsWatchdog();
#if DEBUG_HB
if (millis()-lastHB >= 1000){
const char* m=(s4_curMode==S4_CO)?"CO":(s4_curMode==S4_CO2)?"CO2":"HCHO";
unsigned long now=millis();
Serial.printf("[HB] mode=%s age co=%lums co2=%lums hcho=%lums\n",
m,
gData.t_co? now-gData.t_co: 999999UL,
gData.t_co2?now-gData.t_co2:999999UL,
gData.t_hcho?now-gData.t_hcho:999999UL);
lastHB=now;
}
#endif
vTaskDelay(1);
}
}
/* ===================== 공통/시스템 ===================== */
void setup(){
Serial.begin(115200);
gDataMtx = xSemaphoreCreateMutex();
// RS485 태스크: Core1, 높은 우선순위
xTaskCreatePinnedToCore(S4_taskRS485, "RS485", 4096, nullptr, 3, nullptr, 1);
// 센서 태스크: Core0, 낮은 우선순위
xTaskCreatePinnedToCore(S4_taskSensors, "SENSORS", 4096, nullptr, 1, nullptr, 0);
Serial.println("[Slave 04] start (RS485 Core1 / Sensors Core0, streaming parser)");
}
void loop(){
vTaskDelay(1000);
}