POSA_LEAKSMS/docs/examples/sht30_sensor2_setup.html
유창욱 90f121e14c chore: import codebase with security hardening
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 제외.
2026-06-20 09:37:40 +09:00

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>