i18n(pi): Raspberry Pi 번들 소스 한글 → 영어 (콘솔 mojibake 해소)

헤드리스 Pi 콘솔/journalctl에서 한글이 깨져 보이는 문제로, Pi 번들(02/03/04)에 들어가는 모든 소스의 사람-대상 텍스트를 영어로 전환. 코드·식별자·설정키·경로·숫자·hex·서명 스킴·f-string 플레이스홀더는 불변.

- raspberry_pi4/, raspberry_pi4_sht30/: 로그·주석·문서·센서명·위치 영어화
- tools/make_deploy_bundles.py: 생성 템플릿(config_local.py/setup.sh/SETUP.md/PI_META 위치) 영어화
- tools/harden_pi.sh, tools/set_network.sh: echo/주석 영어화
- WIRING.html: 영어화 + SHT30 전압 경고 정정 반영
- 서버 번들(01, php/)은 한글 유지(브라우저 대시보드, Pi에서 안 깨짐)
- 전 파이썬/셸 문법검사 통과, JSON 본문 서명 무영향
This commit is contained in:
cw2007 2026-07-10 10:44:13 +09:00
parent 3c8e6d6957
commit a72ea18130
27 changed files with 1200 additions and 1182 deletions

View file

@ -1,14 +1,14 @@
# 운영·비밀값 (절대 커밋 금지) # Operational secrets (never commit)
config_local.py config_local.py
# Python venv / 캐시 # Python venv / cache
venv/ venv/
__pycache__/ __pycache__/
*.pyc *.pyc
# 오프라인 설치 번들 (vendor.sh 산출물 — 용량이 큼, 전송은 별도) # Offline install bundle (vendor.sh output - large; transferred separately)
wheelhouse/ wheelhouse/
debs/ debs/
# 런타임 로그 # Runtime logs
*.log *.log

View file

@ -1,127 +1,135 @@
# 누수감지 센서 모듈 — Raspberry Pi 4 Model B 전용 # Leak Detection Sensor Module — Raspberry Pi 4 Model B Only
e-con **5C-08 저항형 누수감지센서**를 Raspberry Pi 4 Model B 의 GPIO 로 읽어, A monitoring module that reads the e-con **5C-08 resistive leak detection sensor** via the
누수 발생 시 즉시 PHP API 서버에 보고(+SMS 발송 트리거)하고 **Pi Camera 로 현장 사진을 GPIO of a Raspberry Pi 4 Model B. On a leak it immediately reports to the PHP API server
촬영해 업로드**하는 모니터링 모듈입니다. (+ triggers an SMS), and **captures an on-site photo with the Pi Camera and uploads it**.
> 원래 **Raspberry Pi Zero 2 W** 용으로 작성된 모듈을 **Pi 4 Model B 전용**으로 이식한 > This folder is a port of a module originally written for the **Raspberry Pi Zero 2 W**,
> 폴더입니다. GPIO(BCM) 번호 체계와 `rpicam`/`libcamera` 카메라 스택은 두 보드가 동일해 > adapted to be **Pi 4 Model B only**. The GPIO (BCM) numbering scheme and the
> 센서·촬영 로직은 그대로이며, 차이는 **배선/전원/이더넷/카메라 커넥터**와 설치 환경뿐입니다. > `rpicam`/`libcamera` camera stack are identical on both boards, so the sensor/capture
> logic is unchanged; the differences are only in the **wiring/power/Ethernet/camera
> connector** and the install environment.
--- ---
## 1. 구성 파일 ## 1. Files
| 파일 | 역할 | | File | Role |
|---|---| |---|---|
| `leak_sensor.py` | 메인 — GPIO 인터럽트 누수 감지, 주기 보고, 누수 시 촬영+업로드 | | `leak_sensor.py` | Main — GPIO interrupt leak detection, periodic reporting, capture+upload on leak |
| `config.py` | 비밀이 아닌 기본 설정(센서/임계/간격/카메라). `config_local` 에서 비밀값 로드 | | `config.py` | Non-secret default settings (sensor/threshold/interval/camera). Loads secrets from `config_local` |
| `config_local.example.py` | 운영·비밀값 템플릿 → `config_local.py` 로 복사해 사용(미커밋) | | `config_local.example.py` | Operational/secret value template → copy to `config_local.py` before use (not committed) |
| `requirements.txt` | Python 의존성(핀 고정) | | `requirements.txt` | Python dependencies (pinned) |
| `install.sh` | Pi 4 설치 스크립트(폐쇄망 오프라인 + 온라인 폴백) | | `install.sh` | Pi 4 install script (air-gapped offline + online fallback) |
| `vendor.sh` | 폐쇄망용 의존성 사전 다운로드(빌드 머신에서 실행) | | `vendor.sh` | Pre-download dependencies for air-gapped use (run on build machine) |
| `test_connection.py` | 서버 연결/서명 점검 | | `test_connection.py` | Server connection/signature check |
| `systemd/leak-sensor.service` | 부팅 자동 실행 서비스 유닛 | | `systemd/leak-sensor.service` | Auto-start-on-boot service unit |
--- ---
## 2. 하드웨어 (Pi 4 Model B 기준) ## 2. Hardware (based on Pi 4 Model B)
- **Raspberry Pi 4 Model B** (BCM2711) + Raspberry Pi OS **Bookworm 64-bit** - **Raspberry Pi 4 Model B** (BCM2711) + Raspberry Pi OS **Bookworm 64-bit**
- e-con **5C-08** 누수감지센서(저항형, 고임피던스 ~1.5MΩ) - e-con **5C-08** leak detection sensor (resistive, high-impedance ~1.5MΩ)
- **외부 4.7MΩ 풀다운 저항** (필수 — 내부 풀저항은 끔) - **External 4.7MΩ pull-down resistor** (required — internal pull resistor is off)
- (선택) **Pi Camera** — Pi 4 = **15핀(1.0mm 피치) 표준 CSI**, Zero 2 W = **22핀(0.5mm 피치) CSI** - (Optional) **Pi Camera** — Pi 4 = **15-pin (1.0mm pitch) standard CSI**, Zero 2 W = **22-pin (0.5mm pitch) CSI**
→ 리본/카메라 보드 호환 안 됨 (Pi 4용 15핀 리본 또는 15↔22 변환 어댑터 필요) → Ribbon/camera boards are not compatible (a Pi 4 15-pin ribbon or a 15↔22 adapter is required)
- 전원: **USB-C 5V/3A** (카메라 사용 시 여유 전원 권장) - Power: **USB-C 5V/3A** (a power margin is recommended when using the camera)
- 네트워크: Pi 4 **Gigabit Ethernet**(유선) 또는 WiFi - Network: Pi 4 **Gigabit Ethernet** (wired) or WiFi
### 2.1 핀 배치 (기본 센서 1개) ### 2.1 Pinout (default single sensor)
| 신호 | BCM | 40핀 헤더 물리 핀 | | Signal | BCM | 40-pin header physical pin |
|---|---|---| |---|---|---|
| 센서 입력 | GPIO17 | 11 | | Sensor input | GPIO17 | 11 |
| 3.3V | — | 1 | | 3.3V | — | 1 |
| GND | — | 6 (또는 9/14/20/25/30/34/39) | | GND | — | 6 (or 9/14/20/25/30/34/39) |
> 센서를 추가하려면 `config.py` 의 `SENSORS` 에 BCM 번호로 항목을 추가합니다. > To add a sensor, add an entry by BCM number to `SENSORS` in `config.py`.
> (예: GPIO27=물리 13번, GPIO22=물리 15번) > (e.g. GPIO27=physical pin 13, GPIO22=physical pin 15)
### 2.2 전기 설계 (중요) ### 2.2 Electrical design (important)
- `ACTIVE_LOW = False`: 건조 시 GPIO=LOW(0), 누수 시 GPIO=HIGH(1) - `ACTIVE_LOW = False`: when dry GPIO=LOW(0), on leak GPIO=HIGH(1)
- `USE_INTERNAL_PULL = False`(**PUD_OFF 유지**): 내부 50kΩ 풀다운을 켜면 1.5MΩ 센서가 - `USE_INTERNAL_PULL = False` (**keep PUD_OFF**): if you turn on the internal 50kΩ pull-down,
GPIO 를 HIGH 로 끌어올리지 못해 **감지 실패**합니다. 외부 4.7MΩ 풀다운만 사용하세요. the 1.5MΩ sensor cannot pull GPIO up to HIGH, causing **detection failure**. Use only the
external 4.7MΩ pull-down.
--- ---
## 3. 서버 호환성 (반드시 확인) ## 3. Server compatibility (must verify)
이 모듈은 `leak_alert` / `leak_recovery` 이벤트와 `photo_upload.php` 를 **수신하는 서버**에 This module reports to a **server that receives** the `leak_alert` / `leak_recovery` events
보고합니다. and `photo_upload.php`.
> ✅ **이 repo 의 `php/` 서버가 바로 그 서버입니다(단일 서버 통합 완료).** > ✅ **The `php/` server in this repo is exactly that server (single-server integration complete).**
> 1번(누수+카메라)·2번(SHT30 온습도) 두 모듈 모두 **동일한 cafe24 `php/` 서버**에 > Both module 1 (leak + camera) and module 2 (SHT30 temperature/humidity) report to the
> raw-body 서명 `X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=요청본문 바이트))` > **same cafe24 `php/` server** with the raw-body signature
> 로 보고하며, 누수 상태·사진·온습도가 하나의 대시보드(`dashboard.php`)에 함께 표시됩니다. > `X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=request body bytes))`,
> and leak status, photos, and temperature/humidity are shown together on a single
> dashboard (`dashboard.php`).
> >
> - **신규 설치:** `sql/schema_sht30.sql` 하나만 실행 — 누수 요소 > - **New install:** run only `sql/schema_sht30.sql` — the leak elements
> (`leak_sensor_log.is_leak` / `leak_sensor_status.is_leak` 컬럼, `leak_incident`, `leak_photo`, > (`leak_sensor_log.is_leak` / `leak_sensor_status.is_leak` columns, `leak_incident`, `leak_photo`,
> `leak_ingest_nonce` 테이블)가 이미 포함되어 있습니다. > `leak_ingest_nonce` tables) are already included.
> >
> `config_local.py` 의 `API_URL` / `PHOTO_UPLOAD_URL` 을 이 통합 서버로 설정하고, > Set `API_URL` / `PHOTO_UPLOAD_URL` in `config_local.py` to this integrated server, and make
> `API_KEY` 는 서버 `config.local.php` 의 `API_KEY` 와 **바이트 단위로 동일**하게 맞춥니다. > `API_KEY` **byte-for-byte identical** to the `API_KEY` in the server's `config.local.php`.
--- ---
## 4. 설치 ## 4. Installation
### 4.1 설정값 준비 ### 4.1 Prepare configuration values
```bash ```bash
cp config_local.example.py config_local.py cp config_local.example.py config_local.py
# config_local.py 를 열어 API_URL / PHOTO_UPLOAD_URL / API_KEY / DEVICE_ID 채움 # Open config_local.py and fill in API_URL / PHOTO_UPLOAD_URL / API_KEY / DEVICE_ID
``` ```
`config_local.py` 가 없으면 실행 시 **명확한 오류로 즉시 중단**됩니다(fail closed). If `config_local.py` is missing, execution **stops immediately with a clear error** (fail closed).
`config_local.py` 와 `API_KEY` 는 **절대 커밋하지 마세요**(`.gitignore` 처리됨). **Never commit** `config_local.py` or `API_KEY` (they are handled by `.gitignore`).
### 4.2 온라인 설치 (인터넷 가능 환경) ### 4.2 Online install (environment with internet)
```bash ```bash
chmod +x install.sh chmod +x install.sh
./install.sh ./install.sh
``` ```
### 4.3 폐쇄망(air-gapped) 오프라인 설치 ### 4.3 Air-gapped offline install
인터넷이 되는 **동일 아키텍처(aarch64, 가급적 동일 Pi 4 + Bookworm 64-bit)** 머신에서 On a machine with internet of the **same architecture (aarch64, ideally the same Pi 4 +
의존성을 먼저 받아 번들을 만든 뒤, 타깃 Pi 4 로 옮겨 설치합니다. Bookworm 64-bit)**, first fetch the dependencies to build a bundle, then move it to the
target Pi 4 to install.
```bash ```bash
# (빌드 머신, 인터넷 O) wheelhouse/ + debs/ 생성 # (build machine, internet YES) create wheelhouse/ + debs/
./vendor.sh ./vendor.sh
# wheelhouse/ 와 debs/ 를 타깃 Pi 4 의 이 폴더로 복사한 뒤: # After copying wheelhouse/ and debs/ into this folder on the target Pi 4:
# (타깃 Pi 4, 인터넷 X) # (target Pi 4, internet NO)
./install.sh # debs/ · wheelhouse/ 가 있으면 자동으로 오프라인 설치 ./install.sh # if debs/ · wheelhouse/ exist, installs offline automatically
``` ```
> `install.sh` 는 `debs/` 가 있으면 `dpkg` 오프라인, `wheelhouse/` 가 있으면 > If `debs/` exists, `install.sh` installs offline with `dpkg`; if `wheelhouse/` exists, it
> `pip --no-index` 오프라인으로 설치합니다. 둘 다 없을 때만 인터넷 apt/pip 로 폴백합니다. > installs offline with `pip --no-index`. It falls back to internet apt/pip only when neither
> wheel/deb 는 아키텍처 종속이므로 x86_64 PC 에서 받은 것은 Pi 4 에서 동작하지 않습니다. > is present. wheel/deb are architecture-dependent, so ones fetched on an x86_64 PC will not
> work on the Pi 4.
--- ---
## 5. 실행 ## 5. Running
```bash ```bash
source venv/bin/activate source venv/bin/activate
python3 leak_sensor.py python3 leak_sensor.py
``` ```
`RPi.GPIO` 가 없는 환경(개발 PC 등)에서는 자동으로 **시뮬레이션 모드**로 동작합니다. In an environment without `RPi.GPIO` (a development PC, etc.), it automatically runs in
**simulation mode**.
### 연결 점검 ### Connection check
```bash ```bash
source venv/bin/activate source venv/bin/activate
@ -130,10 +138,10 @@ python3 test_connection.py
--- ---
## 6. 부팅 자동 실행 (systemd) ## 6. Auto-start on boot (systemd)
```bash ```bash
# 폴더를 /home/pi/raspberry_pi4 에 배치했다고 가정 (다르면 .service 의 경로 수정) # Assumes the folder is placed at /home/pi/raspberry_pi4 (if different, edit the path in .service)
sudo cp systemd/leak-sensor.service /etc/systemd/system/ sudo cp systemd/leak-sensor.service /etc/systemd/system/
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable leak-sensor sudo systemctl enable leak-sensor
@ -142,19 +150,19 @@ sudo systemctl status leak-sensor
journalctl -u leak-sensor -f journalctl -u leak-sensor -f
``` ```
`SupplementaryGroups=video` 가 카메라 접근에 필요합니다. `SupplementaryGroups=video` is required for camera access.
--- ---
## 7. 보안 메모 ## 7. Security notes
- 비밀값(`API_KEY`)·서버 URL·기기 정보는 `config_local.py`(미커밋)에만 둡니다. - Secret values (`API_KEY`), the server URL, and device info live only in `config_local.py`
커밋되는 파일에는 placeholder 만 존재합니다. (not committed). Committed files contain only placeholders.
- 보고 본문은 **raw-body HMAC 서명**으로 위변조를 방지합니다: - The report body is protected against tampering with a **raw-body HMAC signature**:
`X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=요청본문 바이트))` `X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=request body bytes))`
(`leak_sensor.sign_raw` ↔ 서버 `config.php::verify_signature_raw`, 2번 모듈과 동일 스킴). (`leak_sensor.sign_raw` ↔ server `config.php::verify_signature_raw`, same scheme as module 2).
- 사진 업로드 서명의 canonical 문자열은 `device_id|sensor_id|log_id|timestamp` - The canonical string for the photo upload signature is `device_id|sensor_id|log_id|timestamp`
(공백 없음)입니다 (`config_local.example.py` 참고). (no spaces) (see `config_local.example.py`).
- 구버전의 `sha256(API_KEY + 정렬 JSON)` body-field 서명은 **폐기**되었고, 서버 기본 - The old `sha256(API_KEY + sorted JSON)` body-field signature is **deprecated**, and the
설정이 거부합니다(`ALLOW_LEGACY_BODY_SIGNATURE=false`, `php/config.php`). server default rejects it (`ALLOW_LEGACY_BODY_SIGNATURE=false`, `php/config.php`).
- `config_local.py` 노출 시 즉시 `API_KEY` 를 교체(서버 동시 변경)하세요. - If `config_local.py` is exposed, immediately rotate `API_KEY` (change it on the server at the same time).

View file

@ -1,9 +1,9 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>누수 감지 노드 · 배선 &amp; 핀맵 (Raspberry Pi 4)</title> <title>Leak Detection Node · Wiring &amp; Pinmap (Raspberry Pi 4)</title>
<style> <style>
:root{ :root{
--bg:#eef1f5; --card:#ffffff; --ink:#212529; --muted:#6b7280; --line:#e5e8ec; --bg:#eef1f5; --card:#ffffff; --ink:#212529; --muted:#6b7280; --line:#e5e8ec;
@ -124,26 +124,26 @@
<!-- 1) HEADER --> <!-- 1) HEADER -->
<header class="hero"> <header class="hero">
<p class="eyebrow">Field Wiring &amp; Pinmap · 누수 감지 노드</p> <p class="eyebrow">Field Wiring &amp; Pinmap · Leak Detection Node</p>
<h1>누수 감지 노드 · 배선 &amp; 핀맵</h1> <h1>Leak Detection Node · Wiring &amp; Pinmap</h1>
<p>e-con 5C-08 저항형 누수감지센서를 <b>GPIO17(물리 핀 11)</b>에 연결하는 표준 배선도입니다. <p>Standard wiring diagram for connecting the e-con 5C-08 resistive leak sensor to <b>GPIO17 (physical pin 11)</b>.
Pi-A(sensor_id=1 · 1번 지점 · 서버실)와 Pi-C(sensor_id=3 · 3번 지점 · 신규)는 배선이 동일한 <b>공통 노드</b>입니다.</p> Pi-A (sensor_id=1 · Site 1 · Server Room) and Pi-C (sensor_id=3 · Site 3 · New) are <b>common nodes</b> with identical wiring.</p>
<div class="chips"> <div class="chips">
<span class="chip">Raspberry Pi 4 Model B</span> <span class="chip">Raspberry Pi 4 Model B</span>
<span class="chip mono">BCM2711</span> <span class="chip mono">BCM2711</span>
<span class="chip">Raspberry Pi OS Bookworm 64-bit</span> <span class="chip">Raspberry Pi OS Bookworm 64-bit</span>
<span class="chip">e-con 5C-08 · 저항형 ~1.5MΩ</span> <span class="chip">e-con 5C-08 · Resistive ~1.5MΩ</span>
<span class="chip mono">GPIO17 / 핀11</span> <span class="chip mono">GPIO17 / Pin 11</span>
<span class="chip">외부 4.7MΩ 풀다운 필수</span> <span class="chip">External 4.7MΩ pull-down required</span>
</div> </div>
</header> </header>
<!-- 2) 40핀 헤더 핀맵 도해 --> <!-- 2) 40-pin header pinmap diagram -->
<section> <section>
<h2><span class="num">2</span>40핀 헤더 핀맵 도해</h2> <h2><span class="num">2</span>40-Pin Header Pinmap Diagram</h2>
<div class="pinwrap"> <div class="pinwrap">
<div class="diagram"> <div class="diagram">
<svg viewBox="0 0 600 681" role="img" aria-label="Raspberry Pi 4 40핀 GPIO 헤더 핀맵" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;max-width:600px;"> <svg viewBox="0 0 600 681" role="img" aria-label="Raspberry Pi 4 40-pin GPIO header pinmap" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;max-width:600px;">
<rect x="70" y="69" width="460" height="31" fill="#f6f8fa"/> <rect x="70" y="69" width="460" height="31" fill="#f6f8fa"/>
<rect x="70" y="131" width="460" height="31" fill="#f6f8fa"/> <rect x="70" y="131" width="460" height="31" fill="#f6f8fa"/>
<rect x="70" y="193" width="460" height="31" fill="#f6f8fa"/> <rect x="70" y="193" width="460" height="31" fill="#f6f8fa"/>
@ -156,8 +156,8 @@
<rect x="70" y="627" width="460" height="31" fill="#f6f8fa"/> <rect x="70" y="627" width="460" height="31" fill="#f6f8fa"/>
<rect x="234" y="30" width="112" height="633" rx="10" fill="#f1f3f5" stroke="#adb5bd" stroke-width="1.5"/> <rect x="234" y="30" width="112" height="633" rx="10" fill="#f1f3f5" stroke="#adb5bd" stroke-width="1.5"/>
<rect x="236" y="36" width="32" height="32" rx="6" fill="none" stroke="#e03131" stroke-width="1.5" stroke-dasharray="3 2"/> <rect x="236" y="36" width="32" height="32" rx="6" fill="none" stroke="#e03131" stroke-width="1.5" stroke-dasharray="3 2"/>
<text x="252" y="22" text-anchor="middle" font-size="10" fill="#868e96">홀수 핀</text> <text x="252" y="22" text-anchor="middle" font-size="10" fill="#868e96">Odd pins</text>
<text x="330" y="22" text-anchor="middle" font-size="10" fill="#868e96">짝수 핀</text> <text x="330" y="22" text-anchor="middle" font-size="10" fill="#868e96">Even pins</text>
<text x="220" y="56" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="700" fill="#7d1616">3V3</text> <text x="220" y="56" text-anchor="end" font-family="Consolas,monospace" font-size="12" font-weight="700" fill="#7d1616">3V3</text>
<text x="360" y="56" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">5V</text> <text x="360" y="56" text-anchor="start" font-family="Consolas,monospace" font-size="12" font-weight="400" fill="#adb5bd">5V</text>
<circle cx="252" cy="52" r="12" fill="#e03131" stroke="#c92a2a" stroke-width="1.5"/><text x="252" y="55.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#ffffff">1</text> <circle cx="252" cy="52" r="12" fill="#e03131" stroke="#c92a2a" stroke-width="1.5"/><text x="252" y="55.5" text-anchor="middle" font-family="Consolas,monospace" font-size="10" font-weight="700" fill="#ffffff">1</text>
@ -241,171 +241,171 @@
</svg> </svg>
</div> </div>
<div class="legend"> <div class="legend">
<div class="lg"><span class="sw sig"></span><b>신호 (GPIO17 · 핀 11)<span>센서 신호선 입력</span></b></div> <div class="lg"><span class="sw sig"></span><b>Signal (GPIO17 · Pin 11)<span>Sensor signal line input</span></b></div>
<div class="lg"><span class="sw pwr"></span><b>전원 3V3 (핀 1)<span>센서 전원 공급</span></b></div> <div class="lg"><span class="sw pwr"></span><b>Power 3V3 (Pin 1)<span>Sensor power supply</span></b></div>
<div class="lg"><span class="sw gnd"></span><b>접지 GND (핀 6 · 사용)<span>센서 GND 기본 연결</span></b></div> <div class="lg"><span class="sw gnd"></span><b>Ground GND (Pin 6 · used)<span>Default sensor GND connection</span></b></div>
<div class="lg"><span class="sw alt"></span><b>접지 대체 가능<span>핀 9 / 14 / 20 / 25 / 30 / 34 / 39</span></b></div> <div class="lg"><span class="sw alt"></span><b>Ground alternatives<span>Pin 9 / 14 / 20 / 25 / 30 / 34 / 39</span></b></div>
<div class="lg"><span class="sw exp"></span><b>확장 예시 (선택)<span>GPIO27·핀 13, GPIO22·핀 15</span></b></div> <div class="lg"><span class="sw exp"></span><b>Expansion example (optional)<span>GPIO27·Pin 13, GPIO22·Pin 15</span></b></div>
<div class="lg"><span class="sw off"></span><b>미사용 핀<span>이 노드에서 연결하지 않음</span></b></div> <div class="lg"><span class="sw off"></span><b>Unused pins<span>Not connected on this node</span></b></div>
<div class="hint">붉은 점선 사각형이 <b>핀 1</b> 위치입니다. 헤더 방향(핀 1 기준)을 반드시 확인하고 배선하세요. 실제 사용 핀은 <b>1 · 6 · 11</b> 세 개뿐입니다.</div> <div class="hint">The red dashed square marks the <b>Pin 1</b> position. Always verify the header orientation (relative to Pin 1) before wiring. Only three pins are actually used: <b>1 · 6 · 11</b>.</div>
</div> </div>
</div> </div>
</section> </section>
<!-- 3) 핀맵 표 --> <!-- 3) Pinmap table -->
<section> <section>
<h2><span class="num">3</span>핀맵 표</h2> <h2><span class="num">3</span>Pinmap Table</h2>
<table> <table>
<thead> <thead>
<tr><th>신호</th><th class="mono">BCM</th><th class="mono">물리 핀</th><th>비고</th></tr> <tr><th>Signal</th><th class="mono">BCM</th><th class="mono">Physical Pin</th><th>Notes</th></tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td><span class="tag sig">신호</span> 센서 신호선</td> <td><span class="tag sig">Signal</span> Sensor signal line</td>
<td class="mono">GPIO17</td> <td class="mono">GPIO17</td>
<td class="mono">핀 11</td> <td class="mono">Pin 11</td>
<td>센서 신호 입력. 외부 4.7MΩ 풀다운을 <b>핀 11 ↔ GND</b> 사이에 연결.</td> <td>Sensor signal input. Connect an external 4.7MΩ pull-down between <b>Pin 11 ↔ GND</b>.</td>
</tr> </tr>
<tr> <tr>
<td><span class="tag pwr">전원</span> 센서 전원 3.3V</td> <td><span class="tag pwr">Power</span> Sensor power 3.3V</td>
<td class="mono">3V3</td> <td class="mono">3V3</td>
<td class="mono">핀 1</td> <td class="mono">Pin 1</td>
<td>센서 VCC 공급. (예비 3V3 = 핀 17)</td> <td>Sensor VCC supply. (Spare 3V3 = Pin 17)</td>
</tr> </tr>
<tr> <tr>
<td><span class="tag gnd">접지</span> 센서 GND</td> <td><span class="tag gnd">Ground</span> Sensor GND</td>
<td class="mono">GND</td> <td class="mono">GND</td>
<td class="mono">핀 6</td> <td class="mono">Pin 6</td>
<td>대체 가능: 핀 9 / 14 / 20 / 25 / 30 / 34 / 39 중 아무 GND.</td> <td>Alternatives: any GND among Pin 9 / 14 / 20 / 25 / 30 / 34 / 39.</td>
</tr> </tr>
<tr class="exprow"> <tr class="exprow">
<td><span class="tag exp">확장</span> 추가 센서 예시</td> <td><span class="tag exp">Expansion</span> Additional sensor example</td>
<td class="mono">GPIO27</td> <td class="mono">GPIO27</td>
<td class="mono">핀 13</td> <td class="mono">Pin 13</td>
<td>선택. <span class="inline-code">config.py</span>의 SENSORS에 BCM 번호로 추가.</td> <td>Optional. Add by BCM number to SENSORS in <span class="inline-code">config.py</span>.</td>
</tr> </tr>
<tr class="exprow"> <tr class="exprow">
<td><span class="tag exp">확장</span> 추가 센서 예시</td> <td><span class="tag exp">Expansion</span> Additional sensor example</td>
<td class="mono">GPIO22</td> <td class="mono">GPIO22</td>
<td class="mono">핀 15</td> <td class="mono">Pin 15</td>
<td>선택. 위와 동일한 방법으로 추가(각 채널마다 외부 풀다운 필요).</td> <td>Optional. Add the same way as above (each channel needs its own external pull-down).</td>
</tr> </tr>
</tbody> </tbody>
<caption>사용 핀은 3개(핀 1 · 6 · 11). 확장 행은 다중 센서 구성 시 참고용 예시입니다. 기기별 sensor_id / location은 <span class="inline-code">config_local.py</span>에서 설정.</caption> <caption>Three pins used (Pin 1 · 6 · 11). The expansion rows are reference examples for multi-sensor setups. Per-device sensor_id / location is set in <span class="inline-code">config_local.py</span>.</caption>
</table> </table>
</section> </section>
<!-- 4) 배선 스키매틱 --> <!-- 4) Wiring schematic -->
<section> <section>
<h2><span class="num">4</span>배선 스키매틱</h2> <h2><span class="num">4</span>Wiring Schematic</h2>
<div class="diagram"> <div class="diagram">
<svg viewBox="0 0 640 400" role="img" aria-label="누수센서 배선 스키매틱 — 3V3·GPIO17·GND 및 외부 4.7MΩ 풀다운" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto"> <svg viewBox="0 0 640 400" role="img" aria-label="Leak sensor wiring schematic — 3V3·GPIO17·GND and external 4.7MΩ pull-down" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto">
<!-- Pi header block --> <!-- Pi header block -->
<rect x="36" y="70" width="150" height="250" rx="10" fill="#f1f3f5" stroke="#adb5bd" stroke-width="1.5"/> <rect x="36" y="70" width="150" height="250" rx="10" fill="#f1f3f5" stroke="#adb5bd" stroke-width="1.5"/>
<text x="111" y="60" text-anchor="middle" font-size="13" font-weight="700" fill="#212529">Raspberry Pi 4 · 40핀 헤더</text> <text x="111" y="60" text-anchor="middle" font-size="13" font-weight="700" fill="#212529">Raspberry Pi 4 · 40-pin header</text>
<!-- header pin rows --> <!-- header pin rows -->
<circle cx="60" cy="120" r="5" fill="#e03131"/> <circle cx="60" cy="120" r="5" fill="#e03131"/>
<text x="74" y="124" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">핀1 · 3V3</text> <text x="74" y="124" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">Pin 1 · 3V3</text>
<rect x="182" y="114" width="10" height="12" rx="2" fill="#e03131"/> <rect x="182" y="114" width="10" height="12" rx="2" fill="#e03131"/>
<circle cx="60" cy="195" r="5" fill="#2f9e44"/> <circle cx="60" cy="195" r="5" fill="#2f9e44"/>
<text x="74" y="199" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">핀11 · GPIO17</text> <text x="74" y="199" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">Pin 11 · GPIO17</text>
<rect x="182" y="189" width="10" height="12" rx="2" fill="#2f9e44"/> <rect x="182" y="189" width="10" height="12" rx="2" fill="#2f9e44"/>
<circle cx="60" cy="270" r="5" fill="#1971c2"/> <circle cx="60" cy="270" r="5" fill="#1971c2"/>
<text x="74" y="274" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">핀6 · GND</text> <text x="74" y="274" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">Pin 6 · GND</text>
<rect x="182" y="264" width="10" height="12" rx="2" fill="#1971c2"/> <rect x="182" y="264" width="10" height="12" rx="2" fill="#1971c2"/>
<!-- sensor block --> <!-- sensor block -->
<rect x="470" y="95" width="150" height="190" rx="10" fill="#ffffff" stroke="#495057" stroke-width="1.5"/> <rect x="470" y="95" width="150" height="190" rx="10" fill="#ffffff" stroke="#495057" stroke-width="1.5"/>
<text x="545" y="84" text-anchor="middle" font-size="13" font-weight="700" fill="#212529">e-con 5C-08 누수센서</text> <text x="545" y="84" text-anchor="middle" font-size="13" font-weight="700" fill="#212529">e-con 5C-08 leak sensor</text>
<rect x="462" y="114" width="10" height="12" rx="2" fill="#e03131"/> <rect x="462" y="114" width="10" height="12" rx="2" fill="#e03131"/>
<text x="484" y="124" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">VCC 3.3V</text> <text x="484" y="124" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">VCC 3.3V</text>
<rect x="462" y="189" width="10" height="12" rx="2" fill="#2f9e44"/> <rect x="462" y="189" width="10" height="12" rx="2" fill="#2f9e44"/>
<text x="484" y="199" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">SIG 신호</text> <text x="484" y="199" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">SIG signal</text>
<rect x="462" y="264" width="10" height="12" rx="2" fill="#1971c2"/> <rect x="462" y="264" width="10" height="12" rx="2" fill="#1971c2"/>
<text x="484" y="274" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">GND</text> <text x="484" y="274" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">GND</text>
<!-- wires --> <!-- wires -->
<path d="M192,120 H462" stroke="#e03131" stroke-width="2.5" fill="none"/> <path d="M192,120 H462" stroke="#e03131" stroke-width="2.5" fill="none"/>
<path d="M192,195 H462" stroke="#2f9e44" stroke-width="2.5" fill="none"/> <path d="M192,195 H462" stroke="#2f9e44" stroke-width="2.5" fill="none"/>
<path d="M192,270 H462" stroke="#1971c2" stroke-width="2.5" fill="none"/> <path d="M192,270 H462" stroke="#1971c2" stroke-width="2.5" fill="none"/>
<text x="327" y="111" text-anchor="middle" font-size="11" fill="#c92a2a">3.3V 전원 · 핀1</text> <text x="327" y="111" text-anchor="middle" font-size="11" fill="#c92a2a">3.3V power · Pin 1</text>
<text x="250" y="186" text-anchor="middle" font-size="11" fill="#2b8a3e">신호 · GPIO17 · 핀11</text> <text x="250" y="186" text-anchor="middle" font-size="11" fill="#2b8a3e">Signal · GPIO17 · Pin 11</text>
<text x="250" y="261" text-anchor="middle" font-size="11" fill="#1864ab">GND · 핀6</text> <text x="250" y="261" text-anchor="middle" font-size="11" fill="#1864ab">GND · Pin 6</text>
<!-- external pulldown between SIG wire and GND wire --> <!-- external pulldown between SIG wire and GND wire -->
<circle cx="340" cy="195" r="4" fill="#2f9e44"/> <circle cx="340" cy="195" r="4" fill="#2f9e44"/>
<circle cx="340" cy="270" r="4" fill="#1971c2"/> <circle cx="340" cy="270" r="4" fill="#1971c2"/>
<polyline points="340,199 340,206 330,210 350,218 330,226 350,234 330,242 340,246 340,266" fill="none" stroke="#343a40" stroke-width="2"/> <polyline points="340,199 340,206 330,210 350,218 330,226 350,234 330,242 340,246 340,266" fill="none" stroke="#343a40" stroke-width="2"/>
<text x="360" y="226" text-anchor="start" font-size="13" font-weight="700" fill="#343a40">4.7MΩ</text> <text x="360" y="226" text-anchor="start" font-size="13" font-weight="700" fill="#343a40">4.7MΩ</text>
<text x="360" y="242" text-anchor="start" font-size="11" fill="#495057">외부 풀다운(필수)</text> <text x="360" y="242" text-anchor="start" font-size="11" fill="#495057">External pull-down (required)</text>
<!-- internal pull off note --> <!-- internal pull off note -->
<rect x="60" y="300" width="330" height="26" rx="6" fill="#fff5f5" stroke="#ffa8a8" stroke-width="1.5" stroke-dasharray="4 3"/> <rect x="60" y="300" width="330" height="26" rx="6" fill="#fff5f5" stroke="#ffa8a8" stroke-width="1.5" stroke-dasharray="4 3"/>
<text x="72" y="317" text-anchor="start" font-size="11" font-weight="700" fill="#c92a2a">내부 풀 OFF · USE_INTERNAL_PULL=False (PUD_OFF)</text> <text x="72" y="317" text-anchor="start" font-size="11" font-weight="700" fill="#c92a2a">Internal pull OFF · USE_INTERNAL_PULL=False (PUD_OFF)</text>
<!-- logic box --> <!-- logic box -->
<rect x="60" y="350" width="520" height="30" rx="6" fill="#f1f3f5" stroke="#ced4da"/> <rect x="60" y="350" width="520" height="30" rx="6" fill="#f1f3f5" stroke="#ced4da"/>
<text x="320" y="369" text-anchor="middle" font-size="11.5" fill="#343a40">건조 → GPIO=LOW(0) · 누수 → GPIO=HIGH(1) · ACTIVE_LOW=False</text> <text x="320" y="369" text-anchor="middle" font-size="11.5" fill="#343a40">Dry → GPIO=LOW(0) · Leak → GPIO=HIGH(1) · ACTIVE_LOW=False</text>
</svg> </svg>
</div> </div>
<div class="hint">동작 원리: 건조 시 센서는 초고임피던스(거의 개방) 상태라 외부 4.7MΩ 풀다운이 GPIO17을 <b>LOW</b>로 유지합니다. 누수로 물이 전극을 연결하면 3.3V가 ~1.5MΩ을 통해 신호선을 끌어올려 GPIO17이 <b>HIGH</b>로 전환됩니다.</div> <div class="hint">How it works: when dry, the sensor sits in an ultra-high-impedance (nearly open) state, so the external 4.7MΩ pull-down holds GPIO17 <b>LOW</b>. When a leak bridges the electrodes with water, 3.3V pulls the signal line up through ~1.5MΩ and GPIO17 switches to <b>HIGH</b>.</div>
</section> </section>
<!-- 5) 전기 설계 · 주의 --> <!-- 5) Electrical design · cautions -->
<section> <section>
<h2><span class="num">5</span>전기 설계 · 주의</h2> <h2><span class="num">5</span>Electrical Design · Cautions</h2>
<div class="crit"> <div class="crit">
<span class="badge">★ 절대 주의 · CRITICAL</span> <span class="badge">★ Critical Caution · CRITICAL</span>
<h4>내부 풀다운을 켜지 마세요</h4> <h4>Do not enable the internal pull-down</h4>
<p>내부 <b>50kΩ</b> 풀다운을 활성화하면, ~1.5MΩ 고임피던스 센서가 GPIO를 HIGH로 끌어올리지 못해 <p>If you enable the internal <b>50kΩ</b> pull-down, the ~1.5MΩ high-impedance sensor cannot pull the GPIO HIGH,
<b>누수를 감지하지 못합니다(감지 실패)</b>. 반드시 내부 풀은 <span class="inline-code">PUD_OFF</span>로 끄고, so <b>leaks will not be detected (detection failure)</b>. Always turn the internal pull off with <span class="inline-code">PUD_OFF</span>
<b>외부 4.7MΩ 풀다운만</b> 사용하세요. (<span class="inline-code">USE_INTERNAL_PULL = False</span>)</p> and use <b>only the external 4.7MΩ pull-down</b>. (<span class="inline-code">USE_INTERNAL_PULL = False</span>)</p>
</div> </div>
<div class="notes" style="margin-top:14px"> <div class="notes" style="margin-top:14px">
<div class="note"> <div class="note">
<h4>극성 · 로직 레벨</h4> <h4>Polarity · Logic Level</h4>
<p><span class="inline-code">ACTIVE_LOW = False</span> → 건조 시 <b>GPIO=LOW(0)</b>, 누수 시 <b>GPIO=HIGH(1)</b>.</p> <p><span class="inline-code">ACTIVE_LOW = False</span> → when dry <b>GPIO=LOW(0)</b>, on leak <b>GPIO=HIGH(1)</b>.</p>
</div> </div>
<div class="note"> <div class="note">
<h4>외부 풀다운 (필수)</h4> <h4>External Pull-down (required)</h4>
<p>4.7MΩ 저항을 <b>GPIO17(핀 11) ↔ GND</b> 사이에 연결. 고임피던스 센서용으로 반드시 필요.</p> <p>Connect a 4.7MΩ resistor between <b>GPIO17 (Pin 11) ↔ GND</b>. Essential for the high-impedance sensor.</p>
</div> </div>
<div class="note"> <div class="note">
<h4>내부 풀 설정</h4> <h4>Internal Pull Setting</h4>
<p><span class="inline-code">USE_INTERNAL_PULL = False</span> — 내부 풀은 <span class="inline-code">PUD_OFF</span> 유지.</p> <p><span class="inline-code">USE_INTERNAL_PULL = False</span> — keep the internal pull at <span class="inline-code">PUD_OFF</span>.</p>
</div> </div>
<div class="note"> <div class="note">
<h4>디바운스</h4> <h4>Debounce</h4>
<p><span class="inline-code">DEBOUNCE_MS = 500</span> — 고임피던스 특성상 넉넉하게 설정.</p> <p><span class="inline-code">DEBOUNCE_MS = 500</span> — set generously given the high-impedance behavior.</p>
</div> </div>
<div class="note" style="grid-column:1 / -1"> <div class="note" style="grid-column:1 / -1">
<h4>설정 파일</h4> <h4>Configuration Files</h4>
<p>전기 파라미터는 <span class="inline-code">raspberry_pi4/config.py</span> (SENSORS · ACTIVE_LOW · USE_INTERNAL_PULL · DEBOUNCE_MS). <p>Electrical parameters live in <span class="inline-code">raspberry_pi4/config.py</span> (SENSORS · ACTIVE_LOW · USE_INTERNAL_PULL · DEBOUNCE_MS).
기기별 <b>sensor_id / location</b>은 <span class="inline-code">config_local.py</span> Per-device <b>sensor_id / location</b> is in <span class="inline-code">config_local.py</span>
(Pi-A = id 1 · 1번 지점 · 서버실 / Pi-C = id 3 · 3번 지점 · 신규).</p> (Pi-A = id 1 · Site 1 · Server Room / Pi-C = id 3 · Site 3 · New).</p>
</div> </div>
</div> </div>
<div class="cam"> <div class="cam">
<h4>카메라 (선택 · CAMERA_ENABLED=True)</h4> <h4>Camera (optional · CAMERA_ENABLED=True)</h4>
<p>Raspberry Pi 4는 <b>15핀(1.0mm 피치) 표준 CSI 커넥터</b>입니다. Zero 2 W는 22핀(0.5mm)이라 <p>The Raspberry Pi 4 uses a <b>15-pin (1.0mm pitch) standard CSI connector</b>. The Zero 2 W uses 22-pin (0.5mm),
리본·카메라보드가 <b>비호환</b> → Pi 4용 <b>15핀 리본</b> 또는 <b>15↔22 변환 어댑터</b>가 필요합니다. so its ribbon and camera board are <b>incompatible</b> → you need a <b>15-pin ribbon</b> for the Pi 4 or a <b>15↔22 conversion adapter</b>.
촬영은 <span class="inline-code">rpicam-still</span>(Bookworm) / <span class="inline-code">libcamera-still</span>(Bullseye), <b>640×480 JPEG</b>.</p> Capture with <span class="inline-code">rpicam-still</span> (Bookworm) / <span class="inline-code">libcamera-still</span> (Bullseye), <b>640×480 JPEG</b>.</p>
</div> </div>
</section> </section>
<!-- 6) 설치 체크리스트 --> <!-- 6) Installation checklist -->
<section> <section>
<h2><span class="num">6</span>설치 체크리스트</h2> <h2><span class="num">6</span>Installation Checklist</h2>
<ol class="check"> <ol class="check">
<li>Raspberry Pi 전원을 <b>완전히 차단</b>한 상태에서 배선을 시작한다.</li> <li>Start wiring with the Raspberry Pi power <b>fully disconnected</b>.</li>
<li>헤더 방향(핀 1 위치)을 확인한다. 붉은 점선 표시 = 핀 1.</li> <li>Verify the header orientation (Pin 1 position). Red dashed marker = Pin 1.</li>
<li>센서 신호선을 <b>핀 11(GPIO17)</b>에 연결한다.</li> <li>Connect the sensor signal line to <b>Pin 11 (GPIO17)</b>.</li>
<li>센서 전원(3.3V)을 <b>핀 1(3V3)</b>, 센서 GND를 <b>핀 6(GND)</b>에 연결한다.</li> <li>Connect sensor power (3.3V) to <b>Pin 1 (3V3)</b> and sensor GND to <b>Pin 6 (GND)</b>.</li>
<li class="key">외부 <b>4.7MΩ 풀다운</b>을 <b>핀 11 ↔ GND</b> 사이에 연결한다. <b>(필수 · 생략 시 감지 실패)</b></li> <li class="key">Connect an external <b>4.7MΩ pull-down</b> between <b>Pin 11 ↔ GND</b>. <b>(Required · omitting it causes detection failure)</b></li>
<li class="key">내부 풀이 꺼져 있는지 확인한다 — <span class="inline-code">USE_INTERNAL_PULL=False</span> (PUD_OFF). <b>내부 풀다운 절대 금지.</b></li> <li class="key">Confirm the internal pull is off — <span class="inline-code">USE_INTERNAL_PULL=False</span> (PUD_OFF). <b>Never use the internal pull-down.</b></li>
<li><span class="inline-code">config.py</span> 확인: <span class="inline-code">ACTIVE_LOW=False</span>, <span class="inline-code">DEBOUNCE_MS=500</span>, SENSORS에 GPIO17 등록.</li> <li>Check <span class="inline-code">config.py</span>: <span class="inline-code">ACTIVE_LOW=False</span>, <span class="inline-code">DEBOUNCE_MS=500</span>, GPIO17 registered in SENSORS.</li>
<li><span class="inline-code">config_local.py</span>에서 기기별 <b>sensor_id / location</b> 설정 (Pi-A=1/서버실, Pi-C=3/신규).</li> <li>Set the per-device <b>sensor_id / location</b> in <span class="inline-code">config_local.py</span> (Pi-A=1/Server Room, Pi-C=3/New).</li>
<li>전원 인가 후, <b>건조 상태</b>에서 GPIO17 = <b>LOW(0)</b>인지 로그로 확인한다.</li> <li>After powering on, confirm via the log that GPIO17 = <b>LOW(0)</b> in the <b>dry state</b>.</li>
<li>물(또는 젖은 천)로 센서를 적셔 GPIO17이 <b>HIGH(1)</b>로 전환되고 누수 이벤트가 기록되는지 확인한다.</li> <li>Wet the sensor with water (or a damp cloth) and confirm GPIO17 switches to <b>HIGH(1)</b> and a leak event is logged.</li>
<li>(선택) 카메라 사용 시 리본 방향·커넥터 규격(15핀 / 어댑터)을 확인하고 640×480 촬영 테스트를 수행한다.</li> <li>(Optional) When using the camera, verify the ribbon orientation and connector spec (15-pin / adapter) and run a 640×480 capture test.</li>
</ol> </ol>
</section> </section>

View file

@ -1,75 +1,75 @@
# ============================================================================= # =============================================================================
# config.py - 누수감지 시스템 v2 (Pi 4 Model B) 설정 — 비밀이 아닌 기본값 # config.py - Leak Detection System v2 (Pi 4 Model B) settings — non-secret defaults
# #
# 서버 URL / API_KEY / 기기 식별자 등 운영·비밀값은 config_local.py 에서 로드한다. # Operational/secret values such as server URL / API_KEY / device identifier are loaded from config_local.py.
# config_local.py 는 .gitignore 로 커밋 제외 대상이다. # config_local.py is excluded from commits via .gitignore.
# 설정 방법: cp config_local.example.py config_local.py → 실제 값으로 채움 # Setup: cp config_local.example.py config_local.py → fill in with real values
# ============================================================================= # =============================================================================
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 센서 GPIO 설정 (BCM 번호 기준 — Pi 4 40핀 헤더와 동일) # Sensor GPIO settings (BCM numbering — same as Pi 4 40-pin header)
# GPIO17 = 40핀 헤더 물리 11번 핀 # GPIO17 = physical pin 11 on the 40-pin header
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
SENSORS = [ SENSORS = [
{"id": 1, "gpio": 17, "name": "1번 센서 (서버실)"}, {"id": 1, "gpio": 17, "name": "Sensor 1 (Server Room)"},
# 추가 센서가 있으면 아래에 추가 (BCM 번호 기준) # Add more sensors below if present (BCM numbering)
# {"id": 2, "gpio": 27, "name": "2번 센서 (기계실)"}, # 물리 13번 # {"id": 2, "gpio": 27, "name": "Sensor 2 (Machine Room)"}, # physical pin 13
# {"id": 3, "gpio": 22, "name": "3번 센서 (화장실)"}, # 물리 15번 # {"id": 3, "gpio": 22, "name": "Sensor 3 (Restroom)"}, # physical pin 15
] ]
# 센서 활성 레벨 설정 # Sensor active-level setting
# e-con 5C-08 저항형 센서 + 4.7MΩ 풀다운 사용 시: # When using the e-con 5C-08 resistive sensor + 4.7MΩ pull-down:
# 건조: GPIO = LOW(0) → ACTIVE_LOW = False # Dry: GPIO = LOW(0) → ACTIVE_LOW = False
# 누수: GPIO = HIGH(1) # Leak: GPIO = HIGH(1)
ACTIVE_LOW = False ACTIVE_LOW = False
# GPIO 내부 풀저항 설정 # GPIO internal pull-resistor setting
# 반드시 False (PUD_OFF) 유지! # Must stay False (PUD_OFF)!
# 내부 풀다운(50kΩ)을 켜면 누수 시에도 HIGH가 되지 않아 감지 실패함. # Enabling the internal pull-down (50kΩ) keeps the line from going HIGH even on a leak, causing detection failure.
# → 외부 4.7MΩ 풀다운 저항만 사용 # → Use only the external 4.7MΩ pull-down resistor
USE_INTERNAL_PULL = False # True = PUD_DOWN, False = PUD_OFF USE_INTERNAL_PULL = False # True = PUD_DOWN, False = PUD_OFF
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 주기 설정 # Interval settings
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
REPORT_INTERVAL = 600 # 정상 상태 주기 보고 간격 (초) = 10분 REPORT_INTERVAL = 600 # Normal-state periodic report interval (sec) = 10 min
# 서버 HEARTBEAT_TIMEOUT_SEC(기본 1200초)보다 짧아야 # Must be shorter than the server's HEARTBEAT_TIMEOUT_SEC (default 1200 sec)
# 오프라인 오탐이 없다 (600초 = 2회 누락 시 오프라인 판정). # to avoid offline false positives (600 sec = offline after 2 missed reports).
LEAK_REPORT_INTERVAL = 10 # 누수 감지 시 재보고 간격 (초) LEAK_REPORT_INTERVAL = 10 # Re-report interval while a leak is detected (sec)
DEBOUNCE_MS = 500 # 바운싱 방지 시간 (ms) — 고임피던스라 넉넉히 설정 DEBOUNCE_MS = 500 # Debounce time (ms) — set generously due to high impedance
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 로그 설정 # Log settings
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
LOG_FILE = "/var/log/leak_sensor.log" LOG_FILE = "/var/log/leak_sensor.log"
LOG_MAX_BYTES = 5 * 1024 * 1024 # 5MB LOG_MAX_BYTES = 5 * 1024 * 1024 # 5MB
LOG_BACKUP_COUNT = 3 LOG_BACKUP_COUNT = 3
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 재시도 설정 # Retry settings
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
HTTP_TIMEOUT = 10 # HTTP 요청 타임아웃 (초) HTTP_TIMEOUT = 10 # HTTP request timeout (sec)
HTTP_RETRY_COUNT = 3 # 재시도 횟수 HTTP_RETRY_COUNT = 3 # Retry count
HTTP_RETRY_DELAY = 5 # 재시도 대기 (초) HTTP_RETRY_DELAY = 5 # Retry delay (sec)
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 카메라 설정 # Camera settings
# Pi 4 = 15핀(1.0mm 피치) 표준 CSI, Zero 2 W = 22핀(0.5mm 피치) CSI → # Pi 4 = 15-pin (1.0mm pitch) standard CSI, Zero 2 W = 22-pin (0.5mm pitch) CSI →
# 리본/카메라 보드 호환 안 됨 (Pi 4용 15핀 리본 또는 15↔22 변환 어댑터 필요). # ribbon/camera board not compatible (needs a Pi 4 15-pin ribbon or a 15↔22 adapter).
# 촬영은 rpicam-still(Bookworm) / libcamera-still(Bullseye) 사용. # Capture uses rpicam-still (Bookworm) / libcamera-still (Bullseye).
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
CAMERA_ENABLED = True # False로 설정하면 촬영/업로드 건너뜀 CAMERA_ENABLED = True # Set to False to skip capture/upload
PHOTO_WIDTH = 640 # 촬영 해상도 (가로) PHOTO_WIDTH = 640 # Capture resolution (width)
PHOTO_HEIGHT = 480 # 촬영 해상도 (세로) PHOTO_HEIGHT = 480 # Capture resolution (height)
PHOTO_QUALITY = 60 # JPEG 품질 (1-100, 60 ≈ 50-80KB) PHOTO_QUALITY = 60 # JPEG quality (1-100, 60 ≈ 50-80KB)
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 운영·비밀값 로드 (fail closed) # Load operational/secret values (fail closed)
# config_local.py 가 없으면 placeholder/하드코딩 비밀로 잘못 기동하는 대신 # If config_local.py is missing, stop immediately with a clear error
# 명확한 오류로 즉시 중단한다. # instead of wrongly starting with placeholder/hardcoded secrets.
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
try: try:
from config_local import ( # noqa: F401 (leak_sensor.py 가 config 에서 재수입) from config_local import ( # noqa: F401 (leak_sensor.py re-imports from config)
API_URL, API_URL,
API_KEY, API_KEY,
PHOTO_UPLOAD_URL, PHOTO_UPLOAD_URL,
@ -78,25 +78,25 @@ try:
) )
except ImportError as e: except ImportError as e:
raise RuntimeError( raise RuntimeError(
"config_local.py 를 찾을 수 없습니다. " "config_local.py not found. "
"`cp config_local.example.py config_local.py` 후 실제 서버 URL / API_KEY / " "Run `cp config_local.example.py config_local.py`, then fill in the real server URL / API_KEY / "
"기기 정보를 채우세요. (비밀값은 절대 커밋하지 마세요.)" "device info. (Never commit secret values.)"
) from e ) from e
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 선택 오버라이드 — 기기별 값을 config_local.py 에서 덮어쓸 수 있다. # Optional override — per-device values can be overridden in config_local.py.
# 추적 파일(config.py)을 기기마다 수정하면 배포 드리프트가 생기므로, # Editing the tracked file (config.py) per device causes deployment drift, so
# 신규 지점 노드(예: sensor_id 3)는 config_local.py 에 SENSORS 를 정의한다. # define SENSORS in config_local.py for a new site node (e.g. sensor_id 3).
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
try: try:
from config_local import SENSORS # noqa: F811 — 기기별 센서 목록 오버라이드 from config_local import SENSORS # noqa: F811 — per-device sensor list override
except ImportError: except ImportError:
pass # config_local 에 SENSORS 가 없으면 위 기본값 사용 pass # use the default above if config_local has no SENSORS
# SENSORS 형식 검증 (fail closed) — 오버라이드 오타/형식 오류로 잘못된 sensor_id # SENSORS format validation (fail closed) — prevents an override typo/format error from
# (예: 기본값 1)로 조용히 기동해 다른 지점 상태를 덮어쓰는 사고를 막는다. # silently starting with a wrong sensor_id (e.g. default 1) and overwriting another site's state.
if not isinstance(SENSORS, list) or not SENSORS: if not isinstance(SENSORS, list) or not SENSORS:
raise RuntimeError("SENSORS 는 비어있지 않은 list 여야 합니다 (config_local.py 확인).") raise RuntimeError("SENSORS must be a non-empty list (check config_local.py).")
_ids, _gpios = set(), set() _ids, _gpios = set(), set()
for _s in SENSORS: for _s in SENSORS:
if not (isinstance(_s, dict) if not (isinstance(_s, dict)
@ -104,10 +104,10 @@ for _s in SENSORS:
and isinstance(_s.get("gpio"), int) and isinstance(_s.get("gpio"), int)
and isinstance(_s.get("name"), str) and _s["name"]): and isinstance(_s.get("name"), str) and _s["name"]):
raise RuntimeError( raise RuntimeError(
f"SENSORS 항목 형식 오류: {_s!r} — id(int 1..255)/gpio(int)/name(str) 필수." f"SENSORS entry format error: {_s!r} — id(int 1..255)/gpio(int)/name(str) required."
) )
if _s["id"] in _ids or _s["gpio"] in _gpios: if _s["id"] in _ids or _s["gpio"] in _gpios:
raise RuntimeError(f"SENSORS id/gpio 중복: {_s!r}") raise RuntimeError(f"SENSORS duplicate id/gpio: {_s!r}")
_ids.add(_s["id"]) _ids.add(_s["id"])
_gpios.add(_s["gpio"]) _gpios.add(_s["gpio"])
del _ids, _gpios del _ids, _gpios

View file

@ -1,49 +1,49 @@
# ============================================================================= # =============================================================================
# config_local.example.py - 운영·비밀값 템플릿 (Pi 4 누수감지 모듈) # config_local.example.py - Operational/secret value template (Pi 4 leak detection module)
# #
# 사용법: cp config_local.example.py config_local.py # Usage: cp config_local.example.py config_local.py
# → 실제 값으로 채운 뒤 저장. config_local.py 는 절대 커밋하지 말 것 # → Fill in with real values, then save. Never commit config_local.py
# (.gitignore 로 제외되어 있음). # (excluded via .gitignore).
# #
# ⚠️ 서버 호환성 # ⚠️ Server compatibility
# 이 모듈(1번)은 2번 모듈(SHT30 온습도)과 "동일한" cafe24 서버 php/ 로 보고한다(통합). # This module (Module 1) reports to the "same" cafe24 server php/ as Module 2 (SHT30 temp/humidity) — integrated.
# 누수 이벤트(leak_alert / leak_recovery)와 사진 업로드는 서버가 raw-body 서명을 요구한다: # For leak events (leak_alert / leak_recovery) and photo uploads, the server requires a raw-body signature:
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=요청본문 바이트)) # X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=request body bytes))
# 사진 업로드 canonical = device_id|sensor_id|log_id|timestamp (공백 없음) # photo upload canonical = device_id|sensor_id|log_id|timestamp (no spaces)
# → API_KEY 는 서버 config.local.php 의 API_KEY 와 "정확히 동일"해야 한다. # → API_KEY must be "exactly identical" to the API_KEY in the server's config.local.php.
# 서버 DB 는 sql/schema_sht30.sql 하나로 설치되며(누수 요소 포함), # The server DB is installed from the single sql/schema_sht30.sql (includes leak components),
# 누수 상태·사진·온습도가 한 대시보드(dashboard.php)에 함께 표시된다. # and leak status, photos, and temp/humidity are shown together on one dashboard (dashboard.php).
# (구버전의 sha256(API_KEY + 정렬 JSON) body-field 서명은 폐기됨 — 서버가 거부한다.) # (The legacy sha256(API_KEY + sorted JSON) body-field signature is deprecated — the server rejects it.)
# ============================================================================= # =============================================================================
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 서버 API 엔드포인트 (통합 서버 = cafe24 php/, 2번 모듈과 동일 서버) # Server API endpoints (integrated server = cafe24 php/, same server as Module 2)
# INSTALL_PI_SERVER.md 기준 웹 루트: public_html/raspi_leck_detecter/ # Web root per INSTALL_PI_SERVER.md: public_html/raspi_leck_detecter/
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
API_URL = "https://YOUR_SERVER/raspi_leck_detecter/api/sensor_data.php" API_URL = "https://YOUR_SERVER/raspi_leck_detecter/api/sensor_data.php"
PHOTO_UPLOAD_URL = "https://YOUR_SERVER/raspi_leck_detecter/api/photo_upload.php" PHOTO_UPLOAD_URL = "https://YOUR_SERVER/raspi_leck_detecter/api/photo_upload.php"
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 공유 비밀키 — 서버 측 API_KEY 와 정확히 동일해야 함. 절대 커밋 금지. # Shared secret key — must be exactly identical to the server-side API_KEY. Never commit.
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
API_KEY = "CHANGE_ME" API_KEY = "CHANGE_ME"
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 기기 식별 (여러 대 운영 시 구분) # Device identification (distinguishes multiple units)
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
DEVICE_ID = "rpi4-leak-01" DEVICE_ID = "rpi4-leak-01"
DEVICE_LOCATION = "서버실" DEVICE_LOCATION = "Server Room"
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# (선택) 센서 목록 오버라이드 — 신규 지점 노드용 # (Optional) Sensor list override — for new site nodes
# 미정의 시 config.py 기본값(SENSORS = [{"id": 1, ...}]) 사용. # If undefined, uses the config.py default (SENSORS = [{"id": 1, ...}]).
# 신규 지점(예: Pi-C, sensor_id 3) 배포 시 이 파일에서만 정의하면 되고, # When deploying a new site (e.g. Pi-C, sensor_id 3), define it only in this file;
# 추적 파일(config.py)은 수정할 필요 없다. 서버 대시보드 카드 표시는 # the tracked file (config.py) does not need to be modified. For server dashboard card
# 서버 config.local.php 의 'LEAK_SENSOR_IDS' => [1, 3] 도 함께 갱신할 것. # display, also update 'LEAK_SENSOR_IDS' => [1, 3] in the server's config.local.php.
# ⚠️ sensor_id 는 지점마다 고유해야 함 — 1/2 재사용 시 기존 지점 상태를 # ⚠️ sensor_id must be unique per site — reusing 1/2 will silently overwrite the
# 조용히 덮어쓴다 (leak_sensor_status 는 sensor_id 단독 키). # existing site's status (leak_sensor_status is keyed on sensor_id alone).
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# SENSORS = [ # SENSORS = [
# {"id": 3, "gpio": 17, "name": "3번 센서 (신규 지점)"}, # {"id": 3, "gpio": 17, "name": "Sensor 3 (New Site)"},
# ] # ]
# (위를 켤 때 DEVICE_ID 예: "rpi4-leak-02", DEVICE_LOCATION 도 지점명으로 변경) # (When enabling the above, DEVICE_ID e.g. "rpi4-leak-02", and change DEVICE_LOCATION to the site name too)

View file

@ -1,15 +1,16 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ============================================================================= # =============================================================================
# 누수감지 센서 v2 (Raspberry Pi 4 Model B) 설치 스크립트 # Leak Detection Sensor v2 (Raspberry Pi 4 Model B) install script
# 대상 OS: Raspberry Pi OS Bookworm (64-bit, aarch64) # Target OS: Raspberry Pi OS Bookworm (64-bit, aarch64)
# #
# 폐쇄망(air-gapped) 지원: # Air-gapped support:
# - debs/ 또는 wheelhouse/ 가 있으면 자동으로 "오프라인 모드"로 전환. # - If debs/ or wheelhouse/ is present, automatically switch to "offline mode".
# - 오프라인 모드에서는 절대 네트워크에 접근하지 않는다. 번들이 일부만 있으면 # - In offline mode, never access the network. If the bundle is only partial,
# 온라인으로 몰래 빠지지 않고 명확한 오류로 중단한다(폐쇄망 규칙: 네트워크가 # do not silently fall back to online; abort with a clear error (air-gap rule:
# 필요하면 반드시 FLAG). 사전 다운로드는 vendor.sh 로 빌드 머신에서 수행. # if the network is needed, always FLAG it). Pre-download is done on the build
# - 환경변수 OFFLINE=1/0 으로 강제 지정 가능. # machine via vendor.sh.
# - 로그 파일 소유자: SERVICE_USER (기본 pi). # - Can be forced with the OFFLINE=1/0 environment variable.
# - Log file owner: SERVICE_USER (default pi).
# ============================================================================= # =============================================================================
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
@ -17,101 +18,101 @@ cd "$(dirname "$0")"
SERVICE_USER="${SERVICE_USER:-pi}" SERVICE_USER="${SERVICE_USER:-pi}"
echo "==========================================" echo "=========================================="
echo " 누수감지 센서 v2 (Pi 4) 설치" echo " Leak Detection Sensor v2 (Pi 4) install"
echo "==========================================" echo "=========================================="
# 0) 보드 모델 안내 (강제 아님) # 0) Board model notice (not enforced)
if [ -r /proc/device-tree/model ]; then if [ -r /proc/device-tree/model ]; then
MODEL="$(tr -d '\0' < /proc/device-tree/model)" MODEL="$(tr -d '\0' < /proc/device-tree/model)"
echo "[INFO] 보드: ${MODEL}" echo "[INFO] Board: ${MODEL}"
case "${MODEL}" in case "${MODEL}" in
*"Raspberry Pi 4"*) : ;; *"Raspberry Pi 4"*) : ;;
*) echo "[WARN] 이 폴더는 Raspberry Pi 4 Model B 전용입니다 (계속 진행은 가능)." ;; *) echo "[WARN] This folder is intended for the Raspberry Pi 4 Model B only (continuing is possible)." ;;
esac esac
fi fi
# 오프라인 모드 판정 # Determine offline mode
OFFLINE="${OFFLINE:-}" OFFLINE="${OFFLINE:-}"
if [ -z "${OFFLINE}" ]; then if [ -z "${OFFLINE}" ]; then
if compgen -G "debs/*.deb" >/dev/null || [ -d wheelhouse ]; then if compgen -G "debs/*.deb" >/dev/null || [ -d wheelhouse ]; then
OFFLINE=1 OFFLINE=1
echo "[INFO] 오프라인 번들 감지 → 폐쇄망 오프라인 설치 모드 (네트워크 미사용)" echo "[INFO] Offline bundle detected -> air-gapped offline install mode (no network)"
else else
OFFLINE=0 OFFLINE=0
echo "[INFO] 오프라인 번들 없음 → 온라인 설치 모드 (인터넷 필요)" echo "[INFO] No offline bundle -> online install mode (internet required)"
fi fi
fi fi
# 1) 시스템 패키지: RPi.GPIO 대체(lgpio) + requests + venv + 카메라 앱 # 1) System packages: RPi.GPIO replacement (lgpio) + requests + venv + camera app
echo "[1/3] 시스템 패키지 설치..." echo "[1/3] Installing system packages..."
if [ "${OFFLINE}" = "1" ]; then if [ "${OFFLINE}" = "1" ]; then
if ! compgen -G "debs/*.deb" >/dev/null; then if ! compgen -G "debs/*.deb" >/dev/null; then
echo "[ERROR] 오프라인 모드인데 debs/ 가 비어있습니다. vendor.sh 산출물(debs/)을 이 폴더에 두고 재실행하세요." >&2 echo "[ERROR] Offline mode but debs/ is empty. Place the vendor.sh output (debs/) in this folder and re-run." >&2
exit 1 exit 1
fi fi
echo " 오프라인 .deb 설치 (apt-get --no-download — 의존성 순서·해결 자동, 네트워크 미사용)" echo " Installing offline .deb (apt-get --no-download - automatic dependency ordering/resolution, no network)"
# --no-install-recommends: vendor.sh 가 Recommends 를 안 담으므로(--no-recommends) 맞춘다. # --no-install-recommends: vendor.sh does not include Recommends (--no-recommends), so match that.
sudo apt-get install -y --no-install-recommends --no-download ./debs/*.deb sudo apt-get install -y --no-install-recommends --no-download ./debs/*.deb
else else
echo " 온라인 apt 설치 (인터넷 필요)" echo " Online apt install (internet required)"
sudo apt-get update sudo apt-get update
if apt-cache show rpicam-apps >/dev/null 2>&1; then CAM=rpicam-apps; else CAM=libcamera-apps; fi if apt-cache show rpicam-apps >/dev/null 2>&1; then CAM=rpicam-apps; else CAM=libcamera-apps; fi
sudo apt-get install -y python3-rpi-lgpio python3-requests python3-venv "${CAM}" sudo apt-get install -y python3-rpi-lgpio python3-requests python3-venv "${CAM}"
fi fi
# 2) Python venv + requests # 2) Python venv + requests
echo "[2/3] Python 가상환경 설정..." echo "[2/3] Setting up Python virtual environment..."
if [ ! -d venv ]; then if [ ! -d venv ]; then
python3 -m venv --system-site-packages venv python3 -m venv --system-site-packages venv
echo " venv 생성 완료" echo " venv created"
else else
echo " venv 이미 존재 — 건너뜀" echo " venv already exists - skipping"
fi fi
# shellcheck disable=SC1091 # shellcheck disable=SC1091
source venv/bin/activate source venv/bin/activate
if [ "${OFFLINE}" = "1" ]; then if [ "${OFFLINE}" = "1" ]; then
if [ ! -d wheelhouse ]; then if [ ! -d wheelhouse ]; then
echo "[ERROR] 오프라인 모드인데 wheelhouse/ 가 없습니다. vendor.sh 산출물(wheelhouse/)을 두고 재실행하세요." >&2 echo "[ERROR] Offline mode but wheelhouse/ is missing. Place the vendor.sh output (wheelhouse/) and re-run." >&2
exit 1 exit 1
fi fi
echo " 오프라인 wheel 설치 (--no-index — 네트워크 미사용)" echo " Installing offline wheels (--no-index - no network)"
pip install --no-index --find-links=wheelhouse -r requirements.txt pip install --no-index --find-links=wheelhouse -r requirements.txt
else else
echo " 온라인 pip 설치 (인터넷 필요)" echo " Online pip install (internet required)"
pip install --upgrade pip pip install --upgrade pip
pip install -r requirements.txt pip install -r requirements.txt
fi fi
echo " 패키지 설치 완료" echo " Package installation complete"
# 3) 로그 파일 (최소 권한: 소유자만 쓰기 — leak_sensor 가 단독 기록) # 3) Log file (least privilege: owner write only - leak_sensor is the sole writer)
echo "[3/3] 로그 파일 생성..." echo "[3/3] Creating log file..."
sudo mkdir -p /var/log sudo mkdir -p /var/log
sudo touch /var/log/leak_sensor.log sudo touch /var/log/leak_sensor.log
if ! sudo chown "${SERVICE_USER}:${SERVICE_USER}" /var/log/leak_sensor.log; then if ! sudo chown "${SERVICE_USER}:${SERVICE_USER}" /var/log/leak_sensor.log; then
echo "[WARN] chown 실패 — SERVICE_USER=${SERVICE_USER} 존재 여부 확인 (서비스 사용자와 일치해야 파일 로깅 가능)" echo "[WARN] chown failed - check whether SERVICE_USER=${SERVICE_USER} exists (must match the service user for file logging to work)"
fi fi
sudo chmod 640 /var/log/leak_sensor.log sudo chmod 640 /var/log/leak_sensor.log
# 3.5) config_local.py 즉시 잠금 (API_KEY 포함 — harden 전까지 world-readable 방지) # 3.5) Lock config_local.py immediately (contains API_KEY - prevent world-readable until harden)
# 하드닝(harden_pi.sh)이 나중에 다시 조이지만, 그 사이 창(window)에도 노출되지 # Hardening (harden_pi.sh) tightens this again later, but lock it to 600 right at install
# 않도록 설치 시점에 바로 600 으로 잠근다. harden 여부와 무관하게 보호된다. # time so it is not exposed even in the window between. Protected regardless of harden.
if [ -f config_local.py ]; then if [ -f config_local.py ]; then
chmod 600 config_local.py chmod 600 config_local.py
sudo chown "${SERVICE_USER}:${SERVICE_USER}" config_local.py 2>/dev/null || true sudo chown "${SERVICE_USER}:${SERVICE_USER}" config_local.py 2>/dev/null || true
echo " config_local.py 권한 600 (소유자 ${SERVICE_USER}) 설정" echo " config_local.py permissions set to 600 (owner ${SERVICE_USER})"
fi fi
# 4) config_local 안내 (fail closed — 없으면 실행 시 즉시 중단됨) # 4) config_local notice (fail closed - if missing, execution aborts immediately)
if [ ! -f config_local.py ]; then if [ ! -f config_local.py ]; then
echo "" echo ""
echo "[!] config_local.py 가 없습니다. 다음을 실행 후 실제 값으로 채우세요:" echo "[!] config_local.py is missing. Run the following, then fill in the real values:"
echo " cp config_local.example.py config_local.py" echo " cp config_local.example.py config_local.py"
fi fi
echo "" echo ""
echo "==========================================" echo "=========================================="
echo " 설치 완료! (OFFLINE=${OFFLINE})" echo " Installation complete! (OFFLINE=${OFFLINE})"
echo "==========================================" echo "=========================================="
echo " 실행: source venv/bin/activate && python3 leak_sensor.py" echo " Run: source venv/bin/activate && python3 leak_sensor.py"
echo " 서비스: README.md 의 systemd 섹션 참고" echo " Service: see the systemd section in README.md"
echo "" echo ""

View file

@ -1,26 +1,27 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# ============================================================================= # =============================================================================
# leak_sensor.py - e-con 5C-08 누수감지센서 모니터링 메인 스크립트 (v2, Pi 4 전용) # leak_sensor.py - e-con 5C-08 leak detection sensor monitoring main script (v2, Pi 4 only)
# #
# [대상 하드웨어] Raspberry Pi 4 Model B (BCM2711, 40핀 GPIO 헤더) # [Target hardware] Raspberry Pi 4 Model B (BCM2711, 40-pin GPIO header)
# - 원래 Raspberry Pi Zero 2 W 용이던 모듈을 Pi 4 Model B 전용으로 이식. # - Module originally for Raspberry Pi Zero 2 W, ported to Pi 4 Model B only.
# - GPIO BCM 번호 체계·rpicam/libcamera 카메라 스택은 두 보드가 동일하므로 # - GPIO BCM numbering and the rpicam/libcamera camera stack are identical on
# 센서/촬영 로직은 그대로 유지. 차이는 배선/전원/이더넷/카메라 커넥터 문서뿐. # both boards, so the sensor/capture logic is unchanged. Only the wiring/
# (Pi 4 = 15핀 1.0mm CSI / Zero 2 W = 22핀 0.5mm CSI → 리본 비호환, # power/Ethernet/camera-connector docs differ.
# Gigabit Ethernet, USB-C 5V/3A) → README.md 참고. # (Pi 4 = 15-pin 1.0mm CSI / Zero 2 W = 22-pin 0.5mm CSI -> ribbon incompatible,
# Gigabit Ethernet, USB-C 5V/3A) -> see README.md.
# #
# [동작 흐름] # [Operation flow]
# 1. GPIO 초기화 → 센서 핀 입력 설정 # 1. GPIO init -> configure sensor pins as input
# 2. 주기적(REPORT_INTERVAL)으로 현재 상태를 PHP API에 POST # 2. POST current state to the PHP API periodically (REPORT_INTERVAL)
# 3. 누수 감지 인터럽트 발생 시 즉시 알림 POST # 3. On leak-detection interrupt, POST an alert immediately
# 4. 누수 지속 중엔 LEAK_REPORT_INTERVAL 마다 재보고 # 4. While the leak persists, re-report every LEAK_REPORT_INTERVAL
# 5. 복구(정상) 감지 시 복구 알림 POST # 5. On recovery (normal) detection, POST a recovery alert
# 6. 누수 감지 시 Pi Camera로 촬영 → photo_upload.php에 업로드 # 6. On leak detection, capture with the Pi Camera -> upload to photo_upload.php
# (촬영/업로드 실패해도 알림+SMS는 이미 전송된 상태) # (even if capture/upload fails, the alert+SMS has already been sent)
# #
# [설정·비밀값] config.py 는 비밀이 아닌 기본값만 보유하고, 서버 URL/API_KEY/ # [Config/secrets] config.py holds only non-secret defaults; the server URL/API_KEY/
# 기기 식별자는 config_local.py(미커밋)에서 불러온다. config_local.py 가 없으면 # device identifiers are loaded from config_local.py (not committed). If config_local.py
# 기동 시 명확한 오류로 즉시 중단(fail closed)된다. # is missing, startup aborts immediately with a clear error (fail closed).
# ============================================================================= # =============================================================================
import time import time
@ -40,9 +41,9 @@ try:
import RPi.GPIO as GPIO import RPi.GPIO as GPIO
SIMULATION = False SIMULATION = False
except (ImportError, RuntimeError): except (ImportError, RuntimeError):
# 개발/테스트 환경에서 RPi.GPIO 없을 때 시뮬레이션 모드 # Simulation mode when RPi.GPIO is unavailable (dev/test environment)
SIMULATION = True SIMULATION = True
print("[WARNING] RPi.GPIO 없음 → 시뮬레이션 모드로 실행됩니다.") print("[WARNING] RPi.GPIO not found -> running in simulation mode.")
from config import ( from config import (
SENSORS, ACTIVE_LOW, USE_INTERNAL_PULL, SENSORS, ACTIVE_LOW, USE_INTERNAL_PULL,
@ -56,7 +57,7 @@ from config import (
) )
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 로거 설정 # Logger setup
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
def setup_logger(): def setup_logger():
logger = logging.getLogger("leak_sensor") logger = logging.getLogger("leak_sensor")
@ -67,7 +68,7 @@ def setup_logger():
datefmt="%Y-%m-%d %H:%M:%S" datefmt="%Y-%m-%d %H:%M:%S"
) )
# 파일 핸들러 (로테이션) # File handler (rotating)
try: try:
fh = logging.handlers.RotatingFileHandler( fh = logging.handlers.RotatingFileHandler(
LOG_FILE, maxBytes=LOG_MAX_BYTES, backupCount=LOG_BACKUP_COUNT, LOG_FILE, maxBytes=LOG_MAX_BYTES, backupCount=LOG_BACKUP_COUNT,
@ -76,9 +77,9 @@ def setup_logger():
fh.setFormatter(fmt) fh.setFormatter(fmt)
logger.addHandler(fh) logger.addHandler(fh)
except PermissionError: except PermissionError:
pass # 권한 없으면 콘솔만 사용 pass # No permission -> console only
# 콘솔 핸들러 # Console handler
ch = logging.StreamHandler(sys.stdout) ch = logging.StreamHandler(sys.stdout)
ch.setFormatter(fmt) ch.setFormatter(fmt)
logger.addHandler(ch) logger.addHandler(ch)
@ -89,7 +90,7 @@ log = setup_logger()
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 시뮬레이션 GPIO (테스트용) # Simulation GPIO (for testing)
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
class SimGPIO: class SimGPIO:
BCM = "BCM" BCM = "BCM"
@ -118,12 +119,12 @@ if SIMULATION:
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 누수 상태 관리 # Leak state management
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
class SensorState: class SensorState:
def __init__(self): def __init__(self):
self.lock = threading.Lock() self.lock = threading.Lock()
# 센서별 상태 딕셔너리 {sensor_id: bool} # Per-sensor state dictionary {sensor_id: bool}
self.leak_detected = {s["id"]: False for s in SENSORS} self.leak_detected = {s["id"]: False for s in SENSORS}
self.last_report_time = {s["id"]: 0 for s in SENSORS} self.last_report_time = {s["id"]: 0 for s in SENSORS}
@ -146,18 +147,19 @@ state = SensorState()
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# HTTP POST 유틸 # HTTP POST utilities
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# [보안 메모 — raw-body HMAC 서명 (2번 모듈과 동일 스킴)] # [Security note — raw-body HMAC signature (same scheme as sensor module 2)]
# 서버(php/api/sensor_data.php + config.php::verify_signature_raw)는 "받은 본문 # The server (php/api/sensor_data.php + config.php::verify_signature_raw) verifies
# 바이트 그대로"에 대해 X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, # X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=raw_body)) over the
# msg=raw_body)) 를 검증한다. 따라서 "서명한 바이트"와 "실제 전송한 바이트"가 # received body bytes exactly as received. So the "signed bytes" and the "actually
# 100% 동일해야 한다. requests 의 json= 직렬화에 맡기면 공백/순서/부동소수 포맷이 # sent bytes" must be 100% identical. Letting requests' json= serialization handle it
# 달라져 서명이 깨질 수 있으므로, 본문을 직접 문자열로 만들고 그 바이트에 서명한 뒤 # could change whitespace/ordering/float formatting and break the signature, so we
# 그 바이트를 그대로 POST(data=body_bytes) 한다. # build the body as a string ourselves, sign those bytes, and POST those same bytes
# (구버전의 sha256(API_KEY + 정렬 JSON) body-field 서명은 폐기 — 서버가 거부한다.) # as-is (data=body_bytes).
# (The old sha256(API_KEY + sorted JSON) body-field signature is retired — the server rejects it.)
def _esc(s: str) -> str: def _esc(s: str) -> str:
r"""JSON 문자열 값 이스케이프 ('"' / '\\' / 제어문자<0x20 만). 멀티바이트는 그대로.""" r"""Escape a JSON string value (only '"' / '\\' / control chars <0x20). Multibyte left as-is."""
out = [] out = []
for ch in s: for ch in s:
o = ord(ch) o = ord(ch)
@ -173,9 +175,10 @@ def _esc(s: str) -> str:
def leak_event_body(event_type: str, sensor: dict, is_leak: bool, timestamp: int) -> str: def leak_event_body(event_type: str, sensor: dict, is_leak: bool, timestamp: int) -> str:
"""누수 이벤트 본문 JSON 문자열을 고정 필드 순서로 생성(서명 호환). """Build the leak-event body JSON string with a fixed field order (signature-compatible).
필드 순서(서버 파싱은 순서 무관이나, 서명은 바이트 동일성을 요구하므로 고정): Field order (server parsing is order-agnostic, but the signature requires byte
identity, so it is fixed):
device_id, device_location, sensor_id, sensor_name, event_type, is_leak, timestamp device_id, device_location, sensor_id, sensor_name, event_type, is_leak, timestamp
""" """
return ( return (
@ -192,16 +195,16 @@ def leak_event_body(event_type: str, sensor: dict, is_leak: bool, timestamp: int
def sign_raw(body: str) -> str: def sign_raw(body: str) -> str:
"""raw-body 서명: lowercase_hex(HMAC-SHA256(key=API_KEY, msg=body 바이트)).""" """raw-body signature: lowercase_hex(HMAC-SHA256(key=API_KEY, msg=body bytes))."""
return hmac.new(API_KEY.encode("utf-8"), body.encode("utf-8"), return hmac.new(API_KEY.encode("utf-8"), body.encode("utf-8"),
hashlib.sha256).hexdigest() hashlib.sha256).hexdigest()
def _post_event(event_type: str, sensor: dict, is_leak: bool, want_response: bool): def _post_event(event_type: str, sensor: dict, is_leak: bool, want_response: bool):
"""누수 이벤트를 raw-body HMAC 서명으로 POST. """POST a leak event with a raw-body HMAC signature.
body 문자열을 만들고 그 바이트에 서명한 뒤 data=body_bytes 로 그대로 전송한다. Builds the body string, signs those bytes, and sends them as-is via data=body_bytes.
want_response=False 면 성공 여부(bool), True 면 응답 JSON(dict) 또는 None 을 반환. If want_response=False, returns success (bool); if True, returns the response JSON (dict) or None.
""" """
body = leak_event_body(event_type, sensor, is_leak, int(time.time())) body = leak_event_body(event_type, sensor, is_leak, int(time.time()))
body_bytes = body.encode("utf-8") body_bytes = body.encode("utf-8")
@ -222,30 +225,30 @@ def _post_event(event_type: str, sensor: dict, is_leak: bool, want_response: boo
headers=headers, headers=headers,
) )
if resp.status_code == 200: if resp.status_code == 200:
log.info(f"[POST OK] 센서{sensor['id']} event={event_type} leak={is_leak} → {resp.text[:80]}") log.info(f"[POST OK] sensor{sensor['id']} event={event_type} leak={is_leak} -> {resp.text[:80]}")
if not want_response: if not want_response:
return True return True
try: try:
return resp.json() return resp.json()
except ValueError: except ValueError:
log.warning("[POST] 응답 JSON 파싱 실패") log.warning("[POST] failed to parse response JSON")
return None return None
else: else:
log.warning(f"[POST {resp.status_code}] 시도 {attempt}/{HTTP_RETRY_COUNT}: {resp.text[:120]}") log.warning(f"[POST {resp.status_code}] attempt {attempt}/{HTTP_RETRY_COUNT}: {resp.text[:120]}")
except requests.exceptions.Timeout: except requests.exceptions.Timeout:
log.warning(f"[POST TIMEOUT] 시도 {attempt}/{HTTP_RETRY_COUNT}") log.warning(f"[POST TIMEOUT] attempt {attempt}/{HTTP_RETRY_COUNT}")
except requests.exceptions.ConnectionError as e: except requests.exceptions.ConnectionError as e:
log.warning(f"[POST CONN ERR] 시도 {attempt}/{HTTP_RETRY_COUNT}: {e}") log.warning(f"[POST CONN ERR] attempt {attempt}/{HTTP_RETRY_COUNT}: {e}")
if attempt < HTTP_RETRY_COUNT: if attempt < HTTP_RETRY_COUNT:
time.sleep(HTTP_RETRY_DELAY) time.sleep(HTTP_RETRY_DELAY)
log.error(f"[POST FAIL] {HTTP_RETRY_COUNT}회 모두 실패. 센서{sensor['id']}") log.error(f"[POST FAIL] all {HTTP_RETRY_COUNT} attempts failed. sensor{sensor['id']}")
return False if not want_response else None return False if not want_response else None
def post_to_server(event_type: str, sensor: dict, is_leak: bool): def post_to_server(event_type: str, sensor: dict, is_leak: bool):
"""PHP API 서버에 센서 데이터 POST (성공 여부 bool 반환). """POST sensor data to the PHP API server (returns success as bool).
event_type: "periodic" | "startup" | "leak_alert" | "leak_recovery" event_type: "periodic" | "startup" | "leak_alert" | "leak_recovery"
""" """
@ -253,17 +256,17 @@ def post_to_server(event_type: str, sensor: dict, is_leak: bool):
def post_to_server_with_response(event_type: str, sensor: dict, is_leak: bool): def post_to_server_with_response(event_type: str, sensor: dict, is_leak: bool):
"""post_to_server 와 동일하나 응답 JSON(dict) 또는 None 을 반환.""" """Same as post_to_server, but returns the response JSON (dict) or None."""
return _post_event(event_type, sensor, is_leak, want_response=True) return _post_event(event_type, sensor, is_leak, want_response=True)
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 카메라 촬영 / 사진 업로드 # Camera capture / photo upload
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
def capture_photo(output_path: str) -> bool: def capture_photo(output_path: str) -> bool:
""" """
rpicam-still (Bookworm+) 우선, 없으면 libcamera-still (Bullseye)로 사진 촬영. Capture a photo, preferring rpicam-still (Bookworm+), falling back to libcamera-still (Bullseye).
성공 시 True, 실패 시 False. Returns True on success, False on failure.
""" """
for cmd in ["rpicam-still", "libcamera-still"]: for cmd in ["rpicam-still", "libcamera-still"]:
try: try:
@ -281,34 +284,34 @@ def capture_photo(output_path: str) -> bool:
) )
if result.returncode == 0 and os.path.exists(output_path): if result.returncode == 0 and os.path.exists(output_path):
file_size = os.path.getsize(output_path) file_size = os.path.getsize(output_path)
log.info(f"[CAMERA] 촬영 성공: {output_path} ({file_size} bytes, cmd={cmd})") log.info(f"[CAMERA] capture succeeded: {output_path} ({file_size} bytes, cmd={cmd})")
return True return True
else: else:
log.warning(f"[CAMERA] {cmd} 실패 (rc={result.returncode}): {result.stderr[:200]}") log.warning(f"[CAMERA] {cmd} failed (rc={result.returncode}): {result.stderr[:200]}")
except FileNotFoundError: except FileNotFoundError:
continue # 이 명령어가 없으면 다음 것 시도 continue # This command is missing -> try the next one
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
log.warning(f"[CAMERA] {cmd} 타임아웃") log.warning(f"[CAMERA] {cmd} timeout")
except Exception as e: except Exception as e:
log.warning(f"[CAMERA] {cmd} 예외: {e}") log.warning(f"[CAMERA] {cmd} exception: {e}")
log.error("[CAMERA] 촬영 실패: rpicam-still, libcamera-still 모두 사용 불가") log.error("[CAMERA] capture failed: neither rpicam-still nor libcamera-still available")
return False return False
def upload_photo(photo_path: str, log_id: int, sensor: dict) -> bool: def upload_photo(photo_path: str, log_id: int, sensor: dict) -> bool:
""" """
촬영된 사진을 photo_upload.php에 multipart POST. multipart POST the captured photo to photo_upload.php.
성공 시 True, 실패 시 False. Returns True on success, False on failure.
[인증] X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=canonical)) [Auth] X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=canonical))
canonical = f"{DEVICE_ID}|{sensor_id}|{log_id}|{timestamp}" (공백 없음) canonical = f"{DEVICE_ID}|{sensor_id}|{log_id}|{timestamp}" (no whitespace)
서버 php/api/photo_upload.php 의 canonical 과 바이트 동일해야 한다. Must be byte-identical to the canonical in the server's php/api/photo_upload.php.
""" """
timestamp = int(time.time()) timestamp = int(time.time())
sensor_id = int(sensor["id"]) sensor_id = int(sensor["id"])
# 서버와 정확히 동일한 문자열: device_id|sensor_id|log_id|timestamp (공백 없음). # Exact same string as the server: device_id|sensor_id|log_id|timestamp (no whitespace).
# 폼 필드도 동일한 문자열 표현으로 보내야 서버 재구성 canonical 과 일치한다. # The form fields must be sent in the same string representation to match the server's reconstructed canonical.
sensor_id_str = str(sensor_id) sensor_id_str = str(sensor_id)
log_id_str = str(log_id) log_id_str = str(log_id)
timestamp_str = str(timestamp) timestamp_str = str(timestamp)
@ -339,7 +342,7 @@ def upload_photo(photo_path: str, log_id: int, sensor: dict) -> bool:
) )
if resp.status_code == 200: if resp.status_code == 200:
log.info(f"[PHOTO UPLOAD OK] log_id={log_id} → {resp.text[:80]}") log.info(f"[PHOTO UPLOAD OK] log_id={log_id} -> {resp.text[:80]}")
return True return True
else: else:
log.warning(f"[PHOTO UPLOAD {resp.status_code}] {resp.text[:120]}") log.warning(f"[PHOTO UPLOAD {resp.status_code}] {resp.text[:120]}")
@ -352,43 +355,43 @@ def upload_photo(photo_path: str, log_id: int, sensor: dict) -> bool:
def _handle_leak_with_photo(sensor: dict): def _handle_leak_with_photo(sensor: dict):
""" """
누수 감지 시 처리 순서: Processing order on leak detection:
1. sensor_data.php에 POST → log_id 수신 (+ SMS 발송) 1. POST to sensor_data.php -> receive log_id (+ send SMS)
2. 카메라 촬영 (CAMERA_ENABLED 시) 2. Capture with camera (when CAMERA_ENABLED)
3. photo_upload.php에 업로드 3. Upload to photo_upload.php
4. 임시파일 삭제 4. Delete temp file
촬영/업로드 실패해도 1단계(알림+SMS)는 이미 완료. Even if capture/upload fails, step 1 (alert+SMS) is already done.
""" """
# 1) leak_alert POST → log_id 수신 # 1) leak_alert POST -> receive log_id
resp_data = post_to_server_with_response("leak_alert", sensor, True) resp_data = post_to_server_with_response("leak_alert", sensor, True)
if resp_data is None: if resp_data is None:
log.error(f"[PHOTO] 센서{sensor['id']} leak_alert POST 실패 → 촬영 건너뜀") log.error(f"[PHOTO] sensor{sensor['id']} leak_alert POST failed -> skipping capture")
return return
log_id = resp_data.get("log_id") log_id = resp_data.get("log_id")
if not log_id: if not log_id:
log.warning(f"[PHOTO] 센서{sensor['id']} 응답에 log_id 없음 → 촬영 건너뜀") log.warning(f"[PHOTO] sensor{sensor['id']} response has no log_id -> skipping capture")
return return
# 2) 카메라 촬영 # 2) Camera capture
if not CAMERA_ENABLED: if not CAMERA_ENABLED:
log.info(f"[PHOTO] 카메라 비활성화 → 촬영 건너뜀 (log_id={log_id})") log.info(f"[PHOTO] camera disabled -> skipping capture (log_id={log_id})")
return return
# /tmp에 임시 JPEG 파일 생성 # Create a temp JPEG file in /tmp
fd, photo_path = tempfile.mkstemp(suffix=".jpg", prefix="leak_") fd, photo_path = tempfile.mkstemp(suffix=".jpg", prefix="leak_")
os.close(fd) os.close(fd)
try: try:
if not capture_photo(photo_path): if not capture_photo(photo_path):
log.warning(f"[PHOTO] 촬영 실패 → 업로드 건너뜀 (log_id={log_id})") log.warning(f"[PHOTO] capture failed -> skipping upload (log_id={log_id})")
return return
# 3) 업로드 # 3) Upload
upload_photo(photo_path, log_id, sensor) upload_photo(photo_path, log_id, sensor)
finally: finally:
# 4) 임시파일 정리 # 4) Clean up temp file
try: try:
if os.path.exists(photo_path): if os.path.exists(photo_path):
os.unlink(photo_path) os.unlink(photo_path)
@ -397,14 +400,14 @@ def _handle_leak_with_photo(sensor: dict):
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# GPIO 인터럽트 콜백 # GPIO interrupt callback
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
def make_callback(sensor: dict): def make_callback(sensor: dict):
def callback(channel): def callback(channel):
gpio_val = GPIO.input(sensor["gpio"]) gpio_val = GPIO.input(sensor["gpio"])
# ACTIVE_LOW: GPIO=0 이면 누수, GPIO=1 이면 정상 # ACTIVE_LOW: GPIO=0 means leak, GPIO=1 means normal
# ACTIVE_HIGH(트랜지스터 반전): GPIO=1 이면 누수 # ACTIVE_HIGH (transistor-inverted): GPIO=1 means leak
if ACTIVE_LOW: if ACTIVE_LOW:
is_leak = (gpio_val == 0) is_leak = (gpio_val == 0)
else: else:
@ -414,15 +417,15 @@ def make_callback(sensor: dict):
if changed: if changed:
if is_leak: if is_leak:
log.warning(f"[LEAK DETECTED] 센서{sensor['id']} ({sensor['name']}) !!!") log.warning(f"[LEAK DETECTED] sensor{sensor['id']} ({sensor['name']}) !!!")
# leak_alert + 사진 촬영/업로드를 한 스레드에서 순차 실행 # Run leak_alert + photo capture/upload sequentially in one thread
threading.Thread( threading.Thread(
target=_handle_leak_with_photo, target=_handle_leak_with_photo,
args=(sensor,), args=(sensor,),
daemon=True daemon=True
).start() ).start()
else: else:
log.info(f"[LEAK RECOVERED] 센서{sensor['id']} ({sensor['name']}) 정상 복구") log.info(f"[LEAK RECOVERED] sensor{sensor['id']} ({sensor['name']}) recovered to normal")
threading.Thread( threading.Thread(
target=post_to_server, target=post_to_server,
args=("leak_recovery", sensor, False), args=("leak_recovery", sensor, False),
@ -433,14 +436,14 @@ def make_callback(sensor: dict):
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 주기 보고 스레드 # Periodic reporting thread
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
def periodic_reporter(): def periodic_reporter():
""" """
정상 상태: REPORT_INTERVAL 주기로 보고 Normal state: report every REPORT_INTERVAL
누수 상태: LEAK_REPORT_INTERVAL 주기로 반복 보고 (SMS 재발송 방지는 PHP에서 처리) Leak state: repeat-report every LEAK_REPORT_INTERVAL (SMS resend suppression handled in PHP)
""" """
log.info("[REPORTER] 주기 보고 스레드 시작") log.info("[REPORTER] periodic reporting thread started")
last_sent = {s["id"]: 0 for s in SENSORS} last_sent = {s["id"]: 0 for s in SENSORS}
while True: while True:
@ -458,26 +461,26 @@ def periodic_reporter():
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# GPIO 초기화 # GPIO initialization
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
def setup_gpio(): def setup_gpio():
GPIO.setmode(GPIO.BCM) GPIO.setmode(GPIO.BCM)
# 외부 4.7MΩ 풀다운만 사용 → 내부 풀저항은 PUD_OFF 필수 # Use only the external 4.7MΩ pull-down -> internal pull resistor must be PUD_OFF
# (내부 50kΩ 풀다운을 켜면 1.5MΩ 센서가 GPIO를 HIGH로 올리지 못함) # (Enabling the internal 50kΩ pull-down prevents the 1.5MΩ sensor from pulling GPIO HIGH)
pull = GPIO.PUD_DOWN if USE_INTERNAL_PULL else GPIO.PUD_OFF pull = GPIO.PUD_DOWN if USE_INTERNAL_PULL else GPIO.PUD_OFF
for sensor in SENSORS: for sensor in SENSORS:
gpio = sensor["gpio"] gpio = sensor["gpio"]
GPIO.setup(gpio, GPIO.IN, pull_up_down=pull) GPIO.setup(gpio, GPIO.IN, pull_up_down=pull)
# 초기 상태 읽기 # Read initial state
gpio_val = GPIO.input(gpio) gpio_val = GPIO.input(gpio)
is_leak = (gpio_val == 0) if ACTIVE_LOW else (gpio_val == 1) is_leak = (gpio_val == 0) if ACTIVE_LOW else (gpio_val == 1)
state.set_leak(sensor["id"], is_leak) state.set_leak(sensor["id"], is_leak)
log.info(f"[INIT] 센서{sensor['id']} GPIO{gpio} → {'누수!' if is_leak else '정상'}") log.info(f"[INIT] sensor{sensor['id']} GPIO{gpio} -> {'LEAK!' if is_leak else 'normal'}")
# 엣지 감지 인터럽트 등록 # Register the edge-detection interrupt
GPIO.add_event_detect( GPIO.add_event_detect(
gpio, gpio,
GPIO.BOTH, GPIO.BOTH,
@ -485,46 +488,46 @@ def setup_gpio():
bouncetime=DEBOUNCE_MS bouncetime=DEBOUNCE_MS
) )
log.info(f"[INIT] GPIO 설정 완료. 총 {len(SENSORS)}개 센서") log.info(f"[INIT] GPIO setup complete. {len(SENSORS)} sensors total")
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 메인 # Main
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
def main(): def main():
log.info("=" * 60) log.info("=" * 60)
log.info(f"누수감지 시스템 v2 (Pi 4) 시작 | 기기={DEVICE_ID} | 위치={DEVICE_LOCATION}") log.info(f"Leak detection system v2 (Pi 4) starting | device={DEVICE_ID} | location={DEVICE_LOCATION}")
log.info(f"플랫폼={platform.machine()} | API={API_URL}") log.info(f"platform={platform.machine()} | API={API_URL}")
log.info(f"카메라={'활성' if CAMERA_ENABLED else '비활성'}") log.info(f"camera={'enabled' if CAMERA_ENABLED else 'disabled'}")
log.info(f"센서={[s['name'] for s in SENSORS]}") log.info(f"sensors={[s['name'] for s in SENSORS]}")
log.info(f"모드={'시뮬레이션' if SIMULATION else '실제 GPIO'}") log.info(f"mode={'simulation' if SIMULATION else 'real GPIO'}")
log.info("=" * 60) log.info("=" * 60)
try: try:
setup_gpio() setup_gpio()
# 시작 시 현재 상태 즉시 보고 # Report current state immediately at startup
for sensor in SENSORS: for sensor in SENSORS:
post_to_server("startup", sensor, state.is_leaking(sensor["id"])) post_to_server("startup", sensor, state.is_leaking(sensor["id"]))
# 주기 보고 스레드 시작 # Start the periodic reporting thread
reporter_thread = threading.Thread(target=periodic_reporter, daemon=True) reporter_thread = threading.Thread(target=periodic_reporter, daemon=True)
reporter_thread.start() reporter_thread.start()
log.info("[MAIN] 모니터링 시작. Ctrl+C로 종료.") log.info("[MAIN] monitoring started. Press Ctrl+C to exit.")
# 메인 스레드는 살아있어야 인터럽트 작동 # The main thread must stay alive for interrupts to work
while True: while True:
time.sleep(1) time.sleep(1)
except KeyboardInterrupt: except KeyboardInterrupt:
log.info("[MAIN] 사용자에 의해 종료됨.") log.info("[MAIN] terminated by user.")
except Exception as e: except Exception as e:
log.exception(f"[MAIN] 예외 발생: {e}") log.exception(f"[MAIN] exception occurred: {e}")
raise raise
finally: finally:
GPIO.cleanup() GPIO.cleanup()
log.info("[MAIN] GPIO 정리 완료.") log.info("[MAIN] GPIO cleanup complete.")
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -1,4 +1,4 @@
# Pi 4 누수감지 모듈 Python 의존성 (폐쇄망 대비 정확 핀 고정) # Pi 4 leak detection module Python dependencies (exact version pins for air-gapped deployment)
# 오프라인 설치: vendor.sh 로 wheelhouse/ 에 미리 받아두고 # Offline install: pre-download into wheelhouse/ with vendor.sh, then
# pip install --no-index --find-links=wheelhouse -r requirements.txt # pip install --no-index --find-links=wheelhouse -r requirements.txt
requests==2.32.3 requests==2.32.3

View file

@ -1,16 +1,16 @@
# ============================================================================= # =============================================================================
# /etc/systemd/system/leak-sensor.service # /etc/systemd/system/leak-sensor.service
# Raspberry Pi 4 부팅 시 누수감지 모니터를 자동 실행하는 서비스 # Service that auto-runs the leak-detection monitor at Raspberry Pi 4 boot
# #
# 설치 방법: # Installation:
# sudo cp systemd/leak-sensor.service /etc/systemd/system/ # sudo cp systemd/leak-sensor.service /etc/systemd/system/
# sudo systemctl daemon-reload # sudo systemctl daemon-reload
# sudo systemctl enable leak-sensor # sudo systemctl enable leak-sensor
# sudo systemctl start leak-sensor # sudo systemctl start leak-sensor
# sudo systemctl status leak-sensor # sudo systemctl status leak-sensor
# #
# ※ WorkingDirectory/ExecStart 경로는 실제 배치 경로에 맞게 수정. # NOTE: Adjust the WorkingDirectory/ExecStart paths to match the actual deployment path.
# 아래는 /home/pi/raspberry_pi4 에 배치한 경우 기준. # The values below assume deployment under /home/pi/raspberry_pi4.
# ============================================================================= # =============================================================================
[Unit] [Unit]
@ -22,7 +22,7 @@ Wants=network-online.target
Type=simple Type=simple
User=pi User=pi
Group=pi Group=pi
# rpi-lgpio 는 /dev/gpiochip0(root:gpio 660) 접근에 gpio 그룹, 카메라는 video 그룹 필요. # rpi-lgpio needs the gpio group to access /dev/gpiochip0 (root:gpio 660); the camera needs the video group.
SupplementaryGroups=video gpio SupplementaryGroups=video gpio
WorkingDirectory=/home/pi/raspberry_pi4 WorkingDirectory=/home/pi/raspberry_pi4
ExecStart=/home/pi/raspberry_pi4/venv/bin/python leak_sensor.py ExecStart=/home/pi/raspberry_pi4/venv/bin/python leak_sensor.py
@ -32,27 +32,27 @@ StandardOutput=journal
StandardError=journal StandardError=journal
SyslogIdentifier=leak-sensor SyslogIdentifier=leak-sensor
# 환경 변수 # Environment variables
Environment=PYTHONUNBUFFERED=1 Environment=PYTHONUNBUFFERED=1
# lgpio(rpi-lgpio 백엔드)는 add_event_detect 시 작업디렉터리에 .lgd-nfy 알림 파이프를 # lgpio (rpi-lgpio backend) creates a .lgd-nfy notification pipe in the working
# 만든다. 아래 ProtectHome=read-only 로 /home 이 읽기전용이 되므로, PrivateTmp 의 # directory on add_event_detect. Since ProtectHome=read-only below makes /home
# 쓰기가능 /tmp 로 lgpio 작업디렉터리를 옮긴다. (없으면 누수 인터럽트 등록이 실패해 # read-only, the lgpio working directory is moved to PrivateTmp's writable /tmp.
# 데몬이 크래시루프 → 누수 감지 불능.) LG_WD 는 lg 라이브러리가 읽는 환경변수. # (Otherwise leak interrupt registration fails -> daemon crash-loops -> no leak detection.) LG_WD is the env var the lg library reads.
# ※ 이 하드닝은 chardev 백엔드(python3-rpi-lgpio) 를 가정한다. sysfs 기반 # NOTE: This hardening assumes the chardev backend (python3-rpi-lgpio). Reverting to
# python3-rpi.gpio 로 되돌리면 ProtectKernelTunables/ProtectHome 와 충돌하므로 # the sysfs-based python3-rpi.gpio conflicts with ProtectKernelTunables/ProtectHome,
# 백엔드를 바꾸지 말 것(vendor.sh/install.sh 의 python3-rpi-lgpio 핀 유지). # so do not change the backend (keep the python3-rpi-lgpio pin in vendor.sh/install.sh).
Environment=LG_WD=/tmp Environment=LG_WD=/tmp
# 누수 촬영 임시파일을 프로세스 전용 /tmp 로 격리 (공용 /tmp 노출 방지) # Isolate leak-capture temp files to the process-private /tmp (prevents exposure via shared /tmp)
PrivateTmp=true PrivateTmp=true
# ── 샌드박싱(하드닝) — 데몬 침해 시 root 승격·파일시스템 오염을 차단 ────────── # -- Sandboxing (hardening) -- blocks root escalation / filesystem tampering if the daemon is compromised --------
# User=pi 는 NOPASSWD sudo 계정이므로 NoNewPrivileges 로 setuid(sudo) 승격 경로를 막는다. # User=pi is a NOPASSWD sudo account, so NoNewPrivileges blocks the setuid(sudo) escalation path.
NoNewPrivileges=true NoNewPrivileges=true
# 루트파일시스템 읽기전용 + /var/log 만 쓰기 허용(로그 로테이션 포함). /tmp 는 PrivateTmp. # Root filesystem read-only + only /var/log writable (including log rotation). /tmp is PrivateTmp.
ProtectSystem=strict ProtectSystem=strict
ReadWritePaths=/var/log ReadWritePaths=/var/log
# /home 는 읽기전용(자기 코드/config_local.py 읽기만; 쓰기·타 계정 홈 접근 차단). # /home is read-only (reads its own code/config_local.py only; blocks writes and access to other accounts' homes).
ProtectHome=read-only ProtectHome=read-only
ProtectKernelModules=true ProtectKernelModules=true
ProtectKernelTunables=true ProtectKernelTunables=true
@ -60,13 +60,13 @@ ProtectControlGroups=true
RestrictSUIDSGID=true RestrictSUIDSGID=true
LockPersonality=true LockPersonality=true
RestrictRealtime=true RestrictRealtime=true
# HTTPS(POST)+로컬소켓(DNS/journal)만. 카메라/GPIO 는 /dev char (소켓 아님)라 무관. # HTTPS(POST) + local sockets (DNS/journal) only. Camera/GPIO are /dev char (not sockets), so unaffected.
# AF_NETLINK 필수 — glibc getaddrinfo/NSS 가 로컬주소 열거에 netlink 를 쓴다 # AF_NETLINK required -- glibc getaddrinfo/NSS uses netlink to enumerate local addresses
# (빼면 DNS 해석이 간헐 실패 → 서버 POST 불가). AF_INET/6=HTTPS, AF_UNIX=DNS·journal. # (omitting it makes DNS resolution fail intermittently -> server POST impossible). AF_INET/6=HTTPS, AF_UNIX=DNS/journal.
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
# ※ PrivateDevices 는 쓰지 않는다 — /dev/gpiochip*, 카메라 노드가 숨겨져 동작 불능. # NOTE: PrivateDevices is not used -- it would hide /dev/gpiochip* and the camera node, breaking operation.
# 재시작 상한 (10분 내 5번 실패 시 중단) # Restart cap (stop after 5 failures within 10 minutes)
StartLimitBurst=5 StartLimitBurst=5
StartLimitInterval=600s StartLimitInterval=600s

View file

@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""서버 연결 테스트 스크립트 — Pi 4 에서 실행. """Server connection test script — run on the Pi 4.
비밀값(API_URL/API_KEY)은 config_local.py 에서 로드한다 (config 경유).""" Secrets (API_URL/API_KEY) are loaded from config_local.py (via config)."""
import hashlib import hashlib
import hmac import hmac
@ -10,23 +10,23 @@ import subprocess
from config import API_URL, API_KEY, DEVICE_ID, DEVICE_LOCATION, SENSORS from config import API_URL, API_KEY, DEVICE_ID, DEVICE_LOCATION, SENSORS
signed_ok = False # 서명 POST 200 여부 — setup.sh 가 이 exit 코드로 하드닝을 게이트한다 signed_ok = False # whether the signed POST returned 200 — setup.sh gates hardening on this exit code
# 이 기기에 설정된 첫 센서로 테스트한다 — sensor_id 를 하드코딩하면(예: 1) # Test with the first sensor configured on this device — hardcoding sensor_id (e.g. 1)
# 다른 지점 노드에서 테스트할 때 그 지점의 leak_sensor_status 를 오염시킨다. # would pollute another site node's leak_sensor_status when testing from that site.
TEST_SENSOR_ID = SENSORS[0]["id"] TEST_SENSOR_ID = SENSORS[0]["id"]
TEST_SENSOR_NAME = SENSORS[0]["name"] TEST_SENSOR_NAME = SENSORS[0]["name"]
# API_URL 에서 베이스 경로 추출 (.../api/sensor_data.php → .../api) # Extract the base path from API_URL (.../api/sensor_data.php → .../api)
API_BASE = API_URL.rsplit("/", 1)[0] API_BASE = API_URL.rsplit("/", 1)[0]
print("=" * 50) print("=" * 50)
print(" 서버 연결 테스트 (Pi 4)") print(" Server connection test (Pi 4)")
print("=" * 50) print("=" * 50)
print(f" API_BASE = {API_BASE}") print(f" API_BASE = {API_BASE}")
# ── 1단계: curl로 test.php 호출 ── # ── Step 1: call test.php with curl ──
print("\n[1/3] curl로 test.php 연결 확인...") print("\n[1/3] Checking test.php connectivity with curl...")
try: try:
result = subprocess.run( result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
@ -35,19 +35,19 @@ try:
capture_output=True, text=True, timeout=10 capture_output=True, text=True, timeout=10
) )
code = result.stdout.strip() code = result.stdout.strip()
print(f" HTTP 응답코드: {code}") print(f" HTTP response code: {code}")
if code == "200": if code == "200":
print(" → 성공! 서버 연결 정상") print(" → Success! Server connection OK")
elif code == "403": elif code == "403":
print(" → 403 Forbidden: 서버/호스팅 자체가 차단 중") print(" → 403 Forbidden: the server/host itself is blocking")
print(" → 호스팅 설정 또는 상위 .htaccess 확인 필요") print(" → Check the hosting settings or the upstream .htaccess")
else: else:
print(f" → 예상 외 응답: {code}") print(f" → Unexpected response: {code}")
except Exception as e: except Exception as e:
print(f" → curl 실패: {e}") print(f" → curl failed: {e}")
# ── 2단계: curl로 sensor_data.php 호출 ── # ── Step 2: call sensor_data.php with curl ──
print("\n[2/3] curl로 sensor_data.php 연결 확인...") print("\n[2/3] Checking sensor_data.php connectivity with curl...")
try: try:
result = subprocess.run( result = subprocess.run(
["curl", "-s", "-w", "\n%{http_code}", ["curl", "-s", "-w", "\n%{http_code}",
@ -60,25 +60,26 @@ try:
lines = result.stdout.strip().split("\n") lines = result.stdout.strip().split("\n")
code = lines[-1] if lines else "?" code = lines[-1] if lines else "?"
body = "\n".join(lines[:-1]) body = "\n".join(lines[:-1])
print(f" HTTP 응답코드: {code}") print(f" HTTP response code: {code}")
print(f" 응답 내용: {body[:200]}") print(f" Response body: {body[:200]}")
except Exception as e: except Exception as e:
print(f" → curl 실패: {e}") print(f" → curl failed: {e}")
# ── 3단계: Python requests로 서명 포함 호출 ── # ── Step 3: signed call via Python requests ──
print("\n[3/3] Python requests로 sensor_data.php 호출...") print("\n[3/3] Calling sensor_data.php with Python requests...")
try: try:
import requests import requests
# raw-body X-Signature HMAC (서버 verify_signature_raw 와 동일 — leak_sensor.leak_event_body # raw-body X-Signature HMAC (same as the server's verify_signature_raw — build the body
# 와 같은 고정 필드 순서로 본문 문자열을 만들고 그 바이트에 서명한 뒤 그대로 POST 한다). # string with the same fixed field order as leak_sensor.leak_event_body, sign those bytes,
# then POST them verbatim).
ts = int(time.time()) ts = int(time.time())
body = ( body = (
'{' '{'
f'"device_id":"{DEVICE_ID}",' f'"device_id":"{DEVICE_ID}",'
f'"device_location":"{DEVICE_LOCATION}",' f'"device_location":"{DEVICE_LOCATION}",'
f'"sensor_id":{TEST_SENSOR_ID},' f'"sensor_id":{TEST_SENSOR_ID},'
f'"sensor_name":"{TEST_SENSOR_NAME} (연결테스트)",' f'"sensor_name":"{TEST_SENSOR_NAME} (connection test)",'
f'"event_type":"startup",' f'"event_type":"startup",'
f'"is_leak":0,' f'"is_leak":0,'
f'"timestamp":{ts}' f'"timestamp":{ts}'
@ -99,24 +100,24 @@ try:
} }
) )
print(f" HTTP 응답코드: {resp.status_code}") print(f" HTTP response code: {resp.status_code}")
print(f" 응답 내용: {resp.text[:200]}") print(f" Response body: {resp.text[:200]}")
if resp.status_code == 200: if resp.status_code == 200:
signed_ok = True signed_ok = True
print(" → 성공! 모든 연결 정상") print(" → Success! All connections OK")
elif resp.status_code == 403: elif resp.status_code == 403:
print(" → 403: requests 라이브러리가 차단됨 또는 서명 불일치") print(" → 403: the requests library is blocked or the signature does not match")
print(f" → User-Agent: {resp.request.headers.get('User-Agent', '?')}") print(f" → User-Agent: {resp.request.headers.get('User-Agent', '?')}")
else: else:
print(f" → 응답코드: {resp.status_code}") print(f" → Response code: {resp.status_code}")
except ImportError: except ImportError:
print(" → requests 미설치. install.sh 실행 또는 sudo apt install python3-requests") print(" → requests not installed. Run install.sh or sudo apt install python3-requests")
except Exception as e: except Exception as e:
print(f" → 실패: {e}") print(f" → Failed: {e}")
print("\n" + "=" * 50) print("\n" + "=" * 50)
print(" 테스트 완료" + ("" if signed_ok else " — 서명 POST 실패(위 로그 확인)")) print(" Test complete" + ("" if signed_ok else " — signed POST failed (check the log above)"))
print("=" * 50) print("=" * 50)
sys.exit(0 if signed_ok else 1) sys.exit(0 if signed_ok else 1)

View file

@ -1,14 +1,14 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ============================================================================= # =============================================================================
# vendor.sh - 폐쇄망 설치용 의존성 사전 다운로드 # vendor.sh - Pre-download dependencies for air-gapped installation
# 인터넷에 연결된 빌드/스테이징 머신에서 실행한다. # Run on a build/staging machine that has internet access.
# 산출물: wheelhouse/ (pip wheel + sdist), debs/ (apt .deb) → 타깃 Pi 4 로 전송. # Output: wheelhouse/ (pip wheel + sdist), debs/ (apt .deb) → transfer to target Pi 4.
# #
# wheel/deb 는 아키텍처에 종속된다. 기본값은 타깃(Raspberry Pi OS Bookworm 64-bit, # wheel/deb are architecture-dependent. The defaults are explicitly pinned to the target
# aarch64, Python 3.11)에 맞춰 명시 핀하므로 x86_64 빌드 머신에서 받아도 올바른 # (Raspberry Pi OS Bookworm 64-bit, aarch64, Python 3.11), so even downloading on an x86_64
# aarch64 wheel 을 확보한다(빌드호스트 arch 와 무관). 그래도 .deb 는 타깃 OS/arch # build machine yields the correct aarch64 wheel (independent of the build host arch). Still, .deb
# 에서 받는 것이 가장 안전하다. 필요 시 환경변수로 타깃 조정: # files are safest downloaded on the target OS/arch. Adjust the target via env vars if needed:
# TARGET_PLATFORM (기본 manylinux2014_aarch64), TARGET_PYVER (기본 311), CAM # TARGET_PLATFORM (default manylinux2014_aarch64), TARGET_PYVER (default 311), CAM
# ============================================================================= # =============================================================================
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
@ -25,12 +25,12 @@ python3 -m pip download -r requirements.txt -d wheelhouse \
--python-version "${TARGET_PYVER}" \ --python-version "${TARGET_PYVER}" \
--implementation cp \ --implementation cp \
--abi "${TARGET_ABI}" \ --abi "${TARGET_ABI}" \
|| { echo "[ERROR] 타깃 wheel 다운로드 실패 (TARGET_PLATFORM/TARGET_PYVER 확인)"; exit 1; } || { echo "[ERROR] Target wheel download failed (check TARGET_PLATFORM/TARGET_PYVER)"; exit 1; }
python3 -m pip download -r requirements.txt -d wheelhouse --no-binary=:all: \ python3 -m pip download -r requirements.txt -d wheelhouse --no-binary=:all: \
|| echo "[WARN] sdist 폴백 일부 실패 (바이너리 wheel 이 모두 있으면 무시 가능)" || echo "[WARN] sdist fallback partially failed (safe to ignore if all binary wheels are present)"
WHEEL_COUNT="$(ls -A wheelhouse 2>/dev/null | wc -l)" WHEEL_COUNT="$(ls -A wheelhouse 2>/dev/null | wc -l)"
echo " wheelhouse 파일 수: ${WHEEL_COUNT}" echo " wheelhouse file count: ${WHEEL_COUNT}"
[ "${WHEEL_COUNT}" -gt 0 ] || { echo "[ERROR] wheelhouse 비어있음"; exit 1; } [ "${WHEEL_COUNT}" -gt 0 ] || { echo "[ERROR] wheelhouse is empty"; exit 1; }
echo "[vendor] (2/2) apt .deb → debs/" echo "[vendor] (2/2) apt .deb → debs/"
mkdir -p debs mkdir -p debs
@ -38,41 +38,41 @@ CAM="${CAM:-}"
if [ -z "${CAM}" ]; then if [ -z "${CAM}" ]; then
if apt-cache show rpicam-apps >/dev/null 2>&1; then CAM=rpicam-apps; else CAM=libcamera-apps; fi if apt-cache show rpicam-apps >/dev/null 2>&1; then CAM=rpicam-apps; else CAM=libcamera-apps; fi
fi fi
# nftables·unattended-upgrades 도 포함 → harden_pi.sh 를 폐쇄망에서 오프라인 실행 가능. # Include nftables and unattended-upgrades too → harden_pi.sh can run offline in an air-gapped network.
BASE_PKGS="python3-rpi-lgpio python3-requests python3-venv nftables unattended-upgrades ${CAM}" BASE_PKGS="python3-rpi-lgpio python3-requests python3-venv nftables unattended-upgrades ${CAM}"
DEPS="$(apt-cache depends --recurse --no-recommends --no-suggests \ DEPS="$(apt-cache depends --recurse --no-recommends --no-suggests \
--no-conflicts --no-breaks --no-replaces --no-enhances ${BASE_PKGS} \ --no-conflicts --no-breaks --no-replaces --no-enhances ${BASE_PKGS} \
| awk '/^\w/{print $1}' | sort -u)" | awk '/^\w/{print $1}' | sort -u)"
( cd debs && apt-get download ${BASE_PKGS} ${DEPS} ) 2>/dev/null \ ( cd debs && apt-get download ${BASE_PKGS} ${DEPS} ) 2>/dev/null \
|| echo "[WARN] 일부 .deb 다운로드 실패 (가상패키지/전이 의존성일 수 있음 — 필수 패키지는 아래에서 검증)" || echo "[WARN] Some .deb downloads failed (may be virtual packages / transitive deps — required packages are verified below)"
MISS=0 MISS=0
for p in ${BASE_PKGS}; do for p in ${BASE_PKGS}; do
if ! ls debs/"${p}"_*.deb >/dev/null 2>&1; then if ! ls debs/"${p}"_*.deb >/dev/null 2>&1; then
echo "[ERROR] 필수 패키지 .deb 누락: ${p}" echo "[ERROR] Missing required package .deb: ${p}"
MISS=1 MISS=1
fi fi
done done
[ "${MISS}" = 0 ] || { echo "[ERROR] 필수 .deb 누락 — 반드시 타깃 arch(aarch64 Bookworm)에서 vendor.sh 를 실행하세요."; exit 1; } [ "${MISS}" = 0 ] || { echo "[ERROR] Missing required .deb — you must run vendor.sh on the target arch (aarch64 Bookworm)."; exit 1; }
echo " debs 파일 수: $(ls debs/*.deb 2>/dev/null | wc -l) (필수 패키지 모두 확인됨)" echo " debs file count: $(ls debs/*.deb 2>/dev/null | wc -l) (all required packages confirmed)"
# 아키텍처 실검사 — 파일명이 아니라 .deb 내부 Architecture 필드로 타깃(arm64/all) 검증. # Real architecture check — validate the target (arm64/all) via the .deb's internal Architecture field, not the filename.
# (호스트가 amd64면 apt-get download 가 amd64 .deb 를 받아, 타깃 Pi 에서 dpkg 가 # (If the host is amd64, apt-get download fetches amd64 .debs, and on the target Pi dpkg
# "wrong architecture" 로 깨진다. 파일명만 보는 검사는 이를 못 잡는다.) # breaks with "wrong architecture". A filename-only check cannot catch this.)
BAD_ARCH=0 BAD_ARCH=0
for deb in debs/*.deb; do for deb in debs/*.deb; do
[ -e "$deb" ] || continue [ -e "$deb" ] || continue
arch="$(dpkg-deb --field "$deb" Architecture 2>/dev/null || echo '?')" arch="$(dpkg-deb --field "$deb" Architecture 2>/dev/null || echo '?')"
case "$arch" in case "$arch" in
arm64|all) : ;; arm64|all) : ;;
*) echo "[ERROR] arch 불일치: $(basename "$deb") = ${arch} (arm64/all 아님)"; BAD_ARCH=1 ;; *) echo "[ERROR] arch mismatch: $(basename "$deb") = ${arch} (not arm64/all)"; BAD_ARCH=1 ;;
esac esac
done done
if [ "${BAD_ARCH}" != 0 ]; then if [ "${BAD_ARCH}" != 0 ]; then
echo "[ERROR] 타깃(aarch64/arm64) 이 아닌 .deb 가 있습니다. 호스트 arch=$(dpkg --print-architecture 2>/dev/null || echo unknown)." >&2 echo "[ERROR] There are .deb files that are not the target (aarch64/arm64). Host arch=$(dpkg --print-architecture 2>/dev/null || echo unknown)." >&2
echo " 반드시 arm64 Pi(또는 arm64 chroot/qemu)에서 vendor.sh 를 실행하세요." >&2 echo " You must run vendor.sh on an arm64 Pi (or arm64 chroot/qemu)." >&2
exit 1 exit 1
fi fi
echo " arch 검사 통과 (모든 .deb = arm64/all)" echo " arch check passed (all .deb = arm64/all)"
echo "[vendor] 완료. wheelhouse/ 와 debs/ 를 타깃 Pi 4 로 전송 후 install.sh 실행." echo "[vendor] Done. Transfer wheelhouse/ and debs/ to the target Pi 4, then run install.sh."

View file

@ -1,14 +1,14 @@
# 운영·비밀값 (절대 커밋 금지) # Operational secrets (never commit)
config_local.py config_local.py
# Python venv / 캐시 # Python venv / cache
venv/ venv/
__pycache__/ __pycache__/
*.pyc *.pyc
# 오프라인 설치 번들 (vendor.sh 산출물 — 용량이 큼, 전송은 별도) # Offline install bundle (vendor.sh output - large; transferred separately)
wheelhouse/ wheelhouse/
debs/ debs/
# 런타임 로그 # Runtime logs
*.log *.log

View file

@ -1,137 +1,137 @@
# 온습도(SHT30) 센서 모듈 — Raspberry Pi 4 Model B 전용 (2번 모듈) # Temperature/Humidity (SHT30) Sensor Module — Raspberry Pi 4 Model B only (Module 2)
**Sensirion SHT30**(한진데이터 P4422-3, I2C) 온습도 센서를 Raspberry Pi 4 Model B 의 A module that reads a **Sensirion SHT30** (Hanjin Data P4422-3, I2C) temperature/humidity sensor over the
I2C 버스로 읽어, 주기적으로 cafe24 서버(`php/api/sensor_data.php`)에 보고하는 모듈입니다. Raspberry Pi 4 Model B I2C bus and periodically reports to the cafe24 server (`php/api/sensor_data.php`).
서버가 측정값을 저장하고, **고온/저온/고습/저습 임계 판정과 SMS, 대시보드 표시**를 모두 수행합니다. The server stores the measurements and performs **high/low temperature and high/low humidity threshold evaluation, SMS, and dashboard display**.
> 이 폴더는 STM32F407 펌웨어(`sht30_fw`)의 동작을 **Raspberry Pi 4 + Python 으로 이식**한 > This folder is a **port of the STM32F407 firmware (`sht30_fw`) behavior to Raspberry Pi 4 + Python**,
> "2번 모듈"입니다. wire 포맷(본문/서명)·CRC·변환식은 펌웨어와 **바이트 단위로 동일**합니다. > the "Module 2". The wire format (body/signature), CRC, and conversion formulas are **byte-for-byte identical** to the firmware.
> "1번 모듈"(누수+카메라)은 `../raspberry_pi4/` 폴더입니다. 전체 구성은 루트의 > "Module 1" (leak + camera) is in the `../raspberry_pi4/` folder. For the full setup, see
> [`SYSTEM_OVERVIEW.md`](../SYSTEM_OVERVIEW.md)를 보세요. > [`SYSTEM_OVERVIEW.md`](../SYSTEM_OVERVIEW.md) in the root.
--- ---
## 1. 구성 파일 ## 1. Files
| 파일 | 역할 | | File | Role |
|---|---| |---|---|
| `sht30_sensor.py` | 메인 — 주기 측정 → 본문 생성/서명 → POST(재시도), startup→periodic | | `sht30_sensor.py` | Main — periodic measurement → body build/sign → POST (with retry), startup→periodic |
| `sht30_driver.py` | SHT30 I2C 측정 + CRC-8 검증 + 물리값 변환 (smbus2, 시뮬레이션 폴백) | | `sht30_driver.py` | SHT30 I2C measurement + CRC-8 verification + physical-value conversion (smbus2, simulation fallback) |
| `wire.py` | 본문 JSON + raw-body HMAC 서명 (펌웨어 `jsonbody.c`/`sig.c`와 바이트 동일) | | `wire.py` | Body JSON + raw-body HMAC signature (byte-identical to firmware `jsonbody.c`/`sig.c`) |
| `config.py` | 비밀 아닌 기본 설정(센서 id/주기/범위/ I2C). `config_local` 에서 비밀값 로드 | | `config.py` | Non-secret default settings (sensor id/interval/range/I2C). Loads secrets from `config_local` |
| `config_local.example.py` | 운영·비밀값 템플릿 → `config_local.py` 로 복사해 사용(미커밋) | | `config_local.example.py` | Operational/secret-value template → copy to `config_local.py` for use (not committed) |
| `requirements.txt` | Python 의존성(`requests`, `smbus2`, 핀 고정) | | `requirements.txt` | Python dependencies (`requests`, `smbus2`, pinned) |
| `install.sh` | Pi 4 설치(폐쇄망 오프라인 + 온라인 폴백, I2C 활성화 포함) | | `install.sh` | Pi 4 install (air-gapped offline + online fallback, includes enabling I2C) |
| `vendor.sh` | 폐쇄망용 의존성 사전 다운로드(빌드 머신에서 실행) | | `vendor.sh` | Pre-download dependencies for air-gapped use (run on the build machine) |
| `test_connection.py` | 서버 연결/서명 POST 점검 | | `test_connection.py` | Server connectivity/signed-POST check |
| `test_sensor.py` | CRC/포맷 자가점검 + 측정 1회(하드웨어 없이도 포맷 점검 통과) | | `test_sensor.py` | CRC/format self-check + one measurement (format check passes even without hardware) |
| `systemd/sht30-sensor.service` | 부팅 자동 실행 서비스 유닛 | | `systemd/sht30-sensor.service` | Service unit for auto-start on boot |
--- ---
## 2. 하드웨어 (Pi 4 Model B 기준) ## 2. Hardware (Pi 4 Model B baseline)
- **Raspberry Pi 4 Model B** (BCM2711) + Raspberry Pi OS **Bookworm 64-bit** - **Raspberry Pi 4 Model B** (BCM2711) + Raspberry Pi OS **Bookworm 64-bit**
- **SHT30** 온습도 센서 모듈 (I2C, 한진데이터 P4422-3) - **SHT30** temperature/humidity sensor module (I2C, Hanjin Data P4422-3)
- I2C 풀업 저항 **4.7kΩ × 2** (모듈에 내장된 경우 생략) - I2C pull-up resistors **4.7kΩ × 2** (omit if built into the module)
- 전원: **USB-C 5V/3A**, 네트워크: Pi 4 **Gigabit Ethernet**(유선) 또는 WiFi - Power: **USB-C 5V/3A**, network: Pi 4 **Gigabit Ethernet** (wired) or WiFi
### 2.1 핀 배치 (I2C 버스 1) ### 2.1 Pinout (I2C bus 1)
| 신호 | Pi 4 핀 | 비고 | | Signal | Pi 4 pin | Note |
|---|---|---| |---|---|---|
| SHT30 VCC | 3.3V (물리 1) | **5V 금지** | | SHT30 VCC | 3.3V (physical 1) | **No 5V** |
| SHT30 GND | GND (물리 6/9/14/…) | | | SHT30 GND | GND (physical 6/9/14/…) | |
| SHT30 SDA | GPIO2 / SDA1 (물리 3) | + 4.7kΩ 풀업 → 3.3V | | SHT30 SDA | GPIO2 / SDA1 (physical 3) | + 4.7kΩ pull-up → 3.3V |
| SHT30 SCL | GPIO3 / SCL1 (물리 5) | + 4.7kΩ 풀업 → 3.3V | | SHT30 SCL | GPIO3 / SCL1 (physical 5) | + 4.7kΩ pull-up → 3.3V |
| SHT30 ADDR | GND → 주소 **0x44** | VDD 면 0x45 (`config.py` `I2C_ADDR`) | | SHT30 ADDR | GND → address **0x44** | 0x45 if tied to VDD (`config.py` `I2C_ADDR`) |
--- ---
## 3. 서버 호환성 (반드시 확인) ## 3. Server compatibility (must verify)
이 모듈은 **현재 repo 의 `php/` 서버(=cafe24 SHT30 대시보드 서버)**로 보고합니다. This module reports to **the current repo's `php/` server (= the cafe24 SHT30 dashboard server)**.
서버는 **raw-body HMAC 서명**을 요구합니다. The server requires a **raw-body HMAC signature**.
``` ```
X-Signature = lowercase_hex( HMAC-SHA256( key=API_KEY, msg=요청본문 바이트 ) ) X-Signature = lowercase_hex( HMAC-SHA256( key=API_KEY, msg=request body bytes ) )
``` ```
- `config_local.py` 의 `API_KEY` 는 서버 `config.local.php` 의 `API_KEY` 와 **정확히 동일**해야 합니다. - `API_KEY` in `config_local.py` must be **exactly identical** to `API_KEY` in the server's `config.local.php`.
- 서버는 `timestamp` 신선도(±600초)로 재전송을 막으므로 Pi 시각이 **NTP 로 동기**되어 있어야 합니다(Raspberry Pi OS 기본 활성). - The server blocks replays via `timestamp` freshness (±600 seconds), so the Pi clock must be **NTP-synchronized** (enabled by default on Raspberry Pi OS).
- `sensor_id=2` 로 보고하며, 서버 대시보드(`dashboard.php`)는 기본 차트로 sensor 2 를 표시합니다. - It reports with `sensor_id=2`, and the server dashboard (`dashboard.php`) shows sensor 2 as the default chart.
- 임계 경보(고온/저온/고습/저습)·SMS·복구 판정은 **모두 서버**가 수행합니다. 이 모듈은 측정값만 보고합니다. - Threshold alerts (high/low temperature, high/low humidity), SMS, and recovery evaluation are **all performed by the server**. This module only reports measurements.
> 서버 측 스키마/배포는 [`../sql/schema_sht30.sql`](../sql/schema_sht30.sql), > For the server-side schema/deployment, see [`../sql/schema_sht30.sql`](../sql/schema_sht30.sql),
> [`../docs/INSTALL_PI_SERVER.md`](../docs/INSTALL_PI_SERVER.md), > [`../docs/INSTALL_PI_SERVER.md`](../docs/INSTALL_PI_SERVER.md),
> [`../docs/SHT30_SENSOR2_SETUP.md`](../docs/SHT30_SENSOR2_SETUP.md) 참고. > [`../docs/SHT30_SENSOR2_SETUP.md`](../docs/SHT30_SENSOR2_SETUP.md).
--- ---
## 4. 설치 ## 4. Installation
### 4.1 설정값 준비 ### 4.1 Prepare configuration values
```bash ```bash
cp config_local.example.py config_local.py cp config_local.example.py config_local.py
# config_local.py 를 열어 API_URL / API_KEY / DEVICE_ID / DEVICE_LOCATION 채움 # Open config_local.py and fill in API_URL / API_KEY / DEVICE_ID / DEVICE_LOCATION
``` ```
`config_local.py` 가 없으면 실행 시 **명확한 오류로 즉시 중단**됩니다(fail closed). If `config_local.py` is missing, execution **stops immediately with a clear error** (fail closed).
`config_local.py` 와 `API_KEY` 는 **절대 커밋하지 마세요**(`.gitignore` 처리됨). **Never commit** `config_local.py` or `API_KEY` (handled by `.gitignore`).
### 4.2 온라인 설치 (인터넷 가능 환경) ### 4.2 Online install (internet-capable environment)
```bash ```bash
chmod +x install.sh chmod +x install.sh
./install.sh ./install.sh
sudo reboot # I2C 를 새로 켠 경우 1회 재부팅 sudo reboot # reboot once if I2C was newly enabled
``` ```
### 4.3 폐쇄망(air-gapped) 오프라인 설치 ### 4.3 Air-gapped offline install
인터넷이 되는 **동일 아키텍처(aarch64, 가급적 동일 Pi 4 + Bookworm 64-bit)** 머신에서 On an internet-connected machine of the **same architecture (aarch64, ideally the same Pi 4 + Bookworm 64-bit)**,
의존성을 먼저 받아 번들을 만든 뒤, 타깃 Pi 4 로 옮겨 설치합니다. download the dependencies first to build a bundle, then move it to the target Pi 4 and install.
```bash ```bash
# (빌드 머신, 인터넷 O) wheelhouse/ + debs/ 생성 # (build machine, internet YES) generate wheelhouse/ + debs/
./vendor.sh ./vendor.sh
# wheelhouse/ 와 debs/ 를 타깃 Pi 4 의 이 폴더로 복사한 뒤: # after copying wheelhouse/ and debs/ into this folder on the target Pi 4:
# (타깃 Pi 4, 인터넷 X) # (target Pi 4, internet NO)
./install.sh # debs/ · wheelhouse/ 가 있으면 자동으로 오프라인 설치 ./install.sh # if debs/ · wheelhouse/ exist, it installs offline automatically
``` ```
> `smbus2` 는 순수 파이썬(arch 무관)이라 x86 빌드 머신에서 받아도 됩니다. > `smbus2` is pure Python (arch-independent), so it can be downloaded on an x86 build machine.
> `requests` 등 다른 wheel/deb 는 **aarch64** 용을 받아야 합니다(타깃 arch 에서 `vendor.sh` 권장). > Other wheels/debs such as `requests` must be the **aarch64** builds (running `vendor.sh` on the target arch is recommended).
--- ---
## 5. 실행 / 점검 ## 5. Run / check
```bash ```bash
# 센서가 보이는지 (0x44) # is the sensor visible (0x44)
i2cdetect -y 1 i2cdetect -y 1
# 포맷/CRC 자가점검 + 측정 1회 (하드웨어 없으면 시뮬레이션) # format/CRC self-check + one measurement (simulation if no hardware)
source venv/bin/activate source venv/bin/activate
python3 test_sensor.py python3 test_sensor.py
# 서버 연결/서명 점검 # server connectivity/signature check
python3 test_connection.py python3 test_connection.py
# 실제 실행 # actual run
python3 sht30_sensor.py python3 sht30_sensor.py
``` ```
`smbus2` 미설치 또는 I2C 미활성 환경(개발 PC 등)에서는 자동으로 **시뮬레이션 모드**로 동작합니다. In environments without `smbus2` installed or with I2C disabled (dev PC, etc.), it automatically runs in **simulation mode**.
--- ---
## 6. 부팅 자동 실행 (systemd) ## 6. Auto-start on boot (systemd)
```bash ```bash
# 폴더를 /home/pi/raspberry_pi4_sht30 에 배치했다고 가정 (다르면 .service 의 경로 수정) # assumes the folder is placed at /home/pi/raspberry_pi4_sht30 (otherwise edit the path in the .service)
sudo cp systemd/sht30-sensor.service /etc/systemd/system/ sudo cp systemd/sht30-sensor.service /etc/systemd/system/
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable sht30-sensor sudo systemctl enable sht30-sensor
@ -140,17 +140,17 @@ sudo systemctl status sht30-sensor
journalctl -u sht30-sensor -f journalctl -u sht30-sensor -f
``` ```
`SupplementaryGroups=i2c` 가 `/dev/i2c-1` 접근에 필요합니다. `SupplementaryGroups=i2c` is required for `/dev/i2c-1` access.
--- ---
## 7. 보안 메모 ## 7. Security notes
- 비밀값(`API_KEY`)·서버 URL·기기 정보는 `config_local.py`(미커밋)에만 둡니다. - Secrets (`API_KEY`), server URL, and device info live only in `config_local.py` (not committed).
커밋되는 파일에는 placeholder 만 존재합니다. Committed files contain only placeholders.
- 보고 본문은 `HMAC-SHA256(API_KEY, raw_body)` 서명(`X-Signature` 헤더)으로 위변조를 방지합니다 - The report body is protected against tampering by an `HMAC-SHA256(API_KEY, raw_body)` signature (`X-Signature` header)
(길이확장 공격에 면역). 레거시 body-field sha256 폴백은 서버에서 기본 비활성입니다. (immune to length-extension attacks). The legacy body-field sha256 fallback is disabled by default on the server.
- 서명하는 바이트와 전송하는 바이트는 **항상 동일**합니다(`wire.py` 가 본문을 직접 만들어 - The signed bytes and the transmitted bytes are **always identical** (`wire.py` builds the body directly,
그 바이트에 서명 후 raw POST). 펌웨어 `sig.c` / `reference.py` / 서버 `verify_signature_raw` signs those bytes, then raw POSTs). This matches firmware `sig.c` / `reference.py` / server `verify_signature_raw`
와 바이트 단위로 일치합니다. byte-for-byte.
- `config_local.py` 노출 시 즉시 `API_KEY` 를 교체(서버 동시 변경)하세요. - If `config_local.py` is exposed, immediately rotate `API_KEY` (change it on the server at the same time).

View file

@ -1,9 +1,9 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>온습도 노드 배선·핀맵 — Pi-B / SHT30 (sensor_id=2)</title> <title>Temp/Humidity Node Wiring & Pinmap — Pi-B / SHT30 (sensor_id=2)</title>
<style> <style>
:root{ :root{
--ink:#1a1d21; --muted:#5c6570; --line:#dfe3e8; --bg:#eef1f4; --card:#ffffff; --ink:#1a1d21; --muted:#5c6570; --line:#dfe3e8; --bg:#eef1f4; --card:#ffffff;
@ -182,31 +182,31 @@
<!-- ============ 1. HEADER ============ --> <!-- ============ 1. HEADER ============ -->
<header class="hero"> <header class="hero">
<p class="eyebrow">현장 설치 문서 · 배선 &amp; 핀맵</p> <p class="eyebrow">Field Installation Doc · Wiring &amp; Pinmap</p>
<h1>온습도 노드 — 1번 지점 / 서버실</h1> <h1>Temp/Humidity Node — Site 1 / Server Room</h1>
<p class="sum"> <p class="sum">
SHT30 온습도 센서를 라즈베리파이(Pi-B)의 I2C(버스 1, 주소 0x44)에 연결하는 측정 노드입니다. A measurement node connecting the SHT30 temperature/humidity sensor to the Raspberry Pi (Pi-B) over I2C (bus 1, address 0x44).
이 노드는 측정값만 <b>5분 주기</b>로 서버에 보고하며, 고온·저온·고습·저습 임계 판정과 SMS 발송은 서버가 담당합니다. This node only reports readings to the server every <b>5 minutes</b>; high/low temperature and high/low humidity threshold decisions and SMS dispatch are handled by the server.
</p> </p>
<div class="chips"> <div class="chips">
<span class="chip">Raspberry Pi 4 Model B</span> <span class="chip">Raspberry Pi 4 Model B</span>
<span class="chip mono">BCM2711</span> <span class="chip mono">BCM2711</span>
<span class="chip">Raspberry Pi OS Bookworm 64-bit</span> <span class="chip">Raspberry Pi OS Bookworm 64-bit</span>
<span class="chip">Sensirion SHT30 (한진데이터 P4422-3)</span> <span class="chip">Sensirion SHT30 (Hanjin Data P4422-3)</span>
<span class="chip mono">I2C bus <b>1</b></span> <span class="chip mono">I2C bus <b>1</b></span>
<span class="chip mono">addr <b>0x44</b></span> <span class="chip mono">addr <b>0x44</b></span>
<span class="chip mono">sensor_id=<b>2</b></span> <span class="chip mono">sensor_id=<b>2</b></span>
<span class="chip">보고 주기 5분</span> <span class="chip">Report interval 5 min</span>
</div> </div>
</header> </header>
<!-- ============ 2. PINMAP SVG ============ --> <!-- ============ 2. PINMAP SVG ============ -->
<section> <section>
<h2><span class="n">2</span>40핀 헤더 핀맵 도해 <small>사용 핀만 강조 · 나머지는 회색</small></h2> <h2><span class="n">2</span>40-Pin Header Pinmap Diagram <small>Used pins highlighted · others in gray</small></h2>
<svg class="svg-pin" viewBox="0 0 520 672" role="img" aria-label="라즈베리파이 40핀 GPIO 헤더 핀맵"> <svg class="svg-pin" viewBox="0 0 520 672" role="img" aria-label="Raspberry Pi 40-pin GPIO header pinmap">
<!-- caption --> <!-- caption -->
<text class="cap-svg" x="260" y="20">40핀 GPIO 헤더 (J8) · 물리 핀 번호</text> <text class="cap-svg" x="260" y="20">40-pin GPIO header (J8) · physical pin numbers</text>
<!-- rails --> <!-- rails -->
<rect class="rail" x="186" y="46" width="28" height="600" rx="14"/> <rect class="rail" x="186" y="46" width="28" height="600" rx="14"/>
<rect class="rail" x="306" y="46" width="28" height="600" rx="14"/> <rect class="rail" x="306" y="46" width="28" height="600" rx="14"/>
@ -372,56 +372,56 @@
<text class="plbl lbl-r lbl-off" x="344" y="630">GPIO21</text> <text class="plbl lbl-r lbl-off" x="344" y="630">GPIO21</text>
<!-- pin1 hint --> <!-- pin1 hint -->
<text class="cap-svg" x="260" y="662" style="font-size:11px;">■ 사각 표시 = 1번 핀 (기판 방향 기준)</text> <text class="cap-svg" x="260" y="662" style="font-size:11px;">■ square marker = pin 1 (relative to board orientation)</text>
</svg> </svg>
<div class="legend"> <div class="legend">
<span><i class="sw pwr"></i>3.3V 전원 — 핀 1</span> <span><i class="sw pwr"></i>3.3V power — pin 1</span>
<span><i class="sw i2c"></i>I2C SDA/SCL — 핀 3·5</span> <span><i class="sw i2c"></i>I2C SDA/SCL — pins 3·5</span>
<span><i class="sw gnd"></i>GND (사용) — 핀 6</span> <span><i class="sw gnd"></i>GND (used) — pin 6</span>
<span><i class="sw altgnd"></i>대체 GND (선택) — 9·14·20·25·30·34·39</span> <span><i class="sw altgnd"></i>Alt GND (optional) — 9·14·20·25·30·34·39</span>
<span><i class="sw off"></i>미사용 핀</span> <span><i class="sw off"></i>Unused pins</span>
</div> </div>
<p class="diagram-cap">본 도해의 핀 번호·신호는 Raspberry Pi 4 Model B (BCM2711) 40핀 헤더 표준 배열과 일치합니다.</p> <p class="diagram-cap">The pin numbers and signals in this diagram match the standard Raspberry Pi 4 Model B (BCM2711) 40-pin header layout.</p>
</section> </section>
<!-- ============ 3. PINMAP TABLE ============ --> <!-- ============ 3. PINMAP TABLE ============ -->
<section> <section>
<h2><span class="n">3</span>핀맵 표 <small>SHT30 → 40핀 헤더</small></h2> <h2><span class="n">3</span>Pinmap Table <small>SHT30 → 40-pin header</small></h2>
<table> <table>
<thead> <thead>
<tr><th style="width:30%">신호</th><th style="width:24%">BCM</th><th style="width:14%">물리핀</th><th>비고</th></tr> <tr><th style="width:30%">Signal</th><th style="width:24%">BCM</th><th style="width:14%">Phys pin</th><th>Notes</th></tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td><b>3.3V 전원</b> (VCC)</td> <td><b>3.3V power</b> (VCC)</td>
<td class="mono">—</td> <td class="mono">—</td>
<td><span class="pinbadge pwr">1</span></td> <td><span class="pinbadge pwr">1</span></td>
<td><span class="warn-inline">★ 반드시 3.3V — 5V(핀 2·4) 연결 금지</span></td> <td><span class="warn-inline">★ Must be 3.3V — do NOT connect 5V (pins 2·4)</span></td>
</tr> </tr>
<tr> <tr>
<td><b>I2C 데이터</b> (SDA)</td> <td><b>I2C data</b> (SDA)</td>
<td class="mono">GPIO2 / SDA1</td> <td class="mono">GPIO2 / SDA1</td>
<td><span class="pinbadge">3</span></td> <td><span class="pinbadge">3</span></td>
<td>4.7kΩ 풀업 → 3.3V <span style="color:var(--muted)">(모듈 내장 시 생략)</span></td> <td>4.7kΩ pull-up → 3.3V <span style="color:var(--muted)">(omit if built into module)</span></td>
</tr> </tr>
<tr> <tr>
<td><b>I2C 클럭</b> (SCL)</td> <td><b>I2C clock</b> (SCL)</td>
<td class="mono">GPIO3 / SCL1</td> <td class="mono">GPIO3 / SCL1</td>
<td><span class="pinbadge">5</span></td> <td><span class="pinbadge">5</span></td>
<td>4.7kΩ 풀업 → 3.3V <span style="color:var(--muted)">(모듈 내장 시 생략)</span></td> <td>4.7kΩ pull-up → 3.3V <span style="color:var(--muted)">(omit if built into module)</span></td>
</tr> </tr>
<tr> <tr>
<td><b>접지</b> (GND)</td> <td><b>Ground</b> (GND)</td>
<td class="mono">—</td> <td class="mono">—</td>
<td><span class="pinbadge gnd">6</span></td> <td><span class="pinbadge gnd">6</span></td>
<td>대체 GND 사용 가능: <span class="mono">9 · 14 · 20 · 25 · 30 · 34 · 39</span></td> <td>Alt GND available: <span class="mono">9 · 14 · 20 · 25 · 30 · 34 · 39</span></td>
</tr> </tr>
<tr> <tr>
<td><b>주소 선택</b> (ADDR)</td> <td><b>Address select</b> (ADDR)</td>
<td class="mono">—</td> <td class="mono">—</td>
<td><span class="pinbadge na">GND</span></td> <td><span class="pinbadge na">GND</span></td>
<td>ADDR → GND ⇒ <span class="mono">0x44</span> (선택) · ADDR → VDD ⇒ <span class="mono">0x45</span></td> <td>ADDR → GND ⇒ <span class="mono">0x44</span> (selected) · ADDR → VDD ⇒ <span class="mono">0x45</span></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -429,14 +429,14 @@
<!-- ============ 4. SCHEMATIC SVG ============ --> <!-- ============ 4. SCHEMATIC SVG ============ -->
<section> <section>
<h2><span class="n">4</span>배선 스키매틱 <small>센서 ↔ 헤더 ↔ 풀업 저항</small></h2> <h2><span class="n">4</span>Wiring Schematic <small>sensor ↔ header ↔ pull-up resistors</small></h2>
<svg class="svg-sch" viewBox="0 0 840 400" role="img" aria-label="SHT30 배선 스키매틱"> <svg class="svg-sch" viewBox="0 0 840 400" role="img" aria-label="SHT30 wiring schematic">
<!-- component boxes --> <!-- component boxes -->
<rect class="box" x="70" y="90" width="150" height="290" rx="8"/> <rect class="box" x="70" y="90" width="150" height="290" rx="8"/>
<rect class="box" x="640" y="90" width="150" height="290" rx="8"/> <rect class="box" x="640" y="90" width="150" height="290" rx="8"/>
<text class="boxtitle" x="145" y="116">SHT30 센서 모듈</text> <text class="boxtitle" x="145" y="116">SHT30 sensor module</text>
<text class="boxtitle" x="715" y="116">Pi 40핀 헤더</text> <text class="boxtitle" x="715" y="116">Pi 40-pin header</text>
<!-- SHT30 terminal labels (right side) --> <!-- SHT30 terminal labels (right side) -->
<text class="term" x="210" y="150" text-anchor="end">VCC</text> <text class="term" x="210" y="150" text-anchor="end">VCC</text>
@ -446,18 +446,18 @@
<text class="term" x="210" y="350" text-anchor="end">ADDR</text> <text class="term" x="210" y="350" text-anchor="end">ADDR</text>
<!-- Pi terminal labels (left side) --> <!-- Pi terminal labels (left side) -->
<text class="term" x="650" y="150">3V3 (핀 1)</text> <text class="term" x="650" y="150">3V3 (pin 1)</text>
<text class="term" x="650" y="200">SDA1 (핀 3)</text> <text class="term" x="650" y="200">SDA1 (pin 3)</text>
<text class="term" x="650" y="250">SCL1 (핀 5)</text> <text class="term" x="650" y="250">SCL1 (pin 5)</text>
<text class="term" x="650" y="305">GND (핀 6)</text> <text class="term" x="650" y="305">GND (pin 6)</text>
<!-- POWER wire (also 3V3 rail for pull-ups) --> <!-- POWER wire (also 3V3 rail for pull-ups) -->
<path class="w-pwr" d="M220 150 H640"/> <path class="w-pwr" d="M220 150 H640"/>
<text class="wlbl" x="300" y="140" fill="#b02a2a">전원 3.3V</text> <text class="wlbl" x="300" y="140" fill="#b02a2a">Power 3.3V</text>
<!-- GND wire --> <!-- GND wire -->
<path class="w-gnd" d="M220 305 H640"/> <path class="w-gnd" d="M220 305 H640"/>
<text class="wlbl" x="300" y="322" fill="#2b3541">접지 GND</text> <text class="wlbl" x="300" y="322" fill="#2b3541">Ground GND</text>
<!-- SDA wire (with hop over SCL pull-up branch at x=480) --> <!-- SDA wire (with hop over SCL pull-up branch at x=480) -->
<path class="w-i2c" d="M220 200 H470 A10 9 0 0 1 490 200 H640"/> <path class="w-i2c" d="M220 200 H470 A10 9 0 0 1 490 200 H640"/>
@ -490,14 +490,14 @@
</svg> </svg>
<p class="sch-note"> <p class="sch-note">
풀업 저항 R1·R2 (4.7kΩ ×2) 는 3.3V 레일로 연결됩니다. <b>SHT30 모듈에 내장되어 있으면 생략</b>하십시오. Pull-up resistors R1·R2 (4.7kΩ ×2) connect to the 3.3V rail. <b>Omit if built into the SHT30 module</b>.
SDA 선의 반원(hop)은 배선 교차 표시일 뿐 전기적 연결이 아닙니다. The half-circle (hop) on the SDA line only marks a wire crossing — it is not an electrical connection.
</p> </p>
</section> </section>
<!-- ============ 5. ELECTRICAL NOTES ============ --> <!-- ============ 5. ELECTRICAL NOTES ============ -->
<section> <section>
<h2><span class="n">5</span>전기 설계 · 주의</h2> <h2><span class="n">5</span>Electrical Design · Cautions</h2>
<div class="warn"> <div class="warn">
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true"> <svg class="ic" viewBox="0 0 24 24" aria-hidden="true">
@ -506,24 +506,25 @@
<circle cx="12" cy="18" r="1.4" fill="#e03131"/> <circle cx="12" cy="18" r="1.4" fill="#e03131"/>
</svg> </svg>
<div> <div>
<h3>치명적 경고 — 전원은 반드시 3.3V</h3> <h3>Critical Warning — On the Pi it MUST be 3.3V</h3>
<p>SHT30 의 <b>VCC 는 물리 핀 1 (3.3V)</b> 에만 연결하십시오. <p>Connect the SHT30's <b>VCC to physical pin 1 (3.3V)</b>.
<b>5V (핀 2·4) 연결 절대 금지</b> — 소자가 즉시 손상될 수 있습니다. <b>Do NOT connect 5V (pins 2·4) on the Pi</b> — the SHT30 module itself tolerates 2.4–5.5V, but
결선 전 핀 번호를 다시 확인하십시오.</p> powering it with 5V pulls the SDA/SCL lines up to 5V as well, which <b>damages the 3.3V-only Pi GPIO</b>.
Double-check the pin numbers before wiring.</p>
</div> </div>
</div> </div>
<ul class="notes"> <ul class="notes">
<li><b>I2C 풀업:</b> SDA·SCL 각각 4.7kΩ 풀업 저항을 3.3V 로 연결. 센서 모듈에 내장된 경우 생략 가능.</li> <li><b>I2C pull-ups:</b> Connect a 4.7kΩ pull-up resistor on each of SDA and SCL to 3.3V. Can be omitted if built into the sensor module.</li>
<li><b>주소 설정:</b> ADDR → GND 이면 <code>0x44</code>(기본), ADDR → VDD 이면 <code>0x45</code>. <code>config.py</code> 의 <code>I2C_ADDR</code> 값과 반드시 일치해야 합니다.</li> <li><b>Address setting:</b> ADDR → GND gives <code>0x44</code> (default), ADDR → VDD gives <code>0x45</code>. Must match the <code>I2C_ADDR</code> value in <code>config.py</code>.</li>
<li><b>I2C 버스:</b> 버스 1 사용. 부팅 후 I2C 가 활성화되어 있어야 함(<code>raspi-config</code> 또는 <code>dtparam=i2c_arm=on</code>).</li> <li><b>I2C bus:</b> Uses bus 1. I2C must be enabled after boot (<code>raspi-config</code> or <code>dtparam=i2c_arm=on</code>).</li>
<li><b>배선 길이:</b> I2C 는 짧은 케이블을 권장. 케이블이 길면 통신 오류·풀업 값 조정이 필요할 수 있습니다.</li> <li><b>Wiring length:</b> Short cables are recommended for I2C. Long cables may cause communication errors or require adjusting the pull-up values.</li>
<li><b>역할 분담:</b> 이 노드는 측정값만 5분 주기로 서버에 보고합니다. 고온·저온·고습·저습 <b>임계 판정과 SMS 발송은 서버</b>가 수행합니다.</li> <li><b>Role split:</b> This node only reports readings to the server every 5 minutes. High/low temperature and high/low humidity <b>threshold decisions and SMS dispatch are done by the server</b>.</li>
<li><b>설정 파일:</b> <code>raspberry_pi4_sht30/config.py</code> (<code>I2C_BUS=1</code>, <code>I2C_ADDR=0x44</code>). 기기별 값은 <code>config_local.py</code> 로 오버라이드.</li> <li><b>Config file:</b> <code>raspberry_pi4_sht30/config.py</code> (<code>I2C_BUS=1</code>, <code>I2C_ADDR=0x44</code>). Per-device values are overridden in <code>config_local.py</code>.</li>
</ul> </ul>
<div class="cmd"> <div class="cmd">
<span class="cm"># I2C 연결 확인 (0x44 가 표에 보이면 정상)</span> <span class="cm"># Check I2C connection (OK if 0x44 appears in the grid)</span>
$ i2cdetect -y 1 $ i2cdetect -y 1
0 1 2 3 4 5 6 7 8 9 a b c d e f 0 1 2 3 4 5 6 7 8 9 a b c d e f
40: -- -- -- -- <span class="ok">44</span> -- -- -- -- -- -- -- -- -- -- -- 40: -- -- -- -- <span class="ok">44</span> -- -- -- -- -- -- -- -- -- -- --
@ -532,24 +533,24 @@ $ i2cdetect -y 1
<!-- ============ 6. INSTALL CHECKLIST ============ --> <!-- ============ 6. INSTALL CHECKLIST ============ -->
<section> <section>
<h2><span class="n">6</span>설치 체크리스트</h2> <h2><span class="n">6</span>Installation Checklist</h2>
<ol class="check"> <ol class="check">
<li><span class="num"></span><span class="cbox"></span><span class="txt"><b>전원 차단 상태</b>에서 배선 작업 (핫플러그 금지)</span></li> <li><span class="num"></span><span class="cbox"></span><span class="txt">Do wiring work with <b>power disconnected</b> (no hot-plugging)</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">VCC → 물리 핀 1 (3.3V) 연결 — <b>5V 핀(2·4) 아님</b>을 재확인</span></li> <li><span class="num"></span><span class="cbox"></span><span class="txt">Connect VCC → physical pin 1 (3.3V) — re-verify it is <b>NOT a 5V pin (2·4)</b></span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">GND → 물리 핀 6 연결 (대체 GND 사용 시 9·14·20·25·30·34·39)</span></li> <li><span class="num"></span><span class="cbox"></span><span class="txt">Connect GND → physical pin 6 (or 9·14·20·25·30·34·39 for alt GND)</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">SDA → 핀 3 (GPIO2), SCL → 핀 5 (GPIO3) 연결</span></li> <li><span class="num"></span><span class="cbox"></span><span class="txt">Connect SDA → pin 3 (GPIO2), SCL → pin 5 (GPIO3)</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">풀업 저항 4.7kΩ ×2 를 SDA·SCL → 3.3V 로 연결 (모듈 내장 시 생략)</span></li> <li><span class="num"></span><span class="cbox"></span><span class="txt">Connect 4.7kΩ ×2 pull-up resistors from SDA·SCL → 3.3V (omit if built into module)</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">ADDR → GND 확인 (주소 0x44 선택)</span></li> <li><span class="num"></span><span class="cbox"></span><span class="txt">Verify ADDR → GND (address 0x44 selected)</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">결선 전체 재확인 후 Pi 전원 인가</span></li> <li><span class="num"></span><span class="cbox"></span><span class="txt">Re-check all wiring, then power up the Pi</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">I2C 활성화 확인 (raspi-config / dtparam=i2c_arm=on)</span></li> <li><span class="num"></span><span class="cbox"></span><span class="txt">Verify I2C is enabled (raspi-config / dtparam=i2c_arm=on)</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt"><code>i2cdetect -y 1</code> 실행 → <code>0x44</code> 표시 확인</span></li> <li><span class="num"></span><span class="cbox"></span><span class="txt">Run <code>i2cdetect -y 1</code> → confirm <code>0x44</code> is shown</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">config.py / config_local.py 의 I2C_BUS=1, I2C_ADDR=0x44 확인</span></li> <li><span class="num"></span><span class="cbox"></span><span class="txt">Verify I2C_BUS=1, I2C_ADDR=0x44 in config.py / config_local.py</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">측정 로그 및 서버 수신 확인 (5분 주기 보고 정상)</span></li> <li><span class="num"></span><span class="cbox"></span><span class="txt">Check measurement log and server reception (5-min reporting OK)</span></li>
</ol> </ol>
</section> </section>
<footer> <footer>
온습도 노드 · Pi-B (sensor_id=2, 1번 지점/서버실) · SHT30 I2C 배선 문서 — 현장 인쇄용 Temp/Humidity Node · Pi-B (sensor_id=2, Site 1 / Server Room) · SHT30 I2C wiring doc — for on-site printing
</footer> </footer>
</div> </div>

View file

@ -1,74 +1,74 @@
# ============================================================================= # =============================================================================
# config.py - 온습도(SHT30) 모니터링 모듈 (Pi 4 Model B) 설정 — 비밀이 아닌 기본값 # config.py - Temperature/Humidity (SHT30) monitoring module (Pi 4 Model B) config — non-secret defaults
# #
# 서버 URL / API_KEY / 기기 식별자(DEVICE_ID/LOCATION) 등 운영·비밀값은 # Operational/secret values such as server URL / API_KEY / device identifiers (DEVICE_ID/LOCATION)
# config_local.py 에서 로드한다. config_local.py 는 .gitignore 로 커밋 제외. # are loaded from config_local.py. config_local.py is excluded from commits via .gitignore.
# 설정 방법: cp config_local.example.py config_local.py → 실제 값으로 채움 # Setup: cp config_local.example.py config_local.py → fill in the real values
# #
# 이 모듈은 "2번 모듈"(온습도)이다. 서버(php/) 대시보드는 sensor_id=2 를 기본 # This module is "Module 2" (temperature/humidity). The server (php/) dashboard shows sensor_id=2 as the
# 온습도 센서로 표시하므로 SENSOR_ID 기본값은 2 로 둔다. # default temperature/humidity sensor, so the SENSOR_ID default is set to 2.
# ============================================================================= # =============================================================================
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 센서 식별 (서버 대시보드 표시용; 비밀 아님) # Sensor identity (for server dashboard display; not secret)
# SENSOR_ID 는 서버 leak_sensor_metric / leak_sensor_status 의 키. 대시보드 기본 차트=2. # SENSOR_ID is the key in the server's leak_sensor_metric / leak_sensor_status. Dashboard default chart=2.
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
SENSOR_ID = 2 SENSOR_ID = 2
SENSOR_NAME = "2번 센서 (SHT30)" SENSOR_NAME = "Sensor 2 (SHT30)"
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# I2C 버스 설정 (Pi 4 40핀 헤더) # I2C bus config (Pi 4 40-pin header)
# SDA = GPIO2 (물리 3번), SCL = GPIO3 (물리 5번) → I2C 버스 1 # SDA = GPIO2 (physical pin 3), SCL = GPIO3 (physical pin 5) → I2C bus 1
# ADDR 핀: GND=0x44, VDD=0x45 # ADDR pin: GND=0x44, VDD=0x45
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
I2C_BUS = 1 I2C_BUS = 1
I2C_ADDR = 0x44 I2C_ADDR = 0x44
# 강제 시뮬레이션. True 면 하드웨어 없이 더미 측정값으로 동작(서버 연동 점검용). # Force simulation. If True, runs with dummy measurements without hardware (for server integration checks).
# False 라도 smbus2 미설치/ I2C 미활성이면 드라이버가 자동 시뮬레이션으로 폴백. # Even if False, the driver auto-falls back to simulation when smbus2 is not installed / I2C is disabled.
FORCE_SIMULATION = False FORCE_SIMULATION = False
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 측정/보고 주기 (firmware app_config.h 와 동일 기본값) # Measurement/report intervals (same defaults as firmware app_config.h)
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
REPORT_INTERVAL = 300 # 정상 측정·보고 간격 (초) = 5분 REPORT_INTERVAL = 300 # Normal measure/report interval (seconds) = 5 minutes
MEASURE_RETRY_DELAY = 5 # 측정 실패 후 재시도 대기 (초) MEASURE_RETRY_DELAY = 5 # Wait before retry after a failed measurement (seconds)
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 온습도 물리 plausibility 범위 (firmware classify_metric_status 와 동일) # Physical plausibility range for temp/humidity (same as firmware classify_metric_status)
# 이 범위를 벗어나면 metric_status="out_of_range" 로 보고한다. # Values outside this range are reported as metric_status="out_of_range".
# ※ 운영 임계(고온/저온/고습/저습) 판정·SMS 는 모두 서버(config.php METRIC_*)가 # * Operational threshold judgments (high/low temp, high/low humidity) and SMS are all handled
# 수행한다. 이 모듈은 측정값만 충실히 보고한다. # by the server (config.php METRIC_*). This module only faithfully reports measurements.
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
TEMP_LOW_C = -40.0 TEMP_LOW_C = -40.0
TEMP_HIGH_C = 125.0 TEMP_HIGH_C = 125.0
RH_LOW = 0.0 RH_LOW = 0.0
RH_HIGH = 100.0 RH_HIGH = 100.0
# 모듈 식별 버전 (서버 app_version 필드; 서버는 검증하지 않고 기록만 함) # Module identity version (server app_version field; the server only records it, does not validate)
APP_VERSION = "v2606-sht30-rpi4" APP_VERSION = "v2606-sht30-rpi4"
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 로그 설정 # Logging config
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
LOG_FILE = "/var/log/sht30_sensor.log" LOG_FILE = "/var/log/sht30_sensor.log"
LOG_MAX_BYTES = 5 * 1024 * 1024 # 5MB LOG_MAX_BYTES = 5 * 1024 * 1024 # 5MB
LOG_BACKUP_COUNT = 3 LOG_BACKUP_COUNT = 3
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# HTTP 재시도 설정 (firmware APP_HTTP_* 와 동일 기본값) # HTTP retry config (same defaults as firmware APP_HTTP_*)
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
HTTP_TIMEOUT = 10 # 단일 요청 타임아웃 (초) HTTP_TIMEOUT = 10 # Single request timeout (seconds)
HTTP_RETRY_COUNT = 3 # 재시도 횟수 HTTP_RETRY_COUNT = 3 # Number of retries
HTTP_RETRY_DELAY = 5 # 재시도 간 대기 (초) HTTP_RETRY_DELAY = 5 # Wait between retries (seconds)
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 운영·비밀값 로드 (fail closed) # Load operational/secret values (fail closed)
# config_local.py 가 없으면 placeholder/하드코딩 비밀로 잘못 기동하는 대신 # If config_local.py is missing, abort immediately with a clear error instead of
# 명확한 오류로 즉시 중단한다. # starting incorrectly with placeholder/hardcoded secrets.
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
try: try:
from config_local import ( # noqa: F401 (sht30_sensor.py 가 config 에서 재수입) from config_local import ( # noqa: F401 (sht30_sensor.py re-imports from config)
API_URL, API_URL,
API_KEY, API_KEY,
DEVICE_ID, DEVICE_ID,
@ -76,7 +76,7 @@ try:
) )
except ImportError as e: except ImportError as e:
raise RuntimeError( raise RuntimeError(
"config_local.py 를 찾을 수 없습니다. " "config_local.py not found. "
"`cp config_local.example.py config_local.py` 후 실제 서버 URL / API_KEY / " "Run `cp config_local.example.py config_local.py`, then fill in the real server URL / API_KEY / "
"기기 정보를 채우세요. (비밀값은 절대 커밋하지 마세요.)" "device info. (Never commit secret values.)"
) from e ) from e

View file

@ -1,31 +1,31 @@
# ============================================================================= # =============================================================================
# config_local.example.py - 운영·비밀값 템플릿 (Pi 4 온습도 SHT30 모듈 / 2번 모듈) # config_local.example.py - Operational / secret-value template (Pi 4 temp-humidity SHT30 module / module #2)
# #
# 사용법: cp config_local.example.py config_local.py # Usage: cp config_local.example.py config_local.py
# → 실제 값으로 채운 뒤 저장. config_local.py 는 절대 커밋하지 말 것 # -> Fill in the real values and save. Never commit config_local.py
# (.gitignore 로 제외되어 있음). # (it is excluded via .gitignore).
# #
# ⚠️ 서버 호환성 # WARNING: Server compatibility
# 이 모듈(2번)은 SHT30 온습도 측정을 cafe24 서버 php/api/sensor_data.php 로 # This module (#2) reports SHT30 temperature/humidity readings to the cafe24
# 보고한다. 서버는 raw-body 서명을 요구한다: # server php/api/sensor_data.php. The server requires a raw-body signature:
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=요청본문 바이트)) # X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=request body bytes))
# → API_KEY 는 서버 config.local.php 의 API_KEY 와 "정확히 동일"해야 한다. # -> API_KEY must be "exactly identical" to API_KEY in the server config.local.php.
# 서버 임계 판정(고온/저온/고습/저습)·SMS·대시보드는 모두 서버가 수행한다. # Threshold decisions (high/low temp, high/low humidity), SMS, and the dashboard are all done by the server.
# ============================================================================= # =============================================================================
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 서버 API 엔드포인트 (SHT30 수신 서버 = cafe24 php/) # Server API endpoint (SHT30 receiving server = cafe24 php/)
# INSTALL_PI_SERVER.md 기준 웹 루트: public_html/raspi_leck_detecter/ # Web root per INSTALL_PI_SERVER.md: public_html/raspi_leck_detecter/
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
API_URL = "https://YOUR_SERVER/raspi_leck_detecter/api/sensor_data.php" API_URL = "https://YOUR_SERVER/raspi_leck_detecter/api/sensor_data.php"
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 공유 비밀키 — 서버 config.local.php 의 API_KEY 와 정확히 동일해야 함. 절대 커밋 금지. # Shared secret key — must be exactly identical to API_KEY in the server config.local.php. Never commit.
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
API_KEY = "CHANGE_ME" API_KEY = "CHANGE_ME"
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 기기 식별 (여러 대 운영 시 구분). sensor_id/sensor_name 은 config.py 기본값(2)을 사용. # Device identification (to distinguish multiple units). sensor_id/sensor_name use the config.py defaults (2).
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
DEVICE_ID = "rpi4-sht30-01" DEVICE_ID = "rpi4-sht30-01"
DEVICE_LOCATION = "서버실" DEVICE_LOCATION = "Server Room"

View file

@ -1,17 +1,17 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ============================================================================= # =============================================================================
# 온습도(SHT30) 모듈 v2 (Raspberry Pi 4 Model B) 설치 스크립트 — 2번 모듈 # Temp/humidity (SHT30) module v2 (Raspberry Pi 4 Model B) install script — module 2
# 대상 OS: Raspberry Pi OS Bookworm (64-bit, aarch64) # Target OS: Raspberry Pi OS Bookworm (64-bit, aarch64)
# #
# 폐쇄망(air-gapped) 지원: # Air-gapped support:
# - debs/ 또는 wheelhouse/ 가 있으면 자동으로 "오프라인 모드"로 전환. # - If debs/ or wheelhouse/ is present, automatically switch to "offline mode".
# - 오프라인 모드에서는 절대 네트워크에 접근하지 않는다. 번들이 일부만 있으면 # - In offline mode, never access the network. If the bundle is only partial,
# 온라인으로 몰래 빠지지 않고 명확한 오류로 중단한다(폐쇄망 규칙). 사전 # do not silently fall back to online; stop with a clear error (air-gapped rule). Pre-
# 다운로드는 vendor.sh 로 빌드 머신에서 수행. # download is done on the build machine via vendor.sh.
# - 환경변수 OFFLINE=1/0 으로 강제 지정 가능. # - Can be forced via the OFFLINE=1/0 environment variable.
# - 로그 파일 소유자: SERVICE_USER (기본 pi). # - Log file owner: SERVICE_USER (default pi).
# #
# 이 스크립트는 I2C 도 활성화한다(dtparam=i2c_arm=on + i2c-dev 로드 + i2c 그룹). # This script also enables I2C (dtparam=i2c_arm=on + i2c-dev load + i2c group).
# ============================================================================= # =============================================================================
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
@ -19,132 +19,132 @@ cd "$(dirname "$0")"
SERVICE_USER="${SERVICE_USER:-pi}" SERVICE_USER="${SERVICE_USER:-pi}"
echo "==========================================" echo "=========================================="
echo " 온습도(SHT30) 모듈 v2 (Pi 4) 설치 — 2번 모듈" echo " Temp/humidity (SHT30) module v2 (Pi 4) install — module 2"
echo "==========================================" echo "=========================================="
# 0) 보드 모델 안내 (강제 아님) # 0) Board model notice (not enforced)
if [ -r /proc/device-tree/model ]; then if [ -r /proc/device-tree/model ]; then
MODEL="$(tr -d '\0' < /proc/device-tree/model)" MODEL="$(tr -d '\0' < /proc/device-tree/model)"
echo "[INFO] 보드: ${MODEL}" echo "[INFO] Board: ${MODEL}"
case "${MODEL}" in case "${MODEL}" in
*"Raspberry Pi 4"*) : ;; *"Raspberry Pi 4"*) : ;;
*) echo "[WARN] 이 폴더는 Raspberry Pi 4 Model B 전용입니다 (계속 진행은 가능)." ;; *) echo "[WARN] This folder is for Raspberry Pi 4 Model B only (you may continue anyway)." ;;
esac esac
fi fi
# 오프라인 모드 판정 # Offline mode detection
OFFLINE="${OFFLINE:-}" OFFLINE="${OFFLINE:-}"
if [ -z "${OFFLINE}" ]; then if [ -z "${OFFLINE}" ]; then
if compgen -G "debs/*.deb" >/dev/null || [ -d wheelhouse ]; then if compgen -G "debs/*.deb" >/dev/null || [ -d wheelhouse ]; then
OFFLINE=1 OFFLINE=1
echo "[INFO] 오프라인 번들 감지 → 폐쇄망 오프라인 설치 모드 (네트워크 미사용)" echo "[INFO] Offline bundle detected → air-gapped offline install mode (no network)"
else else
OFFLINE=0 OFFLINE=0
echo "[INFO] 오프라인 번들 없음 → 온라인 설치 모드 (인터넷 필요)" echo "[INFO] No offline bundle → online install mode (internet required)"
fi fi
fi fi
# 1) 시스템 패키지: requests + venv + i2c-tools # 1) System packages: requests + venv + i2c-tools
echo "[1/4] 시스템 패키지 설치..." echo "[1/4] Installing system packages..."
if [ "${OFFLINE}" = "1" ]; then if [ "${OFFLINE}" = "1" ]; then
if ! compgen -G "debs/*.deb" >/dev/null; then if ! compgen -G "debs/*.deb" >/dev/null; then
echo "[ERROR] 오프라인 모드인데 debs/ 가 비어있습니다. vendor.sh 산출물(debs/)을 이 폴더에 두고 재실행하세요." >&2 echo "[ERROR] Offline mode but debs/ is empty. Place the vendor.sh output (debs/) in this folder and re-run." >&2
exit 1 exit 1
fi fi
echo " 오프라인 .deb 설치 (apt-get --no-download — 의존성 순서·해결 자동, 네트워크 미사용)" echo " Offline .deb install (apt-get --no-download — dependency ordering/resolution automatic, no network)"
# --no-install-recommends: vendor.sh 가 Recommends 를 안 담으므로(--no-recommends) 맞춘다. # --no-install-recommends: vendor.sh does not bundle Recommends (--no-recommends), so match that.
sudo apt-get install -y --no-install-recommends --no-download ./debs/*.deb sudo apt-get install -y --no-install-recommends --no-download ./debs/*.deb
else else
echo " 온라인 apt 설치 (인터넷 필요)" echo " Online apt install (internet required)"
sudo apt-get update sudo apt-get update
sudo apt-get install -y python3-requests python3-venv i2c-tools sudo apt-get install -y python3-requests python3-venv i2c-tools
fi fi
# 2) I2C 활성화 (dtparam + 모듈 로드 + 그룹) # 2) Enable I2C (dtparam + module load + group)
echo "[2/4] I2C 활성화..." echo "[2/4] Enabling I2C..."
# 부트 설정 경로: Bookworm=/boot/firmware/config.txt, 구버전=/boot/config.txt # Boot config path: Bookworm=/boot/firmware/config.txt, older=/boot/config.txt
BOOT_CFG="" BOOT_CFG=""
for c in /boot/firmware/config.txt /boot/config.txt; do for c in /boot/firmware/config.txt /boot/config.txt; do
if [ -f "$c" ]; then BOOT_CFG="$c"; break; fi if [ -f "$c" ]; then BOOT_CFG="$c"; break; fi
done done
if [ -n "${BOOT_CFG}" ]; then if [ -n "${BOOT_CFG}" ]; then
if ! grep -q "^dtparam=i2c_arm=on" "${BOOT_CFG}"; then if ! grep -q "^dtparam=i2c_arm=on" "${BOOT_CFG}"; then
echo " ${BOOT_CFG} 에 dtparam=i2c_arm=on 추가 (재부팅 후 적용)" echo " Adding dtparam=i2c_arm=on to ${BOOT_CFG} (applied after reboot)"
echo "dtparam=i2c_arm=on" | sudo tee -a "${BOOT_CFG}" >/dev/null echo "dtparam=i2c_arm=on" | sudo tee -a "${BOOT_CFG}" >/dev/null
NEED_REBOOT=1 NEED_REBOOT=1
else else
echo " dtparam=i2c_arm=on 이미 설정됨" echo " dtparam=i2c_arm=on already set"
fi fi
else else
echo "[WARN] config.txt 를 찾지 못함 — raspi-config 로 수동 I2C 활성화 필요" echo "[WARN] config.txt not found — enable I2C manually via raspi-config"
fi fi
# i2c-dev 모듈을 즉시 로드 + 부팅 시 자동 로드 등록 # Load the i2c-dev module immediately + register it for auto-load at boot
sudo modprobe i2c-dev 2>/dev/null || echo "[WARN] i2c-dev 즉시 로드 실패 (재부팅 후 적용될 수 있음)" sudo modprobe i2c-dev 2>/dev/null || echo "[WARN] i2c-dev immediate load failed (may apply after reboot)"
if [ -d /etc/modules-load.d ] && ! grep -qrs "i2c-dev" /etc/modules-load.d/ 2>/dev/null; then if [ -d /etc/modules-load.d ] && ! grep -qrs "i2c-dev" /etc/modules-load.d/ 2>/dev/null; then
echo "i2c-dev" | sudo tee /etc/modules-load.d/i2c.conf >/dev/null echo "i2c-dev" | sudo tee /etc/modules-load.d/i2c.conf >/dev/null
fi fi
# 서비스 사용자를 i2c 그룹에 추가 (/dev/i2c-1 접근 권한) # Add the service user to the i2c group (/dev/i2c-1 access permission)
if getent group i2c >/dev/null 2>&1; then if getent group i2c >/dev/null 2>&1; then
sudo usermod -aG i2c "${SERVICE_USER}" || echo "[WARN] usermod 실패 — SERVICE_USER=${SERVICE_USER} 확인" sudo usermod -aG i2c "${SERVICE_USER}" || echo "[WARN] usermod failed — check SERVICE_USER=${SERVICE_USER}"
fi fi
# 3) Python venv + requests + smbus2 # 3) Python venv + requests + smbus2
echo "[3/4] Python 가상환경 설정..." echo "[3/4] Setting up Python virtual environment..."
if [ ! -d venv ]; then if [ ! -d venv ]; then
python3 -m venv --system-site-packages venv python3 -m venv --system-site-packages venv
echo " venv 생성 완료" echo " venv created"
else else
echo " venv 이미 존재 — 건너뜀" echo " venv already exists — skipping"
fi fi
# shellcheck disable=SC1091 # shellcheck disable=SC1091
source venv/bin/activate source venv/bin/activate
if [ "${OFFLINE}" = "1" ]; then if [ "${OFFLINE}" = "1" ]; then
if [ ! -d wheelhouse ]; then if [ ! -d wheelhouse ]; then
echo "[ERROR] 오프라인 모드인데 wheelhouse/ 가 없습니다. vendor.sh 산출물(wheelhouse/)을 두고 재실행하세요." >&2 echo "[ERROR] Offline mode but wheelhouse/ is missing. Place the vendor.sh output (wheelhouse/) and re-run." >&2
exit 1 exit 1
fi fi
echo " 오프라인 wheel 설치 (--no-index — 네트워크 미사용)" echo " Offline wheel install (--no-index — no network)"
pip install --no-index --find-links=wheelhouse -r requirements.txt pip install --no-index --find-links=wheelhouse -r requirements.txt
else else
echo " 온라인 pip 설치 (인터넷 필요)" echo " Online pip install (internet required)"
pip install --upgrade pip pip install --upgrade pip
pip install -r requirements.txt pip install -r requirements.txt
fi fi
echo " 패키지 설치 완료" echo " Packages installed"
# 4) 로그 파일 (최소 권한: 소유자만 쓰기) # 4) Log file (least privilege: owner-only write)
echo "[4/4] 로그 파일 생성..." echo "[4/4] Creating log file..."
sudo mkdir -p /var/log sudo mkdir -p /var/log
sudo touch /var/log/sht30_sensor.log sudo touch /var/log/sht30_sensor.log
if ! sudo chown "${SERVICE_USER}:${SERVICE_USER}" /var/log/sht30_sensor.log; then if ! sudo chown "${SERVICE_USER}:${SERVICE_USER}" /var/log/sht30_sensor.log; then
echo "[WARN] chown 실패 — SERVICE_USER=${SERVICE_USER} 존재 여부 확인" echo "[WARN] chown failed — check whether SERVICE_USER=${SERVICE_USER} exists"
fi fi
sudo chmod 640 /var/log/sht30_sensor.log sudo chmod 640 /var/log/sht30_sensor.log
# config_local.py 즉시 잠금 (API_KEY 포함 — harden 전까지 world-readable 방지) # Lock config_local.py immediately (contains API_KEY — prevent world-readable until harden)
# 하드닝(harden_pi.sh)이 나중에 다시 조이지만, 그 사이 창(window)에도 노출되지 # Hardening (harden_pi.sh) tightens it again later, but to avoid exposure even in
# 않도록 설치 시점에 바로 600 으로 잠근다. harden 여부와 무관하게 보호된다. # that window, lock it to 600 right at install time. Protected regardless of harden.
if [ -f config_local.py ]; then if [ -f config_local.py ]; then
chmod 600 config_local.py chmod 600 config_local.py
sudo chown "${SERVICE_USER}:${SERVICE_USER}" config_local.py 2>/dev/null || true sudo chown "${SERVICE_USER}:${SERVICE_USER}" config_local.py 2>/dev/null || true
echo " config_local.py 권한 600 (소유자 ${SERVICE_USER}) 설정" echo " config_local.py set to permission 600 (owner ${SERVICE_USER})"
fi fi
# config_local 안내 (fail closed — 없으면 실행 시 즉시 중단됨) # config_local notice (fail closed — if missing, execution stops immediately)
if [ ! -f config_local.py ]; then if [ ! -f config_local.py ]; then
echo "" echo ""
echo "[!] config_local.py 가 없습니다. 다음을 실행 후 실제 값으로 채우세요:" echo "[!] config_local.py is missing. Run the following, then fill in real values:"
echo " cp config_local.example.py config_local.py" echo " cp config_local.example.py config_local.py"
fi fi
echo "" echo ""
echo "==========================================" echo "=========================================="
echo " 설치 완료! (OFFLINE=${OFFLINE})" echo " Install complete! (OFFLINE=${OFFLINE})"
echo "==========================================" echo "=========================================="
if [ "${NEED_REBOOT:-0}" = "1" ]; then if [ "${NEED_REBOOT:-0}" = "1" ]; then
echo " ⚠️ I2C 를 방금 활성화했습니다 → 'sudo reboot' 후 센서가 인식됩니다." echo " ⚠️ I2C was just enabled → the sensor is recognized after 'sudo reboot'."
fi fi
echo " 센서 확인: i2cdetect -y 1 (0x44 또는 0x45 가 보여야 함)" echo " Sensor check: i2cdetect -y 1 (should show 0x44 or 0x45)"
echo " 실행: source venv/bin/activate && python3 sht30_sensor.py" echo " Run: source venv/bin/activate && python3 sht30_sensor.py"
echo " 서비스: README.md 의 systemd 섹션 참고" echo " Service: see the systemd section in README.md"
echo "" echo ""

View file

@ -1,8 +1,8 @@
# Pi 4 온습도(SHT30) 모듈 Python 의존성 (폐쇄망 대비 정확 핀 고정) # Pi 4 temperature/humidity (SHT30) module Python dependencies (exact version pins for air-gapped networks)
# 오프라인 설치: vendor.sh 로 wheelhouse/ 에 미리 받아두고 # Offline install: pre-download into wheelhouse/ with vendor.sh, then
# pip install --no-index --find-links=wheelhouse -r requirements.txt # pip install --no-index --find-links=wheelhouse -r requirements.txt
# #
# smbus2 는 순수 파이썬(arch 무관 py3-none-any wheel)이라 x86 빌드 머신에서 받아도 # smbus2 is pure Python (arch-independent py3-none-any wheel), so even if fetched on an x86 build machine
# Pi 4(aarch64)에서 그대로 동작한다. requests 도 함께 핀 고정. # it runs as-is on Pi 4 (aarch64). requests is version-pinned as well.
requests==2.32.3 requests==2.32.3
smbus2==0.5.0 smbus2==0.5.0

View file

@ -1,30 +1,30 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# ============================================================================= # =============================================================================
# sht30_sensor.py - SHT30 온습도 모니터링 메인 (2번 모듈, Raspberry Pi 4 전용) # sht30_sensor.py - SHT30 temperature/humidity monitoring main (Module 2, Raspberry Pi 4 only)
# #
# [대상 하드웨어] Raspberry Pi 4 Model B (BCM2711, 40핀 GPIO 헤더) # [Target hardware] Raspberry Pi 4 Model B (BCM2711, 40-pin GPIO header)
# - SHT30(Sensirion, 한진데이터 P4422-3) I2C 온습도 센서를 I2C 버스 1에 연결. # - SHT30 (Sensirion, Hanjin Data P4422-3) I2C temp/humidity sensor connected to I2C bus 1.
# - SDA=GPIO2(물리 3), SCL=GPIO3(물리 5), ADDR=GND → 0x44, 3.3V/GND, 4.7kΩ 풀업. # - SDA=GPIO2 (physical 3), SCL=GPIO3 (physical 5), ADDR=GND → 0x44, 3.3V/GND, 4.7kΩ pull-up.
# #
# [이식 출처] STM32 펌웨어 sht30_fw 의 동작을 Raspberry Pi 4 + Python 으로 이식. # [Port source] Ported the behavior of STM32 firmware sht30_fw to Raspberry Pi 4 + Python.
# firmware/board_sht30/app_sht30.c (측정 루프 / startup→periodic / 범위 분류) # firmware/board_sht30/app_sht30.c (measurement loop / startup→periodic / range classification)
# firmware/common/reporter.c (본문 생성 → 서명 → POST → 재시도) # firmware/common/reporter.c (build body → sign → POST → retry)
# firmware/common/sht30*.c (I2C 측정 / CRC / 변환) → sht30_driver.py # firmware/common/sht30*.c (I2C measurement / CRC / conversion) → sht30_driver.py
# firmware/test/host/reference.py (wire 포맷) → wire.py # firmware/test/host/reference.py (wire format) → wire.py
# #
# [동작 흐름] # [Operation flow]
# 1. SHT30 드라이버 open (실패/미가용 시 시뮬레이션 폴백) # 1. Open SHT30 driver (fall back to simulation on failure/unavailability)
# 2. 주기(REPORT_INTERVAL)마다 측정 → 서버에 POST # 2. Measure every interval (REPORT_INTERVAL) → POST to server
# 3. "최초 성공" 측정만 event_type="startup", 이후 "periodic" # 3. Only the "first success" measurement uses event_type="startup", afterwards "periodic"
# 4. 측정 실패(통신/CRC)는 크래시하지 않고 로그 후 짧게 쉬고 재시도 # 4. Measurement failure (comm/CRC) does not crash; log, sleep briefly, then retry
# 5. 물리 범위를 벗어나면 metric_status="out_of_range" (서버가 운영 임계 재판정) # 5. Out of physical range → metric_status="out_of_range" (server re-evaluates operational thresholds)
# #
# [인증] 서버는 raw-body HMAC 서명을 요구한다(레거시 body-sig 비활성). # [Auth] The server requires a raw-body HMAC signature (legacy body-sig disabled).
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=전송 본문 바이트)) # X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=sent body bytes))
# → 서명한 바이트와 전송 바이트가 동일하도록 본문을 직접 만들어 raw POST 한다. # → Build the body directly and raw-POST it so the signed bytes match the sent bytes.
# #
# [설정·비밀값] config.py 는 비밀이 아닌 기본값만, 서버 URL/API_KEY/기기 식별자는 # [Config/secrets] config.py holds only non-secret defaults; the server URL/API_KEY/device
# config_local.py(미커밋)에서 로드한다. 없으면 기동 시 fail-closed 로 즉시 중단. # identifiers are loaded from config_local.py (uncommitted). If missing, fail-closed and abort at startup.
# ============================================================================= # =============================================================================
import time import time
@ -52,7 +52,7 @@ from sht30_driver import SHT30, SMBUS_AVAILABLE
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 로거 설정 # Logger setup
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
def setup_logger(): def setup_logger():
logger = logging.getLogger("sht30_sensor") logger = logging.getLogger("sht30_sensor")
@ -71,7 +71,7 @@ def setup_logger():
fh.setFormatter(fmt) fh.setFormatter(fmt)
logger.addHandler(fh) logger.addHandler(fh)
except PermissionError: except PermissionError:
pass # 권한 없으면 콘솔만 사용 pass # No permission → console only
ch = logging.StreamHandler(sys.stdout) ch = logging.StreamHandler(sys.stdout)
ch.setFormatter(fmt) ch.setFormatter(fmt)
@ -83,7 +83,7 @@ log = setup_logger()
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 종료 시그널 처리 (systemd stop = SIGTERM 깔끔 종료) # Shutdown signal handling (systemd stop = SIGTERM clean shutdown)
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
class StopFlag: class StopFlag:
def __init__(self): def __init__(self):
@ -91,15 +91,15 @@ class StopFlag:
def request(self, signum, frame): def request(self, signum, frame):
self.stop = True self.stop = True
log.info(f"[SIGNAL] {signal.Signals(signum).name} 수신 → 종료 준비") log.info(f"[SIGNAL] {signal.Signals(signum).name} received → preparing to shut down")
stop_flag = StopFlag() stop_flag = StopFlag()
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 온습도 물리 범위 분류 (firmware classify_metric_status 와 동일) # Temp/humidity physical range classification (same as firmware classify_metric_status)
# 서버는 이 값을 신뢰하지 않고 운영 임계로 재판정하므로, 여기서는 센서 # The server does not trust this value and re-evaluates with operational thresholds, so
# 물리 plausibility(범위/ NaN·Inf)만 본다. # here we only check sensor physical plausibility (range / NaN·Inf).
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
def classify_metric_status(temp_c: float, rh: float) -> str: def classify_metric_status(temp_c: float, rh: float) -> str:
if not (math.isfinite(temp_c) and math.isfinite(rh)): if not (math.isfinite(temp_c) and math.isfinite(rh)):
@ -112,13 +112,13 @@ def classify_metric_status(temp_c: float, rh: float) -> str:
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 서버 보고 (raw-body + X-Signature HMAC) # Server reporting (raw-body + X-Signature HMAC)
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
def post_reading(event_type: str, temp_c: float, rh: float, metric_status: str) -> bool: def post_reading(event_type: str, temp_c: float, rh: float, metric_status: str) -> bool:
"""SHT30 측정값을 서버에 POST. 성공(HTTP 200) 시 True. """POST the SHT30 measurement to the server. Returns True on success (HTTP 200).
본문을 직접 만들어 그 바이트에 서명하고 그 바이트를 그대로 전송한다 Build the body directly, sign those bytes, and send those exact bytes
(서명한 바이트 == 전송 바이트 보장 → 서버 raw-body 검증과 일치). (guarantees signed bytes == sent bytes → matches the server's raw-body verification).
""" """
timestamp = int(time.time()) timestamp = int(time.time())
body = wire.sht30_event_body( body = wire.sht30_event_body(
@ -145,7 +145,7 @@ def post_reading(event_type: str, temp_c: float, rh: float, metric_status: str)
for attempt in range(1, HTTP_RETRY_COUNT + 1): for attempt in range(1, HTTP_RETRY_COUNT + 1):
try: try:
# data=body_bytes 로 raw 본문을 그대로 전송(json= 재직렬화 금지). # Send the raw body as-is via data=body_bytes (do not re-serialize with json=).
resp = requests.post( resp = requests.post(
API_URL, data=body_bytes, headers=headers, timeout=HTTP_TIMEOUT API_URL, data=body_bytes, headers=headers, timeout=HTTP_TIMEOUT
) )
@ -153,37 +153,37 @@ def post_reading(event_type: str, temp_c: float, rh: float, metric_status: str)
log.info(f"[POST OK] {event_type} T={temp_c:.2f}C RH={rh:.2f}% " log.info(f"[POST OK] {event_type} T={temp_c:.2f}C RH={rh:.2f}% "
f"status={metric_status} → {resp.text[:100]}") f"status={metric_status} → {resp.text[:100]}")
return True return True
log.warning(f"[POST {resp.status_code}] 시도 {attempt}/{HTTP_RETRY_COUNT}: " log.warning(f"[POST {resp.status_code}] attempt {attempt}/{HTTP_RETRY_COUNT}: "
f"{resp.text[:160]}") f"{resp.text[:160]}")
except requests.exceptions.Timeout: except requests.exceptions.Timeout:
log.warning(f"[POST TIMEOUT] 시도 {attempt}/{HTTP_RETRY_COUNT}") log.warning(f"[POST TIMEOUT] attempt {attempt}/{HTTP_RETRY_COUNT}")
except requests.exceptions.ConnectionError as e: except requests.exceptions.ConnectionError as e:
log.warning(f"[POST CONN ERR] 시도 {attempt}/{HTTP_RETRY_COUNT}: {e}") log.warning(f"[POST CONN ERR] attempt {attempt}/{HTTP_RETRY_COUNT}: {e}")
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
log.warning(f"[POST ERR] 시도 {attempt}/{HTTP_RETRY_COUNT}: {e}") log.warning(f"[POST ERR] attempt {attempt}/{HTTP_RETRY_COUNT}: {e}")
if attempt < HTTP_RETRY_COUNT and not stop_flag.stop: if attempt < HTTP_RETRY_COUNT and not stop_flag.stop:
time.sleep(HTTP_RETRY_DELAY) time.sleep(HTTP_RETRY_DELAY)
log.error(f"[POST FAIL] {HTTP_RETRY_COUNT}회 모두 실패 (event={event_type})") log.error(f"[POST FAIL] all {HTTP_RETRY_COUNT} attempts failed (event={event_type})")
return False return False
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 측정 루프 (firmware sht30_measure_task 대응) # Measurement loop (corresponds to firmware sht30_measure_task)
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
def measure_loop(sensor: SHT30): def measure_loop(sensor: SHT30):
# 최초 성공 측정만 startup, 이후 periodic (서버 leak_sensor_log 1회 startup 기록) # Only the first successful measurement is startup, afterwards periodic (server records startup once in leak_sensor_log)
first_success = True first_success = True
log.info(f"[LOOP] 측정 시작 (간격={REPORT_INTERVAL}s, addr=0x{I2C_ADDR:02X}, bus={I2C_BUS})") log.info(f"[LOOP] measurement started (interval={REPORT_INTERVAL}s, addr=0x{I2C_ADDR:02X}, bus={I2C_BUS})")
while not stop_flag.stop: while not stop_flag.stop:
try: try:
temp_c, rh = sensor.measure() temp_c, rh = sensor.measure()
except (OSError, ValueError, RuntimeError) as e: except (OSError, ValueError, RuntimeError) as e:
# 측정 실패(통신/CRC): 크래시 금지. 로그 후 짧게 쉬고 재시도. # Measurement failure (comm/CRC): no crash. Log, sleep briefly, then retry.
log.error(f"[MEASURE FAIL] {e} → {MEASURE_RETRY_DELAY}s 후 재시도") log.error(f"[MEASURE FAIL] {e} → retry after {MEASURE_RETRY_DELAY}s")
_interruptible_sleep(MEASURE_RETRY_DELAY) _interruptible_sleep(MEASURE_RETRY_DELAY)
continue continue
@ -194,22 +194,22 @@ def measure_loop(sensor: SHT30):
ok = post_reading(event_type, temp_c, rh, metric_status) ok = post_reading(event_type, temp_c, rh, metric_status)
if ok and first_success: if ok and first_success:
# 최초 성공 보고 후에만 startup→periodic 전환(최초 보고가 반드시 startup) # Switch startup→periodic only after the first successful report (first report is always startup)
first_success = False first_success = False
# 정상 보고 주기 대기 (종료 시그널이 오면 즉시 깸) # Wait for the normal report interval (wakes immediately on a shutdown signal)
_interruptible_sleep(REPORT_INTERVAL) _interruptible_sleep(REPORT_INTERVAL)
def _interruptible_sleep(seconds: float): def _interruptible_sleep(seconds: float):
"""1초 단위로 나눠 자며 종료 시그널에 빠르게 반응한다.""" """Sleep in 1-second chunks to react quickly to shutdown signals."""
end = time.time() + seconds end = time.time() + seconds
while not stop_flag.stop and time.time() < end: while not stop_flag.stop and time.time() < end:
time.sleep(min(1.0, end - time.time())) time.sleep(min(1.0, end - time.time()))
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 메인 # Main
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
def main(): def main():
signal.signal(signal.SIGTERM, stop_flag.request) signal.signal(signal.SIGTERM, stop_flag.request)
@ -217,12 +217,12 @@ def main():
simulate = FORCE_SIMULATION or not SMBUS_AVAILABLE simulate = FORCE_SIMULATION or not SMBUS_AVAILABLE
log.info("=" * 60) log.info("=" * 60)
log.info(f"온습도(SHT30) 모니터링 v2 (Pi 4, 2번 모듈) 시작") log.info(f"Temp/humidity (SHT30) monitoring v2 (Pi 4, Module 2) starting")
log.info(f"기기={DEVICE_ID} | 위치={DEVICE_LOCATION} | 센서={SENSOR_NAME}(id={SENSOR_ID})") log.info(f"device={DEVICE_ID} | location={DEVICE_LOCATION} | sensor={SENSOR_NAME}(id={SENSOR_ID})")
log.info(f"API={API_URL}") log.info(f"API={API_URL}")
log.info(f"모드={'시뮬레이션' if simulate else f'실제 I2C (bus={I2C_BUS}, addr=0x{I2C_ADDR:02X})'}") log.info(f"mode={'simulation' if simulate else f'real I2C (bus={I2C_BUS}, addr=0x{I2C_ADDR:02X})'}")
if simulate and not FORCE_SIMULATION: if simulate and not FORCE_SIMULATION:
log.warning("smbus2 미설치/ I2C 미가용 → 시뮬레이션 모드. 실측은 install.sh 후 재실행.") log.warning("smbus2 not installed / I2C unavailable → simulation mode. For real readings, run install.sh then restart.")
log.info("=" * 60) log.info("=" * 60)
sensor = SHT30(bus_num=I2C_BUS, addr=I2C_ADDR, simulate=simulate) sensor = SHT30(bus_num=I2C_BUS, addr=I2C_ADDR, simulate=simulate)
@ -234,12 +234,12 @@ def main():
try: try:
measure_loop(sensor) measure_loop(sensor)
except Exception as e: # noqa: BLE001 — 최상위 안전망(로그 후 종료) except Exception as e: # noqa: BLE001 — top-level safety net (log then exit)
log.exception(f"[MAIN] 예외 발생: {e}") log.exception(f"[MAIN] exception occurred: {e}")
raise raise
finally: finally:
sensor.close() sensor.close()
log.info("[MAIN] 종료 완료.") log.info("[MAIN] shutdown complete.")
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -1,16 +1,16 @@
# ============================================================================= # =============================================================================
# /etc/systemd/system/sht30-sensor.service # /etc/systemd/system/sht30-sensor.service
# Raspberry Pi 4 부팅 시 온습도(SHT30) 모니터를 자동 실행하는 서비스 (2번 모듈) # Service that auto-runs the temperature/humidity (SHT30) monitor on Raspberry Pi 4 boot (Module 2)
# #
# 설치 방법: # Installation:
# sudo cp systemd/sht30-sensor.service /etc/systemd/system/ # sudo cp systemd/sht30-sensor.service /etc/systemd/system/
# sudo systemctl daemon-reload # sudo systemctl daemon-reload
# sudo systemctl enable sht30-sensor # sudo systemctl enable sht30-sensor
# sudo systemctl start sht30-sensor # sudo systemctl start sht30-sensor
# sudo systemctl status sht30-sensor # sudo systemctl status sht30-sensor
# #
# ※ WorkingDirectory/ExecStart 경로는 실제 배치 경로에 맞게 수정. # * Adjust the WorkingDirectory/ExecStart paths to match the actual deployment path.
# 아래는 /home/pi/raspberry_pi4_sht30 에 배치한 경우 기준. # The example below assumes deployment under /home/pi/raspberry_pi4_sht30.
# ============================================================================= # =============================================================================
[Unit] [Unit]
@ -22,7 +22,7 @@ Wants=network-online.target
Type=simple Type=simple
User=pi User=pi
Group=pi Group=pi
# /dev/i2c-1 접근에 i2c 그룹 필요 # i2c group required to access /dev/i2c-1
SupplementaryGroups=i2c SupplementaryGroups=i2c
WorkingDirectory=/home/pi/raspberry_pi4_sht30 WorkingDirectory=/home/pi/raspberry_pi4_sht30
ExecStart=/home/pi/raspberry_pi4_sht30/venv/bin/python sht30_sensor.py ExecStart=/home/pi/raspberry_pi4_sht30/venv/bin/python sht30_sensor.py
@ -32,19 +32,19 @@ StandardOutput=journal
StandardError=journal StandardError=journal
SyslogIdentifier=sht30-sensor SyslogIdentifier=sht30-sensor
# 환경 변수 # Environment variables
Environment=PYTHONUNBUFFERED=1 Environment=PYTHONUNBUFFERED=1
# 프로세스 전용 /tmp 격리 # Process-private /tmp isolation
PrivateTmp=true PrivateTmp=true
# ── 샌드박싱(하드닝) — 데몬 침해 시 root 승격·파일시스템 오염을 차단 ────────── # -- Sandboxing (hardening) -- blocks root escalation and filesystem tampering if the daemon is compromised --------
# User=pi 는 NOPASSWD sudo 계정이므로 NoNewPrivileges 로 setuid(sudo) 승격 경로를 막는다. # User=pi is a NOPASSWD sudo account, so NoNewPrivileges blocks the setuid(sudo) escalation path.
NoNewPrivileges=true NoNewPrivileges=true
# 루트파일시스템 읽기전용 + /var/log 만 쓰기 허용(로그 로테이션 포함). /tmp 는 PrivateTmp. # Root filesystem read-only + only /var/log writable (including log rotation). /tmp is PrivateTmp.
ProtectSystem=strict ProtectSystem=strict
ReadWritePaths=/var/log ReadWritePaths=/var/log
# /home 는 읽기전용(자기 코드/config_local.py 읽기만; 쓰기·타 계정 홈 접근 차단). # /home is read-only (reads own code/config_local.py only; blocks writes and access to other accounts' homes).
ProtectHome=read-only ProtectHome=read-only
ProtectKernelModules=true ProtectKernelModules=true
ProtectKernelTunables=true ProtectKernelTunables=true
@ -52,13 +52,13 @@ ProtectControlGroups=true
RestrictSUIDSGID=true RestrictSUIDSGID=true
LockPersonality=true LockPersonality=true
RestrictRealtime=true RestrictRealtime=true
# HTTPS(POST)+로컬소켓(DNS/journal)만. I2C 는 /dev/i2c-1 char (소켓 아님)라 무관. # HTTPS(POST) + local sockets (DNS/journal) only. I2C is unaffected since /dev/i2c-1 is a char device (not a socket).
# AF_NETLINK 필수 — glibc getaddrinfo/NSS 가 로컬주소 열거에 netlink 를 쓴다 # AF_NETLINK required -- glibc getaddrinfo/NSS uses netlink to enumerate local addresses
# (빼면 DNS 해석이 간헐 실패 → 서버 POST 불가). AF_INET/6=HTTPS, AF_UNIX=DNS·journal. # (omitting it makes DNS resolution intermittently fail -> server POST impossible). AF_INET/6=HTTPS, AF_UNIX=DNS/journal.
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
# ※ PrivateDevices 는 쓰지 않는다 — /dev/i2c-1 이 숨겨져 센서 접근 불능. # * Do not use PrivateDevices -- it hides /dev/i2c-1 and makes the sensor inaccessible.
# 재시작 상한 (10분 내 5번 실패 시 중단) # Restart cap (stop after 5 failures within 10 minutes)
StartLimitBurst=5 StartLimitBurst=5
StartLimitInterval=600s StartLimitInterval=600s

View file

@ -1,8 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""서버 연결 테스트 — Pi 4 SHT30(2번) 모듈에서 실행. """Server connection test — run on the Pi 4 SHT30 (Sensor 2) module.
wire.py 로 본문을 만들고 raw-body HMAC(X-Signature)을 붙여 실제로 한 번 Builds the body with wire.py, attaches a raw-body HMAC (X-Signature), and
서명된 POST 를 보낸다. 비밀값(API_URL/API_KEY)은 config_local.py 에서 로드한다. actually sends one signed POST. Secrets (API_URL/API_KEY) are loaded from config_local.py.
""" """
import sys import sys
import time import time
@ -10,7 +10,7 @@ import subprocess
import requests import requests
signed_ok = False # 서명 POST 200 여부 — setup.sh 가 이 exit 코드로 하드닝을 게이트한다 signed_ok = False # whether the signed POST returned 200 — setup.sh gates hardening on this exit code
from config import ( from config import (
API_URL, API_KEY, DEVICE_ID, DEVICE_LOCATION, API_URL, API_KEY, DEVICE_ID, DEVICE_LOCATION,
@ -21,12 +21,12 @@ import wire
API_BASE = API_URL.rsplit("/", 1)[0] API_BASE = API_URL.rsplit("/", 1)[0]
print("=" * 50) print("=" * 50)
print(" 서버 연결 테스트 (Pi 4 SHT30 / 2번 모듈)") print(" Server connection test (Pi 4 SHT30 / Sensor 2 module)")
print("=" * 50) print("=" * 50)
print(f" API_URL = {API_URL}") print(f" API_URL = {API_URL}")
# ── 1단계: curl 로 엔드포인트 도달 확인 (서명 없음 → 403 인증실패가 정상) ── # ── Step 1: check endpoint reachability with curl (no signature → 403 auth failure is expected) ──
print("\n[1/2] curl 로 sensor_data.php 도달 확인 (서명 없이 → 인증실패 403 이 정상)...") print("\n[1/2] Checking sensor_data.php reachability with curl (no signature → 403 auth failure is expected)...")
try: try:
result = subprocess.run( result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
@ -35,20 +35,20 @@ try:
capture_output=True, text=True, timeout=10 capture_output=True, text=True, timeout=10
) )
code = result.stdout.strip() code = result.stdout.strip()
print(f" HTTP 응답코드: {code}") print(f" HTTP status code: {code}")
if code in ("400", "403"): if code in ("400", "403"):
print(" → 엔드포인트 도달 OK (서명/필드 없이 거부됨 — 정상)") print(" → Endpoint reachable OK (rejected without signature/fields — expected)")
elif code == "200": elif code == "200":
print(" → 200? 서버가 서명 없이 수락 — 서버 설정 점검 필요") print(" → 200? Server accepted without a signature — check server configuration")
elif code == "404": elif code == "404":
print(" → 404: API_URL 경로 확인 필요") print(" → 404: check the API_URL path")
else: else:
print(f" → 응답: {code} (호스팅/방화벽/.htaccess 점검)") print(f" → Response: {code} (check hosting/firewall/.htaccess)")
except Exception as e: except Exception as e:
print(f" → curl 실패: {e}") print(f" → curl failed: {e}")
# ── 2단계: wire.py 로 서명한 실제 측정 POST (startup, 더미값) ── # ── Step 2: real measurement POST signed with wire.py (startup, dummy values) ──
print("\n[2/2] 서명된 측정 POST (startup, 더미 24.00C/48.00%)...") print("\n[2/2] Signed measurement POST (startup, dummy 24.00C/48.00%)...")
try: try:
ts = int(time.time()) ts = int(time.time())
body = wire.sht30_event_body( body = wire.sht30_event_body(
@ -56,7 +56,7 @@ try:
"startup", ts, 24.0, 48.0, "normal", APP_VERSION, "startup", ts, 24.0, 48.0, "normal", APP_VERSION,
) )
sig = wire.sign_raw(API_KEY, body) sig = wire.sign_raw(API_KEY, body)
print(f" 본문: {body}") print(f" Body: {body}")
print(f" X-Signature: {sig[:16]}...") print(f" X-Signature: {sig[:16]}...")
resp = requests.post( resp = requests.post(
API_URL, data=body.encode("utf-8"), API_URL, data=body.encode("utf-8"),
@ -68,23 +68,23 @@ try:
}, },
timeout=10, timeout=10,
) )
print(f" HTTP 응답코드: {resp.status_code}") print(f" HTTP status code: {resp.status_code}")
print(f" 응답 내용: {resp.text[:200]}") print(f" Response body: {resp.text[:200]}")
if resp.status_code == 200: if resp.status_code == 200:
signed_ok = True signed_ok = True
print(" → 성공! 서명 검증 통과, 측정 저장됨 (대시보드 sensor_id=2 확인)") print(" → Success! Signature verified, measurement stored (check dashboard sensor_id=2)")
elif resp.status_code == 403: elif resp.status_code == 403:
print(" → 403 인증 실패: API_KEY 가 서버 config.local.php 와 다르거나 서명 불일치") print(" → 403 auth failure: API_KEY differs from the server's config.local.php, or signature mismatch")
elif resp.status_code == 400: elif resp.status_code == 400:
print(" → 400: timestamp 신선도(±600s) 확인 — Pi 시각(NTP) 동기 점검") print(" → 400: check timestamp freshness (±600s) — verify Pi clock (NTP) sync")
else: else:
print(f" → 응답코드: {resp.status_code}") print(f" → Status code: {resp.status_code}")
except ImportError: except ImportError:
print(" → requests 미설치. install.sh 실행 또는 sudo apt install python3-requests") print(" → requests not installed. Run install.sh or sudo apt install python3-requests")
except Exception as e: except Exception as e:
print(f" → 실패: {e}") print(f" → Failed: {e}")
print("\n" + "=" * 50) print("\n" + "=" * 50)
print(" 테스트 완료" + ("" if signed_ok else " — 서명 POST 실패(위 로그 확인)")) print(" Test complete" + ("" if signed_ok else " — signed POST failed (see log above)"))
print("=" * 50) print("=" * 50)
sys.exit(0 if signed_ok else 1) sys.exit(0 if signed_ok else 1)

View file

@ -1,24 +1,24 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""센서/포맷 자가점검 — Pi 4 SHT30(2번) 모듈. """Sensor/format self-check — Pi 4 SHT30 (Sensor 2) module.
1) CRC-8 / fixed2 / 본문 포맷이 펌웨어·서버 규약과 일치하는지 단위 점검 1) Unit-check that CRC-8 / fixed2 / body format match the firmware and server spec
2) 실제(또는 시뮬레이션) SHT30 측정 1회 → 변환값 출력 2) Take one real (or simulated) SHT30 measurement -> print converted values
하드웨어 없이도 1)은 항상 통과해야 한다(서명 호환성의 핵심 보증). Step 1) must always pass even without hardware (core guarantee of signature compatibility).
""" """
import sys import sys
import wire import wire
from sht30_driver import crc8, parse_frame, SHT30, SMBUS_AVAILABLE from sht30_driver import crc8, parse_frame, SHT30, SMBUS_AVAILABLE
# 포맷/CRC 자가점검은 비밀값(config_local.py) 없이도 가능해야 한다. # The format/CRC self-check must work even without secrets (config_local.py).
# config.py 는 config_local.py 가 없으면 import 시 fail-closed 로 중단하므로, # Since config.py fails closed and aborts on import when config_local.py is missing,
# 개발 PC 점검을 위해 기본값으로 폴백한다(서버 연결은 test_connection.py 담당). # fall back to defaults for dev-PC checks (server connection is handled by test_connection.py).
try: try:
from config import I2C_BUS, I2C_ADDR, FORCE_SIMULATION from config import I2C_BUS, I2C_ADDR, FORCE_SIMULATION
except RuntimeError: except RuntimeError:
I2C_BUS, I2C_ADDR, FORCE_SIMULATION = 1, 0x44, False I2C_BUS, I2C_ADDR, FORCE_SIMULATION = 1, 0x44, False
print(" [info] config_local.py 없음 → I2C 기본값(bus=1, 0x44)으로 점검") print(" [info] config_local.py missing -> checking with I2C defaults (bus=1, 0x44)")
def check(name, got, want): def check(name, got, want):
@ -28,25 +28,25 @@ def check(name, got, want):
print("=" * 50) print("=" * 50)
print(" SHT30 자가점검 (2번 모듈)") print(" SHT30 self-check (Sensor 2 module)")
print("=" * 50) print("=" * 50)
passed = True passed = True
# ── 1) CRC-8 점검 (Sensirion 표준 벡터 0xBEEF → 0x92) ── # ── 1) CRC-8 check (Sensirion standard vector 0xBEEF → 0x92) ──
print("\n[1] CRC-8 (poly 0x31, init 0xFF):") print("\n[1] CRC-8 (poly 0x31, init 0xFF):")
passed &= check("crc8(0xBEEF)", crc8(b"\xBE\xEF"), 0x92) passed &= check("crc8(0xBEEF)", crc8(b"\xBE\xEF"), 0x92)
# ── 2) fixed2 점검 (round-half-up, reference.py/jb_fixed2 동일) ── # ── 2) fixed2 check (round-half-up, same as reference.py/jb_fixed2) ──
print("\n[2] fixed2 (소수 2자리 round-half-up):") print("\n[2] fixed2 (2 decimal places, round-half-up):")
passed &= check("24.0", wire.fixed2(24.0), "24.00") passed &= check("24.0", wire.fixed2(24.0), "24.00")
passed &= check("48.5", wire.fixed2(48.5), "48.50") passed &= check("48.5", wire.fixed2(48.5), "48.50")
passed &= check("-3.245", wire.fixed2(-3.245), "-3.25") passed &= check("-3.245", wire.fixed2(-3.245), "-3.25")
passed &= check("0.0", wire.fixed2(0.0), "0.00") passed &= check("0.0", wire.fixed2(0.0), "0.00")
# ── 3) 프레임 파싱 점검 (CRC 포함 합성 프레임) ── # ── 3) Frame parsing check (synthetic frame including CRC) ──
print("\n[3] parse_frame (합성 프레임 변환):") print("\n[3] parse_frame (synthetic frame conversion):")
raw_t, raw_rh = 0x6400, 0x8000 # 임의 raw 값 raw_t, raw_rh = 0x6400, 0x8000 # arbitrary raw values
frame = bytes([ frame = bytes([
raw_t >> 8, raw_t & 0xFF, crc8(bytes([raw_t >> 8, raw_t & 0xFF])), raw_t >> 8, raw_t & 0xFF, crc8(bytes([raw_t >> 8, raw_t & 0xFF])),
raw_rh >> 8, raw_rh & 0xFF, crc8(bytes([raw_rh >> 8, raw_rh & 0xFF])), raw_rh >> 8, raw_rh & 0xFF, crc8(bytes([raw_rh >> 8, raw_rh & 0xFF])),
@ -57,8 +57,8 @@ exp_rh = 100.0 * (raw_rh / 65535.0)
passed &= check("temp_c", round(t, 4), round(exp_t, 4)) passed &= check("temp_c", round(t, 4), round(exp_t, 4))
passed &= check("rh", round(rh, 4), round(exp_rh, 4)) passed &= check("rh", round(rh, 4), round(exp_rh, 4))
# ── 4) 본문 포맷 점검 (필드 순서/타입) ── # ── 4) Body format check (field order/type) ──
print("\n[4] 본문 포맷 (필드 순서 고정):") print("\n[4] Body format (fixed field order):")
body = wire.sht30_event_body("dev", "loc", 2, "s2", "periodic", body = wire.sht30_event_body("dev", "loc", 2, "s2", "periodic",
1700000000, 24.0, 48.0, "normal", "vX") 1700000000, 24.0, 48.0, "normal", "vX")
expected = ('{"device_id":"dev","device_location":"loc","sensor_id":2,' expected = ('{"device_id":"dev","device_location":"loc","sensor_id":2,'
@ -66,22 +66,22 @@ expected = ('{"device_id":"dev","device_location":"loc","sensor_id":2,'
'"metric_type":"sht30","temperature_c":24.00,"humidity_percent":48.00,' '"metric_type":"sht30","temperature_c":24.00,"humidity_percent":48.00,'
'"metric_status":"normal","app_version":"vX"}') '"metric_status":"normal","app_version":"vX"}')
passed &= check("body", body, expected) passed &= check("body", body, expected)
print(f" 서명(key=test): {wire.sign_raw('test', body)[:16]}...") print(f" Signature(key=test): {wire.sign_raw('test', body)[:16]}...")
# ── 5) 실제/시뮬레이션 측정 1회 ── # ── 5) One real/simulated measurement ──
print("\n[5] 측정 1회:") print("\n[5] One measurement:")
sim = FORCE_SIMULATION or not SMBUS_AVAILABLE sim = FORCE_SIMULATION or not SMBUS_AVAILABLE
print(f" 모드: {'시뮬레이션' if sim else f'실제 I2C(bus={I2C_BUS},0x{I2C_ADDR:02X})'}") print(f" Mode: {'simulation' if sim else f'real I2C(bus={I2C_BUS},0x{I2C_ADDR:02X})'}")
try: try:
with SHT30(bus_num=I2C_BUS, addr=I2C_ADDR, simulate=sim) as s: with SHT30(bus_num=I2C_BUS, addr=I2C_ADDR, simulate=sim) as s:
t, rh = s.measure() t, rh = s.measure()
print(f" 측정값: T={t:.2f}C RH={rh:.2f}%") print(f" Reading: T={t:.2f}C RH={rh:.2f}%")
except Exception as e: except Exception as e:
print(f" 측정 실패: {e}") print(f" Measurement failed: {e}")
if not sim: if not sim:
print(" (i2cdetect -y 1 로 0x44 응답 확인 / 배선·풀업·I2C 활성화 점검)") print(" (check 0x44 response with i2cdetect -y 1 / check wiring, pull-ups, I2C enabled)")
print("\n" + "=" * 50) print("\n" + "=" * 50)
print(f" 결과: {'전체 통과 ✅' if passed else '실패 항목 있음 ❌'}") print(f" Result: {'ALL PASSED ✅' if passed else 'SOME FAILED ❌'}")
print("=" * 50) print("=" * 50)
sys.exit(0 if passed else 1) sys.exit(0 if passed else 1)

View file

@ -1,13 +1,13 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ============================================================================= # =============================================================================
# vendor.sh - 폐쇄망 설치용 의존성 사전 다운로드 (SHT30 2번 모듈) # vendor.sh - Pre-download dependencies for air-gapped install (SHT30 sensor 2 module)
# 인터넷에 연결된 빌드/스테이징 머신에서 실행한다. # Run this on a build/staging machine that has internet access.
# 산출물: wheelhouse/ (pip wheel + sdist), debs/ (apt .deb) → 타깃 Pi 4 로 전송. # Outputs: wheelhouse/ (pip wheel + sdist), debs/ (apt .deb) → transfer to the target Pi 4.
# #
# requests 는 arch 종속이 적으나 smbus2 는 순수 파이썬(py3-none-any)이라 어떤 # requests has few arch dependencies, but smbus2 is pure Python (py3-none-any), so it
# 빌드 머신에서 받아도 Pi 4 에서 동작한다. wheel 핀은 타깃(Bookworm 64-bit, # works on the Pi 4 no matter which build machine downloads it. Pin wheels to the target
# aarch64, Python 3.11)에 맞춘다. .deb 는 타깃 OS/arch 에서 받는 것이 가장 안전. # (Bookworm 64-bit, aarch64, Python 3.11). The .deb files are safest downloaded on the target OS/arch.
# 환경변수: TARGET_PLATFORM(기본 manylinux2014_aarch64), TARGET_PYVER(기본 311) # Env vars: TARGET_PLATFORM (default manylinux2014_aarch64), TARGET_PYVER (default 311)
# ============================================================================= # =============================================================================
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
@ -18,58 +18,58 @@ TARGET_ABI="${TARGET_ABI:-cp${TARGET_PYVER}}"
echo "[vendor] (1/2) Python wheel → wheelhouse/ (platform=${TARGET_PLATFORM}, py=${TARGET_PYVER})" echo "[vendor] (1/2) Python wheel → wheelhouse/ (platform=${TARGET_PLATFORM}, py=${TARGET_PYVER})"
mkdir -p wheelhouse mkdir -p wheelhouse
# requests 등 arch 종속 wheel: 타깃 플랫폼 명시 핀. # requests and other arch-dependent wheels: pin the explicit target platform.
python3 -m pip download -r requirements.txt -d wheelhouse \ python3 -m pip download -r requirements.txt -d wheelhouse \
--only-binary=:all: \ --only-binary=:all: \
--platform "${TARGET_PLATFORM}" \ --platform "${TARGET_PLATFORM}" \
--python-version "${TARGET_PYVER}" \ --python-version "${TARGET_PYVER}" \
--implementation cp \ --implementation cp \
--abi "${TARGET_ABI}" \ --abi "${TARGET_ABI}" \
|| echo "[WARN] 일부 wheel 의 타깃 플랫폼 핀 실패 — 순수파이썬(any) 폴백을 아래에서 받는다." || echo "[WARN] Target-platform pin failed for some wheels — pure-Python (any) fallback is downloaded below."
# smbus2(py3-none-any) 등 순수 파이썬 wheel 및 sdist 폴백. # smbus2 (py3-none-any) and other pure-Python wheels plus sdist fallback.
python3 -m pip download -r requirements.txt -d wheelhouse \ python3 -m pip download -r requirements.txt -d wheelhouse \
|| echo "[WARN] sdist/any 폴백 일부 실패 (바이너리 wheel 이 모두 있으면 무시 가능)" || echo "[WARN] Some sdist/any fallbacks failed (safe to ignore if all binary wheels are present)"
WHEEL_COUNT="$(ls -A wheelhouse 2>/dev/null | wc -l)" WHEEL_COUNT="$(ls -A wheelhouse 2>/dev/null | wc -l)"
echo " wheelhouse 파일 수: ${WHEEL_COUNT}" echo " wheelhouse file count: ${WHEEL_COUNT}"
[ "${WHEEL_COUNT}" -gt 0 ] || { echo "[ERROR] wheelhouse 비어있음"; exit 1; } [ "${WHEEL_COUNT}" -gt 0 ] || { echo "[ERROR] wheelhouse is empty"; exit 1; }
echo "[vendor] (2/2) apt .deb → debs/" echo "[vendor] (2/2) apt .deb → debs/"
mkdir -p debs mkdir -p debs
# nftables·unattended-upgrades 도 포함 → harden_pi.sh 를 폐쇄망에서 오프라인 실행 가능. # nftables and unattended-upgrades are included too → harden_pi.sh can run offline in the air-gapped env.
BASE_PKGS="python3-requests python3-venv i2c-tools nftables unattended-upgrades" BASE_PKGS="python3-requests python3-venv i2c-tools nftables unattended-upgrades"
DEPS="$(apt-cache depends --recurse --no-recommends --no-suggests \ DEPS="$(apt-cache depends --recurse --no-recommends --no-suggests \
--no-conflicts --no-breaks --no-replaces --no-enhances ${BASE_PKGS} \ --no-conflicts --no-breaks --no-replaces --no-enhances ${BASE_PKGS} \
| awk '/^\w/{print $1}' | sort -u)" | awk '/^\w/{print $1}' | sort -u)"
( cd debs && apt-get download ${BASE_PKGS} ${DEPS} ) 2>/dev/null \ ( cd debs && apt-get download ${BASE_PKGS} ${DEPS} ) 2>/dev/null \
|| echo "[WARN] 일부 .deb 다운로드 실패 (가상패키지/전이 의존성일 수 있음 — 필수 패키지는 아래에서 검증)" || echo "[WARN] Some .deb downloads failed (may be virtual/transitive deps — required packages are verified below)"
MISS=0 MISS=0
for p in ${BASE_PKGS}; do for p in ${BASE_PKGS}; do
if ! ls debs/"${p}"_*.deb >/dev/null 2>&1; then if ! ls debs/"${p}"_*.deb >/dev/null 2>&1; then
echo "[ERROR] 필수 패키지 .deb 누락: ${p}" echo "[ERROR] Required package .deb missing: ${p}"
MISS=1 MISS=1
fi fi
done done
[ "${MISS}" = 0 ] || { echo "[ERROR] 필수 .deb 누락 — 반드시 타깃 arch(aarch64 Bookworm)에서 vendor.sh 를 실행하세요."; exit 1; } [ "${MISS}" = 0 ] || { echo "[ERROR] Required .deb missing — you must run vendor.sh on the target arch (aarch64 Bookworm)."; exit 1; }
echo " debs 파일 수: $(ls debs/*.deb 2>/dev/null | wc -l) (필수 패키지 모두 확인됨)" echo " debs file count: $(ls debs/*.deb 2>/dev/null | wc -l) (all required packages verified)"
# 아키텍처 실검사 — 파일명이 아니라 .deb 내부 Architecture 필드로 타깃(arm64/all) 검증. # Real architecture check — verify the target (arm64/all) via the .deb's internal Architecture field, not the filename.
# (호스트가 amd64면 apt-get download 가 amd64 .deb 를 받아, 타깃 Pi 에서 dpkg 가 # (If the host is amd64, apt-get download fetches amd64 .deb files, and dpkg on the target Pi
# "wrong architecture" 로 깨진다. 파일명만 보는 검사는 이를 못 잡는다.) # breaks with "wrong architecture". A filename-only check can't catch this.)
BAD_ARCH=0 BAD_ARCH=0
for deb in debs/*.deb; do for deb in debs/*.deb; do
[ -e "$deb" ] || continue [ -e "$deb" ] || continue
arch="$(dpkg-deb --field "$deb" Architecture 2>/dev/null || echo '?')" arch="$(dpkg-deb --field "$deb" Architecture 2>/dev/null || echo '?')"
case "$arch" in case "$arch" in
arm64|all) : ;; arm64|all) : ;;
*) echo "[ERROR] arch 불일치: $(basename "$deb") = ${arch} (arm64/all 아님)"; BAD_ARCH=1 ;; *) echo "[ERROR] arch mismatch: $(basename "$deb") = ${arch} (not arm64/all)"; BAD_ARCH=1 ;;
esac esac
done done
if [ "${BAD_ARCH}" != 0 ]; then if [ "${BAD_ARCH}" != 0 ]; then
echo "[ERROR] 타깃(aarch64/arm64) 이 아닌 .deb 가 있습니다. 호스트 arch=$(dpkg --print-architecture 2>/dev/null || echo unknown)." >&2 echo "[ERROR] There are .deb files that are not the target (aarch64/arm64). host arch=$(dpkg --print-architecture 2>/dev/null || echo unknown)." >&2
echo " 반드시 arm64 Pi(또는 arm64 chroot/qemu)에서 vendor.sh 를 실행하세요." >&2 echo " You must run vendor.sh on an arm64 Pi (or an arm64 chroot/qemu)." >&2
exit 1 exit 1
fi fi
echo " arch 검사 통과 (모든 .deb = arm64/all)" echo " arch check passed (all .deb = arm64/all)"
echo "[vendor] 완료. wheelhouse/ 와 debs/ 를 타깃 Pi 4 로 전송 후 install.sh 실행." echo "[vendor] Done. Transfer wheelhouse/ and debs/ to the target Pi 4, then run install.sh."

View file

@ -1,18 +1,18 @@
# ============================================================================= # =============================================================================
# wire.py - 서버 전송용 wire 포맷 (본문 JSON + raw-body HMAC 서명) # wire.py - wire format for server transmission (body JSON + raw-body HMAC signature)
# #
# [왜 별도 모듈인가] # [Why a separate module]
# 서버(php/api/sensor_data.php)는 받은 "본문 바이트 그대로"에 대해 # The server (php/api/sensor_data.php) verifies, against the received "body bytes as-is",
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=raw_body)) 를 검증한다. # X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=raw_body)).
# 따라서 "서명한 바이트"와 "실제 전송한 바이트"가 100% 동일해야 한다. # Therefore the "signed bytes" and the "actually transmitted bytes" must be 100% identical.
# 본문을 직접 문자열로 만들고 그 바이트에 서명한 뒤, 그 바이트를 그대로 POST 한다. # We build the body directly as a string, sign those bytes, then POST those same bytes as-is.
# (requests 의 json= 직렬화에 맡기면 부동소수/공백/순서가 달라져 서명이 깨질 수 있다.) # (Leaving serialization to requests' json= can change float/whitespace/order and break the signature.)
# #
# [바이트 단위 호환 대상] — 아래 넷과 출력이 동일해야 한다. # [Byte-level compatibility targets] — output must be identical to the four below.
# - firmware/common/jsonbody.c::jb_sht30_event (STM32 본문 생성) # - firmware/common/jsonbody.c::jb_sht30_event (STM32 body generation)
# - firmware/common/sig.c::sig_raw_body (STM32 HMAC 서명) # - firmware/common/sig.c::sig_raw_body (STM32 HMAC signature)
# - firmware/test/host/reference.py (검증된 Python 레퍼런스) # - firmware/test/host/reference.py (verified Python reference)
# - php/config.php::verify_signature_raw (서버 검증부) # - php/config.php::verify_signature_raw (server verification part)
# #
# fixed2() <-> jb_fixed2() / reference.fixed2() # fixed2() <-> jb_fixed2() / reference.fixed2()
# sht30_event_body() <-> jb_sht30_event() / reference.sht30_event_body() # sht30_event_body() <-> jb_sht30_event() / reference.sht30_event_body()
@ -25,9 +25,9 @@ import hmac
def fixed2(v: float) -> str: def fixed2(v: float) -> str:
"""소수 2자리 고정 포맷 (round-half-up, locale 무관). """Fixed 2-decimal format (round-half-up, locale-independent).
jb_fixed2() / reference.fixed2() 와 바이트 동일. Byte-identical to jb_fixed2() / reference.fixed2().
24.0 -> "24.00" 24.0 -> "24.00"
48.5 -> "48.50" 48.5 -> "48.50"
-3.245 -> "-3.25" -3.245 -> "-3.25"
@ -43,10 +43,10 @@ def fixed2(v: float) -> str:
def _esc(s: str) -> str: def _esc(s: str) -> str:
r"""JSON 문자열 값 이스케이프 (jb_estr / reference._esc 와 동일). r"""Escape JSON string value (identical to jb_estr / reference._esc).
'"' 와 '\\' 와 제어문자(<0x20)만 이스케이프한다. '/' 는 그대로 둔다. Escapes only '"', '\\', and control characters (<0x20). '/' is left as-is.
멀티바이트 UTF-8(한글 등)은 그대로 둔다(서버가 raw 바이트로 검증). Multibyte UTF-8 (e.g. Korean) is left as-is (server verifies raw bytes).
""" """
out = [] out = []
for ch in s: for ch in s:
@ -66,10 +66,10 @@ def sht30_event_body(device_id: str, device_location: str, sensor_id: int,
sensor_name: str, event_type: str, timestamp: int, sensor_name: str, event_type: str, timestamp: int,
temperature_c: float, humidity_percent: float, temperature_c: float, humidity_percent: float,
metric_status: str, app_version: str) -> str: metric_status: str, app_version: str) -> str:
"""SHT30 이벤트 본문 JSON 문자열 생성. """Generate SHT30 event body JSON string.
필드 순서는 펌웨어 jb_sht30_event() 와 동일하게 고정한다(서명 호환). Field order is fixed to match firmware jb_sht30_event() (signature compatibility).
metric_type 은 항상 "sht30" 고정. 온도/습도는 fixed2 로 2자리 직렬화. metric_type is always fixed to "sht30". Temperature/humidity serialized to 2 decimals via fixed2.
""" """
return ( return (
'{' '{'
@ -89,10 +89,10 @@ def sht30_event_body(device_id: str, device_location: str, sensor_id: int,
def sign_raw(api_key: str, body: str) -> str: def sign_raw(api_key: str, body: str) -> str:
"""raw-body 서명: lowercase_hex(HMAC-SHA256(key=api_key, msg=body)). """raw-body signature: lowercase_hex(HMAC-SHA256(key=api_key, msg=body)).
sig_raw_body() / reference.sign_raw() / php verify_signature_raw 와 동일. Identical to sig_raw_body() / reference.sign_raw() / php verify_signature_raw.
본문은 UTF-8 로 인코딩한 뒤 서명한다(전송 바이트와 동일). The body is encoded as UTF-8 then signed (same as transmitted bytes).
""" """
return hmac.new(api_key.encode("utf-8"), body.encode("utf-8"), return hmac.new(api_key.encode("utf-8"), body.encode("utf-8"),
hashlib.sha256).hexdigest() hashlib.sha256).hexdigest()

View file

@ -1,78 +1,78 @@
#!/bin/bash #!/bin/bash
# ============================================================================= # =============================================================================
# harden_pi.sh — Raspberry Pi OS (Bookworm) 최소 하드닝 (센서 노드 공통) # harden_pi.sh — Raspberry Pi OS (Bookworm) minimal hardening (common to sensor nodes)
# #
# 정책 (2026-07 확정): # Policy (finalized 2026-07):
# - SSH 완전 비활성 — 이 시스템은 outbound 전용(Pi → 서버 HTTPS POST). # - SSH fully disabled — this system is outbound-only (Pi → server HTTPS POST).
# 유지보수는 현장 물리 접근(모니터+키보드). 재활성이 필요하면 SD 카드의 # Maintenance is via on-site physical access (monitor + keyboard). To re-enable,
# boot 파티션에 빈 파일 `ssh` 를 만들고 부팅한다(물리 접근 필요 — 의도됨). # create an empty file `ssh` in the SD card's boot partition and boot (physical access required — intended).
# - 인바운드 전부 차단(nftables), 아웃바운드는 허용(apt/NTP/서버 POST). # - All inbound blocked (nftables); outbound allowed (apt/NTP/server POST).
# - 자동 보안 패치(unattended-upgrades) 켬, 자동 리부팅은 끔(센서 공백 방지). # - Automatic security patches (unattended-upgrades) on; automatic reboot off (avoids sensor gaps).
# #
# 실행 순서(중요): SSH 차단은 "맨 마지막" 단계다. 그 앞의 실패하기 쉬운 단계 # Execution order (important): disabling SSH is the "very last" step. If a problem occurs in an
# (패키지 설치·방화벽·권한)에서 문제가 나면 set -e 로 SSH 가 살아있는 상태에서 # earlier failure-prone step (package install / firewall / permissions), set -e aborts while
# 중단되어 원격 디버깅이 가능하다. 모든 앞 단계가 성공해야만 SSH 를 끊는다. # SSH is still alive, allowing remote debugging. SSH is cut only after all prior steps succeed.
# #
# 폐쇄망(air-gapped): 같은 폴더에 vendor.sh 산출물 debs/ 가 있으면 nftables· # Air-gapped: if the vendor.sh output debs/ is in the same folder, nftables and
# unattended-upgrades 도 오프라인(--no-download)으로 설치한다. 없으면 온라인 apt. # unattended-upgrades are also installed offline (--no-download). Otherwise, online apt.
# (vendor.sh 가 이 두 패키지도 미리 받아 debs/ 에 포함한다.) # (vendor.sh pre-downloads these two packages too and includes them in debs/.)
# #
# 실행: 모듈 설치(install.sh)와 검증(test_connection.py)이 전부 끝난 "마지막"에. # Run: at the very "last" moment, after module install (install.sh) and verification (test_connection.py) are all done.
# sudo bash harden_pi.sh # sudo bash harden_pi.sh
# ⚠️ SSH 로 접속해 실행 중이면 마지막 단계에서 세션이 끊기고 재접속 불가. # ⚠️ If running over an SSH connection, the session drops at the last step and cannot reconnect.
# ============================================================================= # =============================================================================
set -euo pipefail set -euo pipefail
[ "$(id -u)" -eq 0 ] || { echo "[ERROR] sudo 로 실행하세요: sudo bash harden_pi.sh"; exit 1; } [ "$(id -u)" -eq 0 ] || { echo "[ERROR] Run with sudo: sudo bash harden_pi.sh"; exit 1; }
# debs/ 를 스크립트 위치 기준으로 찾도록 cwd 를 스크립트 폴더로 이동(절대경로 로직엔 무관). # Move cwd to the script folder so debs/ is found relative to the script location (irrelevant to absolute-path logic).
cd "$(dirname "$0")" 2>/dev/null || true cd "$(dirname "$0")" 2>/dev/null || true
# 오프라인 번들(debs/) 유무 판정 — install.sh 와 동일한 규칙. # Detect presence of offline bundle (debs/) — same rule as install.sh.
OFFLINE_DEBS=0 OFFLINE_DEBS=0
if compgen -G "./debs/*.deb" >/dev/null 2>&1; then if compgen -G "./debs/*.deb" >/dev/null 2>&1; then
OFFLINE_DEBS=1 OFFLINE_DEBS=1
echo "[INFO] 오프라인 번들(debs/) 감지 → 패키지 설치는 네트워크 미사용(--no-download)" echo "[INFO] Offline bundle (debs/) detected → package install uses no network (--no-download)"
fi fi
# 필요 패키지 확보: 이미 설치돼 있으면 skip, 아니면 오프라인 debs/ → 온라인 apt 순. # Ensure required packages: skip if already installed, otherwise offline debs/ → online apt, in that order.
# 설치 후 재검증하여 실패 시 즉시 중단(SSH 아직 살아있음). # Re-verify after install and abort immediately on failure (SSH still alive).
ensure_pkgs() { ensure_pkgs() {
local missing=() p local missing=() p
for p in "$@"; do dpkg -s "$p" >/dev/null 2>&1 || missing+=("$p"); done for p in "$@"; do dpkg -s "$p" >/dev/null 2>&1 || missing+=("$p"); done
if [ "${#missing[@]}" -eq 0 ]; then if [ "${#missing[@]}" -eq 0 ]; then
echo " (${*} 이미 설치됨 — skip)" echo " (${*} already installed — skip)"
elif [ "${OFFLINE_DEBS}" = 1 ]; then elif [ "${OFFLINE_DEBS}" = 1 ]; then
echo " 오프라인 debs/ 에서 설치 (네트워크 미사용): ${missing[*]}" echo " Installing from offline debs/ (no network): ${missing[*]}"
# --no-install-recommends: vendor.sh 가 Recommends 를 번들에 안 담으므로(--no-recommends) # --no-install-recommends: since vendor.sh does not put Recommends in the bundle (--no-recommends),
# 설치 때도 Recommends 를 요구하지 않게 맞춘다(폐쇄망 결정성 — 네트워크 폴백 방지). # match that at install time by not requiring Recommends (air-gapped determinism — prevents network fallback).
apt-get install -y --no-install-recommends --no-download ./debs/*.deb apt-get install -y --no-install-recommends --no-download ./debs/*.deb
else else
echo " 온라인 apt 설치: ${missing[*]}" echo " Online apt install: ${missing[*]}"
# 단독 온라인 실행 대비(로컬 apt 목록이 비어있을 수 있음). install.sh 뒤면 no-op. # In case of a standalone online run (local apt list may be empty). No-op when run after install.sh.
apt-get update -q || true apt-get update -q || true
apt-get install -y -q "${missing[@]}" apt-get install -y -q "${missing[@]}"
fi fi
for p in "$@"; do for p in "$@"; do
dpkg -s "$p" >/dev/null 2>&1 || { dpkg -s "$p" >/dev/null 2>&1 || {
echo "[ERROR] '$p' 설치 실패. 폐쇄망이면 vendor.sh(debs/)에 포함해 재배포하거나," >&2 echo "[ERROR] '$p' install failed. If air-gapped, include it in vendor.sh (debs/) and redeploy," >&2
echo " 온라인이면 네트워크를 확인하세요. (SSH 는 아직 차단되지 않음 — 복구 가능)" >&2 echo " or if online, check the network. (SSH is not yet disabled — recovery possible)" >&2
exit 1 exit 1
} }
done done
} }
echo "[1/6] 방화벽(nftables) — 인바운드 전부 차단..." echo "[1/6] Firewall (nftables) — block all inbound..."
ensure_pkgs nftables ensure_pkgs nftables
cat > /etc/nftables.conf <<'NFT' cat > /etc/nftables.conf <<'NFT'
#!/usr/sbin/nft -f #!/usr/sbin/nft -f
# 센서 노드 방화벽 — outbound 전용 장비. 인바운드는 루프백/응답 트래픽만. # Sensor node firewall — outbound-only device. Inbound is loopback/response traffic only.
flush ruleset flush ruleset
table inet filter { table inet filter {
chain input { chain input {
type filter hook input priority 0; policy drop; type filter hook input priority 0; policy drop;
iif "lo" accept iif "lo" accept
ct state established,related accept ct state established,related accept
# 현장 진단용 ping 허용 (원치 않으면 아래 2줄 삭제) # Allow ping for on-site diagnostics (delete the 2 lines below if not wanted)
ip protocol icmp icmp type echo-request accept ip protocol icmp icmp type echo-request accept
ip6 nexthdr ipv6-icmp accept ip6 nexthdr ipv6-icmp accept
} }
@ -83,16 +83,16 @@ NFT
systemctl enable --now nftables systemctl enable --now nftables
nft -f /etc/nftables.conf nft -f /etc/nftables.conf
echo "[2/6] 자동 보안 패치(unattended-upgrades)..." echo "[2/6] Automatic security patches (unattended-upgrades)..."
ensure_pkgs unattended-upgrades ensure_pkgs unattended-upgrades
# Raspberry Pi OS 는 기본 Origins 패턴(Debian 전용)에 안 걸린다 — Raspbian/RPF origin 추가. # Raspberry Pi OS isn't covered by the default Origins pattern (Debian-only) — add Raspbian/RPF origins.
cat > /etc/apt/apt.conf.d/51unattended-upgrades-raspi <<'APT' cat > /etc/apt/apt.conf.d/51unattended-upgrades-raspi <<'APT'
Unattended-Upgrade::Origins-Pattern { Unattended-Upgrade::Origins-Pattern {
"origin=Debian,codename=${distro_codename}-security,label=Debian-Security"; "origin=Debian,codename=${distro_codename}-security,label=Debian-Security";
"origin=Raspbian,codename=${distro_codename},label=Raspbian"; "origin=Raspbian,codename=${distro_codename},label=Raspbian";
"origin=Raspberry Pi Foundation,codename=${distro_codename},label=Raspberry Pi Foundation"; "origin=Raspberry Pi Foundation,codename=${distro_codename},label=Raspberry Pi Foundation";
}; };
// 자동 리부팅 금지 — 커널 패치 후에도 센서 공백을 만들지 않는다(수동 리부팅). // No automatic reboot — avoid creating a sensor gap even after kernel patches (manual reboot).
Unattended-Upgrade::Automatic-Reboot "false"; Unattended-Upgrade::Automatic-Reboot "false";
APT APT
cat > /etc/apt/apt.conf.d/20auto-upgrades <<'APT' cat > /etc/apt/apt.conf.d/20auto-upgrades <<'APT'
@ -101,42 +101,42 @@ APT::Periodic::Unattended-Upgrade "1";
APT APT
systemctl enable --now unattended-upgrades 2>/dev/null || true systemctl enable --now unattended-upgrades 2>/dev/null || true
echo "[3/6] 로컬 권한 조이기..." echo "[3/6] Tightening local permissions..."
# 시크릿 파일 소유자 전용 (API_KEY 포함). 못 찾으면 조용히 넘기지 말고 경고. # Secret files owner-only (contains API_KEY). If not found, warn instead of silently skipping.
SECRET_FOUND=0 SECRET_FOUND=0
for f in /home/pi/raspberry_pi4/config_local.py /home/pi/raspberry_pi4_sht30/config_local.py; do for f in /home/pi/raspberry_pi4/config_local.py /home/pi/raspberry_pi4_sht30/config_local.py; do
if [ -f "$f" ]; then chmod 600 "$f"; chown pi:pi "$f"; echo " 600: $f"; SECRET_FOUND=1; fi if [ -f "$f" ]; then chmod 600 "$f"; chown pi:pi "$f"; echo " 600: $f"; SECRET_FOUND=1; fi
done done
[ "${SECRET_FOUND}" = 1 ] || echo "[WARN] config_local.py 를 표준 경로에서 못 찾음 — 배치 경로가 다르면" \ [ "${SECRET_FOUND}" = 1 ] || echo "[WARN] config_local.py not found at standard paths — if the deploy path differs," \
"수동으로 잠그세요: chmod 600 <경로>/config_local.py (API_KEY 노출 방지)" "lock it manually: chmod 600 <path>/config_local.py (prevent API_KEY exposure)"
# 콘솔 자동 로그인 비활성 (물리 콘솔도 로그인 요구) # Disable console auto-login (physical console requires login too)
if command -v raspi-config >/dev/null 2>&1; then if command -v raspi-config >/dev/null 2>&1; then
raspi-config nonint do_boot_behaviour B1 || true raspi-config nonint do_boot_behaviour B1 || true
fi fi
echo "[4/6] 불필요 리스닝/주변 서비스 비활성..." echo "[4/6] Disabling unnecessary listening/peripheral services..."
for svc in avahi-daemon.service avahi-daemon.socket bluetooth.service hciuart.service \ for svc in avahi-daemon.service avahi-daemon.socket bluetooth.service hciuart.service \
triggerhappy.service triggerhappy.socket cups.service cups.socket \ triggerhappy.service triggerhappy.socket cups.service cups.socket \
cups-browsed.service ModemManager.service; do cups-browsed.service ModemManager.service; do
systemctl disable --now "$svc" 2>/dev/null || true systemctl disable --now "$svc" 2>/dev/null || true
done done
echo "[5/6] 사전 검증 캡처 (SSH 차단 전 — 방화벽/리스닝 상태)..." echo "[5/6] Pre-verification capture (before SSH disable — firewall/listening state)..."
echo "-- 리스닝 소켓 (비어 있거나 로컬 전용이어야 함):" echo "-- Listening sockets (should be empty or local-only):"
ss -lntup || true ss -lntup || true
echo "-- nftables input chain (policy drop 확인):" echo "-- nftables input chain (verify policy drop):"
# || true: pipefail 하에서 head 가 파이프를 먼저 닫아 nft 가 SIGPIPE(141) 로 죽어도 # || true: under pipefail, even if head closes the pipe first and nft dies with SIGPIPE(141),
# 이 검증 단계가 중단되지 않게 한다(SSH 차단 직전 단계라 오탐 중단 방지). # keep this verification step from aborting (prevents a false-positive abort right before SSH disable).
nft list chain inet filter input | head -12 || true nft list chain inet filter input | head -12 || true
echo "[6/6] SSH 완전 비활성 (마지막 — 되돌리기 어려운 단계)..." echo "[6/6] Fully disable SSH (last — hard-to-reverse step)..."
# 여기까지 왔다면 앞의 모든 단계가 성공한 것. 이제 SSH 를 끊는다. # If we got here, all prior steps succeeded. Now cut SSH.
# boot 파티션 `ssh` 파일에 의한 재활성 경로(sshswitch)는 남겨둔다 — 물리 접근 # Leave the re-enable path via the boot partition `ssh` file (sshswitch) in place — it can't be
# 없이는 사용할 수 없고, 현장 복구 수단으로 유용하다. # used without physical access, and is useful as an on-site recovery mechanism.
systemctl disable --now ssh.service ssh.socket 2>/dev/null || true systemctl disable --now ssh.service ssh.socket 2>/dev/null || true
echo "-- ssh 상태 (enabled 가 아니어야 함):" echo "-- ssh status (should not be enabled):"
systemctl is-enabled ssh.service 2>&1 || true systemctl is-enabled ssh.service 2>&1 || true
echo "" echo ""
echo "완료. 위 [5/6]·[6/6] 출력을 캡처해 보안 증적으로 보관할 것." echo "Done. Capture the [5/6]·[6/6] output above and keep it as a security record."
echo "리부팅 권장: sudo reboot (이후 ss -lntup 재확인)" echo "Reboot recommended: sudo reboot (then re-check ss -lntup)"

View file

@ -1,18 +1,18 @@
# ============================================================================= # =============================================================================
# make_deploy_bundles.py — 기기별 턴키 배포 번들 생성기 # make_deploy_bundles.py - per-device turnkey deploy bundle generator
# #
# python tools/make_deploy_bundles.py # python tools/make_deploy_bundles.py
# #
# repo 루트에 deploy/ 를 만들고, 서버 1 + Pi 3대 각각 "복사 → install.sh → # Creates deploy/ at the repo root and generates, for the 1 server + 3 Pis, a
# systemctl enable" 만으로 운영 가능한 폴더를 생성한다. # folder each that is operable by just "copy -> install.sh -> systemctl enable".
# - API_KEY 는 최초 실행 시 자동 생성(deploy/.secrets/api_key.txt 에 보관, # - API_KEY is auto-generated on first run (stored in deploy/.secrets/api_key.txt,
# 재실행 시 재사용) → 서버/Pi 4곳에 동일 주입. # reused on later runs) and injected identically into the server and all 3 Pis.
# - 관리자 비밀번호도 자동 생성 + bcrypt 해시(php 필요) 주입. # - The admin password is likewise auto-generated + bcrypt-hashed (needs php) and injected.
# - 텍스트 파일은 LF 로 정규화(Windows 체크아웃 CRLF → Pi bash 오류 방지). # - Text files are normalized to LF (Windows checkout CRLF -> avoids Pi bash errors).
# - deploy/ 는 비밀값 포함 — .gitignore 대상. 절대 커밋 금지. # - deploy/ contains secrets -> it is a .gitignore target. Never commit it.
# #
# 남는 수동 설정(외부 비밀이라 사전 주입 불가): # Remaining manual settings (external secrets that cannot be pre-injected):
# 1) 서버 DB 계정(cafe24 패널 값) 2) SMS 계정/발신·수신번호 3) TOTP 등록 1회 # 1) server DB account (cafe24 panel values) 2) SMS account / sender & recipient numbers 3) one-time TOTP enrollment
# ============================================================================= # =============================================================================
import hashlib import hashlib
import hmac import hmac
@ -28,16 +28,17 @@ ROOT = Path(__file__).resolve().parent.parent
DEPLOY = ROOT / "deploy" DEPLOY = ROOT / "deploy"
SECRETS_DIR = DEPLOY / ".secrets" SECRETS_DIR = DEPLOY / ".secrets"
# ── 배포 기본값 ────────────────────────────────────────────────────────────── # -- deploy defaults ----------------------------------------------------------
# WEB_DIR(난독화 경로 폴더명)은 비밀값 — 커밋되는 이 파일에 두지 않고 # WEB_DIR (obfuscated path folder name) is a secret - it is NOT kept in this
# deploy/.secrets/web_dir.txt 에서 로드한다(없으면 랜덤 생성). 아래 DOMAIN 만 수정. # committed file; it is loaded from deploy/.secrets/web_dir.txt (randomly generated if absent).
# Only edit DOMAIN below.
DOMAIN = "https://pores.kr" DOMAIN = "https://pores.kr"
TEXT_EXTS = {".sh", ".py", ".php", ".service", ".sql", ".md", ".txt", ".example", ".html", ".css"} TEXT_EXTS = {".sh", ".py", ".php", ".service", ".sql", ".md", ".txt", ".example", ".html", ".css"}
EXCLUDE_NAMES = {"__pycache__", ".gitignore", "config_local.py"} EXCLUDE_NAMES = {"__pycache__", ".gitignore", "config_local.py"}
BUNDLES = [ BUNDLES = [
# (폴더명, 원본, 종류) # (folder name, source, kind)
("01_server_cafe24", None, "server"), ("01_server_cafe24", None, "server"),
("02_pi_a_leak_id1", "raspberry_pi4", "leak"), ("02_pi_a_leak_id1", "raspberry_pi4", "leak"),
("03_pi_b_sht30_id2", "raspberry_pi4_sht30", "sht30"), ("03_pi_b_sht30_id2", "raspberry_pi4_sht30", "sht30"),
@ -46,18 +47,18 @@ BUNDLES = [
PI_META = { PI_META = {
"02_pi_a_leak_id1": { "02_pi_a_leak_id1": {
"device_id": "rpi4-leak-01", "location": "1번 지점(서버실)", "device_id": "rpi4-leak-01", "location": "Site 1 (Server Room)",
"sensors": None, # config.py 기본값 (sensor_id 1) "sensors": None, # config.py default (sensor_id 1)
"service": "leak-sensor", "service": "leak-sensor",
}, },
"03_pi_b_sht30_id2": { "03_pi_b_sht30_id2": {
"device_id": "rpi4-sht30-01", "location": "1번 지점(서버실)", "device_id": "rpi4-sht30-01", "location": "Site 1 (Server Room)",
"sensors": None, # config.py 기본값 (sensor_id 2) "sensors": None, # config.py default (sensor_id 2)
"service": "sht30-sensor", "service": "sht30-sensor",
}, },
"04_pi_c_leak_id3": { "04_pi_c_leak_id3": {
"device_id": "rpi4-leak-02", "location": "3번 지점(신규)", "device_id": "rpi4-leak-02", "location": "Site 3 (New)",
"sensors": '[\n {"id": 3, "gpio": 17, "name": "3번 센서 (신규 지점)"},\n]', "sensors": '[\n {"id": 3, "gpio": 17, "name": "Sensor 3 (New Site)"},\n]',
"service": "leak-sensor", "service": "leak-sensor",
}, },
} }
@ -75,13 +76,13 @@ def load_or_create_secret(name: str, generate) -> str:
return f.read_text(encoding="utf-8").strip() return f.read_text(encoding="utf-8").strip()
value = generate() value = generate()
f.write_text(value + "\n", encoding="utf-8") f.write_text(value + "\n", encoding="utf-8")
os.chmod(f, 0o600) # Windows 에선 read-only 근사지만 POSIX 빌드머신 대비 os.chmod(f, 0o600) # on Windows this is a read-only approximation; for POSIX build machines
return value return value
def bcrypt_hash(password: str) -> str: def bcrypt_hash(password: str) -> str:
"""php 로 bcrypt 해시 생성 (서버 setup_hash.php 와 동일 결과). """Generate a bcrypt hash via php (same result as the server setup_hash.php).
비밀번호는 argv 가 아니라 stdin 으로 전달한다(공유 빌드머신 프로세스 목록 노출 방지).""" The password is passed on stdin, not argv (avoids exposure in the shared build machine's process list)."""
try: try:
out = subprocess.run( out = subprocess.run(
["php", "-r", "echo password_hash(rtrim(fgets(STDIN), \"\\r\\n\"), PASSWORD_BCRYPT);"], ["php", "-r", "echo password_hash(rtrim(fgets(STDIN), \"\\r\\n\"), PASSWORD_BCRYPT);"],
@ -89,17 +90,17 @@ def bcrypt_hash(password: str) -> str:
capture_output=True, text=True, check=True, capture_output=True, text=True, check=True,
) )
except (subprocess.CalledProcessError, FileNotFoundError) as e: except (subprocess.CalledProcessError, FileNotFoundError) as e:
fail(f"php 실행 실패 — bcrypt 해시를 만들 수 없습니다: {e}") fail(f"php execution failed - cannot create bcrypt hash: {e}")
h = out.stdout.strip() h = out.stdout.strip()
if not h.startswith("$2y$"): if not h.startswith("$2y$"):
fail(f"bcrypt 해시 형식 이상: {h!r}") fail(f"unexpected bcrypt hash format: {h!r}")
return h return h
def normalize_lf(path: Path) -> None: def normalize_lf(path: Path) -> None:
"""번들 내 텍스트 파일 CRLF → LF (Pi bash/systemd/Apache 오류 방지). """Normalize bundle text files CRLF -> LF (avoids Pi bash/systemd/Apache errors).
확장자 없는 설정 dotfile(.htaccess/.gitignore 등)도 확장자와 무관하게 포함한다 Extension-less config dotfiles (.htaccess/.gitignore etc.) are included regardless of
(.htaccess CRLF 는 Apache Header 값에 CR 를 섞을 수 있음).""" extension (.htaccess CRLF can inject a CR into Apache Header values)."""
dotfiles = {".htaccess", ".gitignore", ".env.example"} dotfiles = {".htaccess", ".gitignore", ".env.example"}
for p in path.rglob("*"): for p in path.rglob("*"):
if p.is_file() and (p.suffix in TEXT_EXTS or p.name in dotfiles): if p.is_file() and (p.suffix in TEXT_EXTS or p.name in dotfiles):
@ -120,13 +121,14 @@ def write_lf(path: Path, content: str) -> None:
path.write_text(content, encoding="utf-8", newline="\n") path.write_text(content, encoding="utf-8", newline="\n")
# ── 유출 방어 가드 먼저 — 비밀값을 쓰기 전에 ignore 파일부터 만든다 ───────── # -- leak guard first - create the ignore file before writing any secrets ------
# (루트 .gitignore 의 deploy/ 항목과 이중 방어. 생성 도중 스크립트가 실패해도 # (Double defense together with the root .gitignore's deploy/ entry. Guarantees the
# deploy/ 아래 비밀값이 git 에 스테이징되지 않도록 순서를 보장한다.) # order so that even if the script fails mid-generation, secrets under deploy/ are
# never staged into git.)
DEPLOY.mkdir(parents=True, exist_ok=True) DEPLOY.mkdir(parents=True, exist_ok=True)
write_lf(DEPLOY / ".gitignore", "# 배포 번들 전체 커밋 금지 (비밀값 포함)\n*\n!.gitignore\n") write_lf(DEPLOY / ".gitignore", "# Never commit the whole deploy bundle (contains secrets)\n*\n!.gitignore\n")
# ── 비밀값 준비 ────────────────────────────────────────────────────────────── # -- prepare secrets ----------------------------------------------------------
API_KEY = load_or_create_secret("api_key.txt", lambda: secrets.token_hex(32)) API_KEY = load_or_create_secret("api_key.txt", lambda: secrets.token_hex(32))
ADMIN_PASSWORD = load_or_create_secret("admin_password.txt", lambda: secrets.token_urlsafe(18)) ADMIN_PASSWORD = load_or_create_secret("admin_password.txt", lambda: secrets.token_urlsafe(18))
MFA_SETUP_TOKEN = load_or_create_secret("mfa_setup_token.txt", lambda: secrets.token_hex(16)) MFA_SETUP_TOKEN = load_or_create_secret("mfa_setup_token.txt", lambda: secrets.token_hex(16))
@ -135,18 +137,18 @@ WEB_DIR = load_or_create_secret("web_dir.txt", lambda: secrets.token_hex(8))
BASE_URL = f"{DOMAIN}/{WEB_DIR}" BASE_URL = f"{DOMAIN}/{WEB_DIR}"
API_URL = f"{BASE_URL}/api/sensor_data.php" API_URL = f"{BASE_URL}/api/sensor_data.php"
PHOTO_UPLOAD_URL = f"{BASE_URL}/api/photo_upload.php" PHOTO_UPLOAD_URL = f"{BASE_URL}/api/photo_upload.php"
# cron HTTP 키 — 서버 config.php 의 hash_hmac('sha256','cron',API_KEY) 과 동일. # cron HTTP key - matches the server config.php's hash_hmac('sha256','cron',API_KEY).
# 미리 계산해 SETUP.md 에 넣어, 운영자가 API_KEY 를 셸 명령줄(히스토리/프로세스 # Precomputed and embedded in SETUP.md so the operator never has to put API_KEY on a
# 목록에 노출)에 올리지 않아도 되게 한다. # shell command line (exposed in history / process list).
CRON_SECRET = hmac.new(API_KEY.encode(), b"cron", hashlib.sha256).hexdigest() CRON_SECRET = hmac.new(API_KEY.encode(), b"cron", hashlib.sha256).hexdigest()
# ── 기존 번들 제거 후 재생성 (.secrets 는 보존) ───────────────────────────── # -- remove existing bundles then regenerate (.secrets is preserved) ----------
for name, _, _ in BUNDLES: for name, _, _ in BUNDLES:
d = DEPLOY / name d = DEPLOY / name
if d.exists(): if d.exists():
shutil.rmtree(d) shutil.rmtree(d)
# ── Pi 번들 ────────────────────────────────────────────────────────────────── # -- Pi bundles ---------------------------------------------------------------
for name, src, kind in BUNDLES: for name, src, kind in BUNDLES:
if kind == "server": if kind == "server":
continue continue
@ -158,161 +160,161 @@ for name, src, kind in BUNDLES:
sensors_block = "" sensors_block = ""
if meta["sensors"]: if meta["sensors"]:
sensors_block = ( sensors_block = (
"\n# 이 기기 전용 센서 목록 — config.py 기본값(sensor_id 1) 오버라이드\n" "\n# Sensor list for this device - overrides the config.py default (sensor_id 1)\n"
f"SENSORS = {meta['sensors']}\n" f"SENSORS = {meta['sensors']}\n"
) )
write_lf(dst / "config_local.py", f'''# ============================================================================= write_lf(dst / "config_local.py", f'''# =============================================================================
# config_local.py — {meta["device_id"]} 운영값 (자동 생성, 커밋 금지) # config_local.py - operational values for {meta["device_id"]} (auto-generated, do not commit)
# ============================================================================= # =============================================================================
API_URL = "{API_URL}" API_URL = "{API_URL}"
{'PHOTO_UPLOAD_URL = "' + PHOTO_UPLOAD_URL + '"' if kind == "leak" else '# (SHT30 모듈은 사진 업로드 없음)'} {'PHOTO_UPLOAD_URL = "' + PHOTO_UPLOAD_URL + '"' if kind == "leak" else '# (the SHT30 module has no photo upload)'}
API_KEY = "{API_KEY}" API_KEY = "{API_KEY}"
DEVICE_ID = "{meta["device_id"]}" DEVICE_ID = "{meta["device_id"]}"
DEVICE_LOCATION = "{meta["location"]}" DEVICE_LOCATION = "{meta["location"]}"
{sensors_block}''') {sensors_block}''')
# ── 원샷 설치 래퍼: 설치 → 서비스 등록 → 검증 → 하드닝 ────────────── # -- one-shot install wrapper: install -> register service -> verify -> harden --
# 검증(test_connection.py)이 실패하면 set -e 로 하드닝 전에 멈춘다 → # If verification (test_connection.py) fails, set -e stops before hardening ->
# SSH 가 살아있어 디버깅 가능. 성공했을 때만 하드닝(SSH 차단)까지 간다. # SSH stays alive for debugging. Hardening (SSH lockdown) only runs on success.
write_lf(dst / "setup.sh", f'''#!/usr/bin/env bash write_lf(dst / "setup.sh", f'''#!/usr/bin/env bash
# ============================================================================= # =============================================================================
# setup.sh — {meta["device_id"]} 원샷 설치 (설치→서비스→검증→하드닝) # setup.sh - one-shot install for {meta["device_id"]} (install->service->verify->harden)
# pi 사용자로 실행: bash setup.sh (sudo 로 실행하지 말 것 — 내부에서 sudo) # Run as the pi user: bash setup.sh (do NOT run with sudo - sudo is used internally)
# 검증 실패 시 하드닝 전에 중단(set -e) → SSH 유지되어 디버깅 가능. # On verification failure it stops before hardening (set -e) -> SSH stays up for debugging.
# ============================================================================= # =============================================================================
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
if [ "$(id -u)" -eq 0 ]; then if [ "$(id -u)" -eq 0 ]; then
echo "[ERROR] sudo 없이 pi 사용자로 실행하세요: bash setup.sh" >&2 echo "[ERROR] Run as the pi user without sudo: bash setup.sh" >&2
exit 1 exit 1
fi fi
echo "== [1/4] 패키지·venv 설치 ==" echo "== [1/4] Install packages + venv =="
chmod +x install.sh && ./install.sh chmod +x install.sh && ./install.sh
echo "== [2/4] systemd 서비스 등록 ==" echo "== [2/4] Register systemd service =="
sudo cp systemd/{meta["service"]}.service /etc/systemd/system/ sudo cp systemd/{meta["service"]}.service /etc/systemd/system/
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable --now {meta["service"]} sudo systemctl enable --now {meta["service"]}
echo "== [3/4] 서버 연결 검증 (실패 시 여기서 중단 — SSH 유지됨) ==" echo "== [3/4] Server connectivity check (stops here on failure - SSH stays up) =="
# shellcheck disable=SC1091 # shellcheck disable=SC1091
source venv/bin/activate source venv/bin/activate
python3 test_connection.py python3 test_connection.py
echo "== [4/4] 하드닝 (마지막 — SSH 가 차단된다!) ==" echo "== [4/4] Hardening (last - SSH gets locked down!) =="
sudo bash harden_pi.sh sudo bash harden_pi.sh
echo "" echo ""
echo "설치·검증·하드닝 완료. 마무리 리부팅 권장: sudo reboot" echo "Install, verify and hardening complete. A final reboot is recommended: sudo reboot"
''') ''')
write_lf(dst / "SETUP.md", f'''# {name} 설치 — {meta["device_id"]} write_lf(dst / "SETUP.md", f'''# {name} install - {meta["device_id"]}
사전: Raspberry Pi OS **Bookworm 64-bit**, 사용자 **pi**, SSH 활성(Imager 설정). Prerequisites: Raspberry Pi OS **Bookworm 64-bit**, user **pi**, SSH enabled (set in Imager).
`config_local.py` 는 이미 채워져 있음(API_KEY/URL/기기 ID). 설치 장소가 바뀌면 DEVICE_LOCATION 만 수정. `config_local.py` is already filled in (API_KEY/URL/device ID). If the install location changes, edit only DEVICE_LOCATION.
📐 **배선·핀맵:** 같은 폴더의 `WIRING.html` 을 브라우저로 열어 확인(자체 완결형 — 오프라인 열람 가능). Wiring / pinout: open `WIRING.html` in the same folder in a browser (self-contained - viewable offline).
## 원샷 설치 (권장) ## One-shot install (recommended)
```bash ```bash
# 1. 이 폴더를 Pi 로 복사 # 1. Copy this folder to the Pi
scp -r {name} pi@<PI_IP>:/home/pi/{src} scp -r {name} pi@<PI_IP>:/home/pi/{src}
# 2. 한 번에: 설치 → 서비스 등록 → 검증 → 하드닝 # 2. All at once: install -> register service -> verify -> harden
cd /home/pi/{src} && bash setup.sh cd /home/pi/{src} && bash setup.sh
# (sudo 붙이지 말 것 — 내부에서 sudo 사용) # (do NOT prefix with sudo - sudo is used internally)
``` ```
`setup.sh` 는 4단계를 자동 수행하고, **검증(3단계)이 실패하면 하드닝 전에 멈춘다** `setup.sh` runs 4 stages automatically and **stops before hardening if the verify (stage 3) fails**
(SSH 가 살아있어 원인을 볼 수 있음). 검증이 통과해야만 하드닝까지 진행된다. (SSH stays alive so you can see the cause). Hardening runs only after verification passes.
⚠️ 4단계 하드닝은 **SSH 를 완전히 끈다.** SSH 로 접속해 실행 중이면 완료 직후 Warning: stage 4 hardening **fully disables SSH.** If you are running over SSH, the session
세션이 끊기고 재접속 불가 — 이후 유지보수는 현장 모니터+키보드. 마지막에 `sudo reboot`. drops right after completion and cannot reconnect - further maintenance needs an on-site monitor + keyboard. Finish with `sudo reboot`.
## 수동(단계별) — 문제 발생 시 ## Manual (step by step) - if something goes wrong
```bash ```bash
cd /home/pi/{src} cd /home/pi/{src}
chmod +x install.sh && ./install.sh # 1. 설치 chmod +x install.sh && ./install.sh # 1. install
sudo cp systemd/{meta["service"]}.service /etc/systemd/system/ sudo cp systemd/{meta["service"]}.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now {meta["service"]} # 2. 서비스 sudo systemctl daemon-reload && sudo systemctl enable --now {meta["service"]} # 2. service
source venv/bin/activate && python3 test_connection.py # 3. 검증(서명 200) source venv/bin/activate && python3 test_connection.py # 3. verify (signed 200)
sudo bash harden_pi.sh # 4. 하드닝(SSH 차단) sudo bash harden_pi.sh # 4. harden (SSH lockdown)
``` ```
**하드닝 정책**(`harden_pi.sh`): SSH 완전 비활성 · 인바운드 전부 차단(nftables) · **Hardening policy** (`harden_pi.sh`): fully disable SSH, block all inbound (nftables),
불필요 서비스 제거(avahi/bluetooth/cups 등) · 자동 보안 패치 · `config_local.py` 권한 600. remove unneeded services (avahi/bluetooth/cups etc.), automatic security patches, `config_local.py` mode 600.
이 시스템은 outbound 전용이라 인바운드가 필요 없음. 재활성이 필요하면 SD boot 파티션에 This system is outbound-only, so no inbound is needed. To re-enable, create an empty
빈 `ssh` 파일 생성(물리 접근 필요). `ssh` file on the SD boot partition (physical access required).
''') ''')
# ── 서버 번들 ──────────────────────────────────────────────────────────────── # -- server bundle ------------------------------------------------------------
server = DEPLOY / "01_server_cafe24" server = DEPLOY / "01_server_cafe24"
copy_tree(ROOT / "php", server / WEB_DIR) copy_tree(ROOT / "php", server / WEB_DIR)
# 예시 파일은 혼동 방지를 위해 번들에서 제거 (완성본 config.local.php 를 쓴다) # Remove the example file from the bundle to avoid confusion (the finished config.local.php is used)
(server / WEB_DIR / "config.local.example.php").unlink(missing_ok=True) (server / WEB_DIR / "config.local.example.php").unlink(missing_ok=True)
# var/ 는 런타임 상태(접속 IP·감사로그·하트비트·사진) — 빌드 머신의 로컬 파일이 # var/ is runtime state (client IPs, audit log, heartbeat, photos) - empty its contents and
# 번들에 유입되지 않도록 내용물을 비우고 빈 스켈레톤만 만든다. # create only an empty skeleton so the build machine's local files do not leak into the bundle.
var_dir = server / WEB_DIR / "var" var_dir = server / WEB_DIR / "var"
if var_dir.exists(): if var_dir.exists():
shutil.rmtree(var_dir) shutil.rmtree(var_dir)
var_dir.mkdir() var_dir.mkdir()
write_lf(var_dir / "index.html", "") # 디렉터리 목록 가드 write_lf(var_dir / "index.html", "") # directory listing guard
# SQL 은 단일 파일만 — schema_sht30.sql 이 신규 설치 완전 스키마(누수 요소 포함). # SQL is a single file only - schema_sht30.sql is the complete new-install schema (includes leak elements).
# migration_*.sql 은 기존 서버 레트로핏용이라 신규 설치 번들에서 제외한다. # migration_*.sql is for retrofitting an existing server, so it is excluded from the new-install bundle.
(server / "sql").mkdir(parents=True) (server / "sql").mkdir(parents=True)
schema_src = (ROOT / "sql" / "schema_sht30.sql").read_text(encoding="utf-8") schema_src = (ROOT / "sql" / "schema_sht30.sql").read_text(encoding="utf-8")
write_lf(server / "sql" / "schema.sql", write_lf(server / "sql" / "schema.sql",
"-- schema.sql — 신규 설치용 단일 스키마 (원본: sql/schema_sht30.sql)\n" "-- schema.sql - single new-install schema (source: sql/schema_sht30.sql)\n"
"-- 누수(leak) + 온습도(SHT30) 요소 전부 포함 — 이 파일 하나만 1회 실행하면 됨.\n" "-- Includes both leak and temperature/humidity (SHT30) elements - run this one file once.\n"
"-- 이 파일 하나로 설치 완결 (테이블 전체 leak_ 접두사 — 같은 DB 의 다른 테이블과 격리).\n\n" "-- This single file completes the install (all tables leak_ prefixed - isolated from other tables in the same DB).\n\n"
+ schema_src) + schema_src)
write_lf(server / WEB_DIR / "config.local.php", f'''<?php write_lf(server / WEB_DIR / "config.local.php", f'''<?php
// config.local.php — 자동 생성 운영 설정 (커밋/공유 금지) // config.local.php - auto-generated operational settings (do not commit/share)
// [CHANGE_ME] 3곳만 채우면 운영 가능: DB 계정, SMS 계정, SMS 수신자. // [CHANGE_ME] Fill in just 3 areas to go live: DB account, SMS account, SMS recipients.
return [ return [
// ── cafe24 MySQL (호스팅 패널 > DB 정보) ── [CHANGE_ME] // -- cafe24 MySQL (hosting panel > DB info) -- [CHANGE_ME]
'DB_HOST' => 'localhost', 'DB_HOST' => 'localhost',
'DB_PORT' => 3306, 'DB_PORT' => 3306,
'DB_NAME' => 'CHANGE_ME', 'DB_NAME' => 'CHANGE_ME',
'DB_USER' => 'CHANGE_ME', 'DB_USER' => 'CHANGE_ME',
'DB_PASS' => 'CHANGE_ME', 'DB_PASS' => 'CHANGE_ME',
// ── 공유 비밀키 (Pi 3대와 동일 값 자동 주입됨 — 수정 불필요) ── // -- shared secret key (auto-injected identically into all 3 Pis - no edit needed) --
'API_KEY' => '{API_KEY}', 'API_KEY' => '{API_KEY}',
'ALLOW_LEGACY_BODY_SIGNATURE' => false, 'ALLOW_LEGACY_BODY_SIGNATURE' => false,
// ── cafe24 SMS (사전 등록된 발신번호 필수) ── [CHANGE_ME] // -- cafe24 SMS (a pre-registered sender number is required) -- [CHANGE_ME]
'SMS_USER_ID' => 'CHANGE_ME', 'SMS_USER_ID' => 'CHANGE_ME',
'SMS_SECURE' => 'CHANGE_ME', 'SMS_SECURE' => 'CHANGE_ME',
'SMS_SENDER' => '01000000000', 'SMS_SENDER' => '01000000000',
'SMS_RECIPIENTS' => [ 'SMS_RECIPIENTS' => [
'01000000000', // [CHANGE_ME] 경보 수신 번호 '01000000000', // [CHANGE_ME] alert recipient number
], ],
// ── 관리자 로그인 (비밀번호는 deploy/.secrets/admin_password.txt) ── // -- admin login (password is in deploy/.secrets/admin_password.txt) --
'ADMIN_USER' => 'admin', 'ADMIN_USER' => 'admin',
'ADMIN_PASSWORD_HASH' => '{ADMIN_HASH}', 'ADMIN_PASSWORD_HASH' => '{ADMIN_HASH}',
// TOTP 는 서버에서 1회 등록: setup_mfa.php?token=<아래 토큰> 접속 → 앱 등록 // Enroll TOTP once on the server: open setup_mfa.php?token=<token below> -> register in app
// → 표시된 시크릿을 ADMIN_TOTP_SECRET 에 넣고 → MFA_SETUP_TOKEN 은 '' 로 비울 것. // -> put the shown secret into ADMIN_TOTP_SECRET -> clear MFA_SETUP_TOKEN to ''.
'ADMIN_TOTP_SECRET' => '', 'ADMIN_TOTP_SECRET' => '',
'MFA_SETUP_TOKEN' => '{MFA_SETUP_TOKEN}', 'MFA_SETUP_TOKEN' => '{MFA_SETUP_TOKEN}',
// ── 누수 모듈 sensor_id (1번 지점 + 3번 신규 지점) ── // -- leak module sensor_id (Site 1 + new Site 3) --
'LEAK_SENSOR_IDS' => [1, 3], 'LEAK_SENSOR_IDS' => [1, 3],
// ── 온습도 임계 (운영 권장값) ── // -- temperature/humidity thresholds (recommended operational values) --
'METRIC_TEMP_HIGH_C' => 27, 'METRIC_TEMP_HIGH_C' => 27,
'METRIC_TEMP_LOW_C' => 10, 'METRIC_TEMP_LOW_C' => 10,
'METRIC_RH_HIGH' => 60, 'METRIC_RH_HIGH' => 60,
'METRIC_RH_LOW' => -1, // 저습 경보 비활성 'METRIC_RH_LOW' => -1, // low-humidity alert disabled
'METRIC_DEWPOINT_MARGIN_C' => 0, // 결로 경보 비활성 'METRIC_DEWPOINT_MARGIN_C' => 0, // condensation alert disabled
'METRIC_ALERT_COOLDOWN_SEC' => 1800, 'METRIC_ALERT_COOLDOWN_SEC' => 1800,
'SMS_LOG_RETENTION_DAYS' => 365, 'SMS_LOG_RETENTION_DAYS' => 365,
@ -322,62 +324,62 @@ return [
]; ];
''') ''')
write_lf(server / "SETUP.md", f'''# 01_server_cafe24 설치 write_lf(server / "SETUP.md", f'''# 01_server_cafe24 install
1. **DB 스키마**: cafe24 phpMyAdmin 에서 `sql/schema.sql` **하나만 1회 실행** → 테이블 7개 확인 1. **DB schema**: in cafe24 phpMyAdmin, run `sql/schema.sql` **once, that one file only** -> confirm 7 tables
(leak_sensor_log, leak_sensor_status, leak_sensor_metric, leak_sms_log, leak_ingest_nonce, leak_incident, leak_photo). (leak_sensor_log, leak_sensor_status, leak_sensor_metric, leak_sms_log, leak_ingest_nonce, leak_incident, leak_photo).
2. **업로드**: `{WEB_DIR}/` 폴더 전체를 `public_html/` 아래로 업로드. 2. **Upload**: upload the entire `{WEB_DIR}/` folder under `public_html/`.
3. **config.local.php 의 [CHANGE_ME] 3곳** 채우기: DB 계정 / SMS 계정 / SMS 수신번호. 3. **Fill the 3 [CHANGE_ME] areas in config.local.php**: DB account / SMS account / SMS recipient number.
4. **HTTPS 강제**: cafe24 호스팅 패널에서 설정 (필수 — 미설정 시 로그인 불가). 4. **Force HTTPS**: set in the cafe24 hosting panel (required - login fails without it).
5. **관리자 로그인**: `{BASE_URL}/login.php` 5. **Admin login**: `{BASE_URL}/login.php`
- ID `admin` / 비밀번호는 `deploy/.secrets/admin_password.txt` - ID `admin` / password is in `deploy/.secrets/admin_password.txt`
- 최초 1회 TOTP 등록: `{BASE_URL}/setup_mfa.php?token=<MFA_SETUP_TOKEN 값>` → - First-time TOTP enrollment: `{BASE_URL}/setup_mfa.php?token=<MFA_SETUP_TOKEN value>` ->
앱 등록 → 표시된 시크릿을 `ADMIN_TOTP_SECRET` 에 → `MFA_SETUP_TOKEN` 비우기. register in app -> put the shown secret into `ADMIN_TOTP_SECRET` -> clear `MFA_SETUP_TOKEN`.
6. **cron (1분)**: cafe24 크론탭에 `php /home/<계정>/public_html/{WEB_DIR}/cron_heartbeat.php` 6. **cron (1 min)**: in the cafe24 crontab, `php /home/<account>/public_html/{WEB_DIR}/cron_heartbeat.php`
(HTTP 방식이면 아래 URL 그대로 — CRON_SECRET 은 이미 계산되어 있음: (for the HTTP method, use the URL below as-is - CRON_SECRET is already computed:
`{BASE_URL}/cron_heartbeat.php?key={CRON_SECRET}`) `{BASE_URL}/cron_heartbeat.php?key={CRON_SECRET}`)
7. **검증**: `setup_wizard.php` 전 항목 녹색 + 테스트 SMS 발송. 7. **Verify**: all items in `setup_wizard.php` green + send a test SMS.
''') ''')
# ── 최상위 README + gitignore 방어 ────────────────────────────────────────── # -- top-level README + gitignore guard ---------------------------------------
write_lf(DEPLOY / "README.md", f'''# deploy/ — 기기별 턴키 배포 번들 (자동 생성: tools/make_deploy_bundles.py) write_lf(DEPLOY / "README.md", f'''# deploy/ - per-device turnkey deploy bundles (auto-generated: tools/make_deploy_bundles.py)
⚠️ **API_KEY·관리자 비밀번호 포함 — 절대 커밋/공유 금지.** 비밀값: `.secrets/` Warning: **contains API_KEY and admin password - never commit/share.** Secrets: `.secrets/`
⚠️ **`.secrets/ 백업 필수`** — `git clean -fdx`·재클론·폴더 삭제 시 소멸. 재생성하면 Warning: **`.secrets/` must be backed up** - it is lost on `git clean -fdx`, re-clone, or folder deletion. If regenerated,
새 API_KEY 가 발급되어 **이미 배포된 서버·Pi 전부와 불일치**(전 노드 수집 거부)하게 됨. a new API_KEY is issued and **mismatches every already-deployed server & Pi** (all nodes reject ingestion).
배포 직후 `.secrets/` 를 오프라인 저장소(USB 등)에 복사할 것. Right after building, copy `.secrets/` to offline storage (USB etc.).
| 폴더 | 대상 | sensor_id | 남은 수동 설정 | | folder | target | sensor_id | remaining manual setup |
|---|---|---|---| |---|---|---|---|
| 01_server_cafe24 | cafe24 웹호스팅 | — | DB 계정, SMS 계정/번호, TOTP 1회 | | 01_server_cafe24 | cafe24 web hosting | - | DB account, SMS account/number, one-time TOTP |
| 02_pi_a_leak_id1 | Pi-A 누수+카메라 | 1 | 없음 | | 02_pi_a_leak_id1 | Pi-A leak + camera | 1 | none |
| 03_pi_b_sht30_id2 | Pi-B SHT30 온습도 | 2 | 없음 | | 03_pi_b_sht30_id2 | Pi-B SHT30 temp/humidity | 2 | none |
| 04_pi_c_leak_id3 | Pi-C 누수+카메라(신규 지점) | 3 | 없음 | | 04_pi_c_leak_id3 | Pi-C leak + camera (new site) | 3 | none |
## 순서 ## Order
1. `01_server_cafe24/SETUP.md` — 서버 먼저 (Pi 검증이 서버에 의존) 1. `01_server_cafe24/SETUP.md` - server first (Pi verification depends on the server)
2. Pi 3대 공통 OS: Raspberry Pi Imager → **Bookworm 64-bit**, 사용자 `pi`, SSH 켬, WiFi/유선 설정 2. Common OS for all 3 Pis: Raspberry Pi Imager -> **Bookworm 64-bit**, user `pi`, SSH on, WiFi/wired configured
3. 각 Pi 폴더의 `SETUP.md` (복사 → install.sh → systemctl, 병렬 가능) 3. Each Pi folder's `SETUP.md` (copy -> install.sh -> systemctl; can be done in parallel)
## 참고 ## Notes
- 서버 주소: `{BASE_URL}` — 도메인은 `tools/make_deploy_bundles.py` 상단 DOMAIN, 경로 폴더명은 `.secrets/web_dir.txt` 수정 후 재실행. - Server address: `{BASE_URL}` - the domain is DOMAIN at the top of `tools/make_deploy_bundles.py`; the path folder name is in `.secrets/web_dir.txt`, edit then re-run.
- 오프라인(폐쇄망) 설치가 필요하면: 인터넷 되는 Pi 1대에서 각 모듈 `./vendor.sh` 실행 후 - For offline (air-gapped) install: on one internet-connected Pi run each module's `./vendor.sh`, then
생성된 `wheelhouse/`+`debs/` 를 다른 Pi 번들 폴더에 복사 (install.sh 가 자동 오프라인 전환). copy the generated `wheelhouse/`+`debs/` into the other Pi bundle folders (install.sh switches to offline automatically).
- 전체 배포 계획: `docs/DEPLOYMENT_PLAN_RPI4x3.md` - Full deployment plan: `docs/DEPLOYMENT_PLAN_RPI4x3.md`
''') ''')
# (deploy/.gitignore 는 비밀값 생성 전에 이미 작성됨 — 순서 보장) # (deploy/.gitignore was already written before secrets were generated - order guaranteed)
# ── LF 정규화 ──────────────────────────────────────────────────────────────── # -- LF normalization ---------------------------------------------------------
normalize_lf(DEPLOY) normalize_lf(DEPLOY)
print("deploy/ 생성 완료") print("deploy/ generation complete")
# cp949 콘솔(Windows 기본)에서도 죽지 않도록 em-dash 등 비호환 문자 금지. # Avoid characters incompatible with a cp949 console (Windows default) - no em-dashes etc.
print("[주의] deploy/.secrets/ 를 오프라인 저장소에 백업할 것 (git clean -fdx 로 소멸).") print("[NOTE] Back up deploy/.secrets/ to offline storage (lost on git clean -fdx).")
print(" 재생성 시 새 API_KEY 가 배포된 전 노드와 불일치하게 됨.") print(" Regenerating issues a new API_KEY that mismatches all already-deployed nodes.")
print(f" API_KEY : {API_KEY[:8]}... (deploy/.secrets/api_key.txt)") print(f" API_KEY : {API_KEY[:8]}... (deploy/.secrets/api_key.txt)")
print(f" admin password : deploy/.secrets/admin_password.txt") print(f" admin password : deploy/.secrets/admin_password.txt")
print(f" 서버 URL : {BASE_URL}") print(f" server URL : {BASE_URL}")
for name, _, _ in BUNDLES: for name, _, _ in BUNDLES:
print(f" - deploy/{name}") print(f" - deploy/{name}")

View file

@ -1,48 +1,50 @@
#!/bin/bash #!/bin/bash
# ============================================================================= # =============================================================================
# set_network.sh — 센서 노드 유선 고정 IP 설정 (하드닝 후 현장 콘솔용) # set_network.sh — sensor node wired static IP setup (for on-site console after hardening)
# #
# 배경: harden_pi.sh 실행 후 SSH·콘솔 자동로그인이 꺼진다. 그래서 이 스크립트는 # Background: after harden_pi.sh runs, SSH and console auto-login are disabled. So this
# "현장 물리 콘솔(모니터+키보드)에서 pi 로그인 후" 실행하는 것을 전제로 한다. # script assumes you run it "after logging in as pi at the on-site physical console
# Raspberry Pi OS Bookworm 기본 네트워크 관리자는 NetworkManager(nmcli)다. # (monitor + keyboard)".
# The default network manager on Raspberry Pi OS Bookworm is NetworkManager (nmcli).
# #
# 하드닝 방화벽과의 관계: nftables 는 인바운드만 drop, 아웃바운드는 accept + # Relationship to the hardening firewall: nftables drops inbound only; outbound is accept +
# established/related 허용. 고정 IP 로 바꿔도 서버 POST(아웃바운드)는 그대로 # established/related allowed. Even after switching to a static IP, the server POST
# 나가며 인바운드 규칙 추가가 필요없다. 이 스크립트는 방화벽을 건드리지 않는다. # (outbound) still goes out and no inbound rule needs to be added. This script does not
# touch the firewall.
# #
# 폐쇄망(air-gapped): 네트워크 미사용. nmcli 로컬 설정만 수행한다. # Air-gapped: no network use. Performs local nmcli configuration only.
# #
# 사용: # Usage:
# 대화형: sudo bash set_network.sh # Interactive: sudo bash set_network.sh
# 비대화형: sudo bash set_network.sh --ip 192.168.0.50/24 --gw 192.168.0.1 \ # Non-interactive: sudo bash set_network.sh --ip 192.168.0.50/24 --gw 192.168.0.1 \
# --dns "8.8.8.8 1.1.1.1" --yes # --dns "8.8.8.8 1.1.1.1" --yes
# 현재 확인: sudo bash set_network.sh --show # Show current: sudo bash set_network.sh --show
# DHCP 복귀: sudo bash set_network.sh --dhcp --yes # Revert to DHCP: sudo bash set_network.sh --dhcp --yes
# #
# 옵션: # Options:
# --ip A.B.C.D/PREFIX 고정 IP + 서브넷(CIDR). 예: 192.168.0.50/24 (=255.255.255.0) # --ip A.B.C.D/PREFIX static IP + subnet (CIDR). e.g. 192.168.0.50/24 (=255.255.255.0)
# --gw A.B.C.D 게이트웨이 # --gw A.B.C.D gateway
# --dns "A.B.C.D ..." DNS (공백 구분, 여러 개 가능). 서버가 도메인이면 필수. # --dns "A.B.C.D ..." DNS (space-separated, multiple allowed). Required if the server is a domain.
# --iface NAME 인터페이스 (기본: 자동 감지, 보통 eth0) # --iface NAME interface (default: auto-detect, usually eth0)
# --con NAME NM 연결 이름 (기본: 유선 연결 자동 감지) # --con NAME NM connection name (default: auto-detect wired connection)
# --dhcp 수동 IP 해제 → 자동(DHCP) 복귀 # --dhcp release manual IP -> revert to automatic (DHCP)
# --show 현재 설정만 출력하고 종료 # --show print current settings only and exit
# --yes | -y 확인 프롬프트 건너뜀(비대화형) # --yes | -y skip the confirmation prompt (non-interactive)
# #
# 서브넷 = CIDR 접미사: /24=255.255.255.0 /16=255.255.0.0 /25=255.255.255.128 # Subnet = CIDR suffix: /24=255.255.255.0 /16=255.255.0.0 /25=255.255.255.128
# ============================================================================= # =============================================================================
set -euo pipefail set -euo pipefail
die(){ echo "[ERROR] $*" >&2; exit 1; } die(){ echo "[ERROR] $*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "sudo 로 실행하세요: sudo bash set_network.sh" [ "$(id -u)" -eq 0 ] || die "Run with sudo: sudo bash set_network.sh"
# 스크립트 폴더 기준으로 test_connection.py 등을 찾도록 cwd 이동. # Move cwd to the script folder so it can find test_connection.py etc.
cd "$(dirname "$0")" 2>/dev/null || true cd "$(dirname "$0")" 2>/dev/null || true
command -v nmcli >/dev/null 2>&1 || die \ command -v nmcli >/dev/null 2>&1 || die \
"nmcli(NetworkManager) 없음. Bookworm 기본이 아니면 네트워크 관리자를 확인하세요(dhcpcd/systemd-networkd)." "nmcli(NetworkManager) not found. If this is not the Bookworm default, check your network manager (dhcpcd/systemd-networkd)."
# ---- 인자 파싱 ------------------------------------------------------------- # ---- argument parsing ------------------------------------------------------
IPCIDR="" GW="" DNS="" IFACE="" CON="" ASSUME_YES=0 MODE="static" IPCIDR="" GW="" DNS="" IFACE="" CON="" ASSUME_YES=0 MODE="static"
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
@ -55,11 +57,11 @@ while [ $# -gt 0 ]; do
--show) MODE="show"; shift;; --show) MODE="show"; shift;;
--yes|-y) ASSUME_YES=1; shift;; --yes|-y) ASSUME_YES=1; shift;;
-h|--help) sed -n '2,40p' "$0"; exit 0;; -h|--help) sed -n '2,40p' "$0"; exit 0;;
*) die "알 수 없는 옵션: $1 (도움말: --help)";; *) die "Unknown option: $1 (help: --help)";;
esac esac
done done
# ---- 검증 헬퍼 ------------------------------------------------------------- # ---- validation helpers ----------------------------------------------------
valid_ipv4(){ valid_ipv4(){
local ip="$1" o local ip="$1" o
[[ "$ip" =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]] || return 1 [[ "$ip" =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]] || return 1
@ -73,109 +75,109 @@ valid_cidr(){
[[ "$pfx" =~ ^[0-9]+$ ]] && [ "$pfx" -ge 0 ] && [ "$pfx" -le 32 ] [[ "$pfx" =~ ^[0-9]+$ ]] && [ "$pfx" -ge 0 ] && [ "$pfx" -le 32 ]
} }
ip2int(){ local a b c d; IFS=. read -r a b c d <<<"$1"; echo $(( (a<<24)|(b<<16)|(c<<8)|d )); } ip2int(){ local a b c d; IFS=. read -r a b c d <<<"$1"; echo $(( (a<<24)|(b<<16)|(c<<8)|d )); }
same_subnet(){ # $1=ip $2=gw $3=prefix — 게이트웨이가 IP 와 같은 서브넷인지 same_subnet(){ # $1=ip $2=gw $3=prefix — whether the gateway is on the same subnet as the IP
local m=$(( (0xFFFFFFFF << (32-$3)) & 0xFFFFFFFF )) local m=$(( (0xFFFFFFFF << (32-$3)) & 0xFFFFFFFF ))
[ $(( $(ip2int "$1") & m )) -eq $(( $(ip2int "$2") & m )) ] [ $(( $(ip2int "$1") & m )) -eq $(( $(ip2int "$2") & m )) ]
} }
# ---- 유선 연결/인터페이스 자동 감지 --------------------------------------- # ---- auto-detect wired connection/interface --------------------------------
if [ -z "$IFACE" ]; then if [ -z "$IFACE" ]; then
IFACE="$(nmcli -t -f DEVICE,TYPE dev status 2>/dev/null | awk -F: '$2=="ethernet"{print $1; exit}')" IFACE="$(nmcli -t -f DEVICE,TYPE dev status 2>/dev/null | awk -F: '$2=="ethernet"{print $1; exit}')"
IFACE="${IFACE:-eth0}" IFACE="${IFACE:-eth0}"
fi fi
if [ -z "$CON" ]; then if [ -z "$CON" ]; then
# 해당 인터페이스에 물린 활성 연결 우선 → 없으면 첫 유선 연결 → 최후 기본 이름. # Prefer the active connection bound to this interface -> else the first wired connection -> finally the default name.
CON="$(nmcli -t -f NAME,DEVICE con show --active 2>/dev/null | awk -F: -v d="$IFACE" '$2==d{print $1; exit}')" CON="$(nmcli -t -f NAME,DEVICE con show --active 2>/dev/null | awk -F: -v d="$IFACE" '$2==d{print $1; exit}')"
[ -n "$CON" ] || CON="$(nmcli -t -f NAME,TYPE con show 2>/dev/null | awk -F: '$2=="802-3-ethernet"{print $1; exit}')" [ -n "$CON" ] || CON="$(nmcli -t -f NAME,TYPE con show 2>/dev/null | awk -F: '$2=="802-3-ethernet"{print $1; exit}')"
[ -n "$CON" ] || CON="Wired connection 1" [ -n "$CON" ] || CON="Wired connection 1"
fi fi
show_current(){ show_current(){
echo "== 인터페이스: $IFACE / 연결: $CON ==" echo "== Interface: $IFACE / Connection: $CON =="
nmcli -f ipv4.method,ipv4.addresses,ipv4.gateway,ipv4.dns con show "$CON" 2>/dev/null \ nmcli -f ipv4.method,ipv4.addresses,ipv4.gateway,ipv4.dns con show "$CON" 2>/dev/null \
|| echo " (연결 '$CON' 없음)" || echo " (connection '$CON' not found)"
echo "-- 실제 IP:"; ip -4 addr show "$IFACE" 2>/dev/null | awk '/inet /{print " "$2}' || true echo "-- Actual IP:"; ip -4 addr show "$IFACE" 2>/dev/null | awk '/inet /{print " "$2}' || true
echo "-- 라우팅:"; ip route 2>/dev/null | sed 's/^/ /' || true echo "-- Routing:"; ip route 2>/dev/null | sed 's/^/ /' || true
} }
# ---- --show : 현재 상태만 출력 -------------------------------------------- # ---- --show : print current state only -------------------------------------
if [ "$MODE" = show ]; then show_current; exit 0; fi if [ "$MODE" = show ]; then show_current; exit 0; fi
# ---- --dhcp : 자동 복귀 ---------------------------------------------------- # ---- --dhcp : revert to automatic ------------------------------------------
if [ "$MODE" = dhcp ]; then if [ "$MODE" = dhcp ]; then
echo "연결 '$CON' → 자동(DHCP) 복귀." echo "Connection '$CON' -> revert to automatic (DHCP)."
[ "$ASSUME_YES" = 1 ] || { read -r -p "진행? [y/N] " a; [[ "$a" =~ ^[Yy]$ ]] || die "취소됨"; } [ "$ASSUME_YES" = 1 ] || { read -r -p "Proceed? [y/N] " a; [[ "$a" =~ ^[Yy]$ ]] || die "Cancelled"; }
nmcli con mod "$CON" ipv4.method auto ipv4.addresses "" ipv4.gateway "" ipv4.dns "" ipv4.ignore-auto-dns no nmcli con mod "$CON" ipv4.method auto ipv4.addresses "" ipv4.gateway "" ipv4.dns "" ipv4.ignore-auto-dns no
nmcli con down "$CON" >/dev/null 2>&1 || true nmcli con down "$CON" >/dev/null 2>&1 || true
nmcli con up "$CON" || die "활성화 실패 — 물리 링크/스위치 확인." nmcli con up "$CON" || die "Activation failed — check physical link/switch."
echo "── 결과 ──"; show_current echo "── Result ──"; show_current
exit 0 exit 0
fi fi
# ---- static : 대화형 프롬프트(값 누락 + TTY 인 경우만) -------------------- # ---- static : interactive prompts (only when values are missing and on a TTY) ---
if [ -t 0 ]; then if [ -t 0 ]; then
[ -n "$IPCIDR" ] || read -r -p "고정 IP/서브넷 (예 192.168.0.50/24): " IPCIDR [ -n "$IPCIDR" ] || read -r -p "Static IP/subnet (e.g. 192.168.0.50/24): " IPCIDR
[ -n "$GW" ] || read -r -p "게이트웨이 (예 192.168.0.1): " GW [ -n "$GW" ] || read -r -p "Gateway (e.g. 192.168.0.1): " GW
[ -n "$DNS" ] || read -r -p "DNS (공백구분, 예 '8.8.8.8 1.1.1.1', 없으면 엔터): " DNS [ -n "$DNS" ] || read -r -p "DNS (space-separated, e.g. '8.8.8.8 1.1.1.1', press Enter for none): " DNS
fi fi
# ---- 값 검증 --------------------------------------------------------------- # ---- value validation ------------------------------------------------------
valid_cidr "$IPCIDR" || die "IP/서브넷 형식 오류: '$IPCIDR' (예: 192.168.0.50/24)" valid_cidr "$IPCIDR" || die "Invalid IP/subnet format: '$IPCIDR' (e.g. 192.168.0.50/24)"
valid_ipv4 "$GW" || die "게이트웨이 형식 오류: '$GW'" valid_ipv4 "$GW" || die "Invalid gateway format: '$GW'"
IPADDR="${IPCIDR%/*}"; PREFIX="${IPCIDR#*/}" IPADDR="${IPCIDR%/*}"; PREFIX="${IPCIDR#*/}"
same_subnet "$IPADDR" "$GW" "$PREFIX" \ same_subnet "$IPADDR" "$GW" "$PREFIX" \
|| die "게이트웨이 $GW 가 $IPCIDR 서브넷 밖입니다. IP·서브넷·게이트웨이 값을 확인하세요." || die "Gateway $GW is outside the $IPCIDR subnet. Check the IP/subnet/gateway values."
for d in $DNS; do valid_ipv4 "$d" || die "DNS 형식 오류: '$d'"; done for d in $DNS; do valid_ipv4 "$d" || die "Invalid DNS format: '$d'"; done
[ -n "$DNS" ] || echo "[WARN] DNS 미지정 — 서버 주소가 도메인이면 이름해석 실패. 서버가 IP면 무방." [ -n "$DNS" ] || echo "[WARN] No DNS specified — name resolution will fail if the server address is a domain. Fine if the server is an IP."
# ---- 요약 + 확인 ----------------------------------------------------------- # ---- summary + confirmation ------------------------------------------------
echo "───────────── 적용 예정 ─────────────" echo "───────────── To be applied ─────────────"
echo " 인터페이스 : $IFACE" echo " Interface : $IFACE"
echo " 연결 이름 : $CON" echo " Connection : $CON"
echo " IP/서브넷 : $IPCIDR" echo " IP/subnet : $IPCIDR"
echo " 게이트웨이 : $GW" echo " Gateway : $GW"
echo " DNS : ${DNS:-(없음)}" echo " DNS : ${DNS:-(none)}"
echo "─────────────────────────────────────" echo "─────────────────────────────────────"
[ "$ASSUME_YES" = 1 ] || { read -r -p "적용? [y/N] " a; [[ "$a" =~ ^[Yy]$ ]] || die "취소됨"; } [ "$ASSUME_YES" = 1 ] || { read -r -p "Apply? [y/N] " a; [[ "$a" =~ ^[Yy]$ ]] || die "Cancelled"; }
# ---- 이전 설정 백업(복구용) ------------------------------------------------ # ---- back up previous settings (for recovery) ------------------------------
BK="/root/net_backup_$(date +%Y%m%d_%H%M%S).txt" BK="/root/net_backup_$(date +%Y%m%d_%H%M%S).txt"
{ echo "# '$CON' 이전 설정 (복구 참고 — $(date))"; nmcli con show "$CON" 2>/dev/null | grep -E '^ipv4\.'; } \ { echo "# '$CON' previous settings (recovery reference — $(date))"; nmcli con show "$CON" 2>/dev/null | grep -E '^ipv4\.'; } \
> "$BK" 2>/dev/null || true > "$BK" 2>/dev/null || true
echo "[INFO] 이전 설정 백업: $BK" echo "[INFO] Previous settings backed up: $BK"
# ---- 적용 ------------------------------------------------------------------ # ---- apply -----------------------------------------------------------------
echo "[적용] nmcli 로 고정 IP 설정..." echo "[APPLY] Setting static IP via nmcli..."
nmcli con mod "$CON" \ nmcli con mod "$CON" \
ipv4.method manual \ ipv4.method manual \
ipv4.addresses "$IPCIDR" \ ipv4.addresses "$IPCIDR" \
ipv4.gateway "$GW" \ ipv4.gateway "$GW" \
ipv4.dns "$DNS" \ ipv4.dns "$DNS" \
ipv4.ignore-auto-dns yes ipv4.ignore-auto-dns yes
# 재적용(down 실패는 무시 — 최초 활성화 전일 수 있음) # Re-apply (ignore down failure — may be before the first activation)
nmcli con down "$CON" >/dev/null 2>&1 || true nmcli con down "$CON" >/dev/null 2>&1 || true
nmcli con up "$CON" \ nmcli con up "$CON" \
|| die "활성화 실패. 복구: 'sudo bash set_network.sh --dhcp --yes' 또는 백업($BK) 참고." || die "Activation failed. Recovery: 'sudo bash set_network.sh --dhcp --yes' or see backup ($BK)."
# ---- 검증 ------------------------------------------------------------------ # ---- verification ----------------------------------------------------------
echo "" echo ""
echo "───────────── 검증 ─────────────" echo "───────────── Verification ─────────────"
echo "-- IP:"; ip -4 addr show "$IFACE" 2>/dev/null | awk '/inet /{print " "$2}' || true echo "-- IP:"; ip -4 addr show "$IFACE" 2>/dev/null | awk '/inet /{print " "$2}' || true
echo "-- 라우팅:"; ip route 2>/dev/null | sed 's/^/ /' || true echo "-- Routing:"; ip route 2>/dev/null | sed 's/^/ /' || true
echo "-- 게이트웨이 ping:" echo "-- Gateway ping:"
if ping -c2 -W2 "$GW" >/dev/null 2>&1; then echo " OK ($GW 도달)"; else \ if ping -c2 -W2 "$GW" >/dev/null 2>&1; then echo " OK ($GW reachable)"; else \
echo " [WARN] $GW 무응답 — 배선/스위치/게이트웨이 값 확인"; fi echo " [WARN] $GW not responding — check wiring/switch/gateway value"; fi
# 서버 연결 최종 확인 (test_connection.py 가 있으면 실행) # Final server connectivity check (run test_connection.py if present)
for t in ./test_connection.py "$HOME/raspberry_pi4/test_connection.py" "$HOME/raspberry_pi4_sht30/test_connection.py"; do for t in ./test_connection.py "$HOME/raspberry_pi4/test_connection.py" "$HOME/raspberry_pi4_sht30/test_connection.py"; do
if [ -f "$t" ]; then if [ -f "$t" ]; then
echo "-- 서버 연결 테스트: $t" echo "-- Server connectivity test: $t"
( cd "$(dirname "$t")" && python3 "$(basename "$t")" ) \ ( cd "$(dirname "$t")" && python3 "$(basename "$t")" ) \
|| echo " [WARN] 서버 테스트 실패 — DNS/게이트웨이/서버주소 확인" || echo " [WARN] Server test failed — check DNS/gateway/server address"
break break
fi fi
done done
echo "" echo ""
echo "완료. 문제 시 복구: 백업($BK) 참고하거나 sudo bash set_network.sh --dhcp --yes (DHCP 복귀)." echo "Done. If there are problems, recover: see backup ($BK) or run sudo bash set_network.sh --dhcp --yes (revert to DHCP)."
echo "설정 확인: sudo bash set_network.sh --show" echo "Check settings: sudo bash set_network.sh --show"