SHT30 온습도 모니터링 시스템 전체 소스(서버 PHP, STM32 펌웨어, SQL, 테스트). 전체 코드리뷰에서 도출된 보안 하드닝 10건 반영: - 요청 서명 HMAC-SHA256 전환(펌웨어 sig.c/서버 config.php/호스트 패리티 동시) - 재전송 방어 + 기본 API_KEY fail-closed + 디바이스 문자열 정제(api/sensor_data.php) - 오프라인 SMS 중복 발송 경합 제거(cron_heartbeat.php, 원자적 선점) - CSV 수식 주입 방지(monthly_report.php), 감사로그 회전 락(retention_cleanup.php) - 브루트포스 카운터 원자화(login.php), 예시 TOTP 비밀키 무효화, 마이그레이션 멱등화 _backup/(하드코딩 실 비밀값 포함)·config.local.php·런타임 상태는 .gitignore 제외.
560 lines
20 KiB
HTML
560 lines
20 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>2번 센서 SHT30 라즈베리파이 셋팅 가이드</title>
|
|
<style>
|
|
:root {
|
|
--blue: #1d4ed8;
|
|
--blue-weak: #eff6ff;
|
|
--ink: #172033;
|
|
--muted: #64748b;
|
|
--line: #d9e2ef;
|
|
--paper: #ffffff;
|
|
--page: #f4f7fb;
|
|
--green: #15803d;
|
|
--teal: #0f766e;
|
|
--amber: #b45309;
|
|
--red: #b91c1c;
|
|
--violet: #3730a3;
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
html { scroll-behavior: smooth; }
|
|
body {
|
|
margin: 0;
|
|
font-family: "Noto Sans KR", "Malgun Gothic", "Apple SD Gothic Neo", sans-serif;
|
|
background: var(--page);
|
|
color: var(--ink);
|
|
line-height: 1.65;
|
|
}
|
|
a { color: var(--blue); text-decoration: none; }
|
|
a:hover { text-decoration: underline; }
|
|
header {
|
|
background: #123c7c;
|
|
color: #fff;
|
|
padding: 20px 24px;
|
|
border-bottom: 4px solid #0f766e;
|
|
}
|
|
.topbar {
|
|
max-width: 1120px;
|
|
margin: 0 auto;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
}
|
|
h1 {
|
|
margin: 0;
|
|
font-size: clamp(1.35rem, 2.8vw, 2.25rem);
|
|
line-height: 1.25;
|
|
letter-spacing: 0;
|
|
}
|
|
.version {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
white-space: nowrap;
|
|
border: 1px solid rgba(255,255,255,.35);
|
|
background: rgba(255,255,255,.12);
|
|
border-radius: 8px;
|
|
padding: 7px 10px;
|
|
font-size: .82rem;
|
|
font-weight: 700;
|
|
}
|
|
main {
|
|
max-width: 1120px;
|
|
margin: 0 auto;
|
|
padding: 24px 16px 56px;
|
|
}
|
|
.hero {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) minmax(320px, .92fr);
|
|
gap: 22px;
|
|
align-items: center;
|
|
margin-bottom: 22px;
|
|
}
|
|
.hero-copy {
|
|
background: var(--paper);
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
padding: 22px;
|
|
}
|
|
.eyebrow {
|
|
color: var(--teal);
|
|
font-weight: 800;
|
|
font-size: .82rem;
|
|
margin-bottom: 8px;
|
|
}
|
|
.hero h2 {
|
|
margin: 0 0 10px;
|
|
font-size: clamp(1.25rem, 2.4vw, 1.85rem);
|
|
line-height: 1.3;
|
|
letter-spacing: 0;
|
|
}
|
|
.hero p { margin: 0; color: var(--muted); font-size: .98rem; }
|
|
.hero img {
|
|
width: 100%;
|
|
display: block;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--line);
|
|
background: #fff;
|
|
}
|
|
.toc {
|
|
display: flex;
|
|
gap: 8px;
|
|
overflow-x: auto;
|
|
-webkit-overflow-scrolling: touch;
|
|
padding: 4px 0 18px;
|
|
margin-bottom: 6px;
|
|
}
|
|
.toc a {
|
|
flex: 0 0 auto;
|
|
border: 1px solid #bfdbfe;
|
|
background: var(--blue-weak);
|
|
color: var(--blue);
|
|
border-radius: 999px;
|
|
padding: 8px 12px;
|
|
font-size: .84rem;
|
|
font-weight: 800;
|
|
}
|
|
section {
|
|
background: var(--paper);
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
padding: 22px;
|
|
margin: 16px 0;
|
|
}
|
|
section h2 {
|
|
margin: 0 0 12px;
|
|
font-size: 1.15rem;
|
|
letter-spacing: 0;
|
|
}
|
|
section h3 {
|
|
margin: 18px 0 8px;
|
|
font-size: 1rem;
|
|
color: #334155;
|
|
letter-spacing: 0;
|
|
}
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 12px;
|
|
}
|
|
.tile {
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
padding: 14px;
|
|
background: #fbfdff;
|
|
}
|
|
.tile strong { display: block; margin-bottom: 5px; }
|
|
.tile span { color: var(--muted); font-size: .9rem; }
|
|
.notice {
|
|
border-left: 5px solid var(--amber);
|
|
background: #fffbeb;
|
|
padding: 12px 14px;
|
|
border-radius: 8px;
|
|
color: #713f12;
|
|
margin: 14px 0;
|
|
}
|
|
.ok {
|
|
border-left-color: var(--green);
|
|
background: #f0fdf4;
|
|
color: #14532d;
|
|
}
|
|
.image-panel {
|
|
margin: 14px 0 8px;
|
|
}
|
|
.image-panel img {
|
|
width: 100%;
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
background: #fff;
|
|
display: block;
|
|
}
|
|
.caption {
|
|
color: var(--muted);
|
|
font-size: .82rem;
|
|
margin-top: 7px;
|
|
}
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin: 12px 0;
|
|
overflow: hidden;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--line);
|
|
}
|
|
th, td {
|
|
text-align: left;
|
|
border-bottom: 1px solid var(--line);
|
|
padding: 10px 12px;
|
|
font-size: .9rem;
|
|
vertical-align: top;
|
|
}
|
|
th { background: #f8fafc; color: #475569; }
|
|
tr:last-child td { border-bottom: 0; }
|
|
.codebox {
|
|
position: relative;
|
|
margin: 12px 0;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
border: 1px solid #1e293b;
|
|
background: #0f172a;
|
|
}
|
|
pre {
|
|
margin: 0;
|
|
padding: 16px;
|
|
overflow-x: auto;
|
|
color: #e2e8f0;
|
|
font: 13px/1.55 Consolas, "Courier New", monospace;
|
|
}
|
|
.copy-btn {
|
|
position: absolute;
|
|
top: 8px;
|
|
right: 8px;
|
|
border: 1px solid rgba(255,255,255,.25);
|
|
border-radius: 6px;
|
|
background: rgba(255,255,255,.12);
|
|
color: #fff;
|
|
font-size: .75rem;
|
|
padding: 5px 8px;
|
|
cursor: pointer;
|
|
}
|
|
.step-list {
|
|
display: grid;
|
|
gap: 10px;
|
|
counter-reset: step;
|
|
}
|
|
.step {
|
|
display: grid;
|
|
grid-template-columns: 34px minmax(0, 1fr);
|
|
gap: 12px;
|
|
align-items: start;
|
|
}
|
|
.step::before {
|
|
counter-increment: step;
|
|
content: counter(step);
|
|
width: 34px;
|
|
height: 34px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: var(--blue);
|
|
color: #fff;
|
|
border-radius: 8px;
|
|
font-weight: 900;
|
|
}
|
|
.step p { margin: 0; }
|
|
.status {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
border-radius: 999px;
|
|
padding: 4px 9px;
|
|
font-size: .8rem;
|
|
font-weight: 800;
|
|
background: #dcfce7;
|
|
color: #166534;
|
|
}
|
|
.status.warn { background: #fef3c7; color: #92400e; }
|
|
.status.err { background: #fee2e2; color: #991b1b; }
|
|
.footer-actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
margin-top: 18px;
|
|
}
|
|
.btn {
|
|
border: 0;
|
|
border-radius: 8px;
|
|
background: var(--blue);
|
|
color: #fff;
|
|
padding: 9px 12px;
|
|
cursor: pointer;
|
|
font-weight: 800;
|
|
font-size: .88rem;
|
|
}
|
|
.btn.secondary { background: #334155; }
|
|
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
|
|
|
@media (max-width: 860px) {
|
|
.hero { grid-template-columns: 1fr; }
|
|
.grid { grid-template-columns: 1fr; }
|
|
.topbar { align-items: flex-start; flex-direction: column; }
|
|
.version { white-space: normal; }
|
|
}
|
|
@media (max-width: 560px) {
|
|
header { padding: 16px; }
|
|
main { padding: 16px 10px 42px; }
|
|
section, .hero-copy { padding: 16px; }
|
|
th, td { font-size: .82rem; padding: 8px; }
|
|
pre { font-size: 12px; padding: 42px 12px 12px; }
|
|
}
|
|
@media print {
|
|
body { background: #fff; }
|
|
header, .toc, .footer-actions, .copy-btn { display: none; }
|
|
main { padding: 0; max-width: none; }
|
|
section, .hero-copy { break-inside: avoid; border-color: #cbd5e1; }
|
|
.hero { display: block; }
|
|
.hero img { margin-top: 12px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<div class="topbar">
|
|
<div>
|
|
<h1>2번 센서 SHT30 라즈베리파이 셋팅 가이드</h1>
|
|
<div style="color:#dbeafe;margin-top:7px;font-size:.94rem">온습도 전용 Raspberry Pi를 기존 모니터링에 sensor_id=2로 연결</div>
|
|
</div>
|
|
<div class="version">v2605 Sensor2</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main>
|
|
<div class="hero">
|
|
<div class="hero-copy">
|
|
<div class="eyebrow">설치 목표</div>
|
|
<h2>SHT30 온도와 습도를 5분마다 서버로 보내고 대시보드에 최신값을 표시한다.</h2>
|
|
<p>두 장비 모두 SHT30 센서를 I2C로 읽어 `sensor_metric`에 온습도 이력을 남기며, 임계 초과 시 서버에서 경보 SMS를 발송한다.</p>
|
|
<div class="footer-actions">
|
|
<button class="btn" type="button" onclick="window.print()">인쇄 또는 PDF 저장</button>
|
|
<a class="btn secondary" href="../SHT30_SENSOR2_SETUP.md">Markdown 설명서</a>
|
|
</div>
|
|
</div>
|
|
<img src="assets/sht30_sensor2_data_flow.svg" alt="SHT30 센서2 데이터 흐름도">
|
|
</div>
|
|
|
|
<nav class="toc" aria-label="문서 목차">
|
|
<a href="#prepare">준비물</a>
|
|
<a href="#wiring">배선</a>
|
|
<a href="#server">서버 설정</a>
|
|
<a href="#pi">라즈베리파이</a>
|
|
<a href="#service">자동 실행</a>
|
|
<a href="#verify">확인</a>
|
|
<a href="#trouble">문제 대처</a>
|
|
</nav>
|
|
|
|
<section id="prepare">
|
|
<h2>1. 준비물</h2>
|
|
<div class="grid">
|
|
<div class="tile">
|
|
<strong>라즈베리파이 1대</strong>
|
|
<span>온습도 전용 장비. 장비 ID 예시는 `rpi-sht30-01`.</span>
|
|
</div>
|
|
<div class="tile">
|
|
<strong>SHT30 P4422-3 센서</strong>
|
|
<span>I2C 방식 온습도 센서. 기본 주소는 보통 `0x44`.</span>
|
|
</div>
|
|
<div class="tile">
|
|
<strong>서버 반영 파일</strong>
|
|
<span>API, DB migration, dashboard 수정 파일이 서버에 올라가 있어야 한다.</span>
|
|
</div>
|
|
</div>
|
|
<div class="notice ok">
|
|
센서 번호는 `SHT30_SENSOR_ID=2`로 고정한다. 기존 1번 SHT30 온습도 센서와 sensor_id로 분리해서 운영한다.
|
|
</div>
|
|
</section>
|
|
|
|
<section id="wiring">
|
|
<h2>2. SHT30 배선</h2>
|
|
<div class="image-panel">
|
|
<img src="assets/sht30_sensor2_wiring.svg" alt="라즈베리파이와 SHT30 센서 배선도">
|
|
<div class="caption">이미지 안의 라벨은 한글 폰트 깨짐을 피하기 위해 영문으로 표기했다.</div>
|
|
</div>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr><th>SHT30</th><th>Raspberry Pi</th><th>설명</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr><td>VCC</td><td>3.3V</td><td>5V가 아니라 3.3V 사용을 기본으로 한다.</td></tr>
|
|
<tr><td>GND</td><td>GND</td><td>공통 접지.</td></tr>
|
|
<tr><td>SDA</td><td>GPIO2 / SDA1 / pin 3</td><td>I2C 데이터 라인.</td></tr>
|
|
<tr><td>SCL</td><td>GPIO3 / SCL1 / pin 5</td><td>I2C 클럭 라인.</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="server">
|
|
<h2>3. 서버 DB와 PHP 반영</h2>
|
|
<div class="step-list">
|
|
<div class="step">
|
|
<p>운영 DB에 온습도 이력 테이블을 추가한다.</p>
|
|
</div>
|
|
<div class="step">
|
|
<p>수정된 PHP 파일을 서버의 `public_html/raspi_leck_detecter/` 경로에 반영한다.</p>
|
|
</div>
|
|
<div class="step">
|
|
<p>대시보드 진단 영역에서 `SHT30 metric table` 경고가 사라지는지 확인한다.</p>
|
|
</div>
|
|
</div>
|
|
<div class="codebox">
|
|
<button class="copy-btn" type="button">복사</button>
|
|
<pre><code>SOURCE sql/migration_sensor_metric.sql;</code></pre>
|
|
</div>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr><th>파일</th><th>역할</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr><td>php/api/sensor_data.php</td><td>SHT30 온도/습도 필드 수신 및 저장.</td></tr>
|
|
<tr><td>php/dashboard.php</td><td>센서 카드에 최신 온도/습도 표시.</td></tr>
|
|
<tr><td>sql/migration_sensor_metric.sql</td><td>온습도 이력 테이블 생성.</td></tr>
|
|
<tr><td>sql/schema_v2605.sql</td><td>신규 설치용 통합 스키마.</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="pi">
|
|
<h2>4. 라즈베리파이 설정</h2>
|
|
<h3>I2C 활성화</h3>
|
|
<div class="codebox">
|
|
<button class="copy-btn" type="button">복사</button>
|
|
<pre><code>sudo raspi-config</code></pre>
|
|
</div>
|
|
<p>`Interface Options`에서 `I2C`를 활성화한 뒤 재부팅한다.</p>
|
|
|
|
<h3>패키지 설치</h3>
|
|
<div class="codebox">
|
|
<button class="copy-btn" type="button">복사</button>
|
|
<pre><code>sudo apt-get update
|
|
sudo apt-get install -y python3-venv python3-smbus i2c-tools
|
|
cd /home/pi/leak_sensor
|
|
python3 -m venv --system-site-packages venv
|
|
source venv/bin/activate
|
|
pip install -r requirements.txt</code></pre>
|
|
</div>
|
|
|
|
<h3>SHT30 주소 확인</h3>
|
|
<div class="codebox">
|
|
<button class="copy-btn" type="button">복사</button>
|
|
<pre><code>i2cdetect -y 1</code></pre>
|
|
</div>
|
|
<p>표에서 `44`가 보이면 기본값 그대로 사용한다. `45`가 보이면 환경설정의 `SHT30_I2C_ADDR`를 `0x45`로 바꾼다.</p>
|
|
|
|
<h3>환경설정 생성</h3>
|
|
<div class="codebox">
|
|
<button class="copy-btn" type="button">복사</button>
|
|
<pre><code>sudo cp sht30-sensor.env.example /etc/sht30-sensor.env
|
|
sudo nano /etc/sht30-sensor.env</code></pre>
|
|
</div>
|
|
<div class="codebox">
|
|
<button class="copy-btn" type="button">복사</button>
|
|
<pre><code>LEAK_API_URL=https://your-domain.example/raspi_leck_detecter/api/sensor_data.php
|
|
LEAK_API_KEY=서버_config와_동일한_API_KEY
|
|
LEAK_DEVICE_ID=rpi-sht30-01
|
|
LEAK_DEVICE_LOCATION=온습도 측정 위치
|
|
|
|
SHT30_SENSOR_ID=2
|
|
SHT30_SENSOR_NAME=2번 센서 (SHT30 온습도)
|
|
SHT30_I2C_BUS=1
|
|
SHT30_I2C_ADDR=0x44
|
|
SHT30_REPORT_INTERVAL=300
|
|
SHT30_SIMULATION=false</code></pre>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="service">
|
|
<h2>5. 자동 실행 등록</h2>
|
|
<div class="codebox">
|
|
<button class="copy-btn" type="button">복사</button>
|
|
<pre><code>sudo cp systemd/sht30-sensor.service /etc/systemd/system/
|
|
sudo systemctl daemon-reload
|
|
sudo systemctl enable sht30-sensor
|
|
sudo systemctl start sht30-sensor
|
|
sudo systemctl status sht30-sensor</code></pre>
|
|
</div>
|
|
<p>상태가 `active (running)`이면 부팅 후 자동으로 SHT30 값을 서버에 전송한다.</p>
|
|
</section>
|
|
|
|
<section id="verify">
|
|
<h2>6. 동작 확인</h2>
|
|
<div class="grid">
|
|
<div class="tile">
|
|
<strong>센서 읽기</strong>
|
|
<span>`python3 sht30_monitor.py --read-once`가 온도/습도 JSON을 출력해야 한다.</span>
|
|
</div>
|
|
<div class="tile">
|
|
<strong>서버 전송</strong>
|
|
<span>`python3 sht30_monitor.py --once` 실행 후 응답에 `metric_saved`가 `true`인지 확인한다.</span>
|
|
</div>
|
|
<div class="tile">
|
|
<strong>대시보드</strong>
|
|
<span>2번 센서 카드에 온도와 습도 칩이 표시되어야 한다.</span>
|
|
</div>
|
|
</div>
|
|
<div class="codebox">
|
|
<button class="copy-btn" type="button">복사</button>
|
|
<pre><code>source /home/pi/leak_sensor/venv/bin/activate
|
|
python3 sht30_monitor.py --read-once
|
|
python3 sht30_monitor.py --once
|
|
journalctl -u sht30-sensor -f</code></pre>
|
|
</div>
|
|
<div class="image-panel">
|
|
<img src="assets/sht30_sensor2_dashboard.svg" alt="2번 센서가 대시보드에 온도와 습도를 표시하는 예시">
|
|
<div class="caption">대시보드에서는 센서2 상태와 최신 온습도 값이 같은 센서 카드 안에 표시된다.</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="trouble">
|
|
<h2>7. 문제 대처표</h2>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr><th>증상</th><th>판단</th><th>조치</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><span class="status err">주소 없음</span> `i2cdetect -y 1`에 `44` 또는 `45`가 없음</td>
|
|
<td>배선, I2C 비활성화, 전원 문제 가능성.</td>
|
|
<td>SDA/SCL 반대 연결 여부 확인, `raspi-config` I2C 활성화, 3.3V/GND 재확인.</td>
|
|
</tr>
|
|
<tr>
|
|
<td><span class="status err">403</span> API 인증 실패</td>
|
|
<td>라즈베리파이 `LEAK_API_KEY`와 서버 `API_KEY`가 다름.</td>
|
|
<td>`/etc/sht30-sensor.env`와 서버 `config.local.php`의 키를 동일하게 맞춘다.</td>
|
|
</tr>
|
|
<tr>
|
|
<td><span class="status warn">metric_saved false</span></td>
|
|
<td>서버는 받았지만 `sensor_metric` 테이블이 없음.</td>
|
|
<td>`sql/migration_sensor_metric.sql` 실행 후 다시 `--once` 테스트.</td>
|
|
</tr>
|
|
<tr>
|
|
<td><span class="status warn">오프라인</span> 대시보드에서 센서2 오프라인</td>
|
|
<td>서비스 중단 또는 네트워크/API 장애.</td>
|
|
<td>`systemctl status sht30-sensor`, `journalctl -u sht30-sensor -n 80` 확인.</td>
|
|
</tr>
|
|
<tr>
|
|
<td><span class="status err">CRC 오류</span> 측정 실패 반복</td>
|
|
<td>I2C 신호 불안정 또는 케이블 문제.</td>
|
|
<td>케이블 길이를 줄이고 접지를 재확인한다. 필요하면 센서 주소와 보드를 교차 점검한다.</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<script>
|
|
document.querySelectorAll(".copy-btn").forEach((button) => {
|
|
button.addEventListener("click", async () => {
|
|
const code = button.parentElement.querySelector("code").innerText;
|
|
try {
|
|
await navigator.clipboard.writeText(code);
|
|
const old = button.textContent;
|
|
button.textContent = "완료";
|
|
setTimeout(() => { button.textContent = old; }, 1200);
|
|
} catch (error) {
|
|
button.textContent = "실패";
|
|
setTimeout(() => { button.textContent = "복사"; }, 1200);
|
|
}
|
|
});
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|