First
This commit is contained in:
commit
4ef1c95e92
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
.pio
|
||||
.vscode
|
||||
include/README
|
||||
test/README
|
||||
256
README
Normal file
256
README
Normal 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.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 <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 (현장 상황에 따라 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)은 본 규격을 유지한 채 점진적으로 도입 가능합니다.
|
||||
|
||||
46
lib/README
Normal file
46
lib/README
Normal 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
17
platformio.ini
Normal 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
506
src/master/main.cpp
Normal 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
375
src/slave1/main.cpp
Normal 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
355
src/slave2/main.cpp
Normal 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
266
src/slave3/main.cpp
Normal 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
394
src/slave4/main.cpp
Normal 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);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user