
본문 계속 1/2
STEP 12. ESP32 코드
9.1 프로젝트 설정:
HYDR8_v2.ino는 다섯 가지 일만 합니다. 하위 시스템을 초기화한 다음, 이를 주기적으로 실행합니다.
cpp
이 Serial 대기는 장식이 아닙니다. ESP32-C3에서는 USB 포트가 열거되는 데 시간이 걸리므로, 단순한 delay(300)은 이 과정과 경쟁하게 됩니다. 그러면 부팅 메시지를 모두 놓치고 보드가 고장 났다고 판단하게 됩니다. 타임아웃을 두고 포트를 기다리면 컴퓨터가 연결되어 있든 없든 작동합니다.
9.2 비차단 타이밍:
나머지는 모두 다음 한 가지 규칙을 따릅니다: setup() 외부에서는 어디에서도 delay()를 사용하지 않습니다.
C3는 단일 코어입니다. 웹 서버, WiFi 스택, 센서와 디스플레이가 모두 이 코어를 공유합니다. delay(1000)을 한 번 실행하면 대시보드가 응답을 멈춥니다.
cpp
각 함수는 자체 간격이 경과하지 않았으면 즉시 반환합니다. 모든 함수 내부의 패턴은 동일합니다:
cpp
여기서 부호 없는 뺄셈은 단순한 코딩 스타일이 아닙니다. 별도의 예외 처리 없이도 49일 후 발생하는 millis() 롤오버를 올바르게 처리합니다.
마지막의 yield()는 보기보다 중요합니다. 이것이 없으면 단일 코어 칩에서 타이트한 루프가 비동기 웹 서버와 WiFi 태스크를 굶기고, 대시보드는 이 한 줄의 누락이 원인이라는 사실을 추적하기 매우 어려운 방식으로 버벅이게 됩니다.
9.3 센서 초기화
sensors.cpp가 I2C 버스를 담당합니다. 다른 어떤 코드도 드라이버를 직접 건드리지 않습니다.
cpp
실패 시 자동으로 실행되는 scanBus()는 이 프로젝트에서 그 무엇보다 많은 시간을 절약해 주었습니다. 응답하는 모든 주소를 출력하므로 배선 문제인지 코드 문제인지 즉시 알 수 있습니다:
cpp
9.4 MAX30102 읽기
MAX30102는 점수의 생리학적 부분을 결정하는 심박수를 제공합니다.
까다로운 점은 Maxim의 SpO2 알고리즘이 한 번에 100개 샘플을 요구하고, 뻔한 구현은 샘플을 모으는 동안 2초간 블로킹된다는 것입니다. 웹 서버가 실행 중인 상태에서는 받아들일 수 없습니다. 따라서 FIFO를 루프마다 몇 개의 샘플씩 비웁니다:
cpp
여기서 실제로 중요한 것은 두 가지입니다. guard++ < 8은 FIFO가 얼마나 가득 찼는지와 관계없이 한 번의 호출에서 처리하는 양을 제한합니다. 또한 delta > 300 && delta < 2000은 30~200 bpm의 타당성 검사입니다. 반사형 광학 센서의 박동 감지는 정기적으로 터무니없는 값을 내놓으므로, 필터에 도달하기 전에 이를 버려야 합니다.
이 센서 설정은 손목 측정에 맞춰 조정되어 있습니다:
cpp
샘플링 레이트는 50 Hz, 펄스 폭은 411 us, LED 출력은 중간 수준입니다. 이 보드는 녹색 LED를 사용하지 않으므로 꺼져 있습니다.
9.5 MLX90614 읽기
이 값이 변형 계산에서 피부 온도 항을 제공합니다.
cpp
고장 난 MLX90614는 오류를 발생시키지 않고 조용히 NaN이나 엉뚱한 값을 반환합니다. isnan() 및 20~45 degC의 타당성 범위를 확인하면 두 가지 고장 양상을 모두 잡아낼 수 있습니다.
9.6 온도 및 습도 읽기
이 값이 환경 점수의 전체 부분을 결정합니다.
cpp
9.7 데이터 필터링
웨어러블 센서는 노이즈가 많으므로 단일 측정값만으로 어떤 동작도 트리거해서는 안 됩니다. 모든 값은 고정 크기 링 버퍼를 거치며, 각 인스턴스는 템플릿을 사용해 정적으로 할당됩니다. 힙도, 단편화도 없습니다.
cpp
흥미로운 메서드는 accepts()입니다. 이 메서드는 표준편차 대신 중앙 절대 편차(MAD)를 사용해 이상치를 제거합니다:
cpp
표준편차 대신 MAD를 사용하는 이유는 무엇일까요? 표준편차 자체가 이상치 때문에 망가지기 때문입니다. 한 번의 급격한 변동이 표준편차를 부풀리면 필터가 다음 급격한 변동까지 받아들이기 쉬워집니다. 중앙값은 영향을 받지 않습니다. 1.4826은 정규 분포 데이터에서 MAD를 표준편차와 비교할 수 있는 값으로 변환합니다.
실제로는 센서가 잘못된 프레임 하나를 포착했다고 심박수가 190으로 뛰지 않으면서도, 실제 상승은 통과시킨다는 뜻입니다.
9.8 개인 기준선
심박수 105는 저와 훈련된 운동선수에게 완전히 다른 의미를 가집니다. 따라서 생리학적 수치는 원시값으로 점수에 들어가지 않습니다. 대신 자신의 안정 상태에서 벗어난 정도로 들어갑니다.
보정은 대시보드에서 시작하는 60초간의 캡처입니다:
cpp
그리고 중요한 점은 캡처가 실패할 수 있다는것입니다:
cpp
60초 동안 깨끗한 심박수 샘플이 15개보다 적으면 전체 기준선을 폐기합니다. 자신 있게 틀린 기준선은 이후의 모든 점수를 오염시키므로, 기준선을 만들지 않는 편이 더 안전한 실패 방식입니다.
기준선은 NVS에 저장되며 재부팅 후에도 유지됩니다.
9.9 환경 열 계산
온도만으로는 충분하지 않습니다. 습도 30%의 35 degC와 습도 80%의 35 degC는 생리학적으로 완전히 다른 상황입니다. 땀은 증발할 수 있을 때만 몸을 식히기 때문입니다.
따라서 환경 점수에는 Rothfusz 열지수 회귀식을 사용합니다. 기상 서비스에서 사용하는 것과 같은 체감온도 공식입니다. 화씨로 정의된 공식이므로 코드에서 화씨로 변환했다가 다시 변환합니다:
cpp
그런 다음 열지수를 0~100에 선형으로 매핑합니다:
cpp
9.10 생리적 부담
이는 다음을 바탕으로 수정한 것입니다. Physiological Strain Index Moran 등이 제시한 지수로, 심박수와 체온을 0~10 척도로 결합합니다:
공개된 PSI는 심부 체온을 사용합니다. 저는 심부 체온을 측정할 수 없습니다. 손목의 MLX90614는 피부 표면 온도를 측정하므로 이를 대신 사용하고, 피부는 심부보다 차갑기 때문에 상한을 39.5 degC에서 38 degC로 낮췄습니다.
이 대체가 이것을 측정값이 아닌 추정값으로 만드는 가장 큰 이유이며, 그 사실을 감추기보다 분명히 밝히고 싶습니다.
cpp
부품 카운터는 우아한 성능 저하를 구현합니다. MLX90614가 고장 나면 부담이 조용히 절반으로 줄어들어 점수가 거짓으로 안심할 만해지는 대신 심박수 항을 두 배로 합니다. 고장 난 센서 때문에 실제보다 상황이 좋아 보이게 해서는 안 됩니다.
9.11 HYDR8 점수
모든 요소를 가중합으로 결합합니다:
이 가중치는 임상적으로 검증된 값이 아니라 실험용 프로토타입 매개변수입니다. 환경과 신체가 똑같이 중요하고 방향은 조금만 영향을 주면 된다고 생각해 이 값을 정했습니다. 이 값은 config.h에 있으므로 반드시 조정해야 합니다.
cpp
이전 사이클의 기울기에 관한 주석은 올바르게 이해하는 데 시간이 좀 걸렸습니다. 점수를 계산해 추세 버퍼에 넣은 다음 그 기울기를 다시 읽어 같은 점수를 계산하면, 노이즈를 증폭해 흔들림으로 만드는 피드백 루프가 생깁니다. 이전 사이클의 기울기를 사용하면 이 루프가 끊깁니다.
9.12 신뢰도
정직성을 위한 메커니즘입니다. 센서가 없거나 신호가 불안정하면 점수를 덜 신뢰해야 하며, 대시보드도 이를 알립니다.
cpp
이는 통계적 신뢰 구간이 아니며, UI는 이를 다른 것처럼 가장하지 않고 신뢰성 지표로 표시합니다.
9.13 추세 분석
떨어지는 70점에는 올라가는 70점과 다른 조언이 필요합니다. 최근 30개의 점수 샘플에 최소제곱 회귀를 적용합니다:
cpp
의미 없는 기울기를 계산하는 대신 데이터가 부족하면 STABLE을 반환합니다. 이는 부팅 후 처음 10초 동안 UI가 심하게 요동하는 것을 막는 작은 조치입니다.
9.14 추천 로직
점수만으로는 행동으로 옮길 수 없습니다. 추천 엔진은 스트레스가 어디에서 오는지 살펴봅니다:
cpp
envDriven 검사는 이 프로젝트 전체에서 제가 가장 만족하는 부분입니다. 햇볕을 쬐고 있지만 아직 몸이 크게 힘들어하지 않는다면 물을 마시라는 조언은 잘못된 조언입니다. 그늘이 필요합니다. 물을 더 마셔도 45 degC의 햇볕 아래 서 있는 문제는 해결되지 않으며, 단일 숫자 점수로는 그 차이를 표현할 수 없습니다. 점수를 환경적 부분과 생리적 부분으로 나누었기에 이것이 가능해집니다.
9.15 적응형 알림
30분으로 고정된 타이머는 없습니다. 간격은 현재 스트레스에 따라 조정됩니다:
cpp
기본 30분 간격은 시원하고 편안할 때 45분이 되고, 매우 높은 구간에서는 7.5분이 됩니다. 일정을 완전히 건너뛰는 긴급 재정의도 있습니다:
cpp
그리고 어떤 것도 우회할 수 없는 3분의 엄격한 최소 간격이 있습니다. 30초마다 재촉하는 기기는 벗어 서랍에 넣어 두게 됩니다.
9.16 물 섭취 기록
음료를 기록할 때는 그 상태를 기록하기 전 에 캡처하며, 이 덕분에 나중에 회복을 분석할 수 있습니다:
cpp
ml은 "양이 명시되지 않음"을 뜻할 때 기본값이 0입니다. 여기에는 유량 센서가 없으므로 기기는 사용자가 알려 준 것만 알고, 그 이상을 아는 척하지 않습니다.
로그는 24개 이벤트를 순환 기록합니다. 가득 차면 배열을 늘리는 대신 가장 오래된 항목을 삭제합니다.
9.17 회복 분석
음료를 기록한 뒤 10분이 지나면 회복 창이 닫히고 "이후" 상태가 캡처됩니다:
cpp
중요한 구분입니다. 이는 기록된 음료 섭취 후 측정 상태가 개선되었음을 보여줄 뿐, 물이 그 원인이었음을 보여 주지는 않습니다. 움직임을 멈췄거나 실내로 들어갔거나 자연스럽게 식었을 수도 있습니다. UI의 문구는 실제로 참인 내용만 말하도록 되어 있습니다:
javascript
"물을 마셔 열 스트레스가 22포인트 줄었습니다"라고 쓰는 것은 쉬웠고, 그렇게 하면 더 인상적으로 보였을 것입니다. 하지만 그것은 거짓말이었을 것입니다.
9.18 OLED 로직
이 밴드는 알림을 표시하는 장치이지, 휴대폰 앱의 작은 복사본이 아닙니다. 기본 상태는 시계이며, 중요한 내용은 8초 동안 전체 화면에 표시한 뒤 원래 화면으로 돌아갑니다.
시간은 크게 왼쪽 정렬해 숫자 폭이 바뀌어도 다시 배치되지 않게 했습니다. 점수는 오른쪽 위에 표시됩니다. 아래쪽에는 10분할 레벨 막대가 있습니다:
cpp
128x64 패널에서는 높이가 1픽셀인 아날로그 막대가 뭉개져 보이므로 매끄러운 막대 대신 세그먼트를 사용합니다. 불연속 블록은 흘끗 봐도 읽기 쉽습니다.
알림이 패널 전체를 반전시키므로 주변 시야에서도 놓치기 어렵습니다:
cpp
화면 상태 머신은 update()에 있으며 의도적으로 단순합니다. alert가 우선이고, 그다음은 calibration, boot, fault, clock 순입니다.
9.19 디스플레이 워치독
이 절이 있는 이유는 이 문제를 해결하는 데 알고리즘 전체보다 더 많은 시간이 들었기 때문입니다.
증상은 이랬습니다. 새로 업로드한 직후에는 디스플레이가 완벽하게 작동하다가 이후 이상 동작을 하고, 멈추고, 검게 변합니다. 한편 웹 대시보드는 계속 완벽하게 실행됩니다. 이 마지막 부분이 단서였습니다. MCU는 정상이고 I2C 쪽만 죽어 가고 있었던 것입니다.
근본 원인은 디스플레이가 부팅 시 한 번만 초기화된다는 것이었습니다. 노이즈 글리치가 컨트롤러를 망가뜨려도 이를 알아차리거나 재시도하는 코드가 없었습니다. 그래서 이제 워치독이 있습니다:
cpp
그 Wire.end() 경고는 제가 직접 빠졌다가 빠져나와야 했던 함정입니다. 버스가 멈췄을 때 정확히 해야 할 일처럼 보이지만, 그렇지 않습니다.
해결책의 두 번째 부분은 버스 트래픽을 줄이는 것이었습니다. display()를 호출할 때마다 1 KB를 한 번에 전송하며, 10 Hz에서는 트랜잭션도 많고 충전 펌프 전류도 많이 필요합니다. 하지만 시계 화면은 한 번에 1분 동안 정적입니다. 따라서 코드는 프레임버퍼를 해시하고, 화면이 실제로 바뀌었을 때만 전송합니다:
cpp

본문 계속 2/2
RAM 1 KB를 해시하는 비용은 I2C로 1 KB를 전송하는 비용보다 훨씬 낮습니다. 그 결과 쓰기 횟수는 초당 약 5회에서 분당 약 1회로 줄었습니다.
초기화 전에 안정화 지연도 두었는데, 이것 역시 중요한 것으로 드러났습니다:
cpp
SSD1306의 충전 펌프는 VCC가 상승한 후 초기화 시퀀스를 받아들이기까지 시간이 필요합니다. 이 시간이 없으면 USB로 전원을 켤 때는 열거 과정이 우연히 지연을 제공해 정상적으로 시작되지만, 배터리 전원을 껐다 켜면 화면이 꺼진 채로 남습니다.
마지막으로 I2C_FREQ_HZ는 일반적인 100 또는 400이 아니라 50 kHz로 설정되어 있습니다. 디바이스 네 개가 연결된 점퍼 와이어에서는 400 kHz가 WiFi 송신 노이즈로 트랜잭션이 손상되고 버스가 멈출 때까지는 작동합니다.
복구 카운터는 직렬 하트비트에 출력되므로 추측하지 않고 이 현상을 측정할 수 있습니다:
한 시간에 0회 또는 1회면 괜찮습니다. 꾸준히 증가한다면 워치독이 하드웨어 결함을 임시로 덮고 있다는 뜻이므로 풀업을 개선해야 합니다.
9.20 핫스팟 폴백 기능이 있는 WiFi
라우터가 없는 테이블에서 데모를 진행할 때 "WiFi에 연결"을 필수 조건으로 두는 것은 끔찍합니다. 그래서 펌웨어는 네트워크에 연결을 시도하고, 실패하면 자체 네트워크를 호스팅하는 방식으로 전환합니다:
cpp
WiFi.setSleep(false)는 짚고 넘어갈 만합니다. 모뎀 절전이 활성화되면 대시보드에 무작위로 100~300 ms의 지연이 생기는데, 이는 자신의 코드에 버그가 있는 것처럼 느껴집니다.
따라서 밴드에는 언제나 접속할 수 있습니다. 네트워크의 해당 IP, http://hydr8.local/ 또는 휴대폰을 HYDR8-SETUP에 연결한 뒤 192.168.4.1을 열어 접속하면 됩니다.
9.21 RTC 없이 시간 알기
이 빌드에는 RTC가 없지만 OLED의 주된 역할은 시계를 표시하는 것입니다. NTP에는 인터넷이 필요하므로 오프라인 요구 사항과 맞지 않습니다.
해결책: 휴대폰이 시계입니다. 웹 앱은 페이지를 로드할 때마다 시간을 전송합니다.
cpp
브라우저 측 WebSocket open 핸들러 안에 다음 한 줄을 넣습니다:
javascript
기기가 현지 시간을 직접 저장하도록 시간대 오프셋을 뺍니다. 오차는 시간당 몇 초 정도이며 시계 화면에서는 아무도 알아차리지 못합니다.
9.22 웹 서버 및 API
대시보드 전체는 플래시에서 제공됩니다. 덕분에 RAM이 400 KB인 칩에서도 37 KB 웹 앱을 사용할 수 있습니다:
cpp
beginResponse_P는 플래시에서 곧바로 스트리밍하므로 페이지가 RAM을 차지하지 않습니다. 세 파일로 나누면 브라우저가 CSS와 JS도 별도로 캐시합니다.
전체 엔드포인트 목록:
9.23 String 없는 JSON
이것은 이 프로젝트에서 가장 중요한 ESP32 메모리 관련 교훈입니다. 요청 경로 어디에서도 String 연결을 사용하지 않습니다. 이것이 ESP32에서 힙 단편화를 일으키는 가장 큰 원인입니다. 모든 내용은 snprintf로 하나의 공유 버퍼에 작성합니다:
cpp
버퍼가 부족하면 false를 반환하므로 호출자는 브라우저가 파싱할 수 없는 잘린 JSON을 내보내지 않고 깔끔하게 중단할 수 있습니다:
cpp
모든 핸들러가 공유하는 4 KB 스크래치 버퍼는 하나뿐입니다. 모두 같은 비동기 태스크에서 실행되므로 재진입 문제가 없고, 요청마다 스택에 4 KB를 할당하지 않아도 됩니다.
9.24 기록과 인사이트
분당 하나씩 총 120포인트를 약 1.4 KB로 정적으로 할당합니다:
cpp
온도를 부동 소수점 대신 int16_t에 10분의 1 단위로 저장하면 메모리를 절반으로 줄이면서도 눈에 띄는 정보는 잃지 않습니다.
인사이트는 이 버퍼에 적용하는 간단한 규칙이며, 중요한 점은 충분한 데이터가 없을 때 아무것도 생성하지 않는다는 것입니다:
cpp
STEP 13. 웹앱 구축하기

이 밴드는 의도적으로 거의 아무것도 보여주지 않습니다. 흥미로운 모든 것은 ESP32가 직접 호스팅하는 모바일 대시보드에 담겨 있습니다.
이를 형성한 제약 요인들:
인터넷이 안 됩니다. CDN 폰트도, Chart.js도, Tailwind도 없습니다. 플래시에 포함되지 않은 것은 존재하지 않는 것과 같습니다. 모든 것이 순수한 HTML, CSS 및 JavaScript로 구성되어 있습니다.
모바일 우선. 390 px 휴대폰 화면에 맞춰 설계되었습니다. 그 외의 모든 것은 덤입니다.
작다. 이 앱 전체는 세 개의 파일에 걸쳐 약 37 KB이며, 모두 PROGMEM에 들어 있습니다.
테마
파란색, 짙은 네이비색 텍스트, 넉넉한 여백, 둥근 카드. CSS 변수로 한 번 정의하면:
css
시스템 글꼴 스택을 사용하므로 다운로드가 필요 없고, iOS와 Android 모두에서 기본 앱처럼 자연스럽게 보입니다:
css
여기서 tabular-nums는 사소해 보이지만 매우 중요한 세부 사항입니다. 이것이 없으면 숫자가 업데이트될 때마다 너비가 변하고 모든 표시값이 흔들립니다.
구조 및 탐색
탭은 홈, 신체, 열, 수분, 기록의 다섯 개입니다. 여기에 톱니바퀴 아이콘 뒤에 설정이 있습니다. 모든 페이지는 한 번에 DOM에 존재하며 표시하거나 숨기기만 하므로, 라우팅보다 훨씬 간단합니다:
javascript
하단 탐색 바는 안전 영역 패딩을 적용해 고정되므로, 최신 휴대폰에서 홈 인디케이터를 가리지 않습니다:
css
WebSocket을 통한 실시간 데이터
ESP32는 1초에 한 번씩 상태 페이로드를 전송합니다. 폴링도, 새로 고침 버튼도 없습니다:
javascript
백오프는 겉보기보다 중요합니다. 백오프가 없으면 연결이 끊긴 휴대폰이 초당 여러 번 재연결을 시도하며 ESP32를 맹렬히 압박하는데, 바로 그때 ESP32는 이를 감당할 여력이 가장 적습니다.
펌웨어 측에서는 cleanupClients()가 매초 실행되므로, 읽기를 중단한 휴대폰이 큐를 무한정 쌓아 힙을 소모할 수 없습니다:
cpp
점수 링
순수 SVG와 CSS 전환 하나입니다. 애니메이션 라이브러리는 사용하지 않습니다:
html
css
javascript
stroke-dasharray를 원주 길이로 설정한 다음, stroke-dashoffset을 전체 값에서 해당 값까지 애니메이션 처리합니다. 브라우저가 GPU에서 이징을 처리하므로 세 줄이면 됩니다.
링 색상은 밴드에 따라 바뀌므로, 숫자를 읽기도 전에 상태를 파악할 수 있습니다:
javascript
열 기여도 막대
이 부분이 알고리즘을 눈에 보이게 만듭니다. 점수를 환경, 신체, 추세 요소로 나눕니다:
javascript
css
청록색 구간이 우세하면 열이 외부에서 유입되고 있다는 뜻이므로 그에 따라 조언도 달라집니다. 이를 통해 추상적인 수치를 스스로 판단할 수 있는 정보로 바꿉니다.
손수 만든 차트
약 40줄의 SVG 경로 생성 코드입니다. Chart.js도, D3도 사용하지 않습니다:
javascript
같은 함수가 홈 카드의 작은 스파크라인과 Body, Heat, History의 전체 차트를 구동하며, 크기만 다릅니다. 스파크라인은 경로를 닫아 아래에 채워진 영역을 만듭니다:
javascript
데이터가 충분하지 않을 때는 오해를 불러일으킬 수 있는 평평한 선을 그리는 대신 그렇게 표시합니다:
javascript
수분 섭취 기록
버튼 네 개, fetch 한 번, 그리고 작은 애니메이션입니다:
javascript
css
그 void w.offsetWidth 줄은 말도 안 되는 것처럼 보이지만 그렇지 않습니다. 이 줄은 브라우저가 레이아웃을 다시 계산하도록 강제해 CSS 애니메이션을 재시작합니다. 이 줄이 없으면 버튼을 연달아 두 번 눌러도 애니메이션은 한 번만 실행됩니다.
작은 디테일
심박수 카드는 실시간 신호에 맞춰 박동합니다:
css
그리고 전체 기능은 동작 줄이기 설정도 존중합니다. 두 줄이면 구현할 수 있고, 그렇게 하는 것이 옳습니다:
css
STEP 14. 잘못된 모든 것
1. 작동하다가 작동하지 않게 된 디스플레이:
문제. OLED는 새로 업로드한 후 완벽하게 켜져 한동안 작동하다가 오류를 일으키고 멈춘 뒤 검은 화면이 되었습니다. 재설정해도 계속 검은 화면이었습니다. 한편 웹 대시보드는 계속 완벽하게 실행되었습니다.
제가 시도한 것. 처음에는 패널이 고장 났다고 생각해 교체품을 주문했습니다. 그다음에는 흔히 있는 경우인, SSD1306으로 판매된 SH1106이라고 생각했습니다. 이 경우에는 정확히 “초기화 성공, 화면이 검은색”이라는 양상이 나타납니다. 이를 확인하기 위한 테스트 스케치를 작성했지만, 그것도 아니었습니다.
제가 무시하고 있던 단서는 웹 서버가 한 번도 멈추지 않았다는 점이었습니다. 따라서 칩은 정상이었고 I²C 쪽만 고장 나고 있었습니다.
효과가 있었던 것. 세 가지를 함께 적용했습니다. 디스플레이는 부팅할 때 한 번만 초기화되었으므로, 오류로 컨트롤러가 뒤엉켜도 다시 시도하지 않았습니다. 몇 초마다 주소를 확인하고 초기화를 다시 실행하는 워치독을 추가해 “계속 영원히 죽어 있는” 문제의 절반을 해결했습니다.
그다음에는 트래픽을 줄였습니다. 프레임을 전송할 때마다 한 번에 1 KB를 보내는데, 10 Hz에서는 한계에 가까운 버스에 지속적인 부하가 걸립니다. 프레임버퍼를 해시하고 이미지가 실제로 변경되었을 때만 전송하자 초당 5회의 쓰기가 분당 약 1회로 줄었습니다.
그다음 버스를 50 kHz로 낮추고, 초기화 전에 120 ms의 안정화 지연을 추가했으며, SDA와 SCL에 적절한 4.8 kΩ 풀업 저항을 연결했습니다.
제가 드리고 싶은 말. 풀업 저항은 처음부터 연결해 두세요. “디스플레이가 고장 났다”고 생각해 허비한 날들은 사실 “I²C 버스의 상태가 한계에 가까웠다”는 것이었고, 저항은 약 2루피밖에 하지 않았습니다.
2. 하루를 허비하게 만든 잘못된 이론:
문제. 이 과정에서 저는 Adafruit의 display.begin()이 원인이라고 확신했습니다. periphBegin 인자의 기본값이 true이고 인수 없이 Wire.begin()을 호출하기 때문에, I²C를 칩의 기본 핀에 다시 연결할 것처럼 들렸기 때문입니다.
제가 시도한 것. 버스에 손대지 못하게 false, false를 전달했습니다. 원래 호출이 잘 작동하던 보드에서 디스플레이가 완전히 작동하지 않게 되었습니다.
효과가 있었던 것. 원래대로 되돌렸습니다. 최신 ESP32 코어에서는 인수 없이 Wire.begin()을 호출해도 이미 설정된 핀이 유지되므로, 원래 호출은 처음부터 아무 문제도 일으키지 않았습니다.
아이러니한 점은 이 고장 양상이 실제로 존재하지만, 제가 생각했던 곳에서 발생하는 것은 아니라는 것입니다. 이 문제는 복구 경로에서 발생합니다. 먼저 Wire.end()를 호출하면 주변기기를 종료하면서 저장된 핀이 실제로 지워지기 때문입니다. 저는 잘못 진단했던 바로 그 버그를 직접 만들어 낸 셈입니다.
제가 드리고 싶은 말. “전에는 작동했다”는 사실은 왜 작동하지 않았어야 하는지에 대해 세울 수 있는 어떤 이론보다도 강력한 증거입니다.
3. 스트래핑 핀을 사용했습니다:
문제. 퍼프보드 배치에 맞아 I²C 버스를 GPIO2와 GPIO3인 D0 및 D1에 연결했습니다. 그 결과 부팅이 간헐적으로 실패했습니다.
효과가 있었던 것. D4와 D5(GPIO6 및 GPIO7)로 옮겼습니다.
GPIO2는 ESP32-C3의 스트래핑 핀이며, 칩의 시작 방식을 결정하기 위해 부팅 시 읽힙니다. I²C 풀업은 대개 이 핀을 충분히 높게 유지하므로 대부분 작동하는데, 이것이 가능한 최악의 고장 양상입니다. 보드가 네 번은 제대로 부팅되고 그다음에는 부팅되지 않아 자신의 코드를 탓하게 되기 때문입니다.
제가 드리고 싶은 말. 퍼프보드 배치를 설계하기 전에 스트래핑 핀을 확인하세요. C3에서는 GPIO2, GPIO8 및 GPIO9입니다.
4. 시리얼이 완전히 조용해졌습니다:
문제. 부트로더 오류를 수정한 뒤 보드에서 아무것도 출력되지 않았습니다. 배너도 오류도 없었습니다.
제가 시도한 것. 다른 케이블, 다른 포트, 다른 보드레이트.
효과가 있었던 것. 두 가지였습니다. 보드 설정에서 USB CDC On Boot가 Disabled로 재설정되어 있었고, 그 때문에 Serial이 USB가 아니라 하드웨어 UART 핀으로 연결되었습니다. 그래서 보드는 아무것도 연결하지 않은 핀으로 태연히 출력하고 있었습니다.
또 펌웨어에서 첫 출력 전에 넣은 delay(300)이 C3에서 최대 2초가 걸리는 USB 열거와 경쟁하고 있었습니다. 타임아웃을 두고 포트가 제대로 준비될 때까지 기다리자 문제가 완전히 해결되었습니다.
제가 드리고 싶은 말. C3에서 Serial이 작동하지 않으면 다른 것을 확인하기 전에 USB CDC On Boot부터 확인하세요.
5. 모듈식 프로젝트에서 이름이 충돌하는 문제:
문제. 프로젝트가 컴파일되지 않았습니다. TREND_RISING이 config.h에서는 float 임계값으로, types.h에서는 열거형 값으로 선언되어 있었습니다. types.h가 config.h를 포함하므로 float 선언이 우선되어 열거형 선언이 실패했습니다.
효과가 있었던 것. 임계값 이름을 SLOPE_RISING, SLOPE_RAPID 등으로 변경했습니다. 파일 두 개, 8줄이었습니다.
제가 드리고 싶은 말. 컴파일러는 충돌을 일으킨 줄이 아니라 충돌을 발견한 줄을 표시합니다. 오류는 types.h를 가리켰지만 수정해야 할 곳은 config.h였습니다. 주석을 읽으세요. 이전 선언 줄이 실제 원인입니다.
6. 버전 간에 파일이 뒤섞이는 문제:
문제. 분명히 존재하는 상수에 대한 컴파일 오류가 발생했습니다. 추출 작업 후 새 .cpp 파일 옆에 오래된 config.h가 남아 있었습니다.
효과가 있었던 것. display-private 상수를 config.h에서 oled_ui.cpp로 옮겨, 어떤 config.h가 옆에 있든 해당 파일이 컴파일되게 했습니다. 또한 새 버전을 추출할 때는 덮어쓰지 않고 기존 폴더를 완전히 삭제했습니다.
부팅 시 출력되는 버전 문자열을 추가하는 것도 큰 도움이 됩니다:
30초만 작업하면 현재 실행 중인 것이 자신이 실행 중이라고 생각하는 것인지 즉시 알 수 있습니다.
STEP 15. HYDR8의 기능
실시간 생리학적 모니터링. MAX30102에서 측정된 심박수와 혈중 산소 농도, MLX90614에서 측정된 피부 표면 온도는 모두 원시 데이터 그대로 표시되지 않고 필터링 및 검증 과정을 거쳤습니다.
환경 모니터링. SHT31에서 측정된 주변 온도와 습도를 종합하여 열지수를 산출하므로, 35 °C의 건조한 환경과 35 °C의 습한 환경은 서로 다른 문제로 취급됩니다.
개인 기준선. 60초간의 보정 과정을 통해 휴식 시 심박수, 피부 온도 및 SpO₂를 측정합니다. 이후 모든 생리학적 지표는 일반 인구 평균치가 아닌 본인의 기준치와의 편차로 측정됩니다.
HYDR8 점수. 열 및 수분 스트레스를 0에서 100 사이의 단일 수치로 추정하며, 그 원인을 파악할 수 있도록 환경적 요인과 생리적 요인으로 내부적으로 구분합니다.
신뢰도 지표. 센서가 없거나, 펄스 신호에 노이즈가 있거나, 기준선이 없을 때 수치가 떨어집니다. 대시보드에 이 정보가 표시되므로 해당 수치를 얼마나 신뢰할 수 있는지 알 수 있습니다.
추세 탐지. 최근 점수의 기울기를 바탕으로 산출된 STABLE부터 RAPIDLY RISING을 거쳐 RECOVERING까지의 5가지 상태.
실행 가능한 권고 사항. 정상, 곧 수분 보충, 물 마시기, 휴식 취하기, 체온 낮추기, 그늘 찾기. “물 마시기”와 “햇볕을 피하기”의 구분은 점수의 어느 쪽이 더 높은지에 따라 결정됩니다.
적응형 알림. 편안할 때는 알림 간격이 길어지고 스트레스가 높아질수록 짧아지며, 상태가 높고 상승 중일 때는 긴급 알림이 우선 적용됩니다. 또한 알림 간격에는 3분이라는 엄격한 하한선이 있어 3분보다 짧은 간격으로는 알림을 보내지 않습니다.
OLED 알림. 기본적으로 시계 화면이 표시되고, 중요한 일이 발생하면 전체 화면 반전 알림이 나타난 다음 다시 시계 화면으로 돌아갑니다.
물 섭취 기록. 대시보드에서 150, 250, 500 ml 또는 명시되지 않은 양을 기록할 수 있습니다. 이 앱은 사용자가 입력한 내용만 기록하며, 그 이상의 정보를 아는 척하지 않습니다.
회복 분석. 물 섭취 기록 전과 10분 후의 측정된 상태를 비교하되, 인과관계를 주장하는 방식이 아닌 관찰 결과의 형태로 기술합니다.
모바일 대시보드. 5개의 탭과 실시간 WebSocket 업데이트를 제공하며, 모든 기능은 ESP32에서 호스팅됩니다. 앱도, 계정도, 인터넷도 필요 없습니다.
기록 및 인사이트. 1분당 1개의 데이터 포인트로 구성된 2시간 분량의 이동 데이터를 차트로 표시하며, 참된 결론을 내릴 만큼 데이터가 충분하지 않을 때는 침묵을 유지하는 간단한 규칙 기반 관찰을 제공합니다.
설계상 오프라인. 클라우드도, 원격 측정도, 계정도 없습니다. 라우터가 꺼져 있어도 밴드 자체의 핫스팟에 연결하면 모든 기능이 계속 작동합니다.
STEP 16. 그만한 가치가 있었을까?




나는 여전히 물을 마시는 것을 깜빡한다. 그 사실이 마법처럼 달라진 것은 아니다. 하지만 이제는 이 글을 시작하게 한 질문, 즉 수분 섭취 자체보다는 내가 지금까지 사용한 모든 알림이 왜 그렇게 뻔히 어리석었는지에 관한 질문에 대해 훨씬 더 나은 답을 갖게 되었다.
내가 가장 만족스러워하는 것은 점수가 아니다. 추천 로직이 물을 마시는 대신 그늘을 찾으라고 알려 주는 순간이다. 고작 15줄 정도 되는 작은 코드 조각이지만, 점수가 불투명한 하나의 숫자가 아니라 환경적 요소와 생리적 요소로 나뉘어 있기 때문에 가능해졌다. 더 나은 내부 표현에서 더 나은 답이 나왔고, 이는 이 프로젝트를 훨씬 넘어 일반화할 수 있는 교훈이다.
실제로 다시 활용할 수 있을 것 같은 것들: ESP32에서는 절대로 String을 연결하지 말 것. 실제 스택 높이를 파악한 뒤 인클로저를 설계할 것. 퍼프보드 레이아웃을 확정하기 전에 스트래핑 핀을 확인할 것. 그리고 어제는 작동하던 것이 오늘 작동하지 않는다면, 방금 지어낸 이론보다 그동안의 기록을 믿을 것.
개선하고 싶은 점. 피부 온도로 심부 체온을 대체하는 것은 이 알고리즘에서 가장 취약한 연결고리이며, 나도 그 사실을 알고 있다. 프로토타입으로서는 방어할 수 있는 선택이지만, 더 의미 있는 수치로 나아가는 데 가로놓인 장애물은 바로 이것이다. 가속도계를 추가하는 것도 큰 도움이 될 것이다. 달릴 때의 심박수 상승과 가만히 앉아 있을 때의 심박수 상승은 완전히 다른 의미를 지니는데, 현재 HYDR8은 둘을 구분하지 못하기 때문이다. GSR을 사용하면 땀 신호를 추론하는 대신 실제 땀 신호를 얻을 수 있다. 그리고 가중치는 솔직히 추측에 불과하므로, 내 직감이 아니라 실제 데이터에 맞춰 조정하면 도움이 될 것이다.
장기적으로는 섭취량을 자동으로 기록하는 스마트 물병이 피드백 고리를 제대로 완성할 것이다. 지금은 이 기기가 사용자가 알려 주는 것만 알고 있기 때문이다.
그리고 다시 한 번 분명히 말하자면: HYDR8은 실험적인 열 및 수분 스트레스 추정기다. 이 기기는 사용자의 현재 체내 수분 상태를 측정하지 않으며, 의료 기기도 아니고, 누구의 건강에 관한 결정을 내리는 데 사용해서도 안 된다. 이는 관련 신호를 바탕으로 스트레스를 추정하는 프로토타입이며, 자신의 휴대폰에 짜증이 난 한 학생이 만들었다.






















