From 4ef1c95e92152c8d299876e42df45037a184a1e1 Mon Sep 17 00:00:00 2001 From: choibk Date: Sat, 8 Nov 2025 21:45:54 +0900 Subject: [PATCH] First --- .gitignore | 4 + README | 256 ++++++++++++++++++++++ lib/README | 46 ++++ platformio.ini | 17 ++ src/master/main.cpp | 506 ++++++++++++++++++++++++++++++++++++++++++++ src/slave1/main.cpp | 375 ++++++++++++++++++++++++++++++++ src/slave2/main.cpp | 355 +++++++++++++++++++++++++++++++ src/slave3/main.cpp | 266 +++++++++++++++++++++++ src/slave4/main.cpp | 394 ++++++++++++++++++++++++++++++++++ 9 files changed, 2219 insertions(+) create mode 100644 .gitignore create mode 100644 README create mode 100644 lib/README create mode 100644 platformio.ini create mode 100644 src/master/main.cpp create mode 100644 src/slave1/main.cpp create mode 100644 src/slave2/main.cpp create mode 100644 src/slave3/main.cpp create mode 100644 src/slave4/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/README b/README new file mode 100644 index 0000000..5d30f65 --- /dev/null +++ b/README @@ -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.0–1.5 s 권장, RS485 선점과 충돌 최소화. +슬레이브4 구현 팁 +• 라운드로빈: CO → CO2 → HCHO 순서, 각 모드 dwell과 read budget 조정. +• Preempt on Modbus: 센서 읽기 중 RS485 요청 오면 즉시 중단하고 응답. +• STALE 창: CO5 s, HCHO~10–12 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 , : Wi Fi 후보 추가 +• EDIT , : Wi Fi 후보 수정 +• DELETE : Wi Fi 후보 삭제 +• CLEAR : Wi Fi 후보 전체 삭제 +• MQTTSET ,,, : MQTT 설정 저장 (server에 host:port 형태 허용) +• MQTTINFO : MQTT 설정 조회 +• REQID , : RS485 폴링 슬레이브 ID 범위 지정 +• REQTIME : 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": , : , ... } +• 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 (현장 상황에 따라 50–500 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)은 본 규격을 유지한 채 점진적으로 도입 가능합니다. + 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..72aa7a6 --- /dev/null +++ b/platformio.ini @@ -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 diff --git a/src/master/main.cpp b/src/master/main.cpp new file mode 100644 index 0000000..7fef72a --- /dev/null +++ b/src/master/main.cpp @@ -0,0 +1,506 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +/* =============== 튜닝/설정 =============== */ +#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> 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=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 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(); +} diff --git a/src/slave1/main.cpp b/src/slave1/main.cpp new file mode 100644 index 0000000..52fda89 --- /dev/null +++ b/src/slave1/main.cpp @@ -0,0 +1,375 @@ +/* + ========================================================= + RS485 Modbus RTU Slave + BH1750 + Sound(Modbus) // v1.3 + - 마스터(RS485) 워치독 유지 + - 센서 재초기화 정책 변경: SOUND만 끊겨도 재초기화 안 함 + → SOUND(UART) + BH1750(I2C) 둘 다 연속 실패일 때만 리셋 + - Data1(소음), Data2(조도) / NA(0xFFFF) 유지 + ========================================================= +*/ +#include +#include +#include + +// =========================[ 설정 ]======================== +#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(); + } +} diff --git a/src/slave2/main.cpp b/src/slave2/main.cpp new file mode 100644 index 0000000..926cc04 --- /dev/null +++ b/src/slave2/main.cpp @@ -0,0 +1,355 @@ +#include +#include + +/* ========================= 설정 ========================= */ +#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(); + } +} diff --git a/src/slave3/main.cpp b/src/slave3/main.cpp new file mode 100644 index 0000000..7065e39 --- /dev/null +++ b/src/slave3/main.cpp @@ -0,0 +1,266 @@ +#include +#include + +/* ====================== 설정 ====================== */ +#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(); +} diff --git a/src/slave4/main.cpp b/src/slave4/main.cpp new file mode 100644 index 0000000..e00c79e --- /dev/null +++ b/src/slave4/main.cpp @@ -0,0 +1,394 @@ +#include + +/* ===================== 핀/보드 설정 ===================== */ +// 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> 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>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); +}