메뉴 전체보기

회원메뉴

HYDR8: 수분 보충이 필요한 시점을 알려주는 웨어러블 기기 > 게시판

본문 바로가기

쇼핑몰 검색

NO. 3071
제목 : HYDR8: 수분 보충이 필요한 시점을 알려주는 웨어러블 기기
2026-08-24 23:26

소개: HYDR8: 수분 보충이 필요한 시점을 알려주는 웨어러블 기기


소개: HYDR8: 수분 보충이 필요한 시점을 알려주는 웨어러블 기기


소개: HYDR8: 수분 보충이 필요한 시점을 알려주는 웨어러블 기기


소개: HYDR8: 수분 보충이 필요한 시점을 알려주는 웨어러블 기기


안녕하세요, 저는 아유슈만입니다. 전기·전자공학을 전공하는 학생이고, 여가 시간의 대부분을 아마도 존재할 필요가 없었을 것들을 만드는 데 쏟고 있습니다.

이 제품은 어이없는 문제에서 시작됐습니다. 일을 하려고 자리에 앉아 몰두하다가 4시간 뒤 고개를 들면 두통이 있고, 바로 옆에는 물이 가득 든 물병이 놓여 있곤 했습니다. 한 모금도 마시지 않은 채로요. 자전거를 탈 때도 마찬가지였습니다. 5월에 밖에 서 있을 때도 마찬가지였고요. 그때 하리드와르는 자신이 얼마나 무서운 곳인지 모두에게 일깨워 주니까요.

가장 뻔한 해결책을 시도해 봤습니다. 30분마다 울리도록 휴대폰 알림을 설정한 것입니다. 하지만 대부분의 경우 알림이 상황에 맞지 않았기 때문에, 이틀이 지나자 읽지도 않고 밀어 없애기 시작했습니다. 선풍기로 시원하게 식힌 방에서 알림이 울리면 짜증스럽습니다. 실제로 알림이 필요했던 날에는 어차피 30분이 너무 길었고요.

그 알림은 제가 무엇을 하고 있는지 전혀 몰랐습니다. 가만히 앉아 있든 언덕을 오르고 있든, 22°C든 40°C든, 심박수가 70이든 130이든 — 알림이 아는 것은 30분이 지났다는 사실뿐이었습니다. 그리고 그 정보가 가장 중요하지 않았습니다.

게다가 이곳에서는 결코 사소한 문제가 아닙니다. 1901년 이후 인도에서 기록상 가장 더웠던 해인 2024년에 NCDC는 열사병 사례 약 48,000건과 확인된 사망자 159명을 기록했습니다. 비영리 단체 HeatWatch가 별도로 실시한 분석에서는 그해 뉴스 보도만으로도 열사병 사망자 733명이 집계됐고, 여러 정부 기관은 같은 기간에 대해 크게 다른 합계를 보고합니다. 실제 숫자를 아는 사람은 아무도 없습니다. 집계된 수치보다 더 많고, 그 대부분은 예방할 수 있었다는 점에는 모두가 동의합니다. deccanherald

그래서 이런 생각을 하기 시작했습니다. 단순히 시간을 세는 대신, 웨어러블 기기가 몸과 주변 공기에 무슨 일이 일어나고 있는지를 살핀다면 어떨까?

이것이 HYDR8입니다. XIAO ESP32-C3를 기반으로 한 손목 밴드로, 심박수, 혈중 산소, 피부 표면 온도, 그리고 주변의 온도와 습도를 측정합니다. 사용자의 평상시 안정 상태 수치를 학습하고, 모든 정보를 하나의 0–100 열 및 수분 스트레스 점수로 종합한 뒤 실제로 무엇을 해야 하는지 알려줍니다. 매번 그저 "물을 마시세요"라고 하는 것이 아닙니다. 때로는 햇볕에서 벗어나는 것이 올바른 답일 수 있는데, 타이머는 그런 사실을 절대 알 수 없으니까요.

밴드 자체는 간단합니다. 시간을 표시하고, 필요할 때만 알림을 보냅니다. 전체 대시보드는 ESP32 자체에서 호스팅되어 휴대폰에 표시됩니다. 인터넷은 필요하지 않습니다.

먼저 한 가지 분명히 해 두겠습니다: HYDR8은 사용자의 수분 상태가 어느 정도인지 측정하지 않습니다. 이 빌드로는 그 어떤 것도 측정할 수 없습니다. 관련 신호를 바탕으로 열 및 수분 스트레스를 추정합니다. 이는 실험용 시제품이지 의료 기기가 아니며, 알고리즘을 설명할 때 그 경계가 어디에 놓이는지 구체적으로 말씀드리겠습니다.




STEP 1. 준비물


준비물


전자제품:

  1. XIAO ESP32-C3 (주 마이크로컨트롤러)
  2. MAX30102 (심박수 + SpO₂)
  3. MLX90614 (비접촉식 온도)
  4. DFRobot 온도·습도 센서 (주변 온도 + 습도)
  5. OLED 디스플레이 (시간 + 알림)
  6. 퍼프보드
  7. 리튬 이온 배터리
  8. 전원 스위치
  9. 점퍼 와이어

케이스 및 조립:

  1. 3D 프린터
  2. PLA/PETG 필라멘트
  3. 작은 나사
  4. 손목 스트랩
  5. 기본 납땜 용품




STEP 2. 왜 30분 간격 알림은 효과가 없을까?


물을 더 많이 마셔야 한다는 건 누구나 알고 있습니다. 그게 문제는 아닙니다. 문제는 갈증이 정말 형편없는 경고 신호라는 점입니다. 갈증을 느낄 때쯤이면 이미 늦은 상태이고, 무언가에 집중하고 있다면 아예 눈치채지 못하기 때문입니다.

그래서 우리는 알림을 설정합니다. 그리고 그 알림은 항상 비슷한 형태를 띱니다. 바로 ‘정해진 간격’이죠. 30분마다, 매시간, 하루에 물 여덟 잔 같은 것들입니다. 간단하기 때문에 어디에서나 볼 수 있는 것입니다.

게다가 대부분의 경우 잘못된 알림이기도 합니다.

선풍기가 켜진 책상 앞에서 30분을 보내는 것과 38°C에서 30분 동안 자전거를 타는 것은 전혀 다릅니다. 전자의 경우에는 아마 알림이 필요하지 않았을 것입니다. 후자의 경우에는 15분 전에 알림이 필요했고, 곧 다음 알림도 필요할 것입니다. 알림은 같지만 상황은 크게 다른데, 앱은 당신이 어느 상황에 있는지 전혀 알지 못합니다.

바로 그 불일치가 문제를 망칩니다. 맞는 경우보다 틀리는 경우가 더 많은 알림은, 읽지도 않고 무시하게 만듭니다. 제 경우엔 이틀 만에 그랬습니다.

고정 알림이 잘못하는 두 번째 점이 있는데, 제 생각에는 이것이 더 큰 문제입니다: 물이 항상 해답은 아닙니다. 45°C의 직사광선 아래 서 있고 몸이 이미 열을 배출하는 데 어려움을 겪고 있다면, 물을 더 마신다고 해서 그 문제가 해결되지는 않습니다. 그늘이 필요합니다. 잠시 움직임을 멈춰야 합니다. 타이머는 “수분이 조금 부족한 상태”와 “지금 당장 이 환경에서 벗어나야 하는 상황”을 구분할 수 없기 때문에, 어느 경우든 똑같은 말을 합니다.

그리고 인도에서는 후자의 상황이 결코 가상이 아닙니다. 위험하게 만드는 것은 습도입니다. 땀은 증발해야만 몸을 식힐 수 있는데, 습도가 70%이면 대부분 증발하지 못합니다. 그래서 건조한 라자스탄의 35°C와 해안 지역의 습한 35°C는, 휴대폰 날씨 앱에 같은 숫자가 표시되더라도 실제로는 생리학적으로 전혀 다른 문제입니다.

이 문제에 가장 많이 노출된 사람들은 피트니스 트래커를 착용할 가능성이 가장 낮은 사람들입니다. 건설 노동자, 배달 기사, 농부, 그리고 여름에 야외에서 일하는 모든 사람이 그렇습니다. 퍼프보드(perfboard)를 이용한 취미 프로젝트가 이 문제를 해결한다고 가장하지는 않겠습니다. 하지만 약 ₹1,500어치의 센서와 ESP32로 어디까지 할 수 있을지 알아보고 싶어졌습니다.

더 나은 조언을 하는 데 필요한 정보는 특별한 것이 아닙니다. 심박수는 몸이 얼마나 세게 일하고 있는지를 말해 줍니다. 피부 온도는 열 부하에 대해 알려 줍니다. 기온과 습도는 어떤 환경을 상대하고 있는지를 말해 줍니다. 이 정보들을 당신에게 평소 어떤 상태인지와 비교하고그 값들이 어느 방향으로 변하는지 지켜보면, “30분이 지났다”는 말보다 훨씬 유용한 말을 할 수 있습니다.

그게 바로 제가 시험해 보고 싶었던 아이디어입니다.




STEP 3. 해결책


해결책


해결책


그래서 HYDR8을 만들었습니다

HYDR8은 손목에 착용하는 밴드로, 다음과는 다른 질문에 답합니다. 물을 마신 지 얼마나 됐나요?라고 묻는 대신, 지금 몸이 얼마나 무리하고 있는지, 그리고 현재 공기가 몸에 어떤 영향을 주고 있는지를 묻습니다.

다음 여섯 가지 정보를 읽고 활용합니다:

  1. 심박수— 몸이 얼마나 무리하고 있는지를 알려줍니다
  2. SpO₂— 보조 신호로 사용하는 혈중 산소 포화도
  3. 피부 표면 온도— 손목에 가해지는 열 부하
  4. 주변 온도— 실제로 서 있는 환경의 온도
  5. 습도— 땀으로 몸을 식힐 수 있는지를 좌우합니다
  6. 개인 기준치— 휴식 중일 때 위의 모든 지표가 어떤 상태인지 보여 주는 기준입니다

그리고 예상보다 더 중요하다는 것이 밝혀진 일곱 번째 요소도 있습니다: 추세입니다. 현재 상태뿐 아니라, 어느 방향으로 변하고 있는지도 중요합니다.

이 모든 정보는 하나의 수치, 즉 HYDR8 점수로 통합됩니다. 점수는 0부터 100까지이며, 추정된 열 스트레스와 수분 스트레스를 나타냅니다.

이 수치가 무엇을 의미하는지 솔직하게 말하자면,

HYDR8은 실험적인 열 및 수분 스트레스 추정기입니다. 임상적으로 검증된 탈수 감지기가 아니며, 이 차이를 정확히 짚고 넘어가고 싶습니다.

이 장치에는 체내 수분량을 알아내는 센서가 없습니다. 이 가격과 크기로 손목에 착용할 수 있는 장치에는 그런 센서가 존재하지 않습니다. HYDR8이 측정하는 것은 다음과 같은, 열 및 수분 스트레스와 관련된 신호입니다. 예를 들어 개인의 휴식 시 심박수보다 높아진 심박수, 더 따뜻해진 피부, 덥고 습한 공기 등이 있습니다. HYDR8은 이러한 신호를 종합해 추정치를 산출합니다.

이는 의사가 혈액 검사를 하는 것과 친구가 “상태가 아주 안 좋아 보이니, 앉아서 뭔가 마셔”라고 말하는 것의 차이와 같습니다. 후자의 말도 분명히 도움이 됩니다. 다만 진단은 아닙니다.

이 점수에는 또한 신뢰도 값 이 포함됩니다. 센서가 없거나, 맥박 신호에 잡음이 있거나, 아직 기준치를 보정하지 않은 경우 이 값은 낮아집니다. HYDR8은 확신이 없을 때 조용히 무언가를 지어내는 대신 그렇다고 알려 줍니다.

실제로 알려 주는 것

점수는 단계적으로 높아지는 조언 체계로 연결됩니다:

실제로 알려 주는 것

점수는 단계적으로 높아지는 조언 체계로 연결됩니다:

정상 — 괜찮습니다. 별도로 할 일은 없습니다.

곧 수분을 보충하세요 — 스트레스가 쌓이고 있으니 미리 대응하세요.

물을 마시세요 — 지금 이미 스트레스 상태에 들어섰습니다.

휴식을 취하세요 — 스트레스가 높고 계속 상승하고 있습니다. 물만으로는 따라잡을 수 없습니다.

열을 식히세요 / 그늘을 찾으세요 — 문제는 환경입니다. 물을 더 마신다고 해서 햇볕 아래 서 있는 상황이 해결되지는 않습니다

제품은 두 부분으로 구성됩니다:

밴드 는 의도적으로 단순하게 만들었습니다. 시간을 표시하고, 중요한 일이 생기면 화면을 차지해 DRINK WATER 또는 COOL DOWN을 표시한 다음 시계 화면으로 돌아갑니다. 그게 전부입니다. 끊임없이 주의를 요구하는 웨어러블은 결국 벗어 서랍에 넣어 두게 됩니다.

휴대폰 에는 모든 기능이 들어 있습니다. ESP32 자체에서 완전한 대시보드를 호스팅합니다. 실시간 측정값, 기여 요소별로 나눈 점수, 차트, 수분 섭취 기록, 이력, 간단한 인사이트를 제공합니다. 설치할 앱도, 클라우드 계정도, 인터넷 연결도 필요하지 않습니다. 웹 페이지를 열면 바로 나타납니다.

알림도 이에 맞춰 조정됩니다. 시원하고 편안한 상태에서는 간격이 늘어나고, 스트레스가 높아지면 줄어들며, 점수가 높고 빠르게 상승하고 있으면 알림을 완전히 건너뜁니다. 그래도 최소 간격은 정해져 있으므로 30초마다 알림이 울리는 일은 없습니다.

이 모든 것을 실제로 어떻게 계산하는지는 12절에서 설명합니다. 지금은 이것이 HYDR8의 기능이라고만 알아두면 됩니다.




STEP 4. 밴드 디자인


밴드 디자인


밴드 디자인


밴드 디자인


HYDR8이 마치 손목에 묶어 둔 작은 직사각형 전자기기 상자처럼 보이게 하고 싶지는 않았습니다. 내부에 수많은 센서와 전자 부품이 들어 있는 만큼, 두툼한 직사각형 블록 모양이 되어 버리기 쉬웠을 테니까요.

대신, 저는 좀 더 유동적이고 유기적인 형태를 택했습니다. 일부 현대식 시계와 미래지향적인 하드웨어에서 볼 수 있는 매끄럽고 마치 액체 금속 같은 형태에서 영감을 얻었습니다.

이 디자인의 취지는 케이스를 단순히 상자 안에 부품을 쑤셔 넣은 것이 아니라, 하나의 연속된 물체처럼 느껴지도록 하는 것이었습니다. OLED와 센서가 제대로 장착될 수 있도록 상단 부분은 비교적 평평하게 유지하면서, 가장자리 주변에는 둥근 표면과 유려한 곡선을 적용했습니다.

또한 디자인은 전자 부품들의 배치도 고려해야 했습니다. OLED를 위한 투명한 개구부가 필요했고, MAX30102는 손목과 제대로 접촉할 수 있는 위치에 배치되어야 했으며, 다른 센서들을 위한 개구부도 케이스가 복잡해 보이지 않도록 적절히 마련해야 했습니다.

따라서 이 디자인은 기본적으로 전자 부품을 적절히 배치하는 것과 전자 부품이 형태 속에 자연스럽게 녹아들게 하는 것사이의 균형을 맞추는 것이었습니다.

그 점이 바로 HYDR8을 디자인하면서 제가 가장 마음에 들었던 부분입니다. 모든 것을 평범한 상자 안에 숨기는 대신, 케이스 자체가 프로젝트의 일부가 되었기 때문입니다.




STEP 5. 외함 인쇄


외함 인쇄


외함 인쇄


외함 인쇄


외함 인쇄


Fusion 360에서 케이스 설계를 마무리한 후, 이제 모델을 실제로 착용할 수 있는 제품으로 구현할 차례였습니다.

본체에는 아쿠아색 필라멘트 를 사용해 HYDR8에 밝고 깔끔한 외관을 더했습니다. 스위치는 별도로 검은색 필라멘트로 출력해 본체와 멋진 대비를 이루도록 했습니다.

이 케이스는 조립 시 전자 부품, 디스플레이, 센서를 제대로 장착할 수 있도록 개별 부품으로 출력되었습니다. 출력이 끝난 후, 부품들을 다듬고 OLED, 센서, 스위치의 장착 상태를 확인한 뒤 전자 부품 작업으로 넘어갔습니다.

여기서 가장 중요한 점은 개구부와 간격을 정확하게 맞추는 것이었습니다. OLED는 상단 표면과 수평을 이루어야 했고, 센서들은 해당 개구부와 일직선이 되어야 했으며, 스위치는 걸리지 않고 자유롭게 움직여야 했습니다.

모든 부품이 제자리에 제대로 맞춰지자, 출력된 부품들은 조립할 준비가 되었습니다.




STEP 6. 회로 설계


퍼프보드를 자르거나 납땜을 하기 전에, 먼저 전체 회로와 각 모듈을 XIAO ESP32-C3에 어떻게 연결할지 구상했습니다.

MAX30102, MLX90614 및 OLED는 모두 동일한 I²C 버스를 공유합니다:

  1. GPIO 6 → SDA
  2. GPIO 7 → SCL

세 장치는 3.3V 및 GND도 공유합니다.

DFRobot 온도·습도 센서는 전원 및 접지 연결과 함께 데이터 핀을 통해 별도로 연결됩니다.

부품: 연결:
--------------------------------------------------------------------
MAX30102 SDA → GPIO 6, SCL → GPIO 7, 3.3V, GND
MLX90614 SDA → GPIO 6, SCL → GPIO 7, 3.3V, GND
OLED SDA → GPIO 6, SCL → GPIO 7, 3.3V, GND
온도/습도 센서 VCC, GND, 데이터
전원 스위치 전원 공급선에 직렬 연결
배터리 전원 시스템 입력

세 개의 I²C 장치를 동일한 버스에 연결하면 배선이 훨씬 간단해지고, 작은 케이스 내부의 공간을 절약하는 데 도움이 됩니다.




STEP 7. 퍼프보드 준비하기


모든 부품을 하나의 퍼프보드에 다 넣으려고 하기보다는 두 개의 보드를사용하기로 결정했습니다.

하단 퍼프보드에는 주요 전자 부품인 XIAO ESP32-C3, MAX30102 및 MLX90614가 들어 있습니다. 또한 환경 온도 및 습도 센서의 감지 영역이 주변 공기에 노출된 상태로 유지되도록 세로로 장착했습니다.

상단 퍼프보드는 주로 사용자가 보는 부품을 위한 것으로, OLED 디스플레이와 전원 스위치가 포함됩니다.

먼저 부품의 위치를 표시하고 케이스에 맞도록 퍼프보드를 잘라냈습니다. 납땜하기 전에 모듈을 보드 위에 배치하여 모든 부품을 놓을 공간이 충분한지, 그리고 두 보드를 나중에 함께 맞출 수 있는지 확인했습니다.

배터리는 별도로 두며, 최종 조립 시 전자 부품에 연결합니다.




STEP 8. 회로 납땜하기


회로 납땜하기


회로 납땜하기


레이아웃이 마음에 들자 부품을 제자리에 납땜하기 시작했습니다.

저는 하단 보드는 컨트롤러와 센서를 담당하도록 하고, 상단 보드는 OLED와 스위치를 담당하도록 했습니다. 이렇게 분리하니 배선을 더 쉽게 관리할 수 있었고, 외부에서 보이는 위치에 디스플레이를 배치하기도 수월했습니다.

또한 인클로저 내부에 여유 공간이 많지 않기 때문에 연결부를 최대한 간결하게 유지하려고 했습니다. I²C 연결은 관련 모듈 간에 공유하고, 전원 및 접지 연결은 각 보드에 분산했습니다.

납땜을 마친 후 배터리를 연결하거나 보드에 전원을 공급하기 전에 연결부에 단락이 없는지와 연속성이 있는지 확인했습니다.




STEP 9. 전자 부품 조립하기


전자 부품 조립하기


전자 부품 조립하기


전자 부품 조립하기


전자 부품 조립하기


두 보드를 모두 납땜하고 나니, 드디어 완성된 전자 부품 조립체가 어떻게 맞물리는지 확인할 수 있었습니다.

하단의 퍼프보드는 주요 전자 부품 구역의 역할을 합니다, XIAO ESP32-C3와 센서들을 장착하고 있습니다. 환경 센서는 주변 공기와 계속 상호작용할 수 있도록 수직으로 배치되어 있습니다.

그 위에는 상단 퍼프보드가 놓여 있으며, 여기에는 OLED와 스위치가 장착되어 있습니다. 배터리는 조립체 옆에 놓이며 전원부에 연결됩니다.

두 보드는 서로 연결되어 하나의 시스템으로 작동하면서도 웨어러블 기기를 작고 정돈된 상태로 유지할 수 있습니다.

이 시점에서 전자 부품들은 3D 프린팅으로 제작된 케이스에 들어갈 준비가 되었습니다.




STEP 10. 모든 것을 종합해 보면


모든 것을 종합해 보면


모든 것을 종합해 보면


조립 순서는 겉보이는 것보다 훨씬 중요합니다. 시계 크기의 케이스 안에서는 이미 완성된 부품을 지나쳐 손을 뻗을 여유가 없기 때문입니다.

피부에 닿는 두 개의 센서를 먼저 장착하고, 다른 부품을 장착하기 전에 해당 센서들이 개구부에 정확히 맞는지 확인했습니다. 그런 다음 퍼프보드, 배터리, 스위치, 그리고 OLED를 순서대로 장착한 뒤, 케이스를 닫고 나사를 조였습니다.

마지막으로 덮개를 닫기 전에 한 가지 확인해 볼 만한 사항이 있습니다. 쉘을 조립한 상태에서 전원을 켜고 MLX90614가 여전히 정상적으로 측정되는지 확인하세요. 조립 과정에서 전선이 센서를 축에서 약간 벗어나게 밀어버리기 쉬운데, 겉으로는 이를 알아차리기 어렵습니다. 피부 온도가 실험대에서 측정했을 때보다 갑자기 1~2도 낮게 표시된다면, 센서의 시야에 무언가가 들어온 것입니다.

자, 이제 하드웨어 조립은 끝났습니다. 이제부터는 모두 소프트웨어 작업입니다.




STEP 11. 소프트웨어의 구성 방식


소프트웨어의 구성 방식


코드를 작성하기 전에, 먼저 전체적인 형태를 살펴보겠습니다. 데이터는 한 방향으로 흐르고, 각 단계는 바로 앞 단계만 알고 있습니다:


센서
필터링 중앙값 + 이상치 제거
개인 기준선 사용자의 안정 시 수치와의 편차
환경 스트레스 + 생리적 부담
HYDR8 점수 0~100
추세 어느 방향으로 변하는지
권장 행동
OLED + 웹 앱

ESP32 프로젝트를 할 때 저는 본능적으로 하나의 거대한 .ino 파일을 만들고 싶어집니다. 약 300줄까지는 잘 작동하지만, 그 이상이 되면 끔찍해집니다. HYDR8은 약 4,000줄이므로 모듈로 나누었고, 각 모듈은 헤더 파일과 구현 파일로 구성됩니다:


HYDR8_v2/
├── HYDR8_v2.ino setup()과 loop(), 약 130줄
├── config.h 모든 핀, 주소, 간격, 가중치
├── types.h / .cpp 공유 구조체와 열거형
├── filters.h RingBuffer 템플릿, 중앙값, 기울기, 이상치
├── sensors.h / .cpp I2C 버스를 관리하고 각 드라이버로 분배
├── drv_max30102.cpp 심박수 + SpO2
├── drv_mlx90614.cpp 피부 표면 온도
├── drv_environment.cpp 주변 온도 + 습도
├── baseline.cpp 60초 보정 상태 머신
├── hydr8_score.cpp 실제 알고리즘
├── trend.cpp 이동 기울기
├── advice.cpp 점수 + 추세를 하나의 권장 행동으로 변환
├── hydration_log.cpp 음료 섭취 이벤트, 알림, 회복
├── history.cpp 이동 차트 데이터 + 인사이트
├── oled_ui.cpp 시계, 알림, 디스플레이 워치독
├── net_wifi.cpp 핫스팟 폴백 기능이 있는 Wi-Fi
├── web_server.cpp 경로 + WebSocket
├── api_json.cpp JSON 직렬화
├── storage.cpp NVS 영구 저장
├── settings_mgr.cpp 사용자 설정
└── page_html.h / page_css.h / page_js.h PROGMEM에 저장된 웹 앱

제가 고수한 규칙은 두 가지였고, 둘 다 큰 효과가 있었습니다.

config.h에는 조정 가능한 모든 숫자가 들어 있습니다. 다른 곳에는 매직 상수를 두지 않습니다. 새벽 2시에 디버깅할 때는 확인할 파일이 정확히 하나뿐이기를 바라게 됩니다.

헤더는 선언하고, .cpp 파일은 정의합니다. 서로를 포함하는 헤더가 15개인 헤더 전용 프로젝트에서는 두 파일이 같은 전역 변수를 필요로 하는 순간 다중 정의 오류가 발생합니다. 그래도 이 프로젝트는 하나의 평면적인 Arduino 스케치로 컴파일되며, 실제로 링크까지 수행됩니다.

알아둘 만한 Arduino의 한 가지 특이점이 있습니다. IDE는 스케치 루트와 src/ 폴더에 있는 파일만 컴파일합니다. 저는 처음에 웹 자산을 www/ 하위 폴더에 넣었다가 링커가 왜 그것을 찾지 못하는지 한동안 혼란스러워했습니다. 이제는 모든 것이 평평한 구조로 되어 있습니다.

본문이 길어 아래 덧글 2개로 계속됩니다.


원문: https://www.instructables.com/HYDR8-a-Wearable-That-Knows-When-Its-Time-to-Hydra/

좋아요 0
게시판

본문 계속 1/2




STEP 12. ESP32 코드


9.1 프로젝트 설정:

HYDR8_v2.ino는 다섯 가지 일만 합니다. 하위 시스템을 초기화한 다음, 이를 주기적으로 실행합니다.

cpp

void setup() {
Serial.begin(115200);

// USB CDC on the C3 takes up to a couple of seconds to enumerate after
// a reset. A fixed short delay races it and the first prints vanish,
// which looks exactly like "Serial is dead".
uint32_t t0 = millis();
while (!Serial && (millis() - t0 < 3000)) delay(50);
delay(200);

Storage::begin();
SettingsMgr::begin();
DeviceClock::begin();

Sensors::begin(); // also brings up the I2C bus the OLED shares
OledUI::begin(); // early, so boot is visible on the band

BaselineMgr::begin();
ScoreEngine::begin();
History::begin();
HydrationLog::begin();

Net::begin();
WebUI::begin();
}

이 Serial 대기는 장식이 아닙니다. ESP32-C3에서는 USB 포트가 열거되는 데 시간이 걸리므로, 단순한 delay(300)은 이 과정과 경쟁하게 됩니다. 그러면 부팅 메시지를 모두 놓치고 보드가 고장 났다고 판단하게 됩니다. 타임아웃을 두고 포트를 기다리면 컴퓨터가 연결되어 있든 없든 작동합니다.

9.2 비차단 타이밍:

나머지는 모두 다음 한 가지 규칙을 따릅니다: setup() 외부에서는 어디에서도 delay()를 사용하지 않습니다.

C3는 단일 코어입니다. 웹 서버, WiFi 스택, 센서와 디스플레이가 모두 이 코어를 공유합니다. delay(1000)을 한 번 실행하면 대시보드가 응답을 멈춥니다.

cpp

void loop() {
Sensors::update(); // ~5 ms cadence, bounded FIFO drain
BaselineMgr::update(); // 1 Hz while calibrating
ScoreEngine::update(); // 1 Hz
HydrationLog::update(); // 5 s
History::update(); // 60 s
servicePendingAlert();
OledUI::update(); // 5 Hz
WebUI::update(); // 1 Hz broadcast
Net::update(); // 10 s link check

yield();
}

각 함수는 자체 간격이 경과하지 않았으면 즉시 반환합니다. 모든 함수 내부의 패턴은 동일합니다:

cpp

uint32_t now = millis();
if (now - lastUpdateMs < T_SCORE_UPDATE) return;
lastUpdateMs = now;

여기서 부호 없는 뺄셈은 단순한 코딩 스타일이 아닙니다. 별도의 예외 처리 없이도 49일 후 발생하는 millis() 롤오버를 올바르게 처리합니다.

마지막의 yield()는 보기보다 중요합니다. 이것이 없으면 단일 코어 칩에서 타이트한 루프가 비동기 웹 서버와 WiFi 태스크를 굶기고, 대시보드는 이 한 줄의 누락이 원인이라는 사실을 추적하기 매우 어려운 방식으로 버벅이게 됩니다.

9.3 센서 초기화

sensors.cpp가 I2C 버스를 담당합니다. 다른 어떤 코드도 드라이버를 직접 건드리지 않습니다.

cpp

void begin() {
Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL, I2C_FREQ_HZ);

bool p = PulseSensor::begin();
bool s = SkinSensor::begin();
bool e = EnvSensor::begin();

Serial.printf("[sensors] MAX30102 %s | MLX90614 %s | SHT31 %s\n",
p ? "ok" : "MISSING", s ? "ok" : "MISSING",
e ? "ok" : "MISSING");

if (!p || !s || !e) scanBus();
}

실패 시 자동으로 실행되는 scanBus()는 이 프로젝트에서 그 무엇보다 많은 시간을 절약해 주었습니다. 응답하는 모든 주소를 출력하므로 배선 문제인지 코드 문제인지 즉시 알 수 있습니다:

cpp

void scanBus() {
Serial.println(F("[i2c] scanning..."));
uint8_t found = 0;
for (uint8_t addr = 1; addr < 127; addr++) {
Wire.beginTransmission(addr);
if (Wire.endTransmission() == 0) {
Serial.printf("[i2c] device at 0x%02X\n", addr);
found++;
}
}
if (!found) Serial.println(F("[i2c] nothing found - check wiring/pullups"));
}

9.4 MAX30102 읽기

MAX30102는 점수의 생리학적 부분을 결정하는 심박수를 제공합니다.

까다로운 점은 Maxim의 SpO2 알고리즘이 한 번에 100개 샘플을 요구하고, 뻔한 구현은 샘플을 모으는 동안 2초간 블로킹된다는 것입니다. 웹 서버가 실행 중인 상태에서는 받아들일 수 없습니다. 따라서 FIFO를 루프마다 몇 개의 샘플씩 비웁니다:

cpp

void update() {
if (!present) return;
uint32_t now = millis();
if (now - lastPollMs < T_PULSE_POLL) return;
lastPollMs = now;

sensor.check(); // pull whatever is waiting in the FIFO

// Bounded drain - one loop iteration can never run long.
uint8_t guard = 0;
while (sensor.available() && guard++ < 8) {
uint32_t ir = sensor.getFIFOIR();
uint32_t red = sensor.getFIFORed();
sensor.nextSample();
lastIR = ir;

if (ir < IR_FINGER_THRESHOLD) {
spo2Fill = 0; // contact lost, discard the partial window
continue;
}

if (checkForBeat(ir)) {
uint32_t delta = now - lastBeatMs;
lastBeatMs = now;
if (delta > 300 && delta < 2000) { // 30..200 bpm plausibility
float bpm = 60000.0f / (float)delta;
if (hrBuf.accepts(bpm)) {
hrBuf.push(bpm);
hrFiltered = hrBuf.median();
}
}
}

if (spo2Fill < SPO2_N) {
irBuf[spo2Fill] = ir;
redBuf[spo2Fill] = red;
spo2Fill++;
if (spo2Fill == SPO2_N) computeSpO2Window();
}
}
}

여기서 실제로 중요한 것은 두 가지입니다. guard++ < 8은 FIFO가 얼마나 가득 찼는지와 관계없이 한 번의 호출에서 처리하는 양을 제한합니다. 또한 delta > 300 && delta < 2000은 30~200 bpm의 타당성 검사입니다. 반사형 광학 센서의 박동 감지는 정기적으로 터무니없는 값을 내놓으므로, 필터에 도달하기 전에 이를 버려야 합니다.

이 센서 설정은 손목 측정에 맞춰 조정되어 있습니다:

cpp

sensor.setup(0x1F, 4, 2, 50, 411, 4096);
sensor.setPulseAmplitudeRed(0x1F);
sensor.setPulseAmplitudeIR(0x1F);
sensor.setPulseAmplitudeGreen(0);

샘플링 레이트는 50 Hz, 펄스 폭은 411 us, LED 출력은 중간 수준입니다. 이 보드는 녹색 LED를 사용하지 않으므로 꺼져 있습니다.

9.5 MLX90614 읽기

이 값이 변형 계산에서 피부 온도 항을 제공합니다.

cpp

float obj = mlx.readObjectTempC();
float amb = mlx.readAmbientTempC();

// A disconnected MLX returns NaN or a wild value rather than failing
// loudly, so both are screened here.
if (isnan(obj) || obj < SKIN_TEMP_MIN_C || obj > SKIN_TEMP_MAX_C) return;
if (!isnan(amb)) dieC = amb;

if (skinBuf.accepts(obj)) {
skinBuf.push(obj);
skinFiltered = skinBuf.median();
lastValidMs = now;
}

고장 난 MLX90614는 오류를 발생시키지 않고 조용히 NaN이나 엉뚱한 값을 반환합니다. isnan() 20~45 degC의 타당성 범위를 확인하면 두 가지 고장 양상을 모두 잡아낼 수 있습니다.

9.6 온도 및 습도 읽기

이 값이 환경 점수의 전체 부분을 결정합니다.

cpp

bool begin() {
// Gravity boards ship with ADDR low (0x44), but try 0x45 as well so a
// jumpered module still comes up instead of silently reading nothing.
present = sht.begin(ADDR_SHT31);
if (!present) present = sht.begin(0x45);
return present;
}

void update() {
float t = sht.readTemperature();
float h = sht.readHumidity();
if (isnan(t) || isnan(h)) return;
if (t < AMBIENT_TEMP_MIN_C || t > AMBIENT_TEMP_MAX_C) return;
if (h < 0.0f || h > 100.0f) return;

tempBuf.push(t);
humBuf.push(h);
tempFiltered = tempBuf.median();
humFiltered = humBuf.median();
lastValidMs = now;
}

9.7 데이터 필터링

웨어러블 센서는 노이즈가 많으므로 단일 측정값만으로 어떤 동작도 트리거해서는 안 됩니다. 모든 값은 고정 크기 링 버퍼를 거치며, 각 인스턴스는 템플릿을 사용해 정적으로 할당됩니다. 힙도, 단편화도 없습니다.

cpp

template &lt;typename T, uint8_t N&gt;
class RingBuffer {
public:
void push(T v);
float mean() const;
float median() const;
float cv() const; // coefficient of variation
float slope() const; // least squares, units per sample
bool accepts(T v, float k = 3.0f) const;
private:
T _data[N] = {};
uint8_t _head = 0, _count = 0;
};

흥미로운 메서드는 accepts()입니다. 이 메서드는 표준편차 대신 중앙 절대 편차(MAD)를 사용해 이상치를 제거합니다:

cpp

bool accepts(T v, float k = 3.0f) const {
if (_count < 4) return true;
float med = median();
float devs[N];
for (uint8_t i = 0; i < _count; i++) devs[i] = fabsf((float)_data[i] - med);
// ... sort devs ...
float mad = devs[_count / 2];
if (mad < 0.0001f) return true; // signal is flat, let it through
return fabsf((float)v - med) <= k * 1.4826f * mad;
}

표준편차 대신 MAD를 사용하는 이유는 무엇일까요? 표준편차 자체가 이상치 때문에 망가지기 때문입니다. 한 번의 급격한 변동이 표준편차를 부풀리면 필터가 다음 급격한 변동까지 받아들이기 쉬워집니다. 중앙값은 영향을 받지 않습니다. 1.4826은 정규 분포 데이터에서 MAD를 표준편차와 비교할 수 있는 값으로 변환합니다.

실제로는 센서가 잘못된 프레임 하나를 포착했다고 심박수가 190으로 뛰지 않으면서도, 실제 상승은 통과시킨다는 뜻입니다.

9.8 개인 기준선

심박수 105는 저와 훈련된 운동선수에게 완전히 다른 의미를 가집니다. 따라서 생리학적 수치는 원시값으로 점수에 들어가지 않습니다. 대신 자신의 안정 상태에서 벗어난 정도로 들어갑니다.

보정은 대시보드에서 시작하는 60초간의 캡처입니다:

cpp

void update() {
if (bl.state != CAL_RUNNING) return;

uint32_t now = millis();
if (now - calStartMs >= BASELINE_DURATION_MS) { finish(); return; }

if (now - lastSampleMs < 1000) return; // one sample per second
lastSampleMs = now;

const Readings& r = Sensors::current();
if (r.pulseValid() && r.heartRate > 30 && r.heartRate < 200)
calHR.push(r.heartRate);
if (r.skinValid()) calSkin.push(r.skinTempC);
if (r.pulseValid() && r.spo2 >= 80) calSpO2.push(r.spo2);
}

그리고 중요한 점은 캡처가 실패할 수 있다는것입니다:

cpp

void finish() {
// A baseline built from three noisy samples is worse than no baseline.
if (calHR.size() >= BASELINE_MIN_HR_SAMPLES) {
bl.restHR = calHR.median();
bl.restSkinC = calSkin.median();
bl.restSpO2 = calSpO2.median();
bl.state = CAL_READY;
Storage::saveBaseline(bl);
} else {
bl.state = CAL_NONE;
Serial.println(F("[baseline] not enough clean samples - discarded"));
}
}

60초 동안 깨끗한 심박수 샘플이 15개보다 적으면 전체 기준선을 폐기합니다. 자신 있게 틀린 기준선은 이후의 모든 점수를 오염시키므로, 기준선을 만들지 않는 편이 더 안전한 실패 방식입니다.

기준선은 NVS에 저장되며 재부팅 후에도 유지됩니다.

9.9 환경 열 계산

온도만으로는 충분하지 않습니다. 습도 30%의 35 degC와 습도 80%의 35 degC는 생리학적으로 완전히 다른 상황입니다. 땀은 증발할 수 있을 때만 몸을 식히기 때문입니다.

따라서 환경 점수에는 Rothfusz 열지수 회귀식을 사용합니다. 기상 서비스에서 사용하는 것과 같은 체감온도 공식입니다. 화씨로 정의된 공식이므로 코드에서 화씨로 변환했다가 다시 변환합니다:

cpp

float rothfusz(float tF, float rh) {
// Simple form is accurate enough below ~80F and avoids the full
// regression misbehaving at low temperatures.
float simple = 0.5f * (tF + 61.0f + ((tF - 68.0f) * 1.2f) + (rh * 0.094f));
if ((simple + tF) / 2.0f < 80.0f) return simple;

float hi = -42.379f + 2.04901523f * tF + 10.14333127f * rh
- 0.22475541f * tF * rh - 0.00683783f * tF * tF
- 0.05481717f * rh * rh + 0.00122874f * tF * tF * rh
+ 0.00085282f * tF * rh * rh
- 0.00000199f * tF * tF * rh * rh;

if (rh < 13.0f && tF >= 80.0f && tF <= 112.0f)
hi -= ((13.0f - rh) / 4.0f) * sqrtf((17.0f - fabsf(tF - 95.0f)) / 17.0f);
else if (rh > 85.0f && tF >= 80.0f && tF <= 87.0f)
hi += ((rh - 85.0f) / 10.0f) * ((87.0f - tF) / 5.0f);

return hi;
}

그런 다음 열지수를 0~100에 선형으로 매핑합니다:

cpp

float hiC = (rothfusz(tF, r.humidity) - 32.0f) * 5.0f / 9.0f;
return mapClamped(hiC, HI_COMFORT_C, HI_EXTREME_C, 0.0f, 100.0f);
// 27C or below -> 0, 46C or above -> 100

9.10 생리적 부담

이는 다음을 바탕으로 수정한 것입니다. Physiological Strain Index Moran 등이 제시한 지수로, 심박수와 체온을 0~10 척도로 결합합니다:

PSI = 5 x (HR_now - HR_rest) / (180 - HR_rest)
+ 5 x (T_now - T_rest) / (39.5 - T_rest)

공개된 PSI는 심부 체온을 사용합니다. 저는 심부 체온을 측정할 수 없습니다. 손목의 MLX90614는 피부 표면 온도를 측정하므로 이를 대신 사용하고, 피부는 심부보다 차갑기 때문에 상한을 39.5 degC에서 38 degC로 낮췄습니다.

이 대체가 이것을 측정값이 아닌 추정값으로 만드는 가장 큰 이유이며, 그 사실을 감추기보다 분명히 밝히고 싶습니다.

cpp

float computePhys(const Readings& r) {
const Baseline& b = BaselineMgr::get();
float strainHR = 0, strainTemp = 0;
uint8_t parts = 0;

if (r.pulseValid() && r.heartRate > 0) {
float span = PSI_HR_CEILING - b.restHR;
if (span > 1.0f) {
strainHR = 5.0f * (r.heartRate - b.restHR) / span;
strainHR = clampf(strainHR, 0.0f, 5.0f);
parts++;
}
}

if (r.skinValid()) {
float span = PSI_SKIN_CEILING_C - b.restSkinC;
if (span > 0.2f) {
strainTemp = 5.0f * (r.skinTempC - b.restSkinC) / span;
strainTemp = clampf(strainTemp, 0.0f, 5.0f);
parts++;
}
}

if (parts == 0) return 0;

float psi = strainHR + strainTemp;
// With only one component available, scale it up so a single working
// sensor does not silently halve the strain reading.
if (parts == 1) psi *= 2.0f;
psi = clampf(psi, 0.0f, 10.0f);

float strain = psi * 10.0f;

// SpO2 as a secondary signal only, hard-capped so a noisy optical
// reading can never dominate the score.
if (r.pulseValid() && r.spo2 > 0 && r.spo2 < SPO2_PENALTY_FLOOR) {
float pen = (SPO2_PENALTY_FLOOR - r.spo2) * SPO2_PENALTY_GAIN;
strain += clampf(pen, 0.0f, SPO2_PENALTY_MAX);
}

return clampf(strain, 0.0f, 100.0f);
}

부품 카운터는 우아한 성능 저하를 구현합니다. MLX90614가 고장 나면 부담이 조용히 절반으로 줄어들어 점수가 거짓으로 안심할 만해지는 대신 심박수 항을 두 배로 합니다. 고장 난 센서 때문에 실제보다 상황이 좋아 보이게 해서는 안 됩니다.


9.11 HYDR8 점수

모든 요소를 가중합으로 결합합니다:


HYDR8 SCORE = Environmental Stress x 0.45
+ Physiological Strain x 0.45
+ Trend x 0.10

이 가중치는 임상적으로 검증된 값이 아니라 실험용 프로토타입 매개변수입니다. 환경과 신체가 똑같이 중요하고 방향은 조금만 영향을 주면 된다고 생각해 이 값을 정했습니다. 이 값은 config.h에 있으므로 반드시 조정해야 합니다.

cpp

float env = computeEnv(r, hi);
float phys = computePhys(r);

// The trend component uses the PREVIOUS cycle's slope. Feeding the new
// score into the trend before using it would make the score depend on
// itself and amplify noise.
float slope = TrendMgr::slopePerMin();
float trendFactor = mapClamped(slope, 0.0f, TREND_SLOPE_MAX, 0.0f, 100.0f);

float raw = W_ENVIRONMENT * env + W_PHYSIOLOGY * phys + W_TREND * trendFactor;
raw = clampf(raw, 0.0f, 100.0f);

// Light smoothing so the ring on the dashboard glides instead of
// twitching, without hiding a genuine climb.
smoothedScore = firstRun ? raw : ema(smoothedScore, raw, 0.25f);
firstRun = false;

TrendMgr::push(smoothedScore);

이전 사이클의 기울기에 관한 주석은 올바르게 이해하는 데 시간이 좀 걸렸습니다. 점수를 계산해 추세 버퍼에 넣은 다음 그 기울기를 다시 읽어 같은 점수를 계산하면, 노이즈를 증폭해 흔들림으로 만드는 피드백 루프가 생깁니다. 이전 사이클의 기울기를 사용하면 이 루프가 끊깁니다.

9.12 신뢰도

정직성을 위한 메커니즘입니다. 센서가 없거나 신호가 불안정하면 점수를 덜 신뢰해야 하며, 대시보드도 이를 알립니다.

cpp

int computeConfidence(const Readings& r) {
int c = 100;
if (!r.pulseValid()) c -= CONF_NO_PULSE; // 30
if (!r.skinValid()) c -= CONF_NO_SKIN; // 20
if (!r.envValid()) c -= CONF_NO_ENV; // 20
if (!BaselineMgr::get().valid()) c -= CONF_NO_BASELINE; // 25
if (r.pulseValid() && PulseSensor::hrStability() > HR_CV_UNSTABLE)
c -= CONF_UNSTABLE; // 15
return c < 0 ? 0 : c;
}

이는 통계적 신뢰 구간이 아니며, UI는 이를 다른 것처럼 가장하지 않고 신뢰성 지표로 표시합니다.

9.13 추세 분석

떨어지는 70점에는 올라가는 70점과 다른 조언이 필요합니다. 최근 30개의 점수 샘플에 최소제곱 회귀를 적용합니다:

cpp

float slopePerMin() {
float perSample = scores.slope();
float samplesPerMin = 60000.0f / (float)T_SCORE_UPDATE;
return perSample * samplesPerMin;
}

TrendState state() {
// Below a third of the window the slope is mostly noise, so the system
// says STABLE rather than guessing.
if (scores.size() < TREND_WINDOW / 3) return TREND_STABLE;

float s = slopePerMin();
if (s >= SLOPE_RAPID) return TREND_RAPIDLY_RISING;
if (s >= SLOPE_RISING) return TREND_RISING;
if (s <= SLOPE_RECOVERING) return TREND_RECOVERING;
if (s <= SLOPE_FALLING) return TREND_FALLING;
return TREND_STABLE;
}

의미 없는 기울기를 계산하는 대신 데이터가 부족하면 STABLE을 반환합니다. 이는 부팅 후 처음 10초 동안 UI가 심하게 요동하는 것을 막는 작은 조치입니다.

9.14 추천 로직

점수만으로는 행동으로 옮길 수 없습니다. 추천 엔진은 스트레스가 어디에서 오는지 살펴봅니다:

cpp

Advice decide(const ScoreResult& s, const Readings& r) {
bool rising = (s.trend == TREND_RISING || s.trend == TREND_RAPIDLY_RISING);

// Environment dominating: the useful advice is to change the
// environment, not to drink more.
bool envDriven = s.envStress > 70.0f && s.envStress > s.physStrain + 15.0f;

if (envDriven && s.score >= BAND_HIGH) return ADVICE_FIND_SHADE;
if (s.envStress > 80.0f) return ADVICE_COOL_DOWN;

if (s.band == BAND_VERY_HIGH_LVL)
return rising ? ADVICE_TAKE_BREAK : ADVICE_DRINK_WATER;
if (s.band == BAND_HIGH_LVL)
return rising ? ADVICE_TAKE_BREAK : ADVICE_DRINK_WATER;
if (s.band == BAND_MODERATE_LVL)
return rising ? ADVICE_DRINK_WATER : ADVICE_HYDRATE_SOON;

if (s.trend == TREND_RAPIDLY_RISING) return ADVICE_HYDRATE_SOON;
return ADVICE_NORMAL;
}

envDriven 검사는 이 프로젝트 전체에서 제가 가장 만족하는 부분입니다. 햇볕을 쬐고 있지만 아직 몸이 크게 힘들어하지 않는다면 물을 마시라는 조언은 잘못된 조언입니다. 그늘이 필요합니다. 물을 더 마셔도 45 degC의 햇볕 아래 서 있는 문제는 해결되지 않으며, 단일 숫자 점수로는 그 차이를 표현할 수 없습니다. 점수를 환경적 부분과 생리적 부분으로 나누었기에 이것이 가능해집니다.

9.15 적응형 알림

30분으로 고정된 타이머는 없습니다. 간격은 현재 스트레스에 따라 조정됩니다:

cpp

float intervalScale(float score) {
if (score >= BAND_VERY_HIGH) return 0.25f;
if (score >= BAND_HIGH) return 0.45f;
if (score >= BAND_MODERATE) return 0.75f;
return 1.5f;
}

uint32_t currentIntervalMs() {
const Settings& cfg = SettingsMgr::get();
const ScoreResult& s = ScoreEngine::current();
float sens = mapClamped((float)cfg.sensitivity, 0, 100, 1.5f, 0.5f);
float ms = (float)cfg.reminderBaseMs * intervalScale(s.score) * sens;
if (ms < (float)REMINDER_MIN_GAP_MS) ms = (float)REMINDER_MIN_GAP_MS;
return (uint32_t)ms;
}

기본 30분 간격은 시원하고 편안할 때 45분이 되고, 매우 높은 구간에서는 7.5분이 됩니다. 일정을 완전히 건너뛰는 긴급 재정의도 있습니다:

cpp

if (s.score >= BAND_VERY_HIGH &&
(s.trend == TREND_RISING || s.trend == TREND_RAPIDLY_RISING)) {
pending = true;
lastReminderMs = now;
return;
}

그리고 어떤 것도 우회할 수 없는 3분의 엄격한 최소 간격이 있습니다. 30초마다 재촉하는 기기는 벗어 서랍에 넣어 두게 됩니다.

9.16 물 섭취 기록

음료를 기록할 때는 그 상태를 기록하기 전 에 캡처하며, 이 덕분에 나중에 회복을 분석할 수 있습니다:

cpp

bool logEvent(uint16_t ml) {
const ScoreResult& s = ScoreEngine::current();
const Readings& r = Sensors::current();

if (evtCount >= MAX_HYDRATION_EVENTS) {
for (uint8_t i = 1; i < MAX_HYDRATION_EVENTS; i++) evts[i-1] = evts[i];
evtCount = MAX_HYDRATION_EVENTS - 1;
}

HydrationEvent e;
e.atSec = DeviceClock::nowSec();
e.ml = ml; // 0 means the user didn't state an amount
e.scoreBefore = (uint8_t)clampf(s.score, 0, 100);
e.hrBefore = (uint8_t)clampf(r.heartRate, 0, 255);
evts[evtCount++] = e;

Storage::saveEvents(evts, evtCount);
return true;
}

ml은 "양이 명시되지 않음"을 뜻할 때 기본값이 0입니다. 여기에는 유량 센서가 없으므로 기기는 사용자가 알려 준 것만 알고, 그 이상을 아는 척하지 않습니다.

로그는 24개 이벤트를 순환 기록합니다. 가득 차면 배열을 늘리는 대신 가장 오래된 항목을 삭제합니다.

9.17 회복 분석

음료를 기록한 뒤 10분이 지나면 회복 창이 닫히고 "이후" 상태가 캡처됩니다:

cpp

void closeRecoveryWindows() {
HydrationEvent& e = evts[evtCount - 1];
if (e.recoveryReady) return;
if (millis() - lastEventMs < RECOVERY_WINDOW_MS) return;

e.scoreAfter = (uint8_t)clampf(ScoreEngine::current().score, 0, 100);
e.hrAfter = (uint8_t)clampf(Sensors::current().heartRate, 0, 255);
e.recoveryReady = true;
recoveryIdx = e.scoreBefore - e.scoreAfter;
}

중요한 구분입니다. 이는 기록된 음료 섭취 후 측정 상태가 개선되었음을 보여줄 뿐, 물이 그 원인이었음을 보여 주지는 않습니다. 움직임을 멈췄거나 실내로 들어갔거나 자연스럽게 식었을 수도 있습니다. UI의 문구는 실제로 참인 내용만 말하도록 되어 있습니다:

javascript

// builds: "Your score fell 22 points over that window."
var txt = 'Your score ' + (diff >= 0 ? 'fell ' : 'rose ')
+ Math.abs(diff) + ' points over that window.';

"물을 마셔 열 스트레스가 22포인트 줄었습니다"라고 쓰는 것은 쉬웠고, 그렇게 하면 더 인상적으로 보였을 것입니다. 하지만 그것은 거짓말이었을 것입니다.

9.18 OLED 로직

이 밴드는 알림을 표시하는 장치이지, 휴대폰 앱의 작은 복사본이 아닙니다. 기본 상태는 시계이며, 중요한 내용은 8초 동안 전체 화면에 표시한 뒤 원래 화면으로 돌아갑니다.

HYDR8
----------------------------
10:42 72

HIGH
## ## ## ## ## ## __ __ __

시간은 크게 왼쪽 정렬해 숫자 폭이 바뀌어도 다시 배치되지 않게 했습니다. 점수는 오른쪽 위에 표시됩니다. 아래쪽에는 10분할 레벨 막대가 있습니다:

cpp

void drawSegBar(int16_t x, int16_t y, int16_t w, int16_t h, uint8_t segs,
float frac) {
int16_t gap = 2;
int16_t sw = (w - gap * (segs - 1)) / segs;
uint8_t lit = (uint8_t)(frac * segs + 0.5f);
for (uint8_t i = 0; i < segs; i++) {
int16_t sx = x + i * (sw + gap);
if (i < lit) display.fillRect(sx, y, sw, h, SSD1306_WHITE);
else display.drawFastHLine(sx, y + h - 1, sw, SSD1306_WHITE);
}
}

128x64 패널에서는 높이가 1픽셀인 아날로그 막대가 뭉개져 보이므로 매끄러운 막대 대신 세그먼트를 사용합니다. 불연속 블록은 흘끗 봐도 읽기 쉽습니다.

알림이 패널 전체를 반전시키므로 주변 시야에서도 놓치기 어렵습니다:

cpp

void drawAlert() {
display.fillRect(0, 0, OLED_W, OLED_H, SSD1306_WHITE);
display.setTextColor(SSD1306_BLACK);
display.fillTriangle(3, 32, 9, 26, 9, 38, SSD1306_BLACK);
display.fillTriangle(OLED_W - 4, 32, OLED_W - 10, 26,
OLED_W - 10, 38, SSD1306_BLACK);
drawCentered(alertL1, 2, 26 + yo);
display.setTextColor(SSD1306_WHITE);
}

화면 상태 머신은 update()에 있으며 의도적으로 단순합니다. alert가 우선이고, 그다음은 calibration, boot, fault, clock 순입니다.

9.19 디스플레이 워치독

이 절이 있는 이유는 이 문제를 해결하는 데 알고리즘 전체보다 더 많은 시간이 들었기 때문입니다.

증상은 이랬습니다. 새로 업로드한 직후에는 디스플레이가 완벽하게 작동하다가 이후 이상 동작을 하고, 멈추고, 검게 변합니다. 한편 웹 대시보드는 계속 완벽하게 실행됩니다. 이 마지막 부분이 단서였습니다. MCU는 정상이고 I2C 쪽만 죽어 가고 있었던 것입니다.

근본 원인은 디스플레이가 부팅 시 한 번만 초기화된다는 것이었습니다. 노이즈 글리치가 컨트롤러를 망가뜨려도 이를 알아차리거나 재시도하는 코드가 없었습니다. 그래서 이제 워치독이 있습니다:

cpp

void serviceHealth() {
uint32_t now = millis();

if (now - lastHealthMs >= T_OLED_HEALTH) {
lastHealthMs = now;
if (!addressResponds(activeAddr)) {
// NOTE: deliberately NO Wire.end() here. Ending the peripheral
// clears the stored pin assignment, and Adafruit's begin() then
// calls Wire.begin() with no arguments, which falls back to the
// chip's DEFAULT I2C pins instead of ours.
Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL, I2C_FREQ_HZ);
delay(OLED_RECOVER_SETTLE_MS);

bool acks = addressResponds(activeAddr);
bool inited = reinitPanel();

if (acks && inited) {
recoveryCount++;
present = true;
forcePush = true;
}
return;
}
}
}

그 Wire.end() 경고는 제가 직접 빠졌다가 빠져나와야 했던 함정입니다. 버스가 멈췄을 때 정확히 해야 할 일처럼 보이지만, 그렇지 않습니다.

해결책의 두 번째 부분은 버스 트래픽을 줄이는 것이었습니다. display()를 호출할 때마다 1 KB를 한 번에 전송하며, 10 Hz에서는 트랜잭션도 많고 충전 펌프 전류도 많이 필요합니다. 하지만 시계 화면은 한 번에 1분 동안 정적입니다. 따라서 코드는 프레임버퍼를 해시하고, 화면이 실제로 바뀌었을 때만 전송합니다:

cpp

본문 계속 2/2

uint32_t frameHash() {
const uint8_t* buf = display.getBuffer();
uint32_t h = 2166136261UL; // FNV-1a
for (uint16_t i = 0; i < (OLED_W * OLED_H) / 8; i++) {
h ^= buf[i];
h *= 16777619UL;
}
return h;
}

// after drawing:
uint32_t h = frameHash();
if (h != lastFrameHash || forcePush) {
lastFrameHash = h;
forcePush = false;
display.display();
}

RAM 1 KB를 해시하는 비용은 I2C로 1 KB를 전송하는 비용보다 훨씬 낮습니다. 그 결과 쓰기 횟수는 초당 약 5회에서 분당 약 1회로 줄었습니다.

초기화 전에 안정화 지연도 두었는데, 이것 역시 중요한 것으로 드러났습니다:

cpp

bool begin() {
// Give the panel's charge pump time to come up before talking to it.
delay(OLED_POWER_SETTLE_MS); // 120 ms
...
}

SSD1306의 충전 펌프는 VCC가 상승한 후 초기화 시퀀스를 받아들이기까지 시간이 필요합니다. 이 시간이 없으면 USB로 전원을 켤 때는 열거 과정이 우연히 지연을 제공해 정상적으로 시작되지만, 배터리 전원을 껐다 켜면 화면이 꺼진 채로 남습니다.

마지막으로 I2C_FREQ_HZ는 일반적인 100 또는 400이 아니라 50 kHz로 설정되어 있습니다. 디바이스 네 개가 연결된 점퍼 와이어에서는 400 kHz가 WiFi 송신 노이즈로 트랜잭션이 손상되고 버스가 멈출 때까지는 작동합니다.

복구 카운터는 직렬 하트비트에 출력되므로 추측하지 않고 이 현상을 측정할 수 있습니다:


[oled] recoveries since boot: 3

한 시간에 0회 또는 1회면 괜찮습니다. 꾸준히 증가한다면 워치독이 하드웨어 결함을 임시로 덮고 있다는 뜻이므로 풀업을 개선해야 합니다.

9.20 핫스팟 폴백 기능이 있는 WiFi

라우터가 없는 테이블에서 데모를 진행할 때 "WiFi에 연결"을 필수 조건으로 두는 것은 끔찍합니다. 그래서 펌웨어는 네트워크에 연결을 시도하고, 실패하면 자체 네트워크를 호스팅하는 방식으로 전환합니다:

cpp

void begin() {
WiFi.mode(WIFI_STA);
WiFi.setSleep(false); // keeps the dashboard responsive
WiFi.begin(WIFI_STA_SSID, WIFI_STA_PASS);

uint32_t start = millis();
while (WiFi.status() != WL_CONNECTED &&
millis() - start < WIFI_JOIN_TIMEOUT_MS) {
delay(200); // setup() only, the loop never blocks
}

if (WiFi.status() == WL_CONNECTED) {
apMode = false;
} else {
Serial.println(F("[wifi] join failed, falling back to AP"));
startAP();
}

if (MDNS.begin(HYDR8_HOSTNAME)) MDNS.addService("http", "tcp", 80);
}

WiFi.setSleep(false)는 짚고 넘어갈 만합니다. 모뎀 절전이 활성화되면 대시보드에 무작위로 100~300 ms의 지연이 생기는데, 이는 자신의 코드에 버그가 있는 것처럼 느껴집니다.

따라서 밴드에는 언제나 접속할 수 있습니다. 네트워크의 해당 IP, http://hydr8.local/ 또는 휴대폰을 HYDR8-SETUP에 연결한 뒤 192.168.4.1을 열어 접속하면 됩니다.

9.21 RTC 없이 시간 알기

이 빌드에는 RTC가 없지만 OLED의 주된 역할은 시계를 표시하는 것입니다. NTP에는 인터넷이 필요하므로 오프라인 요구 사항과 맞지 않습니다.

해결책: 휴대폰이 시계입니다. 웹 앱은 페이지를 로드할 때마다 시간을 전송합니다.

cpp

void setTime(uint32_t epochSec) {
epochAtSync = epochSec;
millisAtSync = millis();
synced = true;
}

uint32_t nowSec() {
// Unsigned subtraction handles the 49-day millis() rollover correctly.
uint32_t elapsed = (millis() - millisAtSync) / 1000UL;
return synced ? (epochAtSync + elapsed) : elapsed;
}

브라우저 측 WebSocket open 핸들러 안에 다음 한 줄을 넣습니다:

javascript

post('/api/time', {
epoch: Math.floor(Date.now()/1000) - new Date().getTimezoneOffset()*60
});

기기가 현지 시간을 직접 저장하도록 시간대 오프셋을 뺍니다. 오차는 시간당 몇 초 정도이며 시계 화면에서는 아무도 알아차리지 못합니다.

9.22 웹 서버 및 API

대시보드 전체는 플래시에서 제공됩니다. 덕분에 RAM이 400 KB인 칩에서도 37 KB 웹 앱을 사용할 수 있습니다:

cpp

void serveProgmem(AsyncWebServerRequest* req, const char* type,
const char* body) {
AsyncWebServerResponse* res = req->beginResponse_P(200, type, body);
res->addHeader("Cache-Control", "max-age=600");
req->send(res);
}

server.on("/", HTTP_GET, [](AsyncWebServerRequest* r) {
serveProgmem(r, "text/html", PAGE_HTML);
});
server.on("/app.css", HTTP_GET, [](AsyncWebServerRequest* r) {
serveProgmem(r, "text/css", PAGE_CSS);
});
server.on("/app.js", HTTP_GET, [](AsyncWebServerRequest* r) {
serveProgmem(r, "application/javascript", PAGE_JS);
});

beginResponse_P는 플래시에서 곧바로 스트리밍하므로 페이지가 RAM을 차지하지 않습니다. 세 파일로 나누면 브라우저가 CSS와 JS도 별도로 캐시합니다.

전체 엔드포인트 목록:

GET /api/status full snapshot
GET /api/history downsampled chart arrays
GET /api/hydration events + recovery data
GET /api/settings preferences
POST /api/settings update preferences
GET /api/baseline read baseline
POST /api/baseline/start begin 60 s calibration
DELETE /api/baseline clear baseline
GET /api/insights rule-derived observations
POST /api/hydration/log {ml:250}
DELETE /api/hydration clear the log
DELETE /api/history clear history
POST /api/time browser sets the clock
WS /ws 1 Hz live push

9.23 String 없는 JSON

이것은 이 프로젝트에서 가장 중요한 ESP32 메모리 관련 교훈입니다. 요청 경로 어디에서도 String 연결을 사용하지 않습니다. 이것이 ESP32에서 힙 단편화를 일으키는 가장 큰 원인입니다. 모든 내용은 snprintf로 하나의 공유 버퍼에 작성합니다:

cpp

bool appendf(char* buf, size_t len, size_t* pos, const char* fmt, ...) {
if (*pos >= len) return false;
va_list ap;
va_start(ap, fmt);
int n = vsnprintf(buf + *pos, len - *pos, fmt, ap);
va_end(ap);
if (n < 0 || (size_t)n >= len - *pos) return false;
*pos += n;
return true;
}

버퍼가 부족하면 false를 반환하므로 호출자는 브라우저가 파싱할 수 없는 잘린 JSON을 내보내지 않고 깔끔하게 중단할 수 있습니다:

cpp

for (uint16_t i = 0; i < n; i += step) {
const HistoryPoint& h = History::at(i);
if (!appendf(buf, len, &p, "%s{\"t\":%lu,\"s\":%u,...}", ...))
break; // out of room, emit what we have rather than corrupt JSON
first = false;
}

모든 핸들러가 공유하는 4 KB 스크래치 버퍼는 하나뿐입니다. 모두 같은 비동기 태스크에서 실행되므로 재진입 문제가 없고, 요청마다 스택에 4 KB를 할당하지 않아도 됩니다.

9.24 기록과 인사이트

분당 하나씩 총 120포인트를 약 1.4 KB로 정적으로 할당합니다:

cpp

struct HistoryPoint {
uint32_t atSec;
uint8_t score, hr, spo2;
int16_t skinTempC10, ambientTempC10; // tenths of a degree
uint8_t humidity;
};
static HistoryPoint pts[HISTORY_POINTS];

온도를 부동 소수점 대신 int16_t에 10분의 1 단위로 저장하면 메모리를 절반으로 줄이면서도 눈에 띄는 정보는 잃지 않습니다.

인사이트는 이 버퍼에 적용하는 간단한 규칙이며, 중요한 점은 충분한 데이터가 없을 때 아무것도 생성하지 않는다는 것입니다:

cpp

uint8_t buildInsights(char out[][96], uint8_t maxOut) {
uint8_t n = 0;
// Ten minutes of data is the floor. Below that any "insight" would be
// an invention, so the strip stays empty instead.
if (used < 10 || maxOut == 0) return 0;

uint8_t peak = 0; uint32_t peakAt = 0;
for (uint16_t i = 0; i < used; i++)
if (at(i).score > peak) { peak = at(i).score; peakAt = at(i).atSec; }

if (peak >= BAND_MODERATE && n < maxOut) {
char t[8]; formatClock(peakAt, t, sizeof(t));
snprintf(out[n++], 96, "Your highest heat stress so far was %u at %s.",
(unsigned)peak, t);
}
// more rules follow
return n;
}





STEP 13. 웹앱 구축하기


웹앱 구축하기


이 밴드는 의도적으로 거의 아무것도 보여주지 않습니다. 흥미로운 모든 것은 ESP32가 직접 호스팅하는 모바일 대시보드에 담겨 있습니다.

이를 형성한 제약 요인들:

인터넷이 안 됩니다. CDN 폰트도, Chart.js도, Tailwind도 없습니다. 플래시에 포함되지 않은 것은 존재하지 않는 것과 같습니다. 모든 것이 순수한 HTML, CSS 및 JavaScript로 구성되어 있습니다.

모바일 우선. 390 px 휴대폰 화면에 맞춰 설계되었습니다. 그 외의 모든 것은 덤입니다.

작다. 이 앱 전체는 세 개의 파일에 걸쳐 약 37 KB이며, 모두 PROGMEM에 들어 있습니다.

테마

파란색, 짙은 네이비색 텍스트, 넉넉한 여백, 둥근 카드. CSS 변수로 한 번 정의하면:

css

:root{
--navy:#0E2A4E;
--ink:#0A1A33;
--accent:#2E7DFF;
--cyan:#00B7C7;
--warn:#FF8A34;
--alert:#E8434F;
--bg:#F1F6FD;
--card:#FFFFFF;
--line:#E1EAF7;
--muted:#6B7F9E;
--r:20px;
}

시스템 글꼴 스택을 사용하므로 다운로드가 필요 없고, iOS와 Android 모두에서 기본 앱처럼 자연스럽게 보입니다:

css

font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,
"Helvetica Neue",Arial,sans-serif;
font-variant-numeric: tabular-nums;

여기서 tabular-nums는 사소해 보이지만 매우 중요한 세부 사항입니다. 이것이 없으면 숫자가 업데이트될 때마다 너비가 변하고 모든 표시값이 흔들립니다.

구조 및 탐색

탭은 홈, 신체, 열, 수분, 기록의 다섯 개입니다. 여기에 톱니바퀴 아이콘 뒤에 설정이 있습니다. 모든 페이지는 한 번에 DOM에 존재하며 표시하거나 숨기기만 하므로, 라우팅보다 훨씬 간단합니다:

javascript

document.querySelectorAll('.tab').forEach(function(btn){
btn.addEventListener('click',function(){
document.querySelectorAll('.tab').forEach(function(b){
b.classList.remove('active');
});
document.querySelectorAll('.page').forEach(function(p){
p.classList.remove('active');
});
btn.classList.add('active');
$('page-'+btn.dataset.page).classList.add('active');
window.scrollTo(0,0);
if(btn.dataset.page==='hydration') loadHydration();
if(btn.dataset.page==='history') loadHistory();
});
});

하단 탐색 바는 안전 영역 패딩을 적용해 고정되므로, 최신 휴대폰에서 홈 인디케이터를 가리지 않습니다:

css

.tabs{
position:fixed;left:0;right:0;bottom:0;z-index:30;
display:flex;background:rgba(255,255,255,.94);
backdrop-filter:saturate(180%) blur(12px);
border-top:1px solid var(--line);
padding-bottom:env(safe-area-inset-bottom);
}

WebSocket을 통한 실시간 데이터

ESP32는 1초에 한 번씩 상태 페이로드를 전송합니다. 폴링도, 새로 고침 버튼도 없습니다:

javascript

function connect(){
ws = new WebSocket('ws://'+location.host+'/ws');
ws.onopen = function(){
retry = 1000;
$('offline').classList.add('hidden');
post('/api/time',{epoch: Math.floor(Date.now()/1000)
- new Date().getTimezoneOffset()*60});
};
ws.onmessage = function(ev){
try { renderLive(JSON.parse(ev.data)); } catch(e){}
};
ws.onclose = function(){
$('offline').classList.remove('hidden');
setTimeout(connect, retry);
retry = Math.min(retry*1.6, 8000); // exponential backoff
};
}

백오프는 겉보기보다 중요합니다. 백오프가 없으면 연결이 끊긴 휴대폰이 초당 여러 번 재연결을 시도하며 ESP32를 맹렬히 압박하는데, 바로 그때 ESP32는 이를 감당할 여력이 가장 적습니다.

펌웨어 측에서는 cleanupClients()가 매초 실행되므로, 읽기를 중단한 휴대폰이 큐를 무한정 쌓아 힙을 소모할 수 없습니다:

cpp

void update() {
uint32_t now = millis();
if (now - lastPushMs < T_WS_PUSH) return;
lastPushMs = now;

ws.cleanupClients();
if (ws.count() == 0) return;

size_t n = ApiJson::status(scratch, ApiJson::BUF_STATUS);
if (n) ws.textAll(scratch, n);
}

점수 링

순수 SVG와 CSS 전환 하나입니다. 애니메이션 라이브러리는 사용하지 않습니다:

html

<svg class="ring" viewBox="0 0 220 220">
<circle class="ring-track" cx="110" cy="110" r="94"/>
<circle class="ring-fill" id="ringFill" cx="110" cy="110" r="94"/>
</svg>

css

.ring{ transform: rotate(-90deg); }
.ring-fill{
stroke: var(--accent);
stroke-dasharray: 590.6;
stroke-dashoffset: 590.6;
transition: stroke-dashoffset .8s cubic-bezier(.22,.9,.28,1),
stroke .5s ease;
}

javascript

var C = 2 * Math.PI * 94;
$('ringFill').style.strokeDashoffset = C * (1 - score/100);
$('ringFill').style.stroke = BAND_COLOR[s.band];

stroke-dasharray를 원주 길이로 설정한 다음, stroke-dashoffset을 전체 값에서 해당 값까지 애니메이션 처리합니다. 브라우저가 GPU에서 이징을 처리하므로 세 줄이면 됩니다.

링 색상은 밴드에 따라 바뀌므로, 숫자를 읽기도 전에 상태를 파악할 수 있습니다:

javascript

var BAND_COLOR={
'NORMAL':'#22B573','MODERATE':'#2E7DFF',
'HIGH':'#FF8A34','VERY HIGH':'#E8434F'
};

열 기여도 막대

이 부분이 알고리즘을 눈에 보이게 만듭니다. 점수를 환경, 신체, 추세 요소로 나눕니다:

javascript

var e = s.env*0.45, p = s.phys*0.45, t = s.trendFactor*0.10;
var tot = e+p+t; if(tot<1) tot=1;
$('segEnv').style.width = (e/tot*100)+'%';
$('segPhys').style.width = (p/tot*100)+'%';
$('segTrend').style.width = (t/tot*100)+'%';

css

.split-bar{display:flex;height:14px;border-radius:999px;overflow:hidden}
.seg{height:100%;transition:width .6s cubic-bezier(.22,.9,.28,1)}
.seg-env{background:var(--cyan)}
.seg-phys{background:var(--accent)}
.seg-trend{background:var(--warn)}

청록색 구간이 우세하면 열이 외부에서 유입되고 있다는 뜻이므로 그에 따라 조언도 달라집니다. 이를 통해 추상적인 수치를 스스로 판단할 수 있는 정보로 바꿉니다.

손수 만든 차트

약 40줄의 SVG 경로 생성 코드입니다. Chart.js도, D3도 사용하지 않습니다:

javascript

function buildPath(vals,w,h,pad,lo,hi){
if(!vals.length) return '';
var span=(hi-lo)||1, n=vals.length, d='';
for(var i=0;i<n;i++){
var x = n===1 ? w/2 : pad + (i/(n-1))*(w-2*pad);
var y = h-pad - ((vals[i]-lo)/span)*(h-2*pad);
d += (i?'L':'M')+x.toFixed(1)+' '+y.toFixed(1);
}
return d;
}

같은 함수가 홈 카드의 작은 스파크라인과 Body, Heat, History의 전체 차트를 구동하며, 크기만 다릅니다. 스파크라인은 경로를 닫아 아래에 채워진 영역을 만듭니다:

javascript

el.innerHTML = '<path class="fill" d="'+d+'L100 28L0 28Z"/>'
+ '<path d="'+d+'"/>';

데이터가 충분하지 않을 때는 오해를 불러일으킬 수 있는 평평한 선을 그리는 대신 그렇게 표시합니다:

javascript

if(!any){
el.innerHTML='<text class="empty-txt" x="50%" y="50%" '+
'text-anchor="middle">Collecting data</text>';
return;
}

수분 섭취 기록

버튼 네 개, fetch 한 번, 그리고 작은 애니메이션입니다:

javascript

document.querySelectorAll('[data-ml]').forEach(function(btn){
btn.addEventListener('click',function(){
var ml=parseInt(btn.dataset.ml,10);
post('/api/hydration/log',{ml:ml}).then(function(){
var w=$('waterFill');
w.classList.remove('splash'); void w.offsetWidth; w.classList.add('splash');
toast(ml?('Logged '+ml+' ml'):'Drink logged');
loadHydration();
});
});
});

css

.water-fill{
position:absolute;left:0;right:0;bottom:0;height:0;
background:linear-gradient(180deg,rgba(46,125,255,.22),rgba(0,183,199,.28));
}
.water-fill.splash{animation:splash 1.1s ease-out}
@keyframes splash{0%{height:0}45%{height:100%}100%{height:0}}

그 void w.offsetWidth 줄은 말도 안 되는 것처럼 보이지만 그렇지 않습니다. 이 줄은 브라우저가 레이아웃을 다시 계산하도록 강제해 CSS 애니메이션을 재시작합니다. 이 줄이 없으면 버튼을 연달아 두 번 눌러도 애니메이션은 한 번만 실행됩니다.

작은 디테일

심박수 카드는 실시간 신호에 맞춰 박동합니다:

css

.pulse-dot.beating{animation:beat 1s ease-in-out infinite}
@keyframes beat{
0%,100%{transform:scale(1);opacity:1}
50%{transform:scale(1.55);opacity:.55}
}

그리고 전체 기능은 동작 줄이기 설정도 존중합니다. 두 줄이면 구현할 수 있고, 그렇게 하는 것이 옳습니다:

css

@media (prefers-reduced-motion:reduce){
*,*::before,*::after{
animation-duration:.001ms !important;
transition-duration:.001ms !important;
}
}




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가 옆에 있든 해당 파일이 컴파일되게 했습니다. 또한 새 버전을 추출할 때는 덮어쓰지 않고 기존 폴더를 완전히 삭제했습니다.

부팅 시 출력되는 버전 문자열을 추가하는 것도 큰 도움이 됩니다:

HYDR8 2.0.0
[boot] I2C on SDA=GPIO6 SCL=GPIO7

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은 실험적인 열 및 수분 스트레스 추정기다. 이 기기는 사용자의 현재 체내 수분 상태를 측정하지 않으며, 의료 기기도 아니고, 누구의 건강에 관한 결정을 내리는 데 사용해서도 안 된다. 이는 관련 신호를 바탕으로 스트레스를 추정하는 프로토타입이며, 자신의 휴대폰에 짜증이 난 한 학생이 만들었다.

회사명 공방유니언 주소 각 공방 주소 참조
사업자 등록번호 0000 대표 공방유니언 전화 019-60105846 팩스 0000 이메일 gongbangunion@gmail.com
통신판매업신고번호 0000 개인정보관리책임자 공방유니언
Copyright © 2017 공방유니언. All Rights Reserved.