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:
parent
3c8e6d6957
commit
a72ea18130
27 changed files with 1200 additions and 1182 deletions
8
raspberry_pi4/.gitignore
vendored
8
raspberry_pi4/.gitignore
vendored
|
|
@ -1,14 +1,14 @@
|
|||
# 운영·비밀값 (절대 커밋 금지)
|
||||
# Operational secrets (never commit)
|
||||
config_local.py
|
||||
|
||||
# Python venv / 캐시
|
||||
# Python venv / cache
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# 오프라인 설치 번들 (vendor.sh 산출물 — 용량이 큼, 전송은 별도)
|
||||
# Offline install bundle (vendor.sh output - large; transferred separately)
|
||||
wheelhouse/
|
||||
debs/
|
||||
|
||||
# 런타임 로그
|
||||
# Runtime logs
|
||||
*.log
|
||||
|
|
|
|||
|
|
@ -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 로 읽어,
|
||||
누수 발생 시 즉시 PHP API 서버에 보고(+SMS 발송 트리거)하고 **Pi Camera 로 현장 사진을
|
||||
촬영해 업로드**하는 모니터링 모듈입니다.
|
||||
A monitoring module that reads the e-con **5C-08 resistive leak detection sensor** via the
|
||||
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 전용**으로 이식한
|
||||
> 폴더입니다. GPIO(BCM) 번호 체계와 `rpicam`/`libcamera` 카메라 스택은 두 보드가 동일해
|
||||
> 센서·촬영 로직은 그대로이며, 차이는 **배선/전원/이더넷/카메라 커넥터**와 설치 환경뿐입니다.
|
||||
> This folder is a port of a module originally written for the **Raspberry Pi Zero 2 W**,
|
||||
> 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 인터럽트 누수 감지, 주기 보고, 누수 시 촬영+업로드 |
|
||||
| `config.py` | 비밀이 아닌 기본 설정(센서/임계/간격/카메라). `config_local` 에서 비밀값 로드 |
|
||||
| `config_local.example.py` | 운영·비밀값 템플릿 → `config_local.py` 로 복사해 사용(미커밋) |
|
||||
| `requirements.txt` | Python 의존성(핀 고정) |
|
||||
| `install.sh` | Pi 4 설치 스크립트(폐쇄망 오프라인 + 온라인 폴백) |
|
||||
| `vendor.sh` | 폐쇄망용 의존성 사전 다운로드(빌드 머신에서 실행) |
|
||||
| `test_connection.py` | 서버 연결/서명 점검 |
|
||||
| `systemd/leak-sensor.service` | 부팅 자동 실행 서비스 유닛 |
|
||||
| `leak_sensor.py` | Main — GPIO interrupt leak detection, periodic reporting, capture+upload on leak |
|
||||
| `config.py` | Non-secret default settings (sensor/threshold/interval/camera). Loads secrets from `config_local` |
|
||||
| `config_local.example.py` | Operational/secret value template → copy to `config_local.py` before use (not committed) |
|
||||
| `requirements.txt` | Python dependencies (pinned) |
|
||||
| `install.sh` | Pi 4 install script (air-gapped offline + online fallback) |
|
||||
| `vendor.sh` | Pre-download dependencies for air-gapped use (run on build machine) |
|
||||
| `test_connection.py` | Server connection/signature check |
|
||||
| `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**
|
||||
- e-con **5C-08** 누수감지센서(저항형, 고임피던스 ~1.5MΩ)
|
||||
- **외부 4.7MΩ 풀다운 저항** (필수 — 내부 풀저항은 끔)
|
||||
- (선택) **Pi Camera** — Pi 4 = **15핀(1.0mm 피치) 표준 CSI**, Zero 2 W = **22핀(0.5mm 피치) CSI**
|
||||
→ 리본/카메라 보드 호환 안 됨 (Pi 4용 15핀 리본 또는 15↔22 변환 어댑터 필요)
|
||||
- 전원: **USB-C 5V/3A** (카메라 사용 시 여유 전원 권장)
|
||||
- 네트워크: Pi 4 **Gigabit Ethernet**(유선) 또는 WiFi
|
||||
- e-con **5C-08** leak detection sensor (resistive, high-impedance ~1.5MΩ)
|
||||
- **External 4.7MΩ pull-down resistor** (required — internal pull resistor is off)
|
||||
- (Optional) **Pi Camera** — Pi 4 = **15-pin (1.0mm pitch) standard CSI**, Zero 2 W = **22-pin (0.5mm pitch) CSI**
|
||||
→ Ribbon/camera boards are not compatible (a Pi 4 15-pin ribbon or a 15↔22 adapter is required)
|
||||
- Power: **USB-C 5V/3A** (a power margin is recommended when using the camera)
|
||||
- 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 |
|
||||
| GND | — | 6 (또는 9/14/20/25/30/34/39) |
|
||||
| GND | — | 6 (or 9/14/20/25/30/34/39) |
|
||||
|
||||
> 센서를 추가하려면 `config.py` 의 `SENSORS` 에 BCM 번호로 항목을 추가합니다.
|
||||
> (예: GPIO27=물리 13번, GPIO22=물리 15번)
|
||||
> To add a sensor, add an entry by BCM number to `SENSORS` in `config.py`.
|
||||
> (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)
|
||||
- `USE_INTERNAL_PULL = False`(**PUD_OFF 유지**): 내부 50kΩ 풀다운을 켜면 1.5MΩ 센서가
|
||||
GPIO 를 HIGH 로 끌어올리지 못해 **감지 실패**합니다. 외부 4.7MΩ 풀다운만 사용하세요.
|
||||
- `ACTIVE_LOW = False`: when dry GPIO=LOW(0), on leak GPIO=HIGH(1)
|
||||
- `USE_INTERNAL_PULL = False` (**keep PUD_OFF**): if you turn on the internal 50kΩ pull-down,
|
||||
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/` 서버가 바로 그 서버입니다(단일 서버 통합 완료).**
|
||||
> 1번(누수+카메라)·2번(SHT30 온습도) 두 모듈 모두 **동일한 cafe24 `php/` 서버**에
|
||||
> raw-body 서명 `X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=요청본문 바이트))`
|
||||
> 로 보고하며, 누수 상태·사진·온습도가 하나의 대시보드(`dashboard.php`)에 함께 표시됩니다.
|
||||
> ✅ **The `php/` server in this repo is exactly that server (single-server integration complete).**
|
||||
> Both module 1 (leak + camera) and module 2 (SHT30 temperature/humidity) report to the
|
||||
> **same cafe24 `php/` server** with the raw-body signature
|
||||
> `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` 하나만 실행 — 누수 요소
|
||||
> (`leak_sensor_log.is_leak` / `leak_sensor_status.is_leak` 컬럼, `leak_incident`, `leak_photo`,
|
||||
> `leak_ingest_nonce` 테이블)가 이미 포함되어 있습니다.
|
||||
> - **New install:** run only `sql/schema_sht30.sql` — the leak elements
|
||||
> (`leak_sensor_log.is_leak` / `leak_sensor_status.is_leak` columns, `leak_incident`, `leak_photo`,
|
||||
> `leak_ingest_nonce` tables) are already included.
|
||||
>
|
||||
> `config_local.py` 의 `API_URL` / `PHOTO_UPLOAD_URL` 을 이 통합 서버로 설정하고,
|
||||
> `API_KEY` 는 서버 `config.local.php` 의 `API_KEY` 와 **바이트 단위로 동일**하게 맞춥니다.
|
||||
> Set `API_URL` / `PHOTO_UPLOAD_URL` in `config_local.py` to this integrated server, and make
|
||||
> `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
|
||||
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).
|
||||
`config_local.py` 와 `API_KEY` 는 **절대 커밋하지 마세요**(`.gitignore` 처리됨).
|
||||
If `config_local.py` is missing, execution **stops immediately with a clear error** (fail closed).
|
||||
**Never commit** `config_local.py` or `API_KEY` (they are handled by `.gitignore`).
|
||||
|
||||
### 4.2 온라인 설치 (인터넷 가능 환경)
|
||||
### 4.2 Online install (environment with internet)
|
||||
|
||||
```bash
|
||||
chmod +x install.sh
|
||||
./install.sh
|
||||
```
|
||||
|
||||
### 4.3 폐쇄망(air-gapped) 오프라인 설치
|
||||
### 4.3 Air-gapped offline install
|
||||
|
||||
인터넷이 되는 **동일 아키텍처(aarch64, 가급적 동일 Pi 4 + Bookworm 64-bit)** 머신에서
|
||||
의존성을 먼저 받아 번들을 만든 뒤, 타깃 Pi 4 로 옮겨 설치합니다.
|
||||
On a machine with internet of the **same architecture (aarch64, ideally the same Pi 4 +
|
||||
Bookworm 64-bit)**, first fetch the dependencies to build a bundle, then move it to the
|
||||
target Pi 4 to install.
|
||||
|
||||
```bash
|
||||
# (빌드 머신, 인터넷 O) wheelhouse/ + debs/ 생성
|
||||
# (build machine, internet YES) create wheelhouse/ + debs/
|
||||
./vendor.sh
|
||||
|
||||
# wheelhouse/ 와 debs/ 를 타깃 Pi 4 의 이 폴더로 복사한 뒤:
|
||||
# (타깃 Pi 4, 인터넷 X)
|
||||
./install.sh # debs/ · wheelhouse/ 가 있으면 자동으로 오프라인 설치
|
||||
# After copying wheelhouse/ and debs/ into this folder on the target Pi 4:
|
||||
# (target Pi 4, internet NO)
|
||||
./install.sh # if debs/ · wheelhouse/ exist, installs offline automatically
|
||||
```
|
||||
|
||||
> `install.sh` 는 `debs/` 가 있으면 `dpkg` 오프라인, `wheelhouse/` 가 있으면
|
||||
> `pip --no-index` 오프라인으로 설치합니다. 둘 다 없을 때만 인터넷 apt/pip 로 폴백합니다.
|
||||
> wheel/deb 는 아키텍처 종속이므로 x86_64 PC 에서 받은 것은 Pi 4 에서 동작하지 않습니다.
|
||||
> If `debs/` exists, `install.sh` installs offline with `dpkg`; if `wheelhouse/` exists, it
|
||||
> installs offline with `pip --no-index`. It falls back to internet apt/pip only when neither
|
||||
> 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
|
||||
source venv/bin/activate
|
||||
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
|
||||
source venv/bin/activate
|
||||
|
|
@ -130,10 +138,10 @@ python3 test_connection.py
|
|||
|
||||
---
|
||||
|
||||
## 6. 부팅 자동 실행 (systemd)
|
||||
## 6. Auto-start on boot (systemd)
|
||||
|
||||
```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 systemctl daemon-reload
|
||||
sudo systemctl enable leak-sensor
|
||||
|
|
@ -142,19 +150,19 @@ sudo systemctl status leak-sensor
|
|||
journalctl -u leak-sensor -f
|
||||
```
|
||||
|
||||
`SupplementaryGroups=video` 가 카메라 접근에 필요합니다.
|
||||
`SupplementaryGroups=video` is required for camera access.
|
||||
|
||||
---
|
||||
|
||||
## 7. 보안 메모
|
||||
## 7. Security notes
|
||||
|
||||
- 비밀값(`API_KEY`)·서버 URL·기기 정보는 `config_local.py`(미커밋)에만 둡니다.
|
||||
커밋되는 파일에는 placeholder 만 존재합니다.
|
||||
- 보고 본문은 **raw-body HMAC 서명**으로 위변조를 방지합니다:
|
||||
`X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=요청본문 바이트))`
|
||||
(`leak_sensor.sign_raw` ↔ 서버 `config.php::verify_signature_raw`, 2번 모듈과 동일 스킴).
|
||||
- 사진 업로드 서명의 canonical 문자열은 `device_id|sensor_id|log_id|timestamp`
|
||||
(공백 없음)입니다 (`config_local.example.py` 참고).
|
||||
- 구버전의 `sha256(API_KEY + 정렬 JSON)` body-field 서명은 **폐기**되었고, 서버 기본
|
||||
설정이 거부합니다(`ALLOW_LEGACY_BODY_SIGNATURE=false`, `php/config.php`).
|
||||
- `config_local.py` 노출 시 즉시 `API_KEY` 를 교체(서버 동시 변경)하세요.
|
||||
- Secret values (`API_KEY`), the server URL, and device info live only in `config_local.py`
|
||||
(not committed). Committed files contain only placeholders.
|
||||
- The report body is protected against tampering with a **raw-body HMAC signature**:
|
||||
`X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=request body bytes))`
|
||||
(`leak_sensor.sign_raw` ↔ server `config.php::verify_signature_raw`, same scheme as module 2).
|
||||
- The canonical string for the photo upload signature is `device_id|sensor_id|log_id|timestamp`
|
||||
(no spaces) (see `config_local.example.py`).
|
||||
- The old `sha256(API_KEY + sorted JSON)` body-field signature is **deprecated**, and the
|
||||
server default rejects it (`ALLOW_LEGACY_BODY_SIGNATURE=false`, `php/config.php`).
|
||||
- If `config_local.py` is exposed, immediately rotate `API_KEY` (change it on the server at the same time).
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>누수 감지 노드 · 배선 & 핀맵 (Raspberry Pi 4)</title>
|
||||
<title>Leak Detection Node · Wiring & Pinmap (Raspberry Pi 4)</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#eef1f5; --card:#ffffff; --ink:#212529; --muted:#6b7280; --line:#e5e8ec;
|
||||
|
|
@ -124,26 +124,26 @@
|
|||
|
||||
<!-- 1) HEADER -->
|
||||
<header class="hero">
|
||||
<p class="eyebrow">Field Wiring & Pinmap · 누수 감지 노드</p>
|
||||
<h1>누수 감지 노드 · 배선 & 핀맵</h1>
|
||||
<p>e-con 5C-08 저항형 누수감지센서를 <b>GPIO17(물리 핀 11)</b>에 연결하는 표준 배선도입니다.
|
||||
Pi-A(sensor_id=1 · 1번 지점 · 서버실)와 Pi-C(sensor_id=3 · 3번 지점 · 신규)는 배선이 동일한 <b>공통 노드</b>입니다.</p>
|
||||
<p class="eyebrow">Field Wiring & Pinmap · Leak Detection Node</p>
|
||||
<h1>Leak Detection Node · Wiring & Pinmap</h1>
|
||||
<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 · 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">
|
||||
<span class="chip">Raspberry Pi 4 Model B</span>
|
||||
<span class="chip mono">BCM2711</span>
|
||||
<span class="chip">Raspberry Pi OS Bookworm 64-bit</span>
|
||||
<span class="chip">e-con 5C-08 · 저항형 ~1.5MΩ</span>
|
||||
<span class="chip mono">GPIO17 / 핀11</span>
|
||||
<span class="chip">외부 4.7MΩ 풀다운 필수</span>
|
||||
<span class="chip">e-con 5C-08 · Resistive ~1.5MΩ</span>
|
||||
<span class="chip mono">GPIO17 / Pin 11</span>
|
||||
<span class="chip">External 4.7MΩ pull-down required</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 2) 40핀 헤더 핀맵 도해 -->
|
||||
<!-- 2) 40-pin header pinmap diagram -->
|
||||
<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="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="131" 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="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"/>
|
||||
<text x="252" y="22" text-anchor="middle" font-size="10" fill="#868e96">홀수 핀</text>
|
||||
<text x="330" 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">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="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>
|
||||
|
|
@ -241,171 +241,171 @@
|
|||
</svg>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<div class="lg"><span class="sw sig"></span><b>신호 (GPIO17 · 핀 11)<span>센서 신호선 입력</span></b></div>
|
||||
<div class="lg"><span class="sw pwr"></span><b>전원 3V3 (핀 1)<span>센서 전원 공급</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 alt"></span><b>접지 대체 가능<span>핀 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 off"></span><b>미사용 핀<span>이 노드에서 연결하지 않음</span></b></div>
|
||||
<div class="hint">붉은 점선 사각형이 <b>핀 1</b> 위치입니다. 헤더 방향(핀 1 기준)을 반드시 확인하고 배선하세요. 실제 사용 핀은 <b>1 · 6 · 11</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>Power 3V3 (Pin 1)<span>Sensor power supply</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>Ground alternatives<span>Pin 9 / 14 / 20 / 25 / 30 / 34 / 39</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>Unused pins<span>Not connected on this node</span></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>
|
||||
</section>
|
||||
|
||||
<!-- 3) 핀맵 표 -->
|
||||
<!-- 3) Pinmap table -->
|
||||
<section>
|
||||
<h2><span class="num">3</span>핀맵 표</h2>
|
||||
<h2><span class="num">3</span>Pinmap Table</h2>
|
||||
<table>
|
||||
<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>
|
||||
<tbody>
|
||||
<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">핀 11</td>
|
||||
<td>센서 신호 입력. 외부 4.7MΩ 풀다운을 <b>핀 11 ↔ GND</b> 사이에 연결.</td>
|
||||
<td class="mono">Pin 11</td>
|
||||
<td>Sensor signal input. Connect an external 4.7MΩ pull-down between <b>Pin 11 ↔ GND</b>.</td>
|
||||
</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">핀 1</td>
|
||||
<td>센서 VCC 공급. (예비 3V3 = 핀 17)</td>
|
||||
<td class="mono">Pin 1</td>
|
||||
<td>Sensor VCC supply. (Spare 3V3 = Pin 17)</td>
|
||||
</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">핀 6</td>
|
||||
<td>대체 가능: 핀 9 / 14 / 20 / 25 / 30 / 34 / 39 중 아무 GND.</td>
|
||||
<td class="mono">Pin 6</td>
|
||||
<td>Alternatives: any GND among Pin 9 / 14 / 20 / 25 / 30 / 34 / 39.</td>
|
||||
</tr>
|
||||
<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">핀 13</td>
|
||||
<td>선택. <span class="inline-code">config.py</span>의 SENSORS에 BCM 번호로 추가.</td>
|
||||
<td class="mono">Pin 13</td>
|
||||
<td>Optional. Add by BCM number to SENSORS in <span class="inline-code">config.py</span>.</td>
|
||||
</tr>
|
||||
<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">핀 15</td>
|
||||
<td>선택. 위와 동일한 방법으로 추가(각 채널마다 외부 풀다운 필요).</td>
|
||||
<td class="mono">Pin 15</td>
|
||||
<td>Optional. Add the same way as above (each channel needs its own external pull-down).</td>
|
||||
</tr>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<!-- 4) 배선 스키매틱 -->
|
||||
<!-- 4) Wiring schematic -->
|
||||
<section>
|
||||
<h2><span class="num">4</span>배선 스키매틱</h2>
|
||||
<h2><span class="num">4</span>Wiring Schematic</h2>
|
||||
<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 -->
|
||||
<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 -->
|
||||
<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"/>
|
||||
<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"/>
|
||||
<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"/>
|
||||
<!-- sensor block -->
|
||||
<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"/>
|
||||
<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"/>
|
||||
<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"/>
|
||||
<text x="484" y="274" text-anchor="start" font-family="Consolas,monospace" font-size="12" fill="#212529">GND</text>
|
||||
<!-- wires -->
|
||||
<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,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="250" y="186" text-anchor="middle" font-size="11" fill="#2b8a3e">신호 · GPIO17 · 핀11</text>
|
||||
<text x="250" y="261" text-anchor="middle" font-size="11" fill="#1864ab">GND · 핀6</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">Signal · GPIO17 · Pin 11</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 -->
|
||||
<circle cx="340" cy="195" r="4" fill="#2f9e44"/>
|
||||
<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"/>
|
||||
<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 -->
|
||||
<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 -->
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<!-- 5) 전기 설계 · 주의 -->
|
||||
<!-- 5) Electrical design · cautions -->
|
||||
<section>
|
||||
<h2><span class="num">5</span>전기 설계 · 주의</h2>
|
||||
<h2><span class="num">5</span>Electrical Design · Cautions</h2>
|
||||
|
||||
<div class="crit">
|
||||
<span class="badge">★ 절대 주의 · CRITICAL</span>
|
||||
<h4>내부 풀다운을 켜지 마세요</h4>
|
||||
<p>내부 <b>50kΩ</b> 풀다운을 활성화하면, ~1.5MΩ 고임피던스 센서가 GPIO를 HIGH로 끌어올리지 못해
|
||||
<b>누수를 감지하지 못합니다(감지 실패)</b>. 반드시 내부 풀은 <span class="inline-code">PUD_OFF</span>로 끄고,
|
||||
<b>외부 4.7MΩ 풀다운만</b> 사용하세요. (<span class="inline-code">USE_INTERNAL_PULL = False</span>)</p>
|
||||
<span class="badge">★ Critical Caution · CRITICAL</span>
|
||||
<h4>Do not enable the internal pull-down</h4>
|
||||
<p>If you enable the internal <b>50kΩ</b> pull-down, the ~1.5MΩ high-impedance sensor cannot pull the GPIO HIGH,
|
||||
so <b>leaks will not be detected (detection failure)</b>. Always turn the internal pull off with <span class="inline-code">PUD_OFF</span>
|
||||
and use <b>only the external 4.7MΩ pull-down</b>. (<span class="inline-code">USE_INTERNAL_PULL = False</span>)</p>
|
||||
</div>
|
||||
|
||||
<div class="notes" style="margin-top:14px">
|
||||
<div class="note">
|
||||
<h4>극성 · 로직 레벨</h4>
|
||||
<p><span class="inline-code">ACTIVE_LOW = False</span> → 건조 시 <b>GPIO=LOW(0)</b>, 누수 시 <b>GPIO=HIGH(1)</b>.</p>
|
||||
<h4>Polarity · Logic Level</h4>
|
||||
<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 class="note">
|
||||
<h4>외부 풀다운 (필수)</h4>
|
||||
<p>4.7MΩ 저항을 <b>GPIO17(핀 11) ↔ GND</b> 사이에 연결. 고임피던스 센서용으로 반드시 필요.</p>
|
||||
<h4>External Pull-down (required)</h4>
|
||||
<p>Connect a 4.7MΩ resistor between <b>GPIO17 (Pin 11) ↔ GND</b>. Essential for the high-impedance sensor.</p>
|
||||
</div>
|
||||
<div class="note">
|
||||
<h4>내부 풀 설정</h4>
|
||||
<p><span class="inline-code">USE_INTERNAL_PULL = False</span> — 내부 풀은 <span class="inline-code">PUD_OFF</span> 유지.</p>
|
||||
<h4>Internal Pull Setting</h4>
|
||||
<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 class="note">
|
||||
<h4>디바운스</h4>
|
||||
<p><span class="inline-code">DEBOUNCE_MS = 500</span> — 고임피던스 특성상 넉넉하게 설정.</p>
|
||||
<h4>Debounce</h4>
|
||||
<p><span class="inline-code">DEBOUNCE_MS = 500</span> — set generously given the high-impedance behavior.</p>
|
||||
</div>
|
||||
<div class="note" style="grid-column:1 / -1">
|
||||
<h4>설정 파일</h4>
|
||||
<p>전기 파라미터는 <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>
|
||||
(Pi-A = id 1 · 1번 지점 · 서버실 / Pi-C = id 3 · 3번 지점 · 신규).</p>
|
||||
<h4>Configuration Files</h4>
|
||||
<p>Electrical parameters live in <span class="inline-code">raspberry_pi4/config.py</span> (SENSORS · ACTIVE_LOW · USE_INTERNAL_PULL · DEBOUNCE_MS).
|
||||
Per-device <b>sensor_id / location</b> is in <span class="inline-code">config_local.py</span>
|
||||
(Pi-A = id 1 · Site 1 · Server Room / Pi-C = id 3 · Site 3 · New).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cam">
|
||||
<h4>카메라 (선택 · CAMERA_ENABLED=True)</h4>
|
||||
<p>Raspberry Pi 4는 <b>15핀(1.0mm 피치) 표준 CSI 커넥터</b>입니다. Zero 2 W는 22핀(0.5mm)이라
|
||||
리본·카메라보드가 <b>비호환</b> → Pi 4용 <b>15핀 리본</b> 또는 <b>15↔22 변환 어댑터</b>가 필요합니다.
|
||||
촬영은 <span class="inline-code">rpicam-still</span>(Bookworm) / <span class="inline-code">libcamera-still</span>(Bullseye), <b>640×480 JPEG</b>.</p>
|
||||
<h4>Camera (optional · CAMERA_ENABLED=True)</h4>
|
||||
<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),
|
||||
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>.
|
||||
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>
|
||||
</section>
|
||||
|
||||
<!-- 6) 설치 체크리스트 -->
|
||||
<!-- 6) Installation checklist -->
|
||||
<section>
|
||||
<h2><span class="num">6</span>설치 체크리스트</h2>
|
||||
<h2><span class="num">6</span>Installation Checklist</h2>
|
||||
<ol class="check">
|
||||
<li>Raspberry Pi 전원을 <b>완전히 차단</b>한 상태에서 배선을 시작한다.</li>
|
||||
<li>헤더 방향(핀 1 위치)을 확인한다. 붉은 점선 표시 = 핀 1.</li>
|
||||
<li>센서 신호선을 <b>핀 11(GPIO17)</b>에 연결한다.</li>
|
||||
<li>센서 전원(3.3V)을 <b>핀 1(3V3)</b>, 센서 GND를 <b>핀 6(GND)</b>에 연결한다.</li>
|
||||
<li class="key">외부 <b>4.7MΩ 풀다운</b>을 <b>핀 11 ↔ GND</b> 사이에 연결한다. <b>(필수 · 생략 시 감지 실패)</b></li>
|
||||
<li class="key">내부 풀이 꺼져 있는지 확인한다 — <span class="inline-code">USE_INTERNAL_PULL=False</span> (PUD_OFF). <b>내부 풀다운 절대 금지.</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><span class="inline-code">config_local.py</span>에서 기기별 <b>sensor_id / location</b> 설정 (Pi-A=1/서버실, Pi-C=3/신규).</li>
|
||||
<li>전원 인가 후, <b>건조 상태</b>에서 GPIO17 = <b>LOW(0)</b>인지 로그로 확인한다.</li>
|
||||
<li>물(또는 젖은 천)로 센서를 적셔 GPIO17이 <b>HIGH(1)</b>로 전환되고 누수 이벤트가 기록되는지 확인한다.</li>
|
||||
<li>(선택) 카메라 사용 시 리본 방향·커넥터 규격(15핀 / 어댑터)을 확인하고 640×480 촬영 테스트를 수행한다.</li>
|
||||
<li>Start wiring with the Raspberry Pi power <b>fully disconnected</b>.</li>
|
||||
<li>Verify the header orientation (Pin 1 position). Red dashed marker = Pin 1.</li>
|
||||
<li>Connect the sensor signal line to <b>Pin 11 (GPIO17)</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">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">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>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>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>After powering on, confirm via the log that GPIO17 = <b>LOW(0)</b> in the <b>dry state</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>(Optional) When using the camera, verify the ribbon orientation and connector spec (15-pin / adapter) and run a 640×480 capture test.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 에서 로드한다.
|
||||
# config_local.py 는 .gitignore 로 커밋 제외 대상이다.
|
||||
# 설정 방법: cp config_local.example.py config_local.py → 실제 값으로 채움
|
||||
# Operational/secret values such as server URL / API_KEY / device identifier are loaded from config_local.py.
|
||||
# config_local.py is excluded from commits via .gitignore.
|
||||
# Setup: cp config_local.example.py config_local.py → fill in with real values
|
||||
# =============================================================================
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 센서 GPIO 설정 (BCM 번호 기준 — Pi 4 40핀 헤더와 동일)
|
||||
# GPIO17 = 40핀 헤더 물리 11번 핀
|
||||
# Sensor GPIO settings (BCM numbering — same as Pi 4 40-pin header)
|
||||
# GPIO17 = physical pin 11 on the 40-pin header
|
||||
# ─────────────────────────────────────────────
|
||||
SENSORS = [
|
||||
{"id": 1, "gpio": 17, "name": "1번 센서 (서버실)"},
|
||||
# 추가 센서가 있으면 아래에 추가 (BCM 번호 기준)
|
||||
# {"id": 2, "gpio": 27, "name": "2번 센서 (기계실)"}, # 물리 13번
|
||||
# {"id": 3, "gpio": 22, "name": "3번 센서 (화장실)"}, # 물리 15번
|
||||
{"id": 1, "gpio": 17, "name": "Sensor 1 (Server Room)"},
|
||||
# Add more sensors below if present (BCM numbering)
|
||||
# {"id": 2, "gpio": 27, "name": "Sensor 2 (Machine Room)"}, # physical pin 13
|
||||
# {"id": 3, "gpio": 22, "name": "Sensor 3 (Restroom)"}, # physical pin 15
|
||||
]
|
||||
|
||||
# 센서 활성 레벨 설정
|
||||
# e-con 5C-08 저항형 센서 + 4.7MΩ 풀다운 사용 시:
|
||||
# 건조: GPIO = LOW(0) → ACTIVE_LOW = False
|
||||
# 누수: GPIO = HIGH(1)
|
||||
# Sensor active-level setting
|
||||
# When using the e-con 5C-08 resistive sensor + 4.7MΩ pull-down:
|
||||
# Dry: GPIO = LOW(0) → ACTIVE_LOW = False
|
||||
# Leak: GPIO = HIGH(1)
|
||||
ACTIVE_LOW = False
|
||||
|
||||
# GPIO 내부 풀저항 설정
|
||||
# 반드시 False (PUD_OFF) 유지!
|
||||
# 내부 풀다운(50kΩ)을 켜면 누수 시에도 HIGH가 되지 않아 감지 실패함.
|
||||
# → 외부 4.7MΩ 풀다운 저항만 사용
|
||||
# GPIO internal pull-resistor setting
|
||||
# Must stay False (PUD_OFF)!
|
||||
# Enabling the internal pull-down (50kΩ) keeps the line from going HIGH even on a leak, causing detection failure.
|
||||
# → Use only the external 4.7MΩ pull-down resistor
|
||||
USE_INTERNAL_PULL = False # True = PUD_DOWN, False = PUD_OFF
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 주기 설정
|
||||
# Interval settings
|
||||
# ─────────────────────────────────────────────
|
||||
REPORT_INTERVAL = 600 # 정상 상태 주기 보고 간격 (초) = 10분
|
||||
# 서버 HEARTBEAT_TIMEOUT_SEC(기본 1200초)보다 짧아야
|
||||
# 오프라인 오탐이 없다 (600초 = 2회 누락 시 오프라인 판정).
|
||||
LEAK_REPORT_INTERVAL = 10 # 누수 감지 시 재보고 간격 (초)
|
||||
DEBOUNCE_MS = 500 # 바운싱 방지 시간 (ms) — 고임피던스라 넉넉히 설정
|
||||
REPORT_INTERVAL = 600 # Normal-state periodic report interval (sec) = 10 min
|
||||
# Must be shorter than the server's HEARTBEAT_TIMEOUT_SEC (default 1200 sec)
|
||||
# to avoid offline false positives (600 sec = offline after 2 missed reports).
|
||||
LEAK_REPORT_INTERVAL = 10 # Re-report interval while a leak is detected (sec)
|
||||
DEBOUNCE_MS = 500 # Debounce time (ms) — set generously due to high impedance
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 로그 설정
|
||||
# Log settings
|
||||
# ─────────────────────────────────────────────
|
||||
LOG_FILE = "/var/log/leak_sensor.log"
|
||||
LOG_MAX_BYTES = 5 * 1024 * 1024 # 5MB
|
||||
LOG_BACKUP_COUNT = 3
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 재시도 설정
|
||||
# Retry settings
|
||||
# ─────────────────────────────────────────────
|
||||
HTTP_TIMEOUT = 10 # HTTP 요청 타임아웃 (초)
|
||||
HTTP_RETRY_COUNT = 3 # 재시도 횟수
|
||||
HTTP_RETRY_DELAY = 5 # 재시도 대기 (초)
|
||||
HTTP_TIMEOUT = 10 # HTTP request timeout (sec)
|
||||
HTTP_RETRY_COUNT = 3 # Retry count
|
||||
HTTP_RETRY_DELAY = 5 # Retry delay (sec)
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 카메라 설정
|
||||
# Pi 4 = 15핀(1.0mm 피치) 표준 CSI, Zero 2 W = 22핀(0.5mm 피치) CSI →
|
||||
# 리본/카메라 보드 호환 안 됨 (Pi 4용 15핀 리본 또는 15↔22 변환 어댑터 필요).
|
||||
# 촬영은 rpicam-still(Bookworm) / libcamera-still(Bullseye) 사용.
|
||||
# Camera settings
|
||||
# Pi 4 = 15-pin (1.0mm pitch) standard CSI, Zero 2 W = 22-pin (0.5mm pitch) CSI →
|
||||
# ribbon/camera board not compatible (needs a Pi 4 15-pin ribbon or a 15↔22 adapter).
|
||||
# Capture uses rpicam-still (Bookworm) / libcamera-still (Bullseye).
|
||||
# ─────────────────────────────────────────────
|
||||
CAMERA_ENABLED = True # False로 설정하면 촬영/업로드 건너뜀
|
||||
PHOTO_WIDTH = 640 # 촬영 해상도 (가로)
|
||||
PHOTO_HEIGHT = 480 # 촬영 해상도 (세로)
|
||||
PHOTO_QUALITY = 60 # JPEG 품질 (1-100, 60 ≈ 50-80KB)
|
||||
CAMERA_ENABLED = True # Set to False to skip capture/upload
|
||||
PHOTO_WIDTH = 640 # Capture resolution (width)
|
||||
PHOTO_HEIGHT = 480 # Capture resolution (height)
|
||||
PHOTO_QUALITY = 60 # JPEG quality (1-100, 60 ≈ 50-80KB)
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 운영·비밀값 로드 (fail closed)
|
||||
# config_local.py 가 없으면 placeholder/하드코딩 비밀로 잘못 기동하는 대신
|
||||
# 명확한 오류로 즉시 중단한다.
|
||||
# Load operational/secret values (fail closed)
|
||||
# If config_local.py is missing, stop immediately with a clear error
|
||||
# instead of wrongly starting with placeholder/hardcoded secrets.
|
||||
# ─────────────────────────────────────────────
|
||||
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_KEY,
|
||||
PHOTO_UPLOAD_URL,
|
||||
|
|
@ -78,25 +78,25 @@ try:
|
|||
)
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"config_local.py 를 찾을 수 없습니다. "
|
||||
"`cp config_local.example.py config_local.py` 후 실제 서버 URL / API_KEY / "
|
||||
"기기 정보를 채우세요. (비밀값은 절대 커밋하지 마세요.)"
|
||||
"config_local.py not found. "
|
||||
"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
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 선택 오버라이드 — 기기별 값을 config_local.py 에서 덮어쓸 수 있다.
|
||||
# 추적 파일(config.py)을 기기마다 수정하면 배포 드리프트가 생기므로,
|
||||
# 신규 지점 노드(예: sensor_id 3)는 config_local.py 에 SENSORS 를 정의한다.
|
||||
# Optional override — per-device values can be overridden in config_local.py.
|
||||
# Editing the tracked file (config.py) per device causes deployment drift, so
|
||||
# define SENSORS in config_local.py for a new site node (e.g. sensor_id 3).
|
||||
# ─────────────────────────────────────────────
|
||||
try:
|
||||
from config_local import SENSORS # noqa: F811 — 기기별 센서 목록 오버라이드
|
||||
from config_local import SENSORS # noqa: F811 — per-device sensor list override
|
||||
except ImportError:
|
||||
pass # config_local 에 SENSORS 가 없으면 위 기본값 사용
|
||||
pass # use the default above if config_local has no SENSORS
|
||||
|
||||
# SENSORS 형식 검증 (fail closed) — 오버라이드 오타/형식 오류로 잘못된 sensor_id
|
||||
# (예: 기본값 1)로 조용히 기동해 다른 지점 상태를 덮어쓰는 사고를 막는다.
|
||||
# SENSORS format validation (fail closed) — prevents an override typo/format error from
|
||||
# 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:
|
||||
raise RuntimeError("SENSORS 는 비어있지 않은 list 여야 합니다 (config_local.py 확인).")
|
||||
raise RuntimeError("SENSORS must be a non-empty list (check config_local.py).")
|
||||
_ids, _gpios = set(), set()
|
||||
for _s in SENSORS:
|
||||
if not (isinstance(_s, dict)
|
||||
|
|
@ -104,10 +104,10 @@ for _s in SENSORS:
|
|||
and isinstance(_s.get("gpio"), int)
|
||||
and isinstance(_s.get("name"), str) and _s["name"]):
|
||||
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:
|
||||
raise RuntimeError(f"SENSORS id/gpio 중복: {_s!r}")
|
||||
raise RuntimeError(f"SENSORS duplicate id/gpio: {_s!r}")
|
||||
_ids.add(_s["id"])
|
||||
_gpios.add(_s["gpio"])
|
||||
del _ids, _gpios
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# → 실제 값으로 채운 뒤 저장. config_local.py 는 절대 커밋하지 말 것
|
||||
# (.gitignore 로 제외되어 있음).
|
||||
# Usage: cp config_local.example.py config_local.py
|
||||
# → Fill in with real values, then save. Never commit config_local.py
|
||||
# (excluded via .gitignore).
|
||||
#
|
||||
# ⚠️ 서버 호환성
|
||||
# 이 모듈(1번)은 2번 모듈(SHT30 온습도)과 "동일한" cafe24 서버 php/ 로 보고한다(통합).
|
||||
# 누수 이벤트(leak_alert / leak_recovery)와 사진 업로드는 서버가 raw-body 서명을 요구한다:
|
||||
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=요청본문 바이트))
|
||||
# 사진 업로드 canonical = device_id|sensor_id|log_id|timestamp (공백 없음)
|
||||
# → API_KEY 는 서버 config.local.php 의 API_KEY 와 "정확히 동일"해야 한다.
|
||||
# 서버 DB 는 sql/schema_sht30.sql 하나로 설치되며(누수 요소 포함),
|
||||
# 누수 상태·사진·온습도가 한 대시보드(dashboard.php)에 함께 표시된다.
|
||||
# (구버전의 sha256(API_KEY + 정렬 JSON) body-field 서명은 폐기됨 — 서버가 거부한다.)
|
||||
# ⚠️ Server compatibility
|
||||
# This module (Module 1) reports to the "same" cafe24 server php/ as Module 2 (SHT30 temp/humidity) — integrated.
|
||||
# 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=request body bytes))
|
||||
# photo upload canonical = device_id|sensor_id|log_id|timestamp (no spaces)
|
||||
# → API_KEY must be "exactly identical" to the API_KEY in the server's config.local.php.
|
||||
# The server DB is installed from the single sql/schema_sht30.sql (includes leak components),
|
||||
# and leak status, photos, and temp/humidity are shown together on one dashboard (dashboard.php).
|
||||
# (The legacy sha256(API_KEY + sorted JSON) body-field signature is deprecated — the server rejects it.)
|
||||
# =============================================================================
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 서버 API 엔드포인트 (통합 서버 = cafe24 php/, 2번 모듈과 동일 서버)
|
||||
# INSTALL_PI_SERVER.md 기준 웹 루트: public_html/raspi_leck_detecter/
|
||||
# Server API endpoints (integrated server = cafe24 php/, same server as Module 2)
|
||||
# Web root per INSTALL_PI_SERVER.md: public_html/raspi_leck_detecter/
|
||||
# ─────────────────────────────────────────────
|
||||
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"
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 공유 비밀키 — 서버 측 API_KEY 와 정확히 동일해야 함. 절대 커밋 금지.
|
||||
# Shared secret key — must be exactly identical to the server-side API_KEY. Never commit.
|
||||
# ─────────────────────────────────────────────
|
||||
API_KEY = "CHANGE_ME"
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 기기 식별 (여러 대 운영 시 구분)
|
||||
# Device identification (distinguishes multiple units)
|
||||
# ─────────────────────────────────────────────
|
||||
DEVICE_ID = "rpi4-leak-01"
|
||||
DEVICE_LOCATION = "서버실"
|
||||
DEVICE_LOCATION = "Server Room"
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# (선택) 센서 목록 오버라이드 — 신규 지점 노드용
|
||||
# 미정의 시 config.py 기본값(SENSORS = [{"id": 1, ...}]) 사용.
|
||||
# 신규 지점(예: Pi-C, sensor_id 3) 배포 시 이 파일에서만 정의하면 되고,
|
||||
# 추적 파일(config.py)은 수정할 필요 없다. 서버 대시보드 카드 표시는
|
||||
# 서버 config.local.php 의 'LEAK_SENSOR_IDS' => [1, 3] 도 함께 갱신할 것.
|
||||
# ⚠️ sensor_id 는 지점마다 고유해야 함 — 1/2 재사용 시 기존 지점 상태를
|
||||
# 조용히 덮어쓴다 (leak_sensor_status 는 sensor_id 단독 키).
|
||||
# (Optional) Sensor list override — for new site nodes
|
||||
# If undefined, uses the config.py default (SENSORS = [{"id": 1, ...}]).
|
||||
# When deploying a new site (e.g. Pi-C, sensor_id 3), define it only in this file;
|
||||
# the tracked file (config.py) does not need to be modified. For server dashboard card
|
||||
# display, also update 'LEAK_SENSOR_IDS' => [1, 3] in the server's config.local.php.
|
||||
# ⚠️ sensor_id must be unique per site — reusing 1/2 will silently overwrite the
|
||||
# existing site's status (leak_sensor_status is keyed on sensor_id alone).
|
||||
# ─────────────────────────────────────────────
|
||||
# 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)
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# 누수감지 센서 v2 (Raspberry Pi 4 Model B) 설치 스크립트
|
||||
# 대상 OS: Raspberry Pi OS Bookworm (64-bit, aarch64)
|
||||
# Leak Detection Sensor v2 (Raspberry Pi 4 Model B) install script
|
||||
# Target OS: Raspberry Pi OS Bookworm (64-bit, aarch64)
|
||||
#
|
||||
# 폐쇄망(air-gapped) 지원:
|
||||
# - debs/ 또는 wheelhouse/ 가 있으면 자동으로 "오프라인 모드"로 전환.
|
||||
# - 오프라인 모드에서는 절대 네트워크에 접근하지 않는다. 번들이 일부만 있으면
|
||||
# 온라인으로 몰래 빠지지 않고 명확한 오류로 중단한다(폐쇄망 규칙: 네트워크가
|
||||
# 필요하면 반드시 FLAG). 사전 다운로드는 vendor.sh 로 빌드 머신에서 수행.
|
||||
# - 환경변수 OFFLINE=1/0 으로 강제 지정 가능.
|
||||
# - 로그 파일 소유자: SERVICE_USER (기본 pi).
|
||||
# Air-gapped support:
|
||||
# - 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:
|
||||
# if the network is needed, always FLAG it). Pre-download is done on the build
|
||||
# machine via vendor.sh.
|
||||
# - Can be forced with the OFFLINE=1/0 environment variable.
|
||||
# - Log file owner: SERVICE_USER (default pi).
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
|
@ -17,101 +18,101 @@ cd "$(dirname "$0")"
|
|||
SERVICE_USER="${SERVICE_USER:-pi}"
|
||||
|
||||
echo "=========================================="
|
||||
echo " 누수감지 센서 v2 (Pi 4) 설치"
|
||||
echo " Leak Detection Sensor v2 (Pi 4) install"
|
||||
echo "=========================================="
|
||||
|
||||
# 0) 보드 모델 안내 (강제 아님)
|
||||
# 0) Board model notice (not enforced)
|
||||
if [ -r /proc/device-tree/model ]; then
|
||||
MODEL="$(tr -d '\0' < /proc/device-tree/model)"
|
||||
echo "[INFO] 보드: ${MODEL}"
|
||||
echo "[INFO] Board: ${MODEL}"
|
||||
case "${MODEL}" in
|
||||
*"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
|
||||
fi
|
||||
|
||||
# 오프라인 모드 판정
|
||||
# Determine offline mode
|
||||
OFFLINE="${OFFLINE:-}"
|
||||
if [ -z "${OFFLINE}" ]; then
|
||||
if compgen -G "debs/*.deb" >/dev/null || [ -d wheelhouse ]; then
|
||||
OFFLINE=1
|
||||
echo "[INFO] 오프라인 번들 감지 → 폐쇄망 오프라인 설치 모드 (네트워크 미사용)"
|
||||
echo "[INFO] Offline bundle detected -> air-gapped offline install mode (no network)"
|
||||
else
|
||||
OFFLINE=0
|
||||
echo "[INFO] 오프라인 번들 없음 → 온라인 설치 모드 (인터넷 필요)"
|
||||
echo "[INFO] No offline bundle -> online install mode (internet required)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 1) 시스템 패키지: RPi.GPIO 대체(lgpio) + requests + venv + 카메라 앱
|
||||
echo "[1/3] 시스템 패키지 설치..."
|
||||
# 1) System packages: RPi.GPIO replacement (lgpio) + requests + venv + camera app
|
||||
echo "[1/3] Installing system packages..."
|
||||
if [ "${OFFLINE}" = "1" ]; 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
|
||||
fi
|
||||
echo " 오프라인 .deb 설치 (apt-get --no-download — 의존성 순서·해결 자동, 네트워크 미사용)"
|
||||
# --no-install-recommends: vendor.sh 가 Recommends 를 안 담으므로(--no-recommends) 맞춘다.
|
||||
echo " Installing offline .deb (apt-get --no-download - automatic dependency ordering/resolution, no network)"
|
||||
# --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
|
||||
else
|
||||
echo " 온라인 apt 설치 (인터넷 필요)"
|
||||
echo " Online apt install (internet required)"
|
||||
sudo apt-get update
|
||||
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}"
|
||||
fi
|
||||
|
||||
# 2) Python venv + requests
|
||||
echo "[2/3] Python 가상환경 설정..."
|
||||
echo "[2/3] Setting up Python virtual environment..."
|
||||
if [ ! -d venv ]; then
|
||||
python3 -m venv --system-site-packages venv
|
||||
echo " venv 생성 완료"
|
||||
echo " venv created"
|
||||
else
|
||||
echo " venv 이미 존재 — 건너뜀"
|
||||
echo " venv already exists - skipping"
|
||||
fi
|
||||
# shellcheck disable=SC1091
|
||||
source venv/bin/activate
|
||||
if [ "${OFFLINE}" = "1" ]; 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
|
||||
fi
|
||||
echo " 오프라인 wheel 설치 (--no-index — 네트워크 미사용)"
|
||||
echo " Installing offline wheels (--no-index - no network)"
|
||||
pip install --no-index --find-links=wheelhouse -r requirements.txt
|
||||
else
|
||||
echo " 온라인 pip 설치 (인터넷 필요)"
|
||||
echo " Online pip install (internet required)"
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
fi
|
||||
echo " 패키지 설치 완료"
|
||||
echo " Package installation complete"
|
||||
|
||||
# 3) 로그 파일 (최소 권한: 소유자만 쓰기 — leak_sensor 가 단독 기록)
|
||||
echo "[3/3] 로그 파일 생성..."
|
||||
# 3) Log file (least privilege: owner write only - leak_sensor is the sole writer)
|
||||
echo "[3/3] Creating log file..."
|
||||
sudo mkdir -p /var/log
|
||||
sudo touch /var/log/leak_sensor.log
|
||||
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
|
||||
sudo chmod 640 /var/log/leak_sensor.log
|
||||
|
||||
# 3.5) config_local.py 즉시 잠금 (API_KEY 포함 — harden 전까지 world-readable 방지)
|
||||
# 하드닝(harden_pi.sh)이 나중에 다시 조이지만, 그 사이 창(window)에도 노출되지
|
||||
# 않도록 설치 시점에 바로 600 으로 잠근다. harden 여부와 무관하게 보호된다.
|
||||
# 3.5) Lock config_local.py immediately (contains API_KEY - prevent world-readable until harden)
|
||||
# Hardening (harden_pi.sh) tightens this again later, but lock it to 600 right at install
|
||||
# time so it is not exposed even in the window between. Protected regardless of harden.
|
||||
if [ -f config_local.py ]; then
|
||||
chmod 600 config_local.py
|
||||
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
|
||||
|
||||
# 4) config_local 안내 (fail closed — 없으면 실행 시 즉시 중단됨)
|
||||
# 4) config_local notice (fail closed - if missing, execution aborts immediately)
|
||||
if [ ! -f config_local.py ]; then
|
||||
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"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " 설치 완료! (OFFLINE=${OFFLINE})"
|
||||
echo " Installation complete! (OFFLINE=${OFFLINE})"
|
||||
echo "=========================================="
|
||||
echo " 실행: source venv/bin/activate && python3 leak_sensor.py"
|
||||
echo " 서비스: README.md 의 systemd 섹션 참고"
|
||||
echo " Run: source venv/bin/activate && python3 leak_sensor.py"
|
||||
echo " Service: see the systemd section in README.md"
|
||||
echo ""
|
||||
|
|
|
|||
|
|
@ -1,26 +1,27 @@
|
|||
#!/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 헤더)
|
||||
# - 원래 Raspberry Pi Zero 2 W 용이던 모듈을 Pi 4 Model B 전용으로 이식.
|
||||
# - GPIO BCM 번호 체계·rpicam/libcamera 카메라 스택은 두 보드가 동일하므로
|
||||
# 센서/촬영 로직은 그대로 유지. 차이는 배선/전원/이더넷/카메라 커넥터 문서뿐.
|
||||
# (Pi 4 = 15핀 1.0mm CSI / Zero 2 W = 22핀 0.5mm CSI → 리본 비호환,
|
||||
# Gigabit Ethernet, USB-C 5V/3A) → README.md 참고.
|
||||
# [Target hardware] Raspberry Pi 4 Model B (BCM2711, 40-pin GPIO header)
|
||||
# - Module originally for Raspberry Pi Zero 2 W, ported to Pi 4 Model B only.
|
||||
# - GPIO BCM numbering and the rpicam/libcamera camera stack are identical on
|
||||
# both boards, so the sensor/capture logic is unchanged. Only the wiring/
|
||||
# power/Ethernet/camera-connector docs differ.
|
||||
# (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.
|
||||
#
|
||||
# [동작 흐름]
|
||||
# 1. GPIO 초기화 → 센서 핀 입력 설정
|
||||
# 2. 주기적(REPORT_INTERVAL)으로 현재 상태를 PHP API에 POST
|
||||
# 3. 누수 감지 인터럽트 발생 시 즉시 알림 POST
|
||||
# 4. 누수 지속 중엔 LEAK_REPORT_INTERVAL 마다 재보고
|
||||
# 5. 복구(정상) 감지 시 복구 알림 POST
|
||||
# 6. 누수 감지 시 Pi Camera로 촬영 → photo_upload.php에 업로드
|
||||
# (촬영/업로드 실패해도 알림+SMS는 이미 전송된 상태)
|
||||
# [Operation flow]
|
||||
# 1. GPIO init -> configure sensor pins as input
|
||||
# 2. POST current state to the PHP API periodically (REPORT_INTERVAL)
|
||||
# 3. On leak-detection interrupt, POST an alert immediately
|
||||
# 4. While the leak persists, re-report every LEAK_REPORT_INTERVAL
|
||||
# 5. On recovery (normal) detection, POST a recovery alert
|
||||
# 6. On leak detection, capture with the Pi Camera -> upload to photo_upload.php
|
||||
# (even if capture/upload fails, the alert+SMS has already been sent)
|
||||
#
|
||||
# [설정·비밀값] config.py 는 비밀이 아닌 기본값만 보유하고, 서버 URL/API_KEY/
|
||||
# 기기 식별자는 config_local.py(미커밋)에서 불러온다. config_local.py 가 없으면
|
||||
# 기동 시 명확한 오류로 즉시 중단(fail closed)된다.
|
||||
# [Config/secrets] config.py holds only non-secret defaults; the server URL/API_KEY/
|
||||
# device identifiers are loaded from config_local.py (not committed). If config_local.py
|
||||
# is missing, startup aborts immediately with a clear error (fail closed).
|
||||
# =============================================================================
|
||||
|
||||
import time
|
||||
|
|
@ -40,9 +41,9 @@ try:
|
|||
import RPi.GPIO as GPIO
|
||||
SIMULATION = False
|
||||
except (ImportError, RuntimeError):
|
||||
# 개발/테스트 환경에서 RPi.GPIO 없을 때 시뮬레이션 모드
|
||||
# Simulation mode when RPi.GPIO is unavailable (dev/test environment)
|
||||
SIMULATION = True
|
||||
print("[WARNING] RPi.GPIO 없음 → 시뮬레이션 모드로 실행됩니다.")
|
||||
print("[WARNING] RPi.GPIO not found -> running in simulation mode.")
|
||||
|
||||
from config import (
|
||||
SENSORS, ACTIVE_LOW, USE_INTERNAL_PULL,
|
||||
|
|
@ -56,7 +57,7 @@ from config import (
|
|||
)
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 로거 설정
|
||||
# Logger setup
|
||||
# ─────────────────────────────────────────────
|
||||
def setup_logger():
|
||||
logger = logging.getLogger("leak_sensor")
|
||||
|
|
@ -67,7 +68,7 @@ def setup_logger():
|
|||
datefmt="%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
|
||||
# 파일 핸들러 (로테이션)
|
||||
# File handler (rotating)
|
||||
try:
|
||||
fh = logging.handlers.RotatingFileHandler(
|
||||
LOG_FILE, maxBytes=LOG_MAX_BYTES, backupCount=LOG_BACKUP_COUNT,
|
||||
|
|
@ -76,9 +77,9 @@ def setup_logger():
|
|||
fh.setFormatter(fmt)
|
||||
logger.addHandler(fh)
|
||||
except PermissionError:
|
||||
pass # 권한 없으면 콘솔만 사용
|
||||
pass # No permission -> console only
|
||||
|
||||
# 콘솔 핸들러
|
||||
# Console handler
|
||||
ch = logging.StreamHandler(sys.stdout)
|
||||
ch.setFormatter(fmt)
|
||||
logger.addHandler(ch)
|
||||
|
|
@ -89,7 +90,7 @@ log = setup_logger()
|
|||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 시뮬레이션 GPIO (테스트용)
|
||||
# Simulation GPIO (for testing)
|
||||
# ─────────────────────────────────────────────
|
||||
class SimGPIO:
|
||||
BCM = "BCM"
|
||||
|
|
@ -118,12 +119,12 @@ if SIMULATION:
|
|||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 누수 상태 관리
|
||||
# Leak state management
|
||||
# ─────────────────────────────────────────────
|
||||
class SensorState:
|
||||
def __init__(self):
|
||||
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.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번 모듈과 동일 스킴)]
|
||||
# 서버(php/api/sensor_data.php + config.php::verify_signature_raw)는 "받은 본문
|
||||
# 바이트 그대로"에 대해 X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY,
|
||||
# msg=raw_body)) 를 검증한다. 따라서 "서명한 바이트"와 "실제 전송한 바이트"가
|
||||
# 100% 동일해야 한다. requests 의 json= 직렬화에 맡기면 공백/순서/부동소수 포맷이
|
||||
# 달라져 서명이 깨질 수 있으므로, 본문을 직접 문자열로 만들고 그 바이트에 서명한 뒤
|
||||
# 그 바이트를 그대로 POST(data=body_bytes) 한다.
|
||||
# (구버전의 sha256(API_KEY + 정렬 JSON) body-field 서명은 폐기 — 서버가 거부한다.)
|
||||
# [Security note — raw-body HMAC signature (same scheme as sensor module 2)]
|
||||
# The server (php/api/sensor_data.php + config.php::verify_signature_raw) verifies
|
||||
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=raw_body)) over the
|
||||
# received body bytes exactly as received. So the "signed bytes" and the "actually
|
||||
# sent bytes" must be 100% identical. Letting requests' json= serialization handle it
|
||||
# could change whitespace/ordering/float formatting and break the signature, so we
|
||||
# build the body as a string ourselves, sign those bytes, and POST those same bytes
|
||||
# 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:
|
||||
r"""JSON 문자열 값 이스케이프 ('"' / '\\' / 제어문자<0x20 만). 멀티바이트는 그대로."""
|
||||
r"""Escape a JSON string value (only '"' / '\\' / control chars <0x20). Multibyte left as-is."""
|
||||
out = []
|
||||
for ch in s:
|
||||
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:
|
||||
"""누수 이벤트 본문 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
|
||||
"""
|
||||
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:
|
||||
"""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"),
|
||||
hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
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 로 그대로 전송한다.
|
||||
want_response=False 면 성공 여부(bool), True 면 응답 JSON(dict) 또는 None 을 반환.
|
||||
Builds the body string, signs those bytes, and sends them as-is via data=body_bytes.
|
||||
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_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,
|
||||
)
|
||||
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:
|
||||
return True
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError:
|
||||
log.warning("[POST] 응답 JSON 파싱 실패")
|
||||
log.warning("[POST] failed to parse response JSON")
|
||||
return None
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
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"
|
||||
"""
|
||||
|
|
@ -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):
|
||||
"""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)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 카메라 촬영 / 사진 업로드
|
||||
# Camera capture / photo upload
|
||||
# ─────────────────────────────────────────────
|
||||
def capture_photo(output_path: str) -> bool:
|
||||
"""
|
||||
rpicam-still (Bookworm+) 우선, 없으면 libcamera-still (Bullseye)로 사진 촬영.
|
||||
성공 시 True, 실패 시 False.
|
||||
Capture a photo, preferring rpicam-still (Bookworm+), falling back to libcamera-still (Bullseye).
|
||||
Returns True on success, False on failure.
|
||||
"""
|
||||
for cmd in ["rpicam-still", "libcamera-still"]:
|
||||
try:
|
||||
|
|
@ -281,34 +284,34 @@ def capture_photo(output_path: str) -> bool:
|
|||
)
|
||||
if result.returncode == 0 and os.path.exists(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
|
||||
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:
|
||||
continue # 이 명령어가 없으면 다음 것 시도
|
||||
continue # This command is missing -> try the next one
|
||||
except subprocess.TimeoutExpired:
|
||||
log.warning(f"[CAMERA] {cmd} 타임아웃")
|
||||
log.warning(f"[CAMERA] {cmd} timeout")
|
||||
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
|
||||
|
||||
|
||||
def upload_photo(photo_path: str, log_id: int, sensor: dict) -> bool:
|
||||
"""
|
||||
촬영된 사진을 photo_upload.php에 multipart POST.
|
||||
성공 시 True, 실패 시 False.
|
||||
multipart POST the captured photo to photo_upload.php.
|
||||
Returns True on success, False on failure.
|
||||
|
||||
[인증] X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=canonical))
|
||||
canonical = f"{DEVICE_ID}|{sensor_id}|{log_id}|{timestamp}" (공백 없음)
|
||||
서버 php/api/photo_upload.php 의 canonical 과 바이트 동일해야 한다.
|
||||
[Auth] X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=canonical))
|
||||
canonical = f"{DEVICE_ID}|{sensor_id}|{log_id}|{timestamp}" (no whitespace)
|
||||
Must be byte-identical to the canonical in the server's php/api/photo_upload.php.
|
||||
"""
|
||||
timestamp = int(time.time())
|
||||
sensor_id = int(sensor["id"])
|
||||
# 서버와 정확히 동일한 문자열: device_id|sensor_id|log_id|timestamp (공백 없음).
|
||||
# 폼 필드도 동일한 문자열 표현으로 보내야 서버 재구성 canonical 과 일치한다.
|
||||
# Exact same string as the server: device_id|sensor_id|log_id|timestamp (no whitespace).
|
||||
# The form fields must be sent in the same string representation to match the server's reconstructed canonical.
|
||||
sensor_id_str = str(sensor_id)
|
||||
log_id_str = str(log_id)
|
||||
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:
|
||||
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
|
||||
else:
|
||||
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):
|
||||
"""
|
||||
누수 감지 시 처리 순서:
|
||||
1. sensor_data.php에 POST → log_id 수신 (+ SMS 발송)
|
||||
2. 카메라 촬영 (CAMERA_ENABLED 시)
|
||||
3. photo_upload.php에 업로드
|
||||
4. 임시파일 삭제
|
||||
촬영/업로드 실패해도 1단계(알림+SMS)는 이미 완료.
|
||||
Processing order on leak detection:
|
||||
1. POST to sensor_data.php -> receive log_id (+ send SMS)
|
||||
2. Capture with camera (when CAMERA_ENABLED)
|
||||
3. Upload to photo_upload.php
|
||||
4. Delete temp file
|
||||
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)
|
||||
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
|
||||
|
||||
log_id = resp_data.get("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
|
||||
|
||||
# 2) 카메라 촬영
|
||||
# 2) Camera capture
|
||||
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
|
||||
|
||||
# /tmp에 임시 JPEG 파일 생성
|
||||
# Create a temp JPEG file in /tmp
|
||||
fd, photo_path = tempfile.mkstemp(suffix=".jpg", prefix="leak_")
|
||||
os.close(fd)
|
||||
|
||||
try:
|
||||
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
|
||||
|
||||
# 3) 업로드
|
||||
# 3) Upload
|
||||
upload_photo(photo_path, log_id, sensor)
|
||||
|
||||
finally:
|
||||
# 4) 임시파일 정리
|
||||
# 4) Clean up temp file
|
||||
try:
|
||||
if os.path.exists(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 callback(channel):
|
||||
gpio_val = GPIO.input(sensor["gpio"])
|
||||
|
||||
# ACTIVE_LOW: GPIO=0 이면 누수, GPIO=1 이면 정상
|
||||
# ACTIVE_HIGH(트랜지스터 반전): GPIO=1 이면 누수
|
||||
# ACTIVE_LOW: GPIO=0 means leak, GPIO=1 means normal
|
||||
# ACTIVE_HIGH (transistor-inverted): GPIO=1 means leak
|
||||
if ACTIVE_LOW:
|
||||
is_leak = (gpio_val == 0)
|
||||
else:
|
||||
|
|
@ -414,15 +417,15 @@ def make_callback(sensor: dict):
|
|||
|
||||
if changed:
|
||||
if is_leak:
|
||||
log.warning(f"[LEAK DETECTED] 센서{sensor['id']} ({sensor['name']}) !!!")
|
||||
# leak_alert + 사진 촬영/업로드를 한 스레드에서 순차 실행
|
||||
log.warning(f"[LEAK DETECTED] sensor{sensor['id']} ({sensor['name']}) !!!")
|
||||
# Run leak_alert + photo capture/upload sequentially in one thread
|
||||
threading.Thread(
|
||||
target=_handle_leak_with_photo,
|
||||
args=(sensor,),
|
||||
daemon=True
|
||||
).start()
|
||||
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(
|
||||
target=post_to_server,
|
||||
args=("leak_recovery", sensor, False),
|
||||
|
|
@ -433,14 +436,14 @@ def make_callback(sensor: dict):
|
|||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 주기 보고 스레드
|
||||
# Periodic reporting thread
|
||||
# ─────────────────────────────────────────────
|
||||
def periodic_reporter():
|
||||
"""
|
||||
정상 상태: REPORT_INTERVAL 주기로 보고
|
||||
누수 상태: LEAK_REPORT_INTERVAL 주기로 반복 보고 (SMS 재발송 방지는 PHP에서 처리)
|
||||
Normal state: report every REPORT_INTERVAL
|
||||
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}
|
||||
|
||||
while True:
|
||||
|
|
@ -458,26 +461,26 @@ def periodic_reporter():
|
|||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# GPIO 초기화
|
||||
# GPIO initialization
|
||||
# ─────────────────────────────────────────────
|
||||
def setup_gpio():
|
||||
GPIO.setmode(GPIO.BCM)
|
||||
|
||||
# 외부 4.7MΩ 풀다운만 사용 → 내부 풀저항은 PUD_OFF 필수
|
||||
# (내부 50kΩ 풀다운을 켜면 1.5MΩ 센서가 GPIO를 HIGH로 올리지 못함)
|
||||
# Use only the external 4.7MΩ pull-down -> internal pull resistor must be PUD_OFF
|
||||
# (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
|
||||
|
||||
for sensor in SENSORS:
|
||||
gpio = sensor["gpio"]
|
||||
GPIO.setup(gpio, GPIO.IN, pull_up_down=pull)
|
||||
|
||||
# 초기 상태 읽기
|
||||
# Read initial state
|
||||
gpio_val = GPIO.input(gpio)
|
||||
is_leak = (gpio_val == 0) if ACTIVE_LOW else (gpio_val == 1)
|
||||
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,
|
||||
GPIO.BOTH,
|
||||
|
|
@ -485,46 +488,46 @@ def setup_gpio():
|
|||
bouncetime=DEBOUNCE_MS
|
||||
)
|
||||
|
||||
log.info(f"[INIT] GPIO 설정 완료. 총 {len(SENSORS)}개 센서")
|
||||
log.info(f"[INIT] GPIO setup complete. {len(SENSORS)} sensors total")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 메인
|
||||
# Main
|
||||
# ─────────────────────────────────────────────
|
||||
def main():
|
||||
log.info("=" * 60)
|
||||
log.info(f"누수감지 시스템 v2 (Pi 4) 시작 | 기기={DEVICE_ID} | 위치={DEVICE_LOCATION}")
|
||||
log.info(f"플랫폼={platform.machine()} | API={API_URL}")
|
||||
log.info(f"카메라={'활성' if CAMERA_ENABLED else '비활성'}")
|
||||
log.info(f"센서={[s['name'] for s in SENSORS]}")
|
||||
log.info(f"모드={'시뮬레이션' if SIMULATION else '실제 GPIO'}")
|
||||
log.info(f"Leak detection system v2 (Pi 4) starting | device={DEVICE_ID} | location={DEVICE_LOCATION}")
|
||||
log.info(f"platform={platform.machine()} | API={API_URL}")
|
||||
log.info(f"camera={'enabled' if CAMERA_ENABLED else 'disabled'}")
|
||||
log.info(f"sensors={[s['name'] for s in SENSORS]}")
|
||||
log.info(f"mode={'simulation' if SIMULATION else 'real GPIO'}")
|
||||
log.info("=" * 60)
|
||||
|
||||
try:
|
||||
setup_gpio()
|
||||
|
||||
# 시작 시 현재 상태 즉시 보고
|
||||
# Report current state immediately at startup
|
||||
for sensor in SENSORS:
|
||||
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.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:
|
||||
time.sleep(1)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
log.info("[MAIN] 사용자에 의해 종료됨.")
|
||||
log.info("[MAIN] terminated by user.")
|
||||
except Exception as e:
|
||||
log.exception(f"[MAIN] 예외 발생: {e}")
|
||||
log.exception(f"[MAIN] exception occurred: {e}")
|
||||
raise
|
||||
finally:
|
||||
GPIO.cleanup()
|
||||
log.info("[MAIN] GPIO 정리 완료.")
|
||||
log.info("[MAIN] GPIO cleanup complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Pi 4 누수감지 모듈 Python 의존성 (폐쇄망 대비 정확 핀 고정)
|
||||
# 오프라인 설치: vendor.sh 로 wheelhouse/ 에 미리 받아두고
|
||||
# Pi 4 leak detection module Python dependencies (exact version pins for air-gapped deployment)
|
||||
# Offline install: pre-download into wheelhouse/ with vendor.sh, then
|
||||
# pip install --no-index --find-links=wheelhouse -r requirements.txt
|
||||
requests==2.32.3
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
# =============================================================================
|
||||
# /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 systemctl daemon-reload
|
||||
# sudo systemctl enable leak-sensor
|
||||
# sudo systemctl start leak-sensor
|
||||
# sudo systemctl status leak-sensor
|
||||
#
|
||||
# ※ WorkingDirectory/ExecStart 경로는 실제 배치 경로에 맞게 수정.
|
||||
# 아래는 /home/pi/raspberry_pi4 에 배치한 경우 기준.
|
||||
# NOTE: Adjust the WorkingDirectory/ExecStart paths to match the actual deployment path.
|
||||
# The values below assume deployment under /home/pi/raspberry_pi4.
|
||||
# =============================================================================
|
||||
|
||||
[Unit]
|
||||
|
|
@ -22,7 +22,7 @@ Wants=network-online.target
|
|||
Type=simple
|
||||
User=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
|
||||
WorkingDirectory=/home/pi/raspberry_pi4
|
||||
ExecStart=/home/pi/raspberry_pi4/venv/bin/python leak_sensor.py
|
||||
|
|
@ -32,27 +32,27 @@ StandardOutput=journal
|
|||
StandardError=journal
|
||||
SyslogIdentifier=leak-sensor
|
||||
|
||||
# 환경 변수
|
||||
# Environment variables
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
# lgpio(rpi-lgpio 백엔드)는 add_event_detect 시 작업디렉터리에 .lgd-nfy 알림 파이프를
|
||||
# 만든다. 아래 ProtectHome=read-only 로 /home 이 읽기전용이 되므로, PrivateTmp 의
|
||||
# 쓰기가능 /tmp 로 lgpio 작업디렉터리를 옮긴다. (없으면 누수 인터럽트 등록이 실패해
|
||||
# 데몬이 크래시루프 → 누수 감지 불능.) LG_WD 는 lg 라이브러리가 읽는 환경변수.
|
||||
# ※ 이 하드닝은 chardev 백엔드(python3-rpi-lgpio) 를 가정한다. sysfs 기반
|
||||
# python3-rpi.gpio 로 되돌리면 ProtectKernelTunables/ProtectHome 와 충돌하므로
|
||||
# 백엔드를 바꾸지 말 것(vendor.sh/install.sh 의 python3-rpi-lgpio 핀 유지).
|
||||
# lgpio (rpi-lgpio backend) creates a .lgd-nfy notification pipe in the working
|
||||
# directory on add_event_detect. Since ProtectHome=read-only below makes /home
|
||||
# read-only, the lgpio working directory is moved to PrivateTmp's writable /tmp.
|
||||
# (Otherwise leak interrupt registration fails -> daemon crash-loops -> no leak detection.) LG_WD is the env var the lg library reads.
|
||||
# NOTE: This hardening assumes the chardev backend (python3-rpi-lgpio). Reverting to
|
||||
# the sysfs-based python3-rpi.gpio conflicts with ProtectKernelTunables/ProtectHome,
|
||||
# so do not change the backend (keep the python3-rpi-lgpio pin in vendor.sh/install.sh).
|
||||
Environment=LG_WD=/tmp
|
||||
|
||||
# 누수 촬영 임시파일을 프로세스 전용 /tmp 로 격리 (공용 /tmp 노출 방지)
|
||||
# Isolate leak-capture temp files to the process-private /tmp (prevents exposure via shared /tmp)
|
||||
PrivateTmp=true
|
||||
|
||||
# ── 샌드박싱(하드닝) — 데몬 침해 시 root 승격·파일시스템 오염을 차단 ──────────
|
||||
# User=pi 는 NOPASSWD sudo 계정이므로 NoNewPrivileges 로 setuid(sudo) 승격 경로를 막는다.
|
||||
# -- Sandboxing (hardening) -- blocks root escalation / filesystem tampering if the daemon is compromised --------
|
||||
# User=pi is a NOPASSWD sudo account, so NoNewPrivileges blocks the setuid(sudo) escalation path.
|
||||
NoNewPrivileges=true
|
||||
# 루트파일시스템 읽기전용 + /var/log 만 쓰기 허용(로그 로테이션 포함). /tmp 는 PrivateTmp.
|
||||
# Root filesystem read-only + only /var/log writable (including log rotation). /tmp is PrivateTmp.
|
||||
ProtectSystem=strict
|
||||
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
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelTunables=true
|
||||
|
|
@ -60,13 +60,13 @@ ProtectControlGroups=true
|
|||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
RestrictRealtime=true
|
||||
# HTTPS(POST)+로컬소켓(DNS/journal)만. 카메라/GPIO 는 /dev char (소켓 아님)라 무관.
|
||||
# AF_NETLINK 필수 — glibc getaddrinfo/NSS 가 로컬주소 열거에 netlink 를 쓴다
|
||||
# (빼면 DNS 해석이 간헐 실패 → 서버 POST 불가). AF_INET/6=HTTPS, AF_UNIX=DNS·journal.
|
||||
# HTTPS(POST) + local sockets (DNS/journal) only. Camera/GPIO are /dev char (not sockets), so unaffected.
|
||||
# AF_NETLINK required -- glibc getaddrinfo/NSS uses netlink to enumerate local addresses
|
||||
# (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
|
||||
# ※ 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
|
||||
StartLimitInterval=600s
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env python3
|
||||
"""서버 연결 테스트 스크립트 — Pi 4 에서 실행.
|
||||
비밀값(API_URL/API_KEY)은 config_local.py 에서 로드한다 (config 경유)."""
|
||||
"""Server connection test script — run on the Pi 4.
|
||||
Secrets (API_URL/API_KEY) are loaded from config_local.py (via config)."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
|
|
@ -10,23 +10,23 @@ import subprocess
|
|||
|
||||
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)
|
||||
# 다른 지점 노드에서 테스트할 때 그 지점의 leak_sensor_status 를 오염시킨다.
|
||||
# Test with the first sensor configured on this device — hardcoding sensor_id (e.g. 1)
|
||||
# would pollute another site node's leak_sensor_status when testing from that site.
|
||||
TEST_SENSOR_ID = SENSORS[0]["id"]
|
||||
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]
|
||||
|
||||
print("=" * 50)
|
||||
print(" 서버 연결 테스트 (Pi 4)")
|
||||
print(" Server connection test (Pi 4)")
|
||||
print("=" * 50)
|
||||
print(f" API_BASE = {API_BASE}")
|
||||
|
||||
# ── 1단계: curl로 test.php 호출 ──
|
||||
print("\n[1/3] curl로 test.php 연결 확인...")
|
||||
# ── Step 1: call test.php with curl ──
|
||||
print("\n[1/3] Checking test.php connectivity with curl...")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
|
|
@ -35,19 +35,19 @@ try:
|
|||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
code = result.stdout.strip()
|
||||
print(f" HTTP 응답코드: {code}")
|
||||
print(f" HTTP response code: {code}")
|
||||
if code == "200":
|
||||
print(" → 성공! 서버 연결 정상")
|
||||
print(" → Success! Server connection OK")
|
||||
elif code == "403":
|
||||
print(" → 403 Forbidden: 서버/호스팅 자체가 차단 중")
|
||||
print(" → 호스팅 설정 또는 상위 .htaccess 확인 필요")
|
||||
print(" → 403 Forbidden: the server/host itself is blocking")
|
||||
print(" → Check the hosting settings or the upstream .htaccess")
|
||||
else:
|
||||
print(f" → 예상 외 응답: {code}")
|
||||
print(f" → Unexpected response: {code}")
|
||||
except Exception as e:
|
||||
print(f" → curl 실패: {e}")
|
||||
print(f" → curl failed: {e}")
|
||||
|
||||
# ── 2단계: curl로 sensor_data.php 호출 ──
|
||||
print("\n[2/3] curl로 sensor_data.php 연결 확인...")
|
||||
# ── Step 2: call sensor_data.php with curl ──
|
||||
print("\n[2/3] Checking sensor_data.php connectivity with curl...")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["curl", "-s", "-w", "\n%{http_code}",
|
||||
|
|
@ -60,25 +60,26 @@ try:
|
|||
lines = result.stdout.strip().split("\n")
|
||||
code = lines[-1] if lines else "?"
|
||||
body = "\n".join(lines[:-1])
|
||||
print(f" HTTP 응답코드: {code}")
|
||||
print(f" 응답 내용: {body[:200]}")
|
||||
print(f" HTTP response code: {code}")
|
||||
print(f" Response body: {body[:200]}")
|
||||
except Exception as e:
|
||||
print(f" → curl 실패: {e}")
|
||||
print(f" → curl failed: {e}")
|
||||
|
||||
# ── 3단계: Python requests로 서명 포함 호출 ──
|
||||
print("\n[3/3] Python requests로 sensor_data.php 호출...")
|
||||
# ── Step 3: signed call via Python requests ──
|
||||
print("\n[3/3] Calling sensor_data.php with Python requests...")
|
||||
try:
|
||||
import requests
|
||||
|
||||
# raw-body X-Signature HMAC (서버 verify_signature_raw 와 동일 — leak_sensor.leak_event_body
|
||||
# 와 같은 고정 필드 순서로 본문 문자열을 만들고 그 바이트에 서명한 뒤 그대로 POST 한다).
|
||||
# raw-body X-Signature HMAC (same as the server's verify_signature_raw — build the body
|
||||
# string with the same fixed field order as leak_sensor.leak_event_body, sign those bytes,
|
||||
# then POST them verbatim).
|
||||
ts = int(time.time())
|
||||
body = (
|
||||
'{'
|
||||
f'"device_id":"{DEVICE_ID}",'
|
||||
f'"device_location":"{DEVICE_LOCATION}",'
|
||||
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'"is_leak":0,'
|
||||
f'"timestamp":{ts}'
|
||||
|
|
@ -99,24 +100,24 @@ try:
|
|||
}
|
||||
)
|
||||
|
||||
print(f" HTTP 응답코드: {resp.status_code}")
|
||||
print(f" 응답 내용: {resp.text[:200]}")
|
||||
print(f" HTTP response code: {resp.status_code}")
|
||||
print(f" Response body: {resp.text[:200]}")
|
||||
|
||||
if resp.status_code == 200:
|
||||
signed_ok = True
|
||||
print(" → 성공! 모든 연결 정상")
|
||||
print(" → Success! All connections OK")
|
||||
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', '?')}")
|
||||
else:
|
||||
print(f" → 응답코드: {resp.status_code}")
|
||||
print(f" → Response code: {resp.status_code}")
|
||||
|
||||
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:
|
||||
print(f" → 실패: {e}")
|
||||
print(f" → Failed: {e}")
|
||||
|
||||
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)
|
||||
sys.exit(0 if signed_ok else 1)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# vendor.sh - 폐쇄망 설치용 의존성 사전 다운로드
|
||||
# 인터넷에 연결된 빌드/스테이징 머신에서 실행한다.
|
||||
# 산출물: wheelhouse/ (pip wheel + sdist), debs/ (apt .deb) → 타깃 Pi 4 로 전송.
|
||||
# vendor.sh - Pre-download dependencies for air-gapped installation
|
||||
# Run on a build/staging machine that has internet access.
|
||||
# Output: wheelhouse/ (pip wheel + sdist), debs/ (apt .deb) → transfer to target Pi 4.
|
||||
#
|
||||
# wheel/deb 는 아키텍처에 종속된다. 기본값은 타깃(Raspberry Pi OS Bookworm 64-bit,
|
||||
# aarch64, Python 3.11)에 맞춰 명시 핀하므로 x86_64 빌드 머신에서 받아도 올바른
|
||||
# aarch64 wheel 을 확보한다(빌드호스트 arch 와 무관). 그래도 .deb 는 타깃 OS/arch
|
||||
# 에서 받는 것이 가장 안전하다. 필요 시 환경변수로 타깃 조정:
|
||||
# TARGET_PLATFORM (기본 manylinux2014_aarch64), TARGET_PYVER (기본 311), CAM
|
||||
# wheel/deb are architecture-dependent. The defaults are explicitly pinned to the target
|
||||
# (Raspberry Pi OS Bookworm 64-bit, aarch64, Python 3.11), so even downloading on an x86_64
|
||||
# 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 (default manylinux2014_aarch64), TARGET_PYVER (default 311), CAM
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
|
@ -25,12 +25,12 @@ python3 -m pip download -r requirements.txt -d wheelhouse \
|
|||
--python-version "${TARGET_PYVER}" \
|
||||
--implementation cp \
|
||||
--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: \
|
||||
|| 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)"
|
||||
echo " wheelhouse 파일 수: ${WHEEL_COUNT}"
|
||||
[ "${WHEEL_COUNT}" -gt 0 ] || { echo "[ERROR] wheelhouse 비어있음"; exit 1; }
|
||||
echo " wheelhouse file count: ${WHEEL_COUNT}"
|
||||
[ "${WHEEL_COUNT}" -gt 0 ] || { echo "[ERROR] wheelhouse is empty"; exit 1; }
|
||||
|
||||
echo "[vendor] (2/2) apt .deb → debs/"
|
||||
mkdir -p debs
|
||||
|
|
@ -38,41 +38,41 @@ CAM="${CAM:-}"
|
|||
if [ -z "${CAM}" ]; then
|
||||
if apt-cache show rpicam-apps >/dev/null 2>&1; then CAM=rpicam-apps; else CAM=libcamera-apps; 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}"
|
||||
DEPS="$(apt-cache depends --recurse --no-recommends --no-suggests \
|
||||
--no-conflicts --no-breaks --no-replaces --no-enhances ${BASE_PKGS} \
|
||||
| awk '/^\w/{print $1}' | sort -u)"
|
||||
( 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
|
||||
for p in ${BASE_PKGS}; do
|
||||
if ! ls debs/"${p}"_*.deb >/dev/null 2>&1; then
|
||||
echo "[ERROR] 필수 패키지 .deb 누락: ${p}"
|
||||
echo "[ERROR] Missing required package .deb: ${p}"
|
||||
MISS=1
|
||||
fi
|
||||
done
|
||||
[ "${MISS}" = 0 ] || { echo "[ERROR] 필수 .deb 누락 — 반드시 타깃 arch(aarch64 Bookworm)에서 vendor.sh 를 실행하세요."; exit 1; }
|
||||
echo " debs 파일 수: $(ls debs/*.deb 2>/dev/null | wc -l) (필수 패키지 모두 확인됨)"
|
||||
[ "${MISS}" = 0 ] || { echo "[ERROR] Missing required .deb — you must run vendor.sh on the target arch (aarch64 Bookworm)."; exit 1; }
|
||||
echo " debs file count: $(ls debs/*.deb 2>/dev/null | wc -l) (all required packages confirmed)"
|
||||
|
||||
# 아키텍처 실검사 — 파일명이 아니라 .deb 내부 Architecture 필드로 타깃(arm64/all) 검증.
|
||||
# (호스트가 amd64면 apt-get download 가 amd64 .deb 를 받아, 타깃 Pi 에서 dpkg 가
|
||||
# "wrong architecture" 로 깨진다. 파일명만 보는 검사는 이를 못 잡는다.)
|
||||
# Real architecture check — validate the target (arm64/all) via the .deb's internal Architecture field, not the filename.
|
||||
# (If the host is amd64, apt-get download fetches amd64 .debs, and on the target Pi dpkg
|
||||
# breaks with "wrong architecture". A filename-only check cannot catch this.)
|
||||
BAD_ARCH=0
|
||||
for deb in debs/*.deb; do
|
||||
[ -e "$deb" ] || continue
|
||||
arch="$(dpkg-deb --field "$deb" Architecture 2>/dev/null || echo '?')"
|
||||
case "$arch" in
|
||||
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
|
||||
done
|
||||
if [ "${BAD_ARCH}" != 0 ]; then
|
||||
echo "[ERROR] 타깃(aarch64/arm64) 이 아닌 .deb 가 있습니다. 호스트 arch=$(dpkg --print-architecture 2>/dev/null || echo unknown)." >&2
|
||||
echo " 반드시 arm64 Pi(또는 arm64 chroot/qemu)에서 vendor.sh 를 실행하세요." >&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 " You must run vendor.sh on an arm64 Pi (or arm64 chroot/qemu)." >&2
|
||||
exit 1
|
||||
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."
|
||||
|
|
|
|||
8
raspberry_pi4_sht30/.gitignore
vendored
8
raspberry_pi4_sht30/.gitignore
vendored
|
|
@ -1,14 +1,14 @@
|
|||
# 운영·비밀값 (절대 커밋 금지)
|
||||
# Operational secrets (never commit)
|
||||
config_local.py
|
||||
|
||||
# Python venv / 캐시
|
||||
# Python venv / cache
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# 오프라인 설치 번들 (vendor.sh 산출물 — 용량이 큼, 전송은 별도)
|
||||
# Offline install bundle (vendor.sh output - large; transferred separately)
|
||||
wheelhouse/
|
||||
debs/
|
||||
|
||||
# 런타임 로그
|
||||
# Runtime logs
|
||||
*.log
|
||||
|
|
|
|||
|
|
@ -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 의
|
||||
I2C 버스로 읽어, 주기적으로 cafe24 서버(`php/api/sensor_data.php`)에 보고하는 모듈입니다.
|
||||
서버가 측정값을 저장하고, **고온/저온/고습/저습 임계 판정과 SMS, 대시보드 표시**를 모두 수행합니다.
|
||||
A module that reads a **Sensirion SHT30** (Hanjin Data P4422-3, I2C) temperature/humidity sensor over the
|
||||
Raspberry Pi 4 Model B I2C bus and periodically reports to the cafe24 server (`php/api/sensor_data.php`).
|
||||
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 으로 이식**한
|
||||
> "2번 모듈"입니다. wire 포맷(본문/서명)·CRC·변환식은 펌웨어와 **바이트 단위로 동일**합니다.
|
||||
> "1번 모듈"(누수+카메라)은 `../raspberry_pi4/` 폴더입니다. 전체 구성은 루트의
|
||||
> [`SYSTEM_OVERVIEW.md`](../SYSTEM_OVERVIEW.md)를 보세요.
|
||||
> This folder is a **port of the STM32F407 firmware (`sht30_fw`) behavior to Raspberry Pi 4 + Python**,
|
||||
> the "Module 2". The wire format (body/signature), CRC, and conversion formulas are **byte-for-byte identical** to the firmware.
|
||||
> "Module 1" (leak + camera) is in the `../raspberry_pi4/` folder. For the full setup, see
|
||||
> [`SYSTEM_OVERVIEW.md`](../SYSTEM_OVERVIEW.md) in the root.
|
||||
|
||||
---
|
||||
|
||||
## 1. 구성 파일
|
||||
## 1. Files
|
||||
|
||||
| 파일 | 역할 |
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `sht30_sensor.py` | 메인 — 주기 측정 → 본문 생성/서명 → POST(재시도), startup→periodic |
|
||||
| `sht30_driver.py` | SHT30 I2C 측정 + CRC-8 검증 + 물리값 변환 (smbus2, 시뮬레이션 폴백) |
|
||||
| `wire.py` | 본문 JSON + raw-body HMAC 서명 (펌웨어 `jsonbody.c`/`sig.c`와 바이트 동일) |
|
||||
| `config.py` | 비밀 아닌 기본 설정(센서 id/주기/범위/ I2C). `config_local` 에서 비밀값 로드 |
|
||||
| `config_local.example.py` | 운영·비밀값 템플릿 → `config_local.py` 로 복사해 사용(미커밋) |
|
||||
| `requirements.txt` | Python 의존성(`requests`, `smbus2`, 핀 고정) |
|
||||
| `install.sh` | Pi 4 설치(폐쇄망 오프라인 + 온라인 폴백, I2C 활성화 포함) |
|
||||
| `vendor.sh` | 폐쇄망용 의존성 사전 다운로드(빌드 머신에서 실행) |
|
||||
| `test_connection.py` | 서버 연결/서명 POST 점검 |
|
||||
| `test_sensor.py` | CRC/포맷 자가점검 + 측정 1회(하드웨어 없이도 포맷 점검 통과) |
|
||||
| `systemd/sht30-sensor.service` | 부팅 자동 실행 서비스 유닛 |
|
||||
| `sht30_sensor.py` | Main — periodic measurement → body build/sign → POST (with retry), startup→periodic |
|
||||
| `sht30_driver.py` | SHT30 I2C measurement + CRC-8 verification + physical-value conversion (smbus2, simulation fallback) |
|
||||
| `wire.py` | Body JSON + raw-body HMAC signature (byte-identical to firmware `jsonbody.c`/`sig.c`) |
|
||||
| `config.py` | Non-secret default settings (sensor id/interval/range/I2C). Loads secrets from `config_local` |
|
||||
| `config_local.example.py` | Operational/secret-value template → copy to `config_local.py` for use (not committed) |
|
||||
| `requirements.txt` | Python dependencies (`requests`, `smbus2`, pinned) |
|
||||
| `install.sh` | Pi 4 install (air-gapped offline + online fallback, includes enabling I2C) |
|
||||
| `vendor.sh` | Pre-download dependencies for air-gapped use (run on the build machine) |
|
||||
| `test_connection.py` | Server connectivity/signed-POST check |
|
||||
| `test_sensor.py` | CRC/format self-check + one measurement (format check passes even without hardware) |
|
||||
| `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**
|
||||
- **SHT30** 온습도 센서 모듈 (I2C, 한진데이터 P4422-3)
|
||||
- I2C 풀업 저항 **4.7kΩ × 2** (모듈에 내장된 경우 생략)
|
||||
- 전원: **USB-C 5V/3A**, 네트워크: Pi 4 **Gigabit Ethernet**(유선) 또는 WiFi
|
||||
- **SHT30** temperature/humidity sensor module (I2C, Hanjin Data P4422-3)
|
||||
- I2C pull-up resistors **4.7kΩ × 2** (omit if built into the module)
|
||||
- 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 GND | GND (물리 6/9/14/…) | |
|
||||
| SHT30 SDA | GPIO2 / SDA1 (물리 3) | + 4.7kΩ 풀업 → 3.3V |
|
||||
| SHT30 SCL | GPIO3 / SCL1 (물리 5) | + 4.7kΩ 풀업 → 3.3V |
|
||||
| SHT30 ADDR | GND → 주소 **0x44** | VDD 면 0x45 (`config.py` `I2C_ADDR`) |
|
||||
| SHT30 VCC | 3.3V (physical 1) | **No 5V** |
|
||||
| SHT30 GND | GND (physical 6/9/14/…) | |
|
||||
| SHT30 SDA | GPIO2 / SDA1 (physical 3) | + 4.7kΩ pull-up → 3.3V |
|
||||
| SHT30 SCL | GPIO3 / SCL1 (physical 5) | + 4.7kΩ pull-up → 3.3V |
|
||||
| SHT30 ADDR | GND → address **0x44** | 0x45 if tied to VDD (`config.py` `I2C_ADDR`) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 서버 호환성 (반드시 확인)
|
||||
## 3. Server compatibility (must verify)
|
||||
|
||||
이 모듈은 **현재 repo 의 `php/` 서버(=cafe24 SHT30 대시보드 서버)**로 보고합니다.
|
||||
서버는 **raw-body HMAC 서명**을 요구합니다.
|
||||
This module reports to **the current repo's `php/` server (= the cafe24 SHT30 dashboard server)**.
|
||||
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` 와 **정확히 동일**해야 합니다.
|
||||
- 서버는 `timestamp` 신선도(±600초)로 재전송을 막으므로 Pi 시각이 **NTP 로 동기**되어 있어야 합니다(Raspberry Pi OS 기본 활성).
|
||||
- `sensor_id=2` 로 보고하며, 서버 대시보드(`dashboard.php`)는 기본 차트로 sensor 2 를 표시합니다.
|
||||
- 임계 경보(고온/저온/고습/저습)·SMS·복구 판정은 **모두 서버**가 수행합니다. 이 모듈은 측정값만 보고합니다.
|
||||
- `API_KEY` in `config_local.py` must be **exactly identical** to `API_KEY` in the server's `config.local.php`.
|
||||
- The server blocks replays via `timestamp` freshness (±600 seconds), so the Pi clock must be **NTP-synchronized** (enabled by default on Raspberry Pi OS).
|
||||
- It reports with `sensor_id=2`, and the server dashboard (`dashboard.php`) shows sensor 2 as the default chart.
|
||||
- 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/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
|
||||
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).
|
||||
`config_local.py` 와 `API_KEY` 는 **절대 커밋하지 마세요**(`.gitignore` 처리됨).
|
||||
If `config_local.py` is missing, execution **stops immediately with a clear error** (fail closed).
|
||||
**Never commit** `config_local.py` or `API_KEY` (handled by `.gitignore`).
|
||||
|
||||
### 4.2 온라인 설치 (인터넷 가능 환경)
|
||||
### 4.2 Online install (internet-capable environment)
|
||||
|
||||
```bash
|
||||
chmod +x 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)** 머신에서
|
||||
의존성을 먼저 받아 번들을 만든 뒤, 타깃 Pi 4 로 옮겨 설치합니다.
|
||||
On an internet-connected machine of the **same architecture (aarch64, ideally the same Pi 4 + Bookworm 64-bit)**,
|
||||
download the dependencies first to build a bundle, then move it to the target Pi 4 and install.
|
||||
|
||||
```bash
|
||||
# (빌드 머신, 인터넷 O) wheelhouse/ + debs/ 생성
|
||||
# (build machine, internet YES) generate wheelhouse/ + debs/
|
||||
./vendor.sh
|
||||
|
||||
# wheelhouse/ 와 debs/ 를 타깃 Pi 4 의 이 폴더로 복사한 뒤:
|
||||
# (타깃 Pi 4, 인터넷 X)
|
||||
./install.sh # debs/ · wheelhouse/ 가 있으면 자동으로 오프라인 설치
|
||||
# after copying wheelhouse/ and debs/ into this folder on the target Pi 4:
|
||||
# (target Pi 4, internet NO)
|
||||
./install.sh # if debs/ · wheelhouse/ exist, it installs offline automatically
|
||||
```
|
||||
|
||||
> `smbus2` 는 순수 파이썬(arch 무관)이라 x86 빌드 머신에서 받아도 됩니다.
|
||||
> `requests` 등 다른 wheel/deb 는 **aarch64** 용을 받아야 합니다(타깃 arch 에서 `vendor.sh` 권장).
|
||||
> `smbus2` is pure Python (arch-independent), so it can be downloaded on an x86 build machine.
|
||||
> 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
|
||||
# 센서가 보이는지 (0x44)
|
||||
# is the sensor visible (0x44)
|
||||
i2cdetect -y 1
|
||||
|
||||
# 포맷/CRC 자가점검 + 측정 1회 (하드웨어 없으면 시뮬레이션)
|
||||
# format/CRC self-check + one measurement (simulation if no hardware)
|
||||
source venv/bin/activate
|
||||
python3 test_sensor.py
|
||||
|
||||
# 서버 연결/서명 점검
|
||||
# server connectivity/signature check
|
||||
python3 test_connection.py
|
||||
|
||||
# 실제 실행
|
||||
# actual run
|
||||
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
|
||||
# 폴더를 /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 systemctl daemon-reload
|
||||
sudo systemctl enable sht30-sensor
|
||||
|
|
@ -140,17 +140,17 @@ sudo systemctl status sht30-sensor
|
|||
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`(미커밋)에만 둡니다.
|
||||
커밋되는 파일에는 placeholder 만 존재합니다.
|
||||
- 보고 본문은 `HMAC-SHA256(API_KEY, raw_body)` 서명(`X-Signature` 헤더)으로 위변조를 방지합니다
|
||||
(길이확장 공격에 면역). 레거시 body-field sha256 폴백은 서버에서 기본 비활성입니다.
|
||||
- 서명하는 바이트와 전송하는 바이트는 **항상 동일**합니다(`wire.py` 가 본문을 직접 만들어
|
||||
그 바이트에 서명 후 raw POST). 펌웨어 `sig.c` / `reference.py` / 서버 `verify_signature_raw`
|
||||
와 바이트 단위로 일치합니다.
|
||||
- `config_local.py` 노출 시 즉시 `API_KEY` 를 교체(서버 동시 변경)하세요.
|
||||
- Secrets (`API_KEY`), server URL, and device info live only in `config_local.py` (not committed).
|
||||
Committed files contain only placeholders.
|
||||
- The report body is protected against tampering by an `HMAC-SHA256(API_KEY, raw_body)` signature (`X-Signature` header)
|
||||
(immune to length-extension attacks). The legacy body-field sha256 fallback is disabled by default on the server.
|
||||
- The signed bytes and the transmitted bytes are **always identical** (`wire.py` builds the body directly,
|
||||
signs those bytes, then raw POSTs). This matches firmware `sig.c` / `reference.py` / server `verify_signature_raw`
|
||||
byte-for-byte.
|
||||
- If `config_local.py` is exposed, immediately rotate `API_KEY` (change it on the server at the same time).
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<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>
|
||||
:root{
|
||||
--ink:#1a1d21; --muted:#5c6570; --line:#dfe3e8; --bg:#eef1f4; --card:#ffffff;
|
||||
|
|
@ -182,31 +182,31 @@
|
|||
|
||||
<!-- ============ 1. HEADER ============ -->
|
||||
<header class="hero">
|
||||
<p class="eyebrow">현장 설치 문서 · 배선 & 핀맵</p>
|
||||
<h1>온습도 노드 — 1번 지점 / 서버실</h1>
|
||||
<p class="eyebrow">Field Installation Doc · Wiring & Pinmap</p>
|
||||
<h1>Temp/Humidity Node — Site 1 / Server Room</h1>
|
||||
<p class="sum">
|
||||
SHT30 온습도 센서를 라즈베리파이(Pi-B)의 I2C(버스 1, 주소 0x44)에 연결하는 측정 노드입니다.
|
||||
이 노드는 측정값만 <b>5분 주기</b>로 서버에 보고하며, 고온·저온·고습·저습 임계 판정과 SMS 발송은 서버가 담당합니다.
|
||||
A measurement node connecting the SHT30 temperature/humidity sensor to the Raspberry Pi (Pi-B) over I2C (bus 1, address 0x44).
|
||||
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>
|
||||
<div class="chips">
|
||||
<span class="chip">Raspberry Pi 4 Model B</span>
|
||||
<span class="chip mono">BCM2711</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">addr <b>0x44</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>
|
||||
</header>
|
||||
|
||||
<!-- ============ 2. PINMAP SVG ============ -->
|
||||
<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 -->
|
||||
<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 -->
|
||||
<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"/>
|
||||
|
|
@ -372,56 +372,56 @@
|
|||
<text class="plbl lbl-r lbl-off" x="344" y="630">GPIO21</text>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<div class="legend">
|
||||
<span><i class="sw pwr"></i>3.3V 전원 — 핀 1</span>
|
||||
<span><i class="sw i2c"></i>I2C SDA/SCL — 핀 3·5</span>
|
||||
<span><i class="sw gnd"></i>GND (사용) — 핀 6</span>
|
||||
<span><i class="sw altgnd"></i>대체 GND (선택) — 9·14·20·25·30·34·39</span>
|
||||
<span><i class="sw off"></i>미사용 핀</span>
|
||||
<span><i class="sw pwr"></i>3.3V power — pin 1</span>
|
||||
<span><i class="sw i2c"></i>I2C SDA/SCL — pins 3·5</span>
|
||||
<span><i class="sw gnd"></i>GND (used) — pin 6</span>
|
||||
<span><i class="sw altgnd"></i>Alt GND (optional) — 9·14·20·25·30·34·39</span>
|
||||
<span><i class="sw off"></i>Unused pins</span>
|
||||
</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>
|
||||
|
||||
<!-- ============ 3. PINMAP TABLE ============ -->
|
||||
<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>
|
||||
<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>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><b>3.3V 전원</b> (VCC)</td>
|
||||
<td><b>3.3V power</b> (VCC)</td>
|
||||
<td class="mono">—</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>
|
||||
<td><b>I2C 데이터</b> (SDA)</td>
|
||||
<td><b>I2C data</b> (SDA)</td>
|
||||
<td class="mono">GPIO2 / SDA1</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>
|
||||
<td><b>I2C 클럭</b> (SCL)</td>
|
||||
<td><b>I2C clock</b> (SCL)</td>
|
||||
<td class="mono">GPIO3 / SCL1</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>
|
||||
<td><b>접지</b> (GND)</td>
|
||||
<td><b>Ground</b> (GND)</td>
|
||||
<td class="mono">—</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>
|
||||
<td><b>주소 선택</b> (ADDR)</td>
|
||||
<td><b>Address select</b> (ADDR)</td>
|
||||
<td class="mono">—</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>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -429,14 +429,14 @@
|
|||
|
||||
<!-- ============ 4. SCHEMATIC SVG ============ -->
|
||||
<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 -->
|
||||
<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"/>
|
||||
<text class="boxtitle" x="145" y="116">SHT30 센서 모듈</text>
|
||||
<text class="boxtitle" x="715" y="116">Pi 40핀 헤더</text>
|
||||
<text class="boxtitle" x="145" y="116">SHT30 sensor module</text>
|
||||
<text class="boxtitle" x="715" y="116">Pi 40-pin header</text>
|
||||
|
||||
<!-- SHT30 terminal labels (right side) -->
|
||||
<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>
|
||||
|
||||
<!-- Pi terminal labels (left side) -->
|
||||
<text class="term" x="650" y="150">3V3 (핀 1)</text>
|
||||
<text class="term" x="650" y="200">SDA1 (핀 3)</text>
|
||||
<text class="term" x="650" y="250">SCL1 (핀 5)</text>
|
||||
<text class="term" x="650" y="305">GND (핀 6)</text>
|
||||
<text class="term" x="650" y="150">3V3 (pin 1)</text>
|
||||
<text class="term" x="650" y="200">SDA1 (pin 3)</text>
|
||||
<text class="term" x="650" y="250">SCL1 (pin 5)</text>
|
||||
<text class="term" x="650" y="305">GND (pin 6)</text>
|
||||
|
||||
<!-- POWER wire (also 3V3 rail for pull-ups) -->
|
||||
<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 -->
|
||||
<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) -->
|
||||
<path class="w-i2c" d="M220 200 H470 A10 9 0 0 1 490 200 H640"/>
|
||||
|
|
@ -490,14 +490,14 @@
|
|||
</svg>
|
||||
|
||||
<p class="sch-note">
|
||||
풀업 저항 R1·R2 (4.7kΩ ×2) 는 3.3V 레일로 연결됩니다. <b>SHT30 모듈에 내장되어 있으면 생략</b>하십시오.
|
||||
SDA 선의 반원(hop)은 배선 교차 표시일 뿐 전기적 연결이 아닙니다.
|
||||
Pull-up resistors R1·R2 (4.7kΩ ×2) connect to the 3.3V rail. <b>Omit if built into the SHT30 module</b>.
|
||||
The half-circle (hop) on the SDA line only marks a wire crossing — it is not an electrical connection.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- ============ 5. ELECTRICAL NOTES ============ -->
|
||||
<section>
|
||||
<h2><span class="n">5</span>전기 설계 · 주의</h2>
|
||||
<h2><span class="n">5</span>Electrical Design · Cautions</h2>
|
||||
|
||||
<div class="warn">
|
||||
<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"/>
|
||||
</svg>
|
||||
<div>
|
||||
<h3>치명적 경고 — 전원은 반드시 3.3V</h3>
|
||||
<p>SHT30 의 <b>VCC 는 물리 핀 1 (3.3V)</b> 에만 연결하십시오.
|
||||
<b>5V (핀 2·4) 연결 절대 금지</b> — 소자가 즉시 손상될 수 있습니다.
|
||||
결선 전 핀 번호를 다시 확인하십시오.</p>
|
||||
<h3>Critical Warning — On the Pi it MUST be 3.3V</h3>
|
||||
<p>Connect the SHT30's <b>VCC to physical pin 1 (3.3V)</b>.
|
||||
<b>Do NOT connect 5V (pins 2·4) on the Pi</b> — the SHT30 module itself tolerates 2.4–5.5V, but
|
||||
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>
|
||||
|
||||
<ul class="notes">
|
||||
<li><b>I2C 풀업:</b> SDA·SCL 각각 4.7kΩ 풀업 저항을 3.3V 로 연결. 센서 모듈에 내장된 경우 생략 가능.</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>I2C 버스:</b> 버스 1 사용. 부팅 후 I2C 가 활성화되어 있어야 함(<code>raspi-config</code> 또는 <code>dtparam=i2c_arm=on</code>).</li>
|
||||
<li><b>배선 길이:</b> I2C 는 짧은 케이블을 권장. 케이블이 길면 통신 오류·풀업 값 조정이 필요할 수 있습니다.</li>
|
||||
<li><b>역할 분담:</b> 이 노드는 측정값만 5분 주기로 서버에 보고합니다. 고온·저온·고습·저습 <b>임계 판정과 SMS 발송은 서버</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>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>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 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>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>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>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>
|
||||
|
||||
<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
|
||||
0 1 2 3 4 5 6 7 8 9 a b c d e f
|
||||
40: -- -- -- -- <span class="ok">44</span> -- -- -- -- -- -- -- -- -- -- --
|
||||
|
|
@ -532,24 +533,24 @@ $ i2cdetect -y 1
|
|||
|
||||
<!-- ============ 6. INSTALL CHECKLIST ============ -->
|
||||
<section>
|
||||
<h2><span class="n">6</span>설치 체크리스트</h2>
|
||||
<h2><span class="n">6</span>Installation Checklist</h2>
|
||||
<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">VCC → 물리 핀 1 (3.3V) 연결 — <b>5V 핀(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">SDA → 핀 3 (GPIO2), SCL → 핀 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">ADDR → GND 확인 (주소 0x44 선택)</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">I2C 활성화 확인 (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">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">측정 로그 및 서버 수신 확인 (5분 주기 보고 정상)</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">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">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">Connect SDA → pin 3 (GPIO2), SCL → pin 5 (GPIO3)</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">Verify ADDR → GND (address 0x44 selected)</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">Verify I2C is enabled (raspi-config / dtparam=i2c_arm=on)</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">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">Check measurement log and server reception (5-min reporting OK)</span></li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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) 등 운영·비밀값은
|
||||
# config_local.py 에서 로드한다. config_local.py 는 .gitignore 로 커밋 제외.
|
||||
# 설정 방법: cp config_local.example.py config_local.py → 실제 값으로 채움
|
||||
# Operational/secret values such as server URL / API_KEY / device identifiers (DEVICE_ID/LOCATION)
|
||||
# are loaded from config_local.py. config_local.py is excluded from commits via .gitignore.
|
||||
# Setup: cp config_local.example.py config_local.py → fill in the real values
|
||||
#
|
||||
# 이 모듈은 "2번 모듈"(온습도)이다. 서버(php/) 대시보드는 sensor_id=2 를 기본
|
||||
# 온습도 센서로 표시하므로 SENSOR_ID 기본값은 2 로 둔다.
|
||||
# This module is "Module 2" (temperature/humidity). The server (php/) dashboard shows sensor_id=2 as the
|
||||
# default temperature/humidity sensor, so the SENSOR_ID default is set to 2.
|
||||
# =============================================================================
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 센서 식별 (서버 대시보드 표시용; 비밀 아님)
|
||||
# SENSOR_ID 는 서버 leak_sensor_metric / leak_sensor_status 의 키. 대시보드 기본 차트=2.
|
||||
# Sensor identity (for server dashboard display; not secret)
|
||||
# SENSOR_ID is the key in the server's leak_sensor_metric / leak_sensor_status. Dashboard default chart=2.
|
||||
# ─────────────────────────────────────────────
|
||||
SENSOR_ID = 2
|
||||
SENSOR_NAME = "2번 센서 (SHT30)"
|
||||
SENSOR_NAME = "Sensor 2 (SHT30)"
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# I2C 버스 설정 (Pi 4 40핀 헤더)
|
||||
# SDA = GPIO2 (물리 3번), SCL = GPIO3 (물리 5번) → I2C 버스 1
|
||||
# ADDR 핀: GND=0x44, VDD=0x45
|
||||
# I2C bus config (Pi 4 40-pin header)
|
||||
# SDA = GPIO2 (physical pin 3), SCL = GPIO3 (physical pin 5) → I2C bus 1
|
||||
# ADDR pin: GND=0x44, VDD=0x45
|
||||
# ─────────────────────────────────────────────
|
||||
I2C_BUS = 1
|
||||
I2C_ADDR = 0x44
|
||||
|
||||
# 강제 시뮬레이션. True 면 하드웨어 없이 더미 측정값으로 동작(서버 연동 점검용).
|
||||
# False 라도 smbus2 미설치/ I2C 미활성이면 드라이버가 자동 시뮬레이션으로 폴백.
|
||||
# Force simulation. If True, runs with dummy measurements without hardware (for server integration checks).
|
||||
# Even if False, the driver auto-falls back to simulation when smbus2 is not installed / I2C is disabled.
|
||||
FORCE_SIMULATION = False
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 측정/보고 주기 (firmware app_config.h 와 동일 기본값)
|
||||
# Measurement/report intervals (same defaults as firmware app_config.h)
|
||||
# ─────────────────────────────────────────────
|
||||
REPORT_INTERVAL = 300 # 정상 측정·보고 간격 (초) = 5분
|
||||
MEASURE_RETRY_DELAY = 5 # 측정 실패 후 재시도 대기 (초)
|
||||
REPORT_INTERVAL = 300 # Normal measure/report interval (seconds) = 5 minutes
|
||||
MEASURE_RETRY_DELAY = 5 # Wait before retry after a failed measurement (seconds)
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 온습도 물리 plausibility 범위 (firmware classify_metric_status 와 동일)
|
||||
# 이 범위를 벗어나면 metric_status="out_of_range" 로 보고한다.
|
||||
# ※ 운영 임계(고온/저온/고습/저습) 판정·SMS 는 모두 서버(config.php METRIC_*)가
|
||||
# 수행한다. 이 모듈은 측정값만 충실히 보고한다.
|
||||
# Physical plausibility range for temp/humidity (same as firmware classify_metric_status)
|
||||
# Values outside this range are reported as metric_status="out_of_range".
|
||||
# * 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_HIGH_C = 125.0
|
||||
RH_LOW = 0.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"
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 로그 설정
|
||||
# Logging config
|
||||
# ─────────────────────────────────────────────
|
||||
LOG_FILE = "/var/log/sht30_sensor.log"
|
||||
LOG_MAX_BYTES = 5 * 1024 * 1024 # 5MB
|
||||
LOG_BACKUP_COUNT = 3
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# HTTP 재시도 설정 (firmware APP_HTTP_* 와 동일 기본값)
|
||||
# HTTP retry config (same defaults as firmware APP_HTTP_*)
|
||||
# ─────────────────────────────────────────────
|
||||
HTTP_TIMEOUT = 10 # 단일 요청 타임아웃 (초)
|
||||
HTTP_RETRY_COUNT = 3 # 재시도 횟수
|
||||
HTTP_RETRY_DELAY = 5 # 재시도 간 대기 (초)
|
||||
HTTP_TIMEOUT = 10 # Single request timeout (seconds)
|
||||
HTTP_RETRY_COUNT = 3 # Number of retries
|
||||
HTTP_RETRY_DELAY = 5 # Wait between retries (seconds)
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 운영·비밀값 로드 (fail closed)
|
||||
# config_local.py 가 없으면 placeholder/하드코딩 비밀로 잘못 기동하는 대신
|
||||
# 명확한 오류로 즉시 중단한다.
|
||||
# Load operational/secret values (fail closed)
|
||||
# If config_local.py is missing, abort immediately with a clear error instead of
|
||||
# starting incorrectly with placeholder/hardcoded secrets.
|
||||
# ─────────────────────────────────────────────
|
||||
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_KEY,
|
||||
DEVICE_ID,
|
||||
|
|
@ -76,7 +76,7 @@ try:
|
|||
)
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"config_local.py 를 찾을 수 없습니다. "
|
||||
"`cp config_local.example.py config_local.py` 후 실제 서버 URL / API_KEY / "
|
||||
"기기 정보를 채우세요. (비밀값은 절대 커밋하지 마세요.)"
|
||||
"config_local.py not found. "
|
||||
"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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# → 실제 값으로 채운 뒤 저장. config_local.py 는 절대 커밋하지 말 것
|
||||
# (.gitignore 로 제외되어 있음).
|
||||
# Usage: cp config_local.example.py config_local.py
|
||||
# -> Fill in the real values and save. Never commit config_local.py
|
||||
# (it is excluded via .gitignore).
|
||||
#
|
||||
# ⚠️ 서버 호환성
|
||||
# 이 모듈(2번)은 SHT30 온습도 측정을 cafe24 서버 php/api/sensor_data.php 로
|
||||
# 보고한다. 서버는 raw-body 서명을 요구한다:
|
||||
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=요청본문 바이트))
|
||||
# → API_KEY 는 서버 config.local.php 의 API_KEY 와 "정확히 동일"해야 한다.
|
||||
# 서버 임계 판정(고온/저온/고습/저습)·SMS·대시보드는 모두 서버가 수행한다.
|
||||
# WARNING: Server compatibility
|
||||
# This module (#2) reports SHT30 temperature/humidity readings to the cafe24
|
||||
# server php/api/sensor_data.php. The server requires a raw-body signature:
|
||||
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=request body bytes))
|
||||
# -> API_KEY must be "exactly identical" to API_KEY in the server config.local.php.
|
||||
# Threshold decisions (high/low temp, high/low humidity), SMS, and the dashboard are all done by the server.
|
||||
# =============================================================================
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 서버 API 엔드포인트 (SHT30 수신 서버 = cafe24 php/)
|
||||
# INSTALL_PI_SERVER.md 기준 웹 루트: public_html/raspi_leck_detecter/
|
||||
# Server API endpoint (SHT30 receiving server = cafe24 php/)
|
||||
# Web root per INSTALL_PI_SERVER.md: public_html/raspi_leck_detecter/
|
||||
# ─────────────────────────────────────────────
|
||||
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"
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 기기 식별 (여러 대 운영 시 구분). 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_LOCATION = "서버실"
|
||||
DEVICE_LOCATION = "Server Room"
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# 온습도(SHT30) 모듈 v2 (Raspberry Pi 4 Model B) 설치 스크립트 — 2번 모듈
|
||||
# 대상 OS: Raspberry Pi OS Bookworm (64-bit, aarch64)
|
||||
# Temp/humidity (SHT30) module v2 (Raspberry Pi 4 Model B) install script — module 2
|
||||
# Target OS: Raspberry Pi OS Bookworm (64-bit, aarch64)
|
||||
#
|
||||
# 폐쇄망(air-gapped) 지원:
|
||||
# - debs/ 또는 wheelhouse/ 가 있으면 자동으로 "오프라인 모드"로 전환.
|
||||
# - 오프라인 모드에서는 절대 네트워크에 접근하지 않는다. 번들이 일부만 있으면
|
||||
# 온라인으로 몰래 빠지지 않고 명확한 오류로 중단한다(폐쇄망 규칙). 사전
|
||||
# 다운로드는 vendor.sh 로 빌드 머신에서 수행.
|
||||
# - 환경변수 OFFLINE=1/0 으로 강제 지정 가능.
|
||||
# - 로그 파일 소유자: SERVICE_USER (기본 pi).
|
||||
# Air-gapped support:
|
||||
# - 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-
|
||||
# download is done on the build machine via vendor.sh.
|
||||
# - Can be forced via the OFFLINE=1/0 environment variable.
|
||||
# - 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
|
||||
cd "$(dirname "$0")"
|
||||
|
|
@ -19,132 +19,132 @@ cd "$(dirname "$0")"
|
|||
SERVICE_USER="${SERVICE_USER:-pi}"
|
||||
|
||||
echo "=========================================="
|
||||
echo " 온습도(SHT30) 모듈 v2 (Pi 4) 설치 — 2번 모듈"
|
||||
echo " Temp/humidity (SHT30) module v2 (Pi 4) install — module 2"
|
||||
echo "=========================================="
|
||||
|
||||
# 0) 보드 모델 안내 (강제 아님)
|
||||
# 0) Board model notice (not enforced)
|
||||
if [ -r /proc/device-tree/model ]; then
|
||||
MODEL="$(tr -d '\0' < /proc/device-tree/model)"
|
||||
echo "[INFO] 보드: ${MODEL}"
|
||||
echo "[INFO] Board: ${MODEL}"
|
||||
case "${MODEL}" in
|
||||
*"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
|
||||
fi
|
||||
|
||||
# 오프라인 모드 판정
|
||||
# Offline mode detection
|
||||
OFFLINE="${OFFLINE:-}"
|
||||
if [ -z "${OFFLINE}" ]; then
|
||||
if compgen -G "debs/*.deb" >/dev/null || [ -d wheelhouse ]; then
|
||||
OFFLINE=1
|
||||
echo "[INFO] 오프라인 번들 감지 → 폐쇄망 오프라인 설치 모드 (네트워크 미사용)"
|
||||
echo "[INFO] Offline bundle detected → air-gapped offline install mode (no network)"
|
||||
else
|
||||
OFFLINE=0
|
||||
echo "[INFO] 오프라인 번들 없음 → 온라인 설치 모드 (인터넷 필요)"
|
||||
echo "[INFO] No offline bundle → online install mode (internet required)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 1) 시스템 패키지: requests + venv + i2c-tools
|
||||
echo "[1/4] 시스템 패키지 설치..."
|
||||
# 1) System packages: requests + venv + i2c-tools
|
||||
echo "[1/4] Installing system packages..."
|
||||
if [ "${OFFLINE}" = "1" ]; 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
|
||||
fi
|
||||
echo " 오프라인 .deb 설치 (apt-get --no-download — 의존성 순서·해결 자동, 네트워크 미사용)"
|
||||
# --no-install-recommends: vendor.sh 가 Recommends 를 안 담으므로(--no-recommends) 맞춘다.
|
||||
echo " Offline .deb install (apt-get --no-download — dependency ordering/resolution automatic, no network)"
|
||||
# --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
|
||||
else
|
||||
echo " 온라인 apt 설치 (인터넷 필요)"
|
||||
echo " Online apt install (internet required)"
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y python3-requests python3-venv i2c-tools
|
||||
fi
|
||||
|
||||
# 2) I2C 활성화 (dtparam + 모듈 로드 + 그룹)
|
||||
echo "[2/4] I2C 활성화..."
|
||||
# 부트 설정 경로: Bookworm=/boot/firmware/config.txt, 구버전=/boot/config.txt
|
||||
# 2) Enable I2C (dtparam + module load + group)
|
||||
echo "[2/4] Enabling I2C..."
|
||||
# Boot config path: Bookworm=/boot/firmware/config.txt, older=/boot/config.txt
|
||||
BOOT_CFG=""
|
||||
for c in /boot/firmware/config.txt /boot/config.txt; do
|
||||
if [ -f "$c" ]; then BOOT_CFG="$c"; break; fi
|
||||
done
|
||||
if [ -n "${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
|
||||
NEED_REBOOT=1
|
||||
else
|
||||
echo " dtparam=i2c_arm=on 이미 설정됨"
|
||||
echo " dtparam=i2c_arm=on already set"
|
||||
fi
|
||||
else
|
||||
echo "[WARN] config.txt 를 찾지 못함 — raspi-config 로 수동 I2C 활성화 필요"
|
||||
echo "[WARN] config.txt not found — enable I2C manually via raspi-config"
|
||||
fi
|
||||
# i2c-dev 모듈을 즉시 로드 + 부팅 시 자동 로드 등록
|
||||
sudo modprobe i2c-dev 2>/dev/null || echo "[WARN] 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 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
|
||||
echo "i2c-dev" | sudo tee /etc/modules-load.d/i2c.conf >/dev/null
|
||||
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
|
||||
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
|
||||
|
||||
# 3) Python venv + requests + smbus2
|
||||
echo "[3/4] Python 가상환경 설정..."
|
||||
echo "[3/4] Setting up Python virtual environment..."
|
||||
if [ ! -d venv ]; then
|
||||
python3 -m venv --system-site-packages venv
|
||||
echo " venv 생성 완료"
|
||||
echo " venv created"
|
||||
else
|
||||
echo " venv 이미 존재 — 건너뜀"
|
||||
echo " venv already exists — skipping"
|
||||
fi
|
||||
# shellcheck disable=SC1091
|
||||
source venv/bin/activate
|
||||
if [ "${OFFLINE}" = "1" ]; 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
|
||||
fi
|
||||
echo " 오프라인 wheel 설치 (--no-index — 네트워크 미사용)"
|
||||
echo " Offline wheel install (--no-index — no network)"
|
||||
pip install --no-index --find-links=wheelhouse -r requirements.txt
|
||||
else
|
||||
echo " 온라인 pip 설치 (인터넷 필요)"
|
||||
echo " Online pip install (internet required)"
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
fi
|
||||
echo " 패키지 설치 완료"
|
||||
echo " Packages installed"
|
||||
|
||||
# 4) 로그 파일 (최소 권한: 소유자만 쓰기)
|
||||
echo "[4/4] 로그 파일 생성..."
|
||||
# 4) Log file (least privilege: owner-only write)
|
||||
echo "[4/4] Creating log file..."
|
||||
sudo mkdir -p /var/log
|
||||
sudo touch /var/log/sht30_sensor.log
|
||||
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
|
||||
sudo chmod 640 /var/log/sht30_sensor.log
|
||||
|
||||
# config_local.py 즉시 잠금 (API_KEY 포함 — harden 전까지 world-readable 방지)
|
||||
# 하드닝(harden_pi.sh)이 나중에 다시 조이지만, 그 사이 창(window)에도 노출되지
|
||||
# 않도록 설치 시점에 바로 600 으로 잠근다. harden 여부와 무관하게 보호된다.
|
||||
# Lock config_local.py immediately (contains API_KEY — prevent world-readable until harden)
|
||||
# Hardening (harden_pi.sh) tightens it again later, but to avoid exposure even in
|
||||
# that window, lock it to 600 right at install time. Protected regardless of harden.
|
||||
if [ -f config_local.py ]; then
|
||||
chmod 600 config_local.py
|
||||
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
|
||||
|
||||
# config_local 안내 (fail closed — 없으면 실행 시 즉시 중단됨)
|
||||
# config_local notice (fail closed — if missing, execution stops immediately)
|
||||
if [ ! -f config_local.py ]; then
|
||||
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"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " 설치 완료! (OFFLINE=${OFFLINE})"
|
||||
echo " Install complete! (OFFLINE=${OFFLINE})"
|
||||
echo "=========================================="
|
||||
if [ "${NEED_REBOOT:-0}" = "1" ]; then
|
||||
echo " ⚠️ I2C 를 방금 활성화했습니다 → 'sudo reboot' 후 센서가 인식됩니다."
|
||||
echo " ⚠️ I2C was just enabled → the sensor is recognized after 'sudo reboot'."
|
||||
fi
|
||||
echo " 센서 확인: i2cdetect -y 1 (0x44 또는 0x45 가 보여야 함)"
|
||||
echo " 실행: source venv/bin/activate && python3 sht30_sensor.py"
|
||||
echo " 서비스: README.md 의 systemd 섹션 참고"
|
||||
echo " Sensor check: i2cdetect -y 1 (should show 0x44 or 0x45)"
|
||||
echo " Run: source venv/bin/activate && python3 sht30_sensor.py"
|
||||
echo " Service: see the systemd section in README.md"
|
||||
echo ""
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Pi 4 온습도(SHT30) 모듈 Python 의존성 (폐쇄망 대비 정확 핀 고정)
|
||||
# 오프라인 설치: vendor.sh 로 wheelhouse/ 에 미리 받아두고
|
||||
# Pi 4 temperature/humidity (SHT30) module Python dependencies (exact version pins for air-gapped networks)
|
||||
# Offline install: pre-download into wheelhouse/ with vendor.sh, then
|
||||
# pip install --no-index --find-links=wheelhouse -r requirements.txt
|
||||
#
|
||||
# smbus2 는 순수 파이썬(arch 무관 py3-none-any wheel)이라 x86 빌드 머신에서 받아도
|
||||
# Pi 4(aarch64)에서 그대로 동작한다. requests 도 함께 핀 고정.
|
||||
# smbus2 is pure Python (arch-independent py3-none-any wheel), so even if fetched on an x86 build machine
|
||||
# it runs as-is on Pi 4 (aarch64). requests is version-pinned as well.
|
||||
requests==2.32.3
|
||||
smbus2==0.5.0
|
||||
|
|
|
|||
|
|
@ -1,30 +1,30 @@
|
|||
#!/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 헤더)
|
||||
# - SHT30(Sensirion, 한진데이터 P4422-3) I2C 온습도 센서를 I2C 버스 1에 연결.
|
||||
# - SDA=GPIO2(물리 3), SCL=GPIO3(물리 5), ADDR=GND → 0x44, 3.3V/GND, 4.7kΩ 풀업.
|
||||
# [Target hardware] Raspberry Pi 4 Model B (BCM2711, 40-pin GPIO header)
|
||||
# - SHT30 (Sensirion, Hanjin Data P4422-3) I2C temp/humidity sensor connected to I2C bus 1.
|
||||
# - 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 으로 이식.
|
||||
# firmware/board_sht30/app_sht30.c (측정 루프 / startup→periodic / 범위 분류)
|
||||
# firmware/common/reporter.c (본문 생성 → 서명 → POST → 재시도)
|
||||
# firmware/common/sht30*.c (I2C 측정 / CRC / 변환) → sht30_driver.py
|
||||
# firmware/test/host/reference.py (wire 포맷) → wire.py
|
||||
# [Port source] Ported the behavior of STM32 firmware sht30_fw to Raspberry Pi 4 + Python.
|
||||
# firmware/board_sht30/app_sht30.c (measurement loop / startup→periodic / range classification)
|
||||
# firmware/common/reporter.c (build body → sign → POST → retry)
|
||||
# firmware/common/sht30*.c (I2C measurement / CRC / conversion) → sht30_driver.py
|
||||
# firmware/test/host/reference.py (wire format) → wire.py
|
||||
#
|
||||
# [동작 흐름]
|
||||
# 1. SHT30 드라이버 open (실패/미가용 시 시뮬레이션 폴백)
|
||||
# 2. 주기(REPORT_INTERVAL)마다 측정 → 서버에 POST
|
||||
# 3. "최초 성공" 측정만 event_type="startup", 이후 "periodic"
|
||||
# 4. 측정 실패(통신/CRC)는 크래시하지 않고 로그 후 짧게 쉬고 재시도
|
||||
# 5. 물리 범위를 벗어나면 metric_status="out_of_range" (서버가 운영 임계 재판정)
|
||||
# [Operation flow]
|
||||
# 1. Open SHT30 driver (fall back to simulation on failure/unavailability)
|
||||
# 2. Measure every interval (REPORT_INTERVAL) → POST to server
|
||||
# 3. Only the "first success" measurement uses event_type="startup", afterwards "periodic"
|
||||
# 4. Measurement failure (comm/CRC) does not crash; log, sleep briefly, then retry
|
||||
# 5. Out of physical range → metric_status="out_of_range" (server re-evaluates operational thresholds)
|
||||
#
|
||||
# [인증] 서버는 raw-body HMAC 서명을 요구한다(레거시 body-sig 비활성).
|
||||
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=전송 본문 바이트))
|
||||
# → 서명한 바이트와 전송 바이트가 동일하도록 본문을 직접 만들어 raw POST 한다.
|
||||
# [Auth] The server requires a raw-body HMAC signature (legacy body-sig disabled).
|
||||
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=sent body bytes))
|
||||
# → Build the body directly and raw-POST it so the signed bytes match the sent bytes.
|
||||
#
|
||||
# [설정·비밀값] config.py 는 비밀이 아닌 기본값만, 서버 URL/API_KEY/기기 식별자는
|
||||
# config_local.py(미커밋)에서 로드한다. 없으면 기동 시 fail-closed 로 즉시 중단.
|
||||
# [Config/secrets] config.py holds only non-secret defaults; the server URL/API_KEY/device
|
||||
# identifiers are loaded from config_local.py (uncommitted). If missing, fail-closed and abort at startup.
|
||||
# =============================================================================
|
||||
|
||||
import time
|
||||
|
|
@ -52,7 +52,7 @@ from sht30_driver import SHT30, SMBUS_AVAILABLE
|
|||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 로거 설정
|
||||
# Logger setup
|
||||
# ─────────────────────────────────────────────
|
||||
def setup_logger():
|
||||
logger = logging.getLogger("sht30_sensor")
|
||||
|
|
@ -71,7 +71,7 @@ def setup_logger():
|
|||
fh.setFormatter(fmt)
|
||||
logger.addHandler(fh)
|
||||
except PermissionError:
|
||||
pass # 권한 없으면 콘솔만 사용
|
||||
pass # No permission → console only
|
||||
|
||||
ch = logging.StreamHandler(sys.stdout)
|
||||
ch.setFormatter(fmt)
|
||||
|
|
@ -83,7 +83,7 @@ log = setup_logger()
|
|||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 종료 시그널 처리 (systemd stop = SIGTERM 깔끔 종료)
|
||||
# Shutdown signal handling (systemd stop = SIGTERM clean shutdown)
|
||||
# ─────────────────────────────────────────────
|
||||
class StopFlag:
|
||||
def __init__(self):
|
||||
|
|
@ -91,15 +91,15 @@ class StopFlag:
|
|||
|
||||
def request(self, signum, frame):
|
||||
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()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 온습도 물리 범위 분류 (firmware classify_metric_status 와 동일)
|
||||
# 서버는 이 값을 신뢰하지 않고 운영 임계로 재판정하므로, 여기서는 센서
|
||||
# 물리 plausibility(범위/ NaN·Inf)만 본다.
|
||||
# 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
|
||||
# here we only check sensor physical plausibility (range / NaN·Inf).
|
||||
# ─────────────────────────────────────────────
|
||||
def classify_metric_status(temp_c: float, rh: float) -> str:
|
||||
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:
|
||||
"""SHT30 측정값을 서버에 POST. 성공(HTTP 200) 시 True.
|
||||
"""POST the SHT30 measurement to the server. Returns True on success (HTTP 200).
|
||||
|
||||
본문을 직접 만들어 그 바이트에 서명하고 그 바이트를 그대로 전송한다
|
||||
(서명한 바이트 == 전송 바이트 보장 → 서버 raw-body 검증과 일치).
|
||||
Build the body directly, sign those bytes, and send those exact bytes
|
||||
(guarantees signed bytes == sent bytes → matches the server's raw-body verification).
|
||||
"""
|
||||
timestamp = int(time.time())
|
||||
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):
|
||||
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(
|
||||
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}% "
|
||||
f"status={metric_status} → {resp.text[:100]}")
|
||||
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]}")
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 측정 루프 (firmware sht30_measure_task 대응)
|
||||
# Measurement loop (corresponds to firmware sht30_measure_task)
|
||||
# ─────────────────────────────────────────────
|
||||
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
|
||||
|
||||
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:
|
||||
try:
|
||||
temp_c, rh = sensor.measure()
|
||||
except (OSError, ValueError, RuntimeError) as e:
|
||||
# 측정 실패(통신/CRC): 크래시 금지. 로그 후 짧게 쉬고 재시도.
|
||||
log.error(f"[MEASURE FAIL] {e} → {MEASURE_RETRY_DELAY}s 후 재시도")
|
||||
# Measurement failure (comm/CRC): no crash. Log, sleep briefly, then retry.
|
||||
log.error(f"[MEASURE FAIL] {e} → retry after {MEASURE_RETRY_DELAY}s")
|
||||
_interruptible_sleep(MEASURE_RETRY_DELAY)
|
||||
continue
|
||||
|
||||
|
|
@ -194,22 +194,22 @@ def measure_loop(sensor: SHT30):
|
|||
|
||||
ok = post_reading(event_type, temp_c, rh, metric_status)
|
||||
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
|
||||
|
||||
# 정상 보고 주기 대기 (종료 시그널이 오면 즉시 깸)
|
||||
# Wait for the normal report interval (wakes immediately on a shutdown signal)
|
||||
_interruptible_sleep(REPORT_INTERVAL)
|
||||
|
||||
|
||||
def _interruptible_sleep(seconds: float):
|
||||
"""1초 단위로 나눠 자며 종료 시그널에 빠르게 반응한다."""
|
||||
"""Sleep in 1-second chunks to react quickly to shutdown signals."""
|
||||
end = time.time() + seconds
|
||||
while not stop_flag.stop and time.time() < end:
|
||||
time.sleep(min(1.0, end - time.time()))
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 메인
|
||||
# Main
|
||||
# ─────────────────────────────────────────────
|
||||
def main():
|
||||
signal.signal(signal.SIGTERM, stop_flag.request)
|
||||
|
|
@ -217,12 +217,12 @@ def main():
|
|||
|
||||
simulate = FORCE_SIMULATION or not SMBUS_AVAILABLE
|
||||
log.info("=" * 60)
|
||||
log.info(f"온습도(SHT30) 모니터링 v2 (Pi 4, 2번 모듈) 시작")
|
||||
log.info(f"기기={DEVICE_ID} | 위치={DEVICE_LOCATION} | 센서={SENSOR_NAME}(id={SENSOR_ID})")
|
||||
log.info(f"Temp/humidity (SHT30) monitoring v2 (Pi 4, Module 2) starting")
|
||||
log.info(f"device={DEVICE_ID} | location={DEVICE_LOCATION} | sensor={SENSOR_NAME}(id={SENSOR_ID})")
|
||||
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:
|
||||
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)
|
||||
|
||||
sensor = SHT30(bus_num=I2C_BUS, addr=I2C_ADDR, simulate=simulate)
|
||||
|
|
@ -234,12 +234,12 @@ def main():
|
|||
|
||||
try:
|
||||
measure_loop(sensor)
|
||||
except Exception as e: # noqa: BLE001 — 최상위 안전망(로그 후 종료)
|
||||
log.exception(f"[MAIN] 예외 발생: {e}")
|
||||
except Exception as e: # noqa: BLE001 — top-level safety net (log then exit)
|
||||
log.exception(f"[MAIN] exception occurred: {e}")
|
||||
raise
|
||||
finally:
|
||||
sensor.close()
|
||||
log.info("[MAIN] 종료 완료.")
|
||||
log.info("[MAIN] shutdown complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
# =============================================================================
|
||||
# /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 systemctl daemon-reload
|
||||
# sudo systemctl enable sht30-sensor
|
||||
# sudo systemctl start sht30-sensor
|
||||
# sudo systemctl status sht30-sensor
|
||||
#
|
||||
# ※ WorkingDirectory/ExecStart 경로는 실제 배치 경로에 맞게 수정.
|
||||
# 아래는 /home/pi/raspberry_pi4_sht30 에 배치한 경우 기준.
|
||||
# * Adjust the WorkingDirectory/ExecStart paths to match the actual deployment path.
|
||||
# The example below assumes deployment under /home/pi/raspberry_pi4_sht30.
|
||||
# =============================================================================
|
||||
|
||||
[Unit]
|
||||
|
|
@ -22,7 +22,7 @@ Wants=network-online.target
|
|||
Type=simple
|
||||
User=pi
|
||||
Group=pi
|
||||
# /dev/i2c-1 접근에 i2c 그룹 필요
|
||||
# i2c group required to access /dev/i2c-1
|
||||
SupplementaryGroups=i2c
|
||||
WorkingDirectory=/home/pi/raspberry_pi4_sht30
|
||||
ExecStart=/home/pi/raspberry_pi4_sht30/venv/bin/python sht30_sensor.py
|
||||
|
|
@ -32,19 +32,19 @@ StandardOutput=journal
|
|||
StandardError=journal
|
||||
SyslogIdentifier=sht30-sensor
|
||||
|
||||
# 환경 변수
|
||||
# Environment variables
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
# 프로세스 전용 /tmp 격리
|
||||
# Process-private /tmp isolation
|
||||
PrivateTmp=true
|
||||
|
||||
# ── 샌드박싱(하드닝) — 데몬 침해 시 root 승격·파일시스템 오염을 차단 ──────────
|
||||
# User=pi 는 NOPASSWD sudo 계정이므로 NoNewPrivileges 로 setuid(sudo) 승격 경로를 막는다.
|
||||
# -- Sandboxing (hardening) -- blocks root escalation and filesystem tampering if the daemon is compromised --------
|
||||
# User=pi is a NOPASSWD sudo account, so NoNewPrivileges blocks the setuid(sudo) escalation path.
|
||||
NoNewPrivileges=true
|
||||
# 루트파일시스템 읽기전용 + /var/log 만 쓰기 허용(로그 로테이션 포함). /tmp 는 PrivateTmp.
|
||||
# Root filesystem read-only + only /var/log writable (including log rotation). /tmp is PrivateTmp.
|
||||
ProtectSystem=strict
|
||||
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
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelTunables=true
|
||||
|
|
@ -52,13 +52,13 @@ ProtectControlGroups=true
|
|||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
RestrictRealtime=true
|
||||
# HTTPS(POST)+로컬소켓(DNS/journal)만. I2C 는 /dev/i2c-1 char (소켓 아님)라 무관.
|
||||
# AF_NETLINK 필수 — glibc getaddrinfo/NSS 가 로컬주소 열거에 netlink 를 쓴다
|
||||
# (빼면 DNS 해석이 간헐 실패 → 서버 POST 불가). AF_INET/6=HTTPS, AF_UNIX=DNS·journal.
|
||||
# HTTPS(POST) + local sockets (DNS/journal) only. I2C is unaffected since /dev/i2c-1 is a char device (not a socket).
|
||||
# AF_NETLINK required -- glibc getaddrinfo/NSS uses netlink to enumerate local addresses
|
||||
# (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
|
||||
# ※ 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
|
||||
StartLimitInterval=600s
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
#!/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)을 붙여 실제로 한 번
|
||||
서명된 POST 를 보낸다. 비밀값(API_URL/API_KEY)은 config_local.py 에서 로드한다.
|
||||
Builds the body with wire.py, attaches a raw-body HMAC (X-Signature), and
|
||||
actually sends one signed POST. Secrets (API_URL/API_KEY) are loaded from config_local.py.
|
||||
"""
|
||||
import sys
|
||||
import time
|
||||
|
|
@ -10,7 +10,7 @@ import subprocess
|
|||
|
||||
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 (
|
||||
API_URL, API_KEY, DEVICE_ID, DEVICE_LOCATION,
|
||||
|
|
@ -21,12 +21,12 @@ import wire
|
|||
API_BASE = API_URL.rsplit("/", 1)[0]
|
||||
|
||||
print("=" * 50)
|
||||
print(" 서버 연결 테스트 (Pi 4 SHT30 / 2번 모듈)")
|
||||
print(" Server connection test (Pi 4 SHT30 / Sensor 2 module)")
|
||||
print("=" * 50)
|
||||
print(f" API_URL = {API_URL}")
|
||||
|
||||
# ── 1단계: curl 로 엔드포인트 도달 확인 (서명 없음 → 403 인증실패가 정상) ──
|
||||
print("\n[1/2] curl 로 sensor_data.php 도달 확인 (서명 없이 → 인증실패 403 이 정상)...")
|
||||
# ── Step 1: check endpoint reachability with curl (no signature → 403 auth failure is expected) ──
|
||||
print("\n[1/2] Checking sensor_data.php reachability with curl (no signature → 403 auth failure is expected)...")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
|
|
@ -35,20 +35,20 @@ try:
|
|||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
code = result.stdout.strip()
|
||||
print(f" HTTP 응답코드: {code}")
|
||||
print(f" HTTP status code: {code}")
|
||||
if code in ("400", "403"):
|
||||
print(" → 엔드포인트 도달 OK (서명/필드 없이 거부됨 — 정상)")
|
||||
print(" → Endpoint reachable OK (rejected without signature/fields — expected)")
|
||||
elif code == "200":
|
||||
print(" → 200? 서버가 서명 없이 수락 — 서버 설정 점검 필요")
|
||||
print(" → 200? Server accepted without a signature — check server configuration")
|
||||
elif code == "404":
|
||||
print(" → 404: API_URL 경로 확인 필요")
|
||||
print(" → 404: check the API_URL path")
|
||||
else:
|
||||
print(f" → 응답: {code} (호스팅/방화벽/.htaccess 점검)")
|
||||
print(f" → Response: {code} (check hosting/firewall/.htaccess)")
|
||||
except Exception as e:
|
||||
print(f" → curl 실패: {e}")
|
||||
print(f" → curl failed: {e}")
|
||||
|
||||
# ── 2단계: wire.py 로 서명한 실제 측정 POST (startup, 더미값) ──
|
||||
print("\n[2/2] 서명된 측정 POST (startup, 더미 24.00C/48.00%)...")
|
||||
# ── Step 2: real measurement POST signed with wire.py (startup, dummy values) ──
|
||||
print("\n[2/2] Signed measurement POST (startup, dummy 24.00C/48.00%)...")
|
||||
try:
|
||||
ts = int(time.time())
|
||||
body = wire.sht30_event_body(
|
||||
|
|
@ -56,7 +56,7 @@ try:
|
|||
"startup", ts, 24.0, 48.0, "normal", APP_VERSION,
|
||||
)
|
||||
sig = wire.sign_raw(API_KEY, body)
|
||||
print(f" 본문: {body}")
|
||||
print(f" Body: {body}")
|
||||
print(f" X-Signature: {sig[:16]}...")
|
||||
resp = requests.post(
|
||||
API_URL, data=body.encode("utf-8"),
|
||||
|
|
@ -68,23 +68,23 @@ try:
|
|||
},
|
||||
timeout=10,
|
||||
)
|
||||
print(f" HTTP 응답코드: {resp.status_code}")
|
||||
print(f" 응답 내용: {resp.text[:200]}")
|
||||
print(f" HTTP status code: {resp.status_code}")
|
||||
print(f" Response body: {resp.text[:200]}")
|
||||
if resp.status_code == 200:
|
||||
signed_ok = True
|
||||
print(" → 성공! 서명 검증 통과, 측정 저장됨 (대시보드 sensor_id=2 확인)")
|
||||
print(" → Success! Signature verified, measurement stored (check dashboard sensor_id=2)")
|
||||
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:
|
||||
print(" → 400: timestamp 신선도(±600s) 확인 — Pi 시각(NTP) 동기 점검")
|
||||
print(" → 400: check timestamp freshness (±600s) — verify Pi clock (NTP) sync")
|
||||
else:
|
||||
print(f" → 응답코드: {resp.status_code}")
|
||||
print(f" → Status code: {resp.status_code}")
|
||||
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:
|
||||
print(f" → 실패: {e}")
|
||||
print(f" → Failed: {e}")
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print(" 테스트 완료" + ("" if signed_ok else " — 서명 POST 실패(위 로그 확인)"))
|
||||
print(" Test complete" + ("" if signed_ok else " — signed POST failed (see log above)"))
|
||||
print("=" * 50)
|
||||
sys.exit(0 if signed_ok else 1)
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
#!/usr/bin/env python3
|
||||
"""센서/포맷 자가점검 — Pi 4 SHT30(2번) 모듈.
|
||||
"""Sensor/format self-check — Pi 4 SHT30 (Sensor 2) module.
|
||||
|
||||
1) CRC-8 / fixed2 / 본문 포맷이 펌웨어·서버 규약과 일치하는지 단위 점검
|
||||
2) 실제(또는 시뮬레이션) SHT30 측정 1회 → 변환값 출력
|
||||
1) Unit-check that CRC-8 / fixed2 / body format match the firmware and server spec
|
||||
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 wire
|
||||
from sht30_driver import crc8, parse_frame, SHT30, SMBUS_AVAILABLE
|
||||
|
||||
# 포맷/CRC 자가점검은 비밀값(config_local.py) 없이도 가능해야 한다.
|
||||
# config.py 는 config_local.py 가 없으면 import 시 fail-closed 로 중단하므로,
|
||||
# 개발 PC 점검을 위해 기본값으로 폴백한다(서버 연결은 test_connection.py 담당).
|
||||
# The format/CRC self-check must work even without secrets (config_local.py).
|
||||
# Since config.py fails closed and aborts on import when config_local.py is missing,
|
||||
# fall back to defaults for dev-PC checks (server connection is handled by test_connection.py).
|
||||
try:
|
||||
from config import I2C_BUS, I2C_ADDR, FORCE_SIMULATION
|
||||
except RuntimeError:
|
||||
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):
|
||||
|
|
@ -28,25 +28,25 @@ def check(name, got, want):
|
|||
|
||||
|
||||
print("=" * 50)
|
||||
print(" SHT30 자가점검 (2번 모듈)")
|
||||
print(" SHT30 self-check (Sensor 2 module)")
|
||||
print("=" * 50)
|
||||
|
||||
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):")
|
||||
passed &= check("crc8(0xBEEF)", crc8(b"\xBE\xEF"), 0x92)
|
||||
|
||||
# ── 2) fixed2 점검 (round-half-up, reference.py/jb_fixed2 동일) ──
|
||||
print("\n[2] fixed2 (소수 2자리 round-half-up):")
|
||||
# ── 2) fixed2 check (round-half-up, same as reference.py/jb_fixed2) ──
|
||||
print("\n[2] fixed2 (2 decimal places, round-half-up):")
|
||||
passed &= check("24.0", wire.fixed2(24.0), "24.00")
|
||||
passed &= check("48.5", wire.fixed2(48.5), "48.50")
|
||||
passed &= check("-3.245", wire.fixed2(-3.245), "-3.25")
|
||||
passed &= check("0.0", wire.fixed2(0.0), "0.00")
|
||||
|
||||
# ── 3) 프레임 파싱 점검 (CRC 포함 합성 프레임) ──
|
||||
print("\n[3] parse_frame (합성 프레임 변환):")
|
||||
raw_t, raw_rh = 0x6400, 0x8000 # 임의 raw 값
|
||||
# ── 3) Frame parsing check (synthetic frame including CRC) ──
|
||||
print("\n[3] parse_frame (synthetic frame conversion):")
|
||||
raw_t, raw_rh = 0x6400, 0x8000 # arbitrary raw values
|
||||
frame = bytes([
|
||||
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])),
|
||||
|
|
@ -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("rh", round(rh, 4), round(exp_rh, 4))
|
||||
|
||||
# ── 4) 본문 포맷 점검 (필드 순서/타입) ──
|
||||
print("\n[4] 본문 포맷 (필드 순서 고정):")
|
||||
# ── 4) Body format check (field order/type) ──
|
||||
print("\n[4] Body format (fixed field order):")
|
||||
body = wire.sht30_event_body("dev", "loc", 2, "s2", "periodic",
|
||||
1700000000, 24.0, 48.0, "normal", "vX")
|
||||
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_status":"normal","app_version":"vX"}')
|
||||
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회 ──
|
||||
print("\n[5] 측정 1회:")
|
||||
# ── 5) One real/simulated measurement ──
|
||||
print("\n[5] One measurement:")
|
||||
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:
|
||||
with SHT30(bus_num=I2C_BUS, addr=I2C_ADDR, simulate=sim) as s:
|
||||
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:
|
||||
print(f" 측정 실패: {e}")
|
||||
print(f" Measurement failed: {e}")
|
||||
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(f" 결과: {'전체 통과 ✅' if passed else '실패 항목 있음 ❌'}")
|
||||
print(f" Result: {'ALL PASSED ✅' if passed else 'SOME FAILED ❌'}")
|
||||
print("=" * 50)
|
||||
sys.exit(0 if passed else 1)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# vendor.sh - 폐쇄망 설치용 의존성 사전 다운로드 (SHT30 2번 모듈)
|
||||
# 인터넷에 연결된 빌드/스테이징 머신에서 실행한다.
|
||||
# 산출물: wheelhouse/ (pip wheel + sdist), debs/ (apt .deb) → 타깃 Pi 4 로 전송.
|
||||
# vendor.sh - Pre-download dependencies for air-gapped install (SHT30 sensor 2 module)
|
||||
# Run this on a build/staging machine that has internet access.
|
||||
# Outputs: wheelhouse/ (pip wheel + sdist), debs/ (apt .deb) → transfer to the target Pi 4.
|
||||
#
|
||||
# requests 는 arch 종속이 적으나 smbus2 는 순수 파이썬(py3-none-any)이라 어떤
|
||||
# 빌드 머신에서 받아도 Pi 4 에서 동작한다. wheel 핀은 타깃(Bookworm 64-bit,
|
||||
# aarch64, Python 3.11)에 맞춘다. .deb 는 타깃 OS/arch 에서 받는 것이 가장 안전.
|
||||
# 환경변수: TARGET_PLATFORM(기본 manylinux2014_aarch64), TARGET_PYVER(기본 311)
|
||||
# requests has few arch dependencies, but smbus2 is pure Python (py3-none-any), so it
|
||||
# works on the Pi 4 no matter which build machine downloads it. Pin wheels to the target
|
||||
# (Bookworm 64-bit, aarch64, Python 3.11). The .deb files are safest downloaded on the target OS/arch.
|
||||
# Env vars: TARGET_PLATFORM (default manylinux2014_aarch64), TARGET_PYVER (default 311)
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
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})"
|
||||
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 \
|
||||
--only-binary=:all: \
|
||||
--platform "${TARGET_PLATFORM}" \
|
||||
--python-version "${TARGET_PYVER}" \
|
||||
--implementation cp \
|
||||
--abi "${TARGET_ABI}" \
|
||||
|| echo "[WARN] 일부 wheel 의 타깃 플랫폼 핀 실패 — 순수파이썬(any) 폴백을 아래에서 받는다."
|
||||
# smbus2(py3-none-any) 등 순수 파이썬 wheel 및 sdist 폴백.
|
||||
|| echo "[WARN] Target-platform pin failed for some wheels — pure-Python (any) fallback is downloaded below."
|
||||
# smbus2 (py3-none-any) and other pure-Python wheels plus sdist fallback.
|
||||
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)"
|
||||
echo " wheelhouse 파일 수: ${WHEEL_COUNT}"
|
||||
[ "${WHEEL_COUNT}" -gt 0 ] || { echo "[ERROR] wheelhouse 비어있음"; exit 1; }
|
||||
echo " wheelhouse file count: ${WHEEL_COUNT}"
|
||||
[ "${WHEEL_COUNT}" -gt 0 ] || { echo "[ERROR] wheelhouse is empty"; exit 1; }
|
||||
|
||||
echo "[vendor] (2/2) apt .deb → 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"
|
||||
DEPS="$(apt-cache depends --recurse --no-recommends --no-suggests \
|
||||
--no-conflicts --no-breaks --no-replaces --no-enhances ${BASE_PKGS} \
|
||||
| awk '/^\w/{print $1}' | sort -u)"
|
||||
( 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
|
||||
for p in ${BASE_PKGS}; do
|
||||
if ! ls debs/"${p}"_*.deb >/dev/null 2>&1; then
|
||||
echo "[ERROR] 필수 패키지 .deb 누락: ${p}"
|
||||
echo "[ERROR] Required package .deb missing: ${p}"
|
||||
MISS=1
|
||||
fi
|
||||
done
|
||||
[ "${MISS}" = 0 ] || { echo "[ERROR] 필수 .deb 누락 — 반드시 타깃 arch(aarch64 Bookworm)에서 vendor.sh 를 실행하세요."; exit 1; }
|
||||
echo " debs 파일 수: $(ls debs/*.deb 2>/dev/null | wc -l) (필수 패키지 모두 확인됨)"
|
||||
[ "${MISS}" = 0 ] || { echo "[ERROR] Required .deb missing — you must run vendor.sh on the target arch (aarch64 Bookworm)."; exit 1; }
|
||||
echo " debs file count: $(ls debs/*.deb 2>/dev/null | wc -l) (all required packages verified)"
|
||||
|
||||
# 아키텍처 실검사 — 파일명이 아니라 .deb 내부 Architecture 필드로 타깃(arm64/all) 검증.
|
||||
# (호스트가 amd64면 apt-get download 가 amd64 .deb 를 받아, 타깃 Pi 에서 dpkg 가
|
||||
# "wrong architecture" 로 깨진다. 파일명만 보는 검사는 이를 못 잡는다.)
|
||||
# Real architecture check — verify the target (arm64/all) via the .deb's internal Architecture field, not the filename.
|
||||
# (If the host is amd64, apt-get download fetches amd64 .deb files, and dpkg on the target Pi
|
||||
# breaks with "wrong architecture". A filename-only check can't catch this.)
|
||||
BAD_ARCH=0
|
||||
for deb in debs/*.deb; do
|
||||
[ -e "$deb" ] || continue
|
||||
arch="$(dpkg-deb --field "$deb" Architecture 2>/dev/null || echo '?')"
|
||||
case "$arch" in
|
||||
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
|
||||
done
|
||||
if [ "${BAD_ARCH}" != 0 ]; then
|
||||
echo "[ERROR] 타깃(aarch64/arm64) 이 아닌 .deb 가 있습니다. 호스트 arch=$(dpkg --print-architecture 2>/dev/null || echo unknown)." >&2
|
||||
echo " 반드시 arm64 Pi(또는 arm64 chroot/qemu)에서 vendor.sh 를 실행하세요." >&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 " You must run vendor.sh on an arm64 Pi (or an arm64 chroot/qemu)." >&2
|
||||
exit 1
|
||||
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."
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
# =============================================================================
|
||||
# wire.py - 서버 전송용 wire 포맷 (본문 JSON + raw-body HMAC 서명)
|
||||
# wire.py - wire format for server transmission (body JSON + raw-body HMAC signature)
|
||||
#
|
||||
# [왜 별도 모듈인가]
|
||||
# 서버(php/api/sensor_data.php)는 받은 "본문 바이트 그대로"에 대해
|
||||
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=raw_body)) 를 검증한다.
|
||||
# 따라서 "서명한 바이트"와 "실제 전송한 바이트"가 100% 동일해야 한다.
|
||||
# 본문을 직접 문자열로 만들고 그 바이트에 서명한 뒤, 그 바이트를 그대로 POST 한다.
|
||||
# (requests 의 json= 직렬화에 맡기면 부동소수/공백/순서가 달라져 서명이 깨질 수 있다.)
|
||||
# [Why a separate module]
|
||||
# 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)).
|
||||
# Therefore the "signed bytes" and the "actually transmitted bytes" must be 100% identical.
|
||||
# We build the body directly as a string, sign those bytes, then POST those same bytes as-is.
|
||||
# (Leaving serialization to requests' json= can change float/whitespace/order and break the signature.)
|
||||
#
|
||||
# [바이트 단위 호환 대상] — 아래 넷과 출력이 동일해야 한다.
|
||||
# - firmware/common/jsonbody.c::jb_sht30_event (STM32 본문 생성)
|
||||
# - firmware/common/sig.c::sig_raw_body (STM32 HMAC 서명)
|
||||
# - firmware/test/host/reference.py (검증된 Python 레퍼런스)
|
||||
# - php/config.php::verify_signature_raw (서버 검증부)
|
||||
# [Byte-level compatibility targets] — output must be identical to the four below.
|
||||
# - firmware/common/jsonbody.c::jb_sht30_event (STM32 body generation)
|
||||
# - firmware/common/sig.c::sig_raw_body (STM32 HMAC signature)
|
||||
# - firmware/test/host/reference.py (verified Python reference)
|
||||
# - php/config.php::verify_signature_raw (server verification part)
|
||||
#
|
||||
# fixed2() <-> jb_fixed2() / reference.fixed2()
|
||||
# sht30_event_body() <-> jb_sht30_event() / reference.sht30_event_body()
|
||||
|
|
@ -25,9 +25,9 @@ import hmac
|
|||
|
||||
|
||||
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"
|
||||
48.5 -> "48.50"
|
||||
-3.245 -> "-3.25"
|
||||
|
|
@ -43,10 +43,10 @@ def fixed2(v: float) -> str:
|
|||
|
||||
|
||||
def _esc(s: str) -> str:
|
||||
r"""JSON 문자열 값 이스케이프 (jb_estr / reference._esc 와 동일).
|
||||
r"""Escape JSON string value (identical to jb_estr / reference._esc).
|
||||
|
||||
'"' 와 '\\' 와 제어문자(<0x20)만 이스케이프한다. '/' 는 그대로 둔다.
|
||||
멀티바이트 UTF-8(한글 등)은 그대로 둔다(서버가 raw 바이트로 검증).
|
||||
Escapes only '"', '\\', and control characters (<0x20). '/' is left as-is.
|
||||
Multibyte UTF-8 (e.g. Korean) is left as-is (server verifies raw bytes).
|
||||
"""
|
||||
out = []
|
||||
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,
|
||||
temperature_c: float, humidity_percent: float,
|
||||
metric_status: str, app_version: str) -> str:
|
||||
"""SHT30 이벤트 본문 JSON 문자열 생성.
|
||||
"""Generate SHT30 event body JSON string.
|
||||
|
||||
필드 순서는 펌웨어 jb_sht30_event() 와 동일하게 고정한다(서명 호환).
|
||||
metric_type 은 항상 "sht30" 고정. 온도/습도는 fixed2 로 2자리 직렬화.
|
||||
Field order is fixed to match firmware jb_sht30_event() (signature compatibility).
|
||||
metric_type is always fixed to "sht30". Temperature/humidity serialized to 2 decimals via fixed2.
|
||||
"""
|
||||
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:
|
||||
"""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 와 동일.
|
||||
본문은 UTF-8 로 인코딩한 뒤 서명한다(전송 바이트와 동일).
|
||||
Identical to sig_raw_body() / reference.sign_raw() / php verify_signature_raw.
|
||||
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"),
|
||||
hashlib.sha256).hexdigest()
|
||||
|
|
|
|||
|
|
@ -1,78 +1,78 @@
|
|||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# harden_pi.sh — Raspberry Pi OS (Bookworm) 최소 하드닝 (센서 노드 공통)
|
||||
# harden_pi.sh — Raspberry Pi OS (Bookworm) minimal hardening (common to sensor nodes)
|
||||
#
|
||||
# 정책 (2026-07 확정):
|
||||
# - SSH 완전 비활성 — 이 시스템은 outbound 전용(Pi → 서버 HTTPS POST).
|
||||
# 유지보수는 현장 물리 접근(모니터+키보드). 재활성이 필요하면 SD 카드의
|
||||
# boot 파티션에 빈 파일 `ssh` 를 만들고 부팅한다(물리 접근 필요 — 의도됨).
|
||||
# - 인바운드 전부 차단(nftables), 아웃바운드는 허용(apt/NTP/서버 POST).
|
||||
# - 자동 보안 패치(unattended-upgrades) 켬, 자동 리부팅은 끔(센서 공백 방지).
|
||||
# Policy (finalized 2026-07):
|
||||
# - SSH fully disabled — this system is outbound-only (Pi → server HTTPS POST).
|
||||
# Maintenance is via on-site physical access (monitor + keyboard). To re-enable,
|
||||
# create an empty file `ssh` in the SD card's boot partition and boot (physical access required — intended).
|
||||
# - All inbound blocked (nftables); outbound allowed (apt/NTP/server POST).
|
||||
# - Automatic security patches (unattended-upgrades) on; automatic reboot off (avoids sensor gaps).
|
||||
#
|
||||
# 실행 순서(중요): SSH 차단은 "맨 마지막" 단계다. 그 앞의 실패하기 쉬운 단계
|
||||
# (패키지 설치·방화벽·권한)에서 문제가 나면 set -e 로 SSH 가 살아있는 상태에서
|
||||
# 중단되어 원격 디버깅이 가능하다. 모든 앞 단계가 성공해야만 SSH 를 끊는다.
|
||||
# Execution order (important): disabling SSH is the "very last" step. If a problem occurs in an
|
||||
# earlier failure-prone step (package install / firewall / permissions), set -e aborts while
|
||||
# SSH is still alive, allowing remote debugging. SSH is cut only after all prior steps succeed.
|
||||
#
|
||||
# 폐쇄망(air-gapped): 같은 폴더에 vendor.sh 산출물 debs/ 가 있으면 nftables·
|
||||
# unattended-upgrades 도 오프라인(--no-download)으로 설치한다. 없으면 온라인 apt.
|
||||
# (vendor.sh 가 이 두 패키지도 미리 받아 debs/ 에 포함한다.)
|
||||
# Air-gapped: if the vendor.sh output debs/ is in the same folder, nftables and
|
||||
# unattended-upgrades are also installed offline (--no-download). Otherwise, online apt.
|
||||
# (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
|
||||
# ⚠️ SSH 로 접속해 실행 중이면 마지막 단계에서 세션이 끊기고 재접속 불가.
|
||||
# ⚠️ If running over an SSH connection, the session drops at the last step and cannot reconnect.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || { echo "[ERROR] sudo 로 실행하세요: sudo bash harden_pi.sh"; exit 1; }
|
||||
# debs/ 를 스크립트 위치 기준으로 찾도록 cwd 를 스크립트 폴더로 이동(절대경로 로직엔 무관).
|
||||
[ "$(id -u)" -eq 0 ] || { echo "[ERROR] Run with sudo: sudo bash harden_pi.sh"; exit 1; }
|
||||
# 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
|
||||
|
||||
# 오프라인 번들(debs/) 유무 판정 — install.sh 와 동일한 규칙.
|
||||
# Detect presence of offline bundle (debs/) — same rule as install.sh.
|
||||
OFFLINE_DEBS=0
|
||||
if compgen -G "./debs/*.deb" >/dev/null 2>&1; then
|
||||
OFFLINE_DEBS=1
|
||||
echo "[INFO] 오프라인 번들(debs/) 감지 → 패키지 설치는 네트워크 미사용(--no-download)"
|
||||
echo "[INFO] Offline bundle (debs/) detected → package install uses no network (--no-download)"
|
||||
fi
|
||||
|
||||
# 필요 패키지 확보: 이미 설치돼 있으면 skip, 아니면 오프라인 debs/ → 온라인 apt 순.
|
||||
# 설치 후 재검증하여 실패 시 즉시 중단(SSH 아직 살아있음).
|
||||
# Ensure required packages: skip if already installed, otherwise offline debs/ → online apt, in that order.
|
||||
# Re-verify after install and abort immediately on failure (SSH still alive).
|
||||
ensure_pkgs() {
|
||||
local missing=() p
|
||||
for p in "$@"; do dpkg -s "$p" >/dev/null 2>&1 || missing+=("$p"); done
|
||||
if [ "${#missing[@]}" -eq 0 ]; then
|
||||
echo " (${*} 이미 설치됨 — skip)"
|
||||
echo " (${*} already installed — skip)"
|
||||
elif [ "${OFFLINE_DEBS}" = 1 ]; then
|
||||
echo " 오프라인 debs/ 에서 설치 (네트워크 미사용): ${missing[*]}"
|
||||
# --no-install-recommends: vendor.sh 가 Recommends 를 번들에 안 담으므로(--no-recommends)
|
||||
# 설치 때도 Recommends 를 요구하지 않게 맞춘다(폐쇄망 결정성 — 네트워크 폴백 방지).
|
||||
echo " Installing from offline debs/ (no network): ${missing[*]}"
|
||||
# --no-install-recommends: since vendor.sh does not put Recommends in the bundle (--no-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
|
||||
else
|
||||
echo " 온라인 apt 설치: ${missing[*]}"
|
||||
# 단독 온라인 실행 대비(로컬 apt 목록이 비어있을 수 있음). install.sh 뒤면 no-op.
|
||||
echo " Online apt install: ${missing[*]}"
|
||||
# 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 install -y -q "${missing[@]}"
|
||||
fi
|
||||
for p in "$@"; do
|
||||
dpkg -s "$p" >/dev/null 2>&1 || {
|
||||
echo "[ERROR] '$p' 설치 실패. 폐쇄망이면 vendor.sh(debs/)에 포함해 재배포하거나," >&2
|
||||
echo " 온라인이면 네트워크를 확인하세요. (SSH 는 아직 차단되지 않음 — 복구 가능)" >&2
|
||||
echo "[ERROR] '$p' install failed. If air-gapped, include it in vendor.sh (debs/) and redeploy," >&2
|
||||
echo " or if online, check the network. (SSH is not yet disabled — recovery possible)" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
}
|
||||
|
||||
echo "[1/6] 방화벽(nftables) — 인바운드 전부 차단..."
|
||||
echo "[1/6] Firewall (nftables) — block all inbound..."
|
||||
ensure_pkgs nftables
|
||||
cat > /etc/nftables.conf <<'NFT'
|
||||
#!/usr/sbin/nft -f
|
||||
# 센서 노드 방화벽 — outbound 전용 장비. 인바운드는 루프백/응답 트래픽만.
|
||||
# Sensor node firewall — outbound-only device. Inbound is loopback/response traffic only.
|
||||
flush ruleset
|
||||
table inet filter {
|
||||
chain input {
|
||||
type filter hook input priority 0; policy drop;
|
||||
iif "lo" 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
|
||||
ip6 nexthdr ipv6-icmp accept
|
||||
}
|
||||
|
|
@ -83,16 +83,16 @@ NFT
|
|||
systemctl enable --now nftables
|
||||
nft -f /etc/nftables.conf
|
||||
|
||||
echo "[2/6] 자동 보안 패치(unattended-upgrades)..."
|
||||
echo "[2/6] Automatic security patches (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'
|
||||
Unattended-Upgrade::Origins-Pattern {
|
||||
"origin=Debian,codename=${distro_codename}-security,label=Debian-Security";
|
||||
"origin=Raspbian,codename=${distro_codename},label=Raspbian";
|
||||
"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";
|
||||
APT
|
||||
cat > /etc/apt/apt.conf.d/20auto-upgrades <<'APT'
|
||||
|
|
@ -101,42 +101,42 @@ APT::Periodic::Unattended-Upgrade "1";
|
|||
APT
|
||||
systemctl enable --now unattended-upgrades 2>/dev/null || true
|
||||
|
||||
echo "[3/6] 로컬 권한 조이기..."
|
||||
# 시크릿 파일 소유자 전용 (API_KEY 포함). 못 찾으면 조용히 넘기지 말고 경고.
|
||||
echo "[3/6] Tightening local permissions..."
|
||||
# Secret files owner-only (contains API_KEY). If not found, warn instead of silently skipping.
|
||||
SECRET_FOUND=0
|
||||
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
|
||||
done
|
||||
[ "${SECRET_FOUND}" = 1 ] || echo "[WARN] config_local.py 를 표준 경로에서 못 찾음 — 배치 경로가 다르면" \
|
||||
"수동으로 잠그세요: chmod 600 <경로>/config_local.py (API_KEY 노출 방지)"
|
||||
# 콘솔 자동 로그인 비활성 (물리 콘솔도 로그인 요구)
|
||||
[ "${SECRET_FOUND}" = 1 ] || echo "[WARN] config_local.py not found at standard paths — if the deploy path differs," \
|
||||
"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
|
||||
raspi-config nonint do_boot_behaviour B1 || true
|
||||
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 \
|
||||
triggerhappy.service triggerhappy.socket cups.service cups.socket \
|
||||
cups-browsed.service ModemManager.service; do
|
||||
systemctl disable --now "$svc" 2>/dev/null || true
|
||||
done
|
||||
|
||||
echo "[5/6] 사전 검증 캡처 (SSH 차단 전 — 방화벽/리스닝 상태)..."
|
||||
echo "-- 리스닝 소켓 (비어 있거나 로컬 전용이어야 함):"
|
||||
echo "[5/6] Pre-verification capture (before SSH disable — firewall/listening state)..."
|
||||
echo "-- Listening sockets (should be empty or local-only):"
|
||||
ss -lntup || true
|
||||
echo "-- nftables input chain (policy drop 확인):"
|
||||
# || true: pipefail 하에서 head 가 파이프를 먼저 닫아 nft 가 SIGPIPE(141) 로 죽어도
|
||||
# 이 검증 단계가 중단되지 않게 한다(SSH 차단 직전 단계라 오탐 중단 방지).
|
||||
echo "-- nftables input chain (verify policy drop):"
|
||||
# || true: under pipefail, even if head closes the pipe first and nft dies with SIGPIPE(141),
|
||||
# keep this verification step from aborting (prevents a false-positive abort right before SSH disable).
|
||||
nft list chain inet filter input | head -12 || true
|
||||
|
||||
echo "[6/6] SSH 완전 비활성 (마지막 — 되돌리기 어려운 단계)..."
|
||||
# 여기까지 왔다면 앞의 모든 단계가 성공한 것. 이제 SSH 를 끊는다.
|
||||
# boot 파티션 `ssh` 파일에 의한 재활성 경로(sshswitch)는 남겨둔다 — 물리 접근
|
||||
# 없이는 사용할 수 없고, 현장 복구 수단으로 유용하다.
|
||||
echo "[6/6] Fully disable SSH (last — hard-to-reverse step)..."
|
||||
# If we got here, all prior steps succeeded. Now cut SSH.
|
||||
# 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
|
||||
echo "-- ssh 상태 (enabled 가 아니어야 함):"
|
||||
echo "-- ssh status (should not be enabled):"
|
||||
systemctl is-enabled ssh.service 2>&1 || true
|
||||
|
||||
echo ""
|
||||
echo "완료. 위 [5/6]·[6/6] 출력을 캡처해 보안 증적으로 보관할 것."
|
||||
echo "리부팅 권장: sudo reboot (이후 ss -lntup 재확인)"
|
||||
echo "Done. Capture the [5/6]·[6/6] output above and keep it as a security record."
|
||||
echo "Reboot recommended: sudo reboot (then re-check ss -lntup)"
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
# =============================================================================
|
||||
# make_deploy_bundles.py — 기기별 턴키 배포 번들 생성기
|
||||
# make_deploy_bundles.py - per-device turnkey deploy bundle generator
|
||||
#
|
||||
# python tools/make_deploy_bundles.py
|
||||
#
|
||||
# repo 루트에 deploy/ 를 만들고, 서버 1 + Pi 3대 각각 "복사 → install.sh →
|
||||
# systemctl enable" 만으로 운영 가능한 폴더를 생성한다.
|
||||
# - API_KEY 는 최초 실행 시 자동 생성(deploy/.secrets/api_key.txt 에 보관,
|
||||
# 재실행 시 재사용) → 서버/Pi 4곳에 동일 주입.
|
||||
# - 관리자 비밀번호도 자동 생성 + bcrypt 해시(php 필요) 주입.
|
||||
# - 텍스트 파일은 LF 로 정규화(Windows 체크아웃 CRLF → Pi bash 오류 방지).
|
||||
# - deploy/ 는 비밀값 포함 — .gitignore 대상. 절대 커밋 금지.
|
||||
# Creates deploy/ at the repo root and generates, for the 1 server + 3 Pis, a
|
||||
# folder each that is operable by just "copy -> install.sh -> systemctl enable".
|
||||
# - API_KEY is auto-generated on first run (stored in deploy/.secrets/api_key.txt,
|
||||
# reused on later runs) and injected identically into the server and all 3 Pis.
|
||||
# - The admin password is likewise auto-generated + bcrypt-hashed (needs php) and injected.
|
||||
# - Text files are normalized to LF (Windows checkout CRLF -> avoids Pi bash errors).
|
||||
# - deploy/ contains secrets -> it is a .gitignore target. Never commit it.
|
||||
#
|
||||
# 남는 수동 설정(외부 비밀이라 사전 주입 불가):
|
||||
# 1) 서버 DB 계정(cafe24 패널 값) 2) SMS 계정/발신·수신번호 3) TOTP 등록 1회
|
||||
# Remaining manual settings (external secrets that cannot be pre-injected):
|
||||
# 1) server DB account (cafe24 panel values) 2) SMS account / sender & recipient numbers 3) one-time TOTP enrollment
|
||||
# =============================================================================
|
||||
import hashlib
|
||||
import hmac
|
||||
|
|
@ -28,16 +28,17 @@ ROOT = Path(__file__).resolve().parent.parent
|
|||
DEPLOY = ROOT / "deploy"
|
||||
SECRETS_DIR = DEPLOY / ".secrets"
|
||||
|
||||
# ── 배포 기본값 ──────────────────────────────────────────────────────────────
|
||||
# WEB_DIR(난독화 경로 폴더명)은 비밀값 — 커밋되는 이 파일에 두지 않고
|
||||
# deploy/.secrets/web_dir.txt 에서 로드한다(없으면 랜덤 생성). 아래 DOMAIN 만 수정.
|
||||
# -- deploy defaults ----------------------------------------------------------
|
||||
# WEB_DIR (obfuscated path folder name) is a secret - it is NOT kept in this
|
||||
# committed file; it is loaded from deploy/.secrets/web_dir.txt (randomly generated if absent).
|
||||
# Only edit DOMAIN below.
|
||||
DOMAIN = "https://pores.kr"
|
||||
|
||||
TEXT_EXTS = {".sh", ".py", ".php", ".service", ".sql", ".md", ".txt", ".example", ".html", ".css"}
|
||||
EXCLUDE_NAMES = {"__pycache__", ".gitignore", "config_local.py"}
|
||||
|
||||
BUNDLES = [
|
||||
# (폴더명, 원본, 종류)
|
||||
# (folder name, source, kind)
|
||||
("01_server_cafe24", None, "server"),
|
||||
("02_pi_a_leak_id1", "raspberry_pi4", "leak"),
|
||||
("03_pi_b_sht30_id2", "raspberry_pi4_sht30", "sht30"),
|
||||
|
|
@ -46,18 +47,18 @@ BUNDLES = [
|
|||
|
||||
PI_META = {
|
||||
"02_pi_a_leak_id1": {
|
||||
"device_id": "rpi4-leak-01", "location": "1번 지점(서버실)",
|
||||
"sensors": None, # config.py 기본값 (sensor_id 1)
|
||||
"device_id": "rpi4-leak-01", "location": "Site 1 (Server Room)",
|
||||
"sensors": None, # config.py default (sensor_id 1)
|
||||
"service": "leak-sensor",
|
||||
},
|
||||
"03_pi_b_sht30_id2": {
|
||||
"device_id": "rpi4-sht30-01", "location": "1번 지점(서버실)",
|
||||
"sensors": None, # config.py 기본값 (sensor_id 2)
|
||||
"device_id": "rpi4-sht30-01", "location": "Site 1 (Server Room)",
|
||||
"sensors": None, # config.py default (sensor_id 2)
|
||||
"service": "sht30-sensor",
|
||||
},
|
||||
"04_pi_c_leak_id3": {
|
||||
"device_id": "rpi4-leak-02", "location": "3번 지점(신규)",
|
||||
"sensors": '[\n {"id": 3, "gpio": 17, "name": "3번 센서 (신규 지점)"},\n]',
|
||||
"device_id": "rpi4-leak-02", "location": "Site 3 (New)",
|
||||
"sensors": '[\n {"id": 3, "gpio": 17, "name": "Sensor 3 (New Site)"},\n]',
|
||||
"service": "leak-sensor",
|
||||
},
|
||||
}
|
||||
|
|
@ -75,13 +76,13 @@ def load_or_create_secret(name: str, generate) -> str:
|
|||
return f.read_text(encoding="utf-8").strip()
|
||||
value = generate()
|
||||
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
|
||||
|
||||
|
||||
def bcrypt_hash(password: str) -> str:
|
||||
"""php 로 bcrypt 해시 생성 (서버 setup_hash.php 와 동일 결과).
|
||||
비밀번호는 argv 가 아니라 stdin 으로 전달한다(공유 빌드머신 프로세스 목록 노출 방지)."""
|
||||
"""Generate a bcrypt hash via php (same result as the server setup_hash.php).
|
||||
The password is passed on stdin, not argv (avoids exposure in the shared build machine's process list)."""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["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,
|
||||
)
|
||||
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()
|
||||
if not h.startswith("$2y$"):
|
||||
fail(f"bcrypt 해시 형식 이상: {h!r}")
|
||||
fail(f"unexpected bcrypt hash format: {h!r}")
|
||||
return h
|
||||
|
||||
|
||||
def normalize_lf(path: Path) -> None:
|
||||
"""번들 내 텍스트 파일 CRLF → LF (Pi bash/systemd/Apache 오류 방지).
|
||||
확장자 없는 설정 dotfile(.htaccess/.gitignore 등)도 확장자와 무관하게 포함한다
|
||||
(.htaccess CRLF 는 Apache Header 값에 CR 를 섞을 수 있음)."""
|
||||
"""Normalize bundle text files CRLF -> LF (avoids Pi bash/systemd/Apache errors).
|
||||
Extension-less config dotfiles (.htaccess/.gitignore etc.) are included regardless of
|
||||
extension (.htaccess CRLF can inject a CR into Apache Header values)."""
|
||||
dotfiles = {".htaccess", ".gitignore", ".env.example"}
|
||||
for p in path.rglob("*"):
|
||||
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")
|
||||
|
||||
|
||||
# ── 유출 방어 가드 먼저 — 비밀값을 쓰기 전에 ignore 파일부터 만든다 ─────────
|
||||
# (루트 .gitignore 의 deploy/ 항목과 이중 방어. 생성 도중 스크립트가 실패해도
|
||||
# deploy/ 아래 비밀값이 git 에 스테이징되지 않도록 순서를 보장한다.)
|
||||
# -- leak guard first - create the ignore file before writing any secrets ------
|
||||
# (Double defense together with the root .gitignore's deploy/ entry. Guarantees the
|
||||
# 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)
|
||||
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))
|
||||
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))
|
||||
|
|
@ -135,18 +137,18 @@ WEB_DIR = load_or_create_secret("web_dir.txt", lambda: secrets.token_hex(8))
|
|||
BASE_URL = f"{DOMAIN}/{WEB_DIR}"
|
||||
API_URL = f"{BASE_URL}/api/sensor_data.php"
|
||||
PHOTO_UPLOAD_URL = f"{BASE_URL}/api/photo_upload.php"
|
||||
# cron HTTP 키 — 서버 config.php 의 hash_hmac('sha256','cron',API_KEY) 과 동일.
|
||||
# 미리 계산해 SETUP.md 에 넣어, 운영자가 API_KEY 를 셸 명령줄(히스토리/프로세스
|
||||
# 목록에 노출)에 올리지 않아도 되게 한다.
|
||||
# cron HTTP key - matches the server config.php's hash_hmac('sha256','cron',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()
|
||||
|
||||
# ── 기존 번들 제거 후 재생성 (.secrets 는 보존) ─────────────────────────────
|
||||
# -- remove existing bundles then regenerate (.secrets is preserved) ----------
|
||||
for name, _, _ in BUNDLES:
|
||||
d = DEPLOY / name
|
||||
if d.exists():
|
||||
shutil.rmtree(d)
|
||||
|
||||
# ── Pi 번들 ──────────────────────────────────────────────────────────────────
|
||||
# -- Pi bundles ---------------------------------------------------------------
|
||||
for name, src, kind in BUNDLES:
|
||||
if kind == "server":
|
||||
continue
|
||||
|
|
@ -158,161 +160,161 @@ for name, src, kind in BUNDLES:
|
|||
sensors_block = ""
|
||||
if meta["sensors"]:
|
||||
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"
|
||||
)
|
||||
|
||||
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}"
|
||||
{'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}"
|
||||
DEVICE_ID = "{meta["device_id"]}"
|
||||
DEVICE_LOCATION = "{meta["location"]}"
|
||||
{sensors_block}''')
|
||||
|
||||
# ── 원샷 설치 래퍼: 설치 → 서비스 등록 → 검증 → 하드닝 ──────────────
|
||||
# 검증(test_connection.py)이 실패하면 set -e 로 하드닝 전에 멈춘다 →
|
||||
# SSH 가 살아있어 디버깅 가능. 성공했을 때만 하드닝(SSH 차단)까지 간다.
|
||||
# -- one-shot install wrapper: install -> register service -> verify -> harden --
|
||||
# If verification (test_connection.py) fails, set -e stops before hardening ->
|
||||
# SSH stays alive for debugging. Hardening (SSH lockdown) only runs on success.
|
||||
write_lf(dst / "setup.sh", f'''#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# setup.sh — {meta["device_id"]} 원샷 설치 (설치→서비스→검증→하드닝)
|
||||
# pi 사용자로 실행: bash setup.sh (sudo 로 실행하지 말 것 — 내부에서 sudo)
|
||||
# 검증 실패 시 하드닝 전에 중단(set -e) → SSH 유지되어 디버깅 가능.
|
||||
# setup.sh - one-shot install for {meta["device_id"]} (install->service->verify->harden)
|
||||
# Run as the pi user: bash setup.sh (do NOT run with sudo - sudo is used internally)
|
||||
# On verification failure it stops before hardening (set -e) -> SSH stays up for debugging.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
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
|
||||
fi
|
||||
|
||||
echo "== [1/4] 패키지·venv 설치 =="
|
||||
echo "== [1/4] Install packages + venv =="
|
||||
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 systemctl daemon-reload
|
||||
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
|
||||
source venv/bin/activate
|
||||
python3 test_connection.py
|
||||
|
||||
echo "== [4/4] 하드닝 (마지막 — SSH 가 차단된다!) =="
|
||||
echo "== [4/4] Hardening (last - SSH gets locked down!) =="
|
||||
sudo bash harden_pi.sh
|
||||
|
||||
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 설정).
|
||||
`config_local.py` 는 이미 채워져 있음(API_KEY/URL/기기 ID). 설치 장소가 바뀌면 DEVICE_LOCATION 만 수정.
|
||||
Prerequisites: Raspberry Pi OS **Bookworm 64-bit**, user **pi**, SSH enabled (set in Imager).
|
||||
`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
|
||||
# 1. 이 폴더를 Pi 로 복사
|
||||
# 1. Copy this folder to the Pi
|
||||
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
|
||||
# (sudo 붙이지 말 것 — 내부에서 sudo 사용)
|
||||
# (do NOT prefix with sudo - sudo is used internally)
|
||||
```
|
||||
|
||||
`setup.sh` 는 4단계를 자동 수행하고, **검증(3단계)이 실패하면 하드닝 전에 멈춘다**
|
||||
(SSH 가 살아있어 원인을 볼 수 있음). 검증이 통과해야만 하드닝까지 진행된다.
|
||||
`setup.sh` runs 4 stages automatically and **stops before hardening if the verify (stage 3) fails**
|
||||
(SSH stays alive so you can see the cause). Hardening runs only after verification passes.
|
||||
|
||||
⚠️ 4단계 하드닝은 **SSH 를 완전히 끈다.** SSH 로 접속해 실행 중이면 완료 직후
|
||||
세션이 끊기고 재접속 불가 — 이후 유지보수는 현장 모니터+키보드. 마지막에 `sudo reboot`.
|
||||
Warning: stage 4 hardening **fully disables SSH.** If you are running over SSH, the session
|
||||
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
|
||||
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 systemctl daemon-reload && sudo systemctl enable --now {meta["service"]} # 2. 서비스
|
||||
source venv/bin/activate && python3 test_connection.py # 3. 검증(서명 200)
|
||||
sudo bash harden_pi.sh # 4. 하드닝(SSH 차단)
|
||||
sudo systemctl daemon-reload && sudo systemctl enable --now {meta["service"]} # 2. service
|
||||
source venv/bin/activate && python3 test_connection.py # 3. verify (signed 200)
|
||||
sudo bash harden_pi.sh # 4. harden (SSH lockdown)
|
||||
```
|
||||
|
||||
**하드닝 정책**(`harden_pi.sh`): SSH 완전 비활성 · 인바운드 전부 차단(nftables) ·
|
||||
불필요 서비스 제거(avahi/bluetooth/cups 등) · 자동 보안 패치 · `config_local.py` 권한 600.
|
||||
이 시스템은 outbound 전용이라 인바운드가 필요 없음. 재활성이 필요하면 SD boot 파티션에
|
||||
빈 `ssh` 파일 생성(물리 접근 필요).
|
||||
**Hardening policy** (`harden_pi.sh`): fully disable SSH, block all inbound (nftables),
|
||||
remove unneeded services (avahi/bluetooth/cups etc.), automatic security patches, `config_local.py` mode 600.
|
||||
This system is outbound-only, so no inbound is needed. To re-enable, create an empty
|
||||
`ssh` file on the SD boot partition (physical access required).
|
||||
''')
|
||||
|
||||
# ── 서버 번들 ────────────────────────────────────────────────────────────────
|
||||
# -- server bundle ------------------------------------------------------------
|
||||
server = DEPLOY / "01_server_cafe24"
|
||||
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)
|
||||
# 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"
|
||||
if var_dir.exists():
|
||||
shutil.rmtree(var_dir)
|
||||
var_dir.mkdir()
|
||||
write_lf(var_dir / "index.html", "") # 디렉터리 목록 가드
|
||||
write_lf(var_dir / "index.html", "") # directory listing guard
|
||||
|
||||
# SQL 은 단일 파일만 — schema_sht30.sql 이 신규 설치 완전 스키마(누수 요소 포함).
|
||||
# migration_*.sql 은 기존 서버 레트로핏용이라 신규 설치 번들에서 제외한다.
|
||||
# SQL is a single file only - schema_sht30.sql is the complete new-install schema (includes leak elements).
|
||||
# migration_*.sql is for retrofitting an existing server, so it is excluded from the new-install bundle.
|
||||
(server / "sql").mkdir(parents=True)
|
||||
schema_src = (ROOT / "sql" / "schema_sht30.sql").read_text(encoding="utf-8")
|
||||
write_lf(server / "sql" / "schema.sql",
|
||||
"-- schema.sql — 신규 설치용 단일 스키마 (원본: sql/schema_sht30.sql)\n"
|
||||
"-- 누수(leak) + 온습도(SHT30) 요소 전부 포함 — 이 파일 하나만 1회 실행하면 됨.\n"
|
||||
"-- 이 파일 하나로 설치 완결 (테이블 전체 leak_ 접두사 — 같은 DB 의 다른 테이블과 격리).\n\n"
|
||||
"-- schema.sql - single new-install schema (source: sql/schema_sht30.sql)\n"
|
||||
"-- Includes both leak and temperature/humidity (SHT30) elements - run this one file once.\n"
|
||||
"-- This single file completes the install (all tables leak_ prefixed - isolated from other tables in the same DB).\n\n"
|
||||
+ schema_src)
|
||||
|
||||
write_lf(server / WEB_DIR / "config.local.php", f'''<?php
|
||||
// config.local.php — 자동 생성 운영 설정 (커밋/공유 금지)
|
||||
// [CHANGE_ME] 3곳만 채우면 운영 가능: DB 계정, SMS 계정, SMS 수신자.
|
||||
// config.local.php - auto-generated operational settings (do not commit/share)
|
||||
// [CHANGE_ME] Fill in just 3 areas to go live: DB account, SMS account, SMS recipients.
|
||||
return [
|
||||
// ── cafe24 MySQL (호스팅 패널 > DB 정보) ── [CHANGE_ME]
|
||||
// -- cafe24 MySQL (hosting panel > DB info) -- [CHANGE_ME]
|
||||
'DB_HOST' => 'localhost',
|
||||
'DB_PORT' => 3306,
|
||||
'DB_NAME' => 'CHANGE_ME',
|
||||
'DB_USER' => '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}',
|
||||
'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_SECURE' => 'CHANGE_ME',
|
||||
'SMS_SENDER' => '01000000000',
|
||||
'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_PASSWORD_HASH' => '{ADMIN_HASH}',
|
||||
// TOTP 는 서버에서 1회 등록: setup_mfa.php?token=<아래 토큰> 접속 → 앱 등록
|
||||
// → 표시된 시크릿을 ADMIN_TOTP_SECRET 에 넣고 → MFA_SETUP_TOKEN 은 '' 로 비울 것.
|
||||
// Enroll TOTP once on the server: open setup_mfa.php?token=<token below> -> register in app
|
||||
// -> put the shown secret into ADMIN_TOTP_SECRET -> clear MFA_SETUP_TOKEN to ''.
|
||||
'ADMIN_TOTP_SECRET' => '',
|
||||
'MFA_SETUP_TOKEN' => '{MFA_SETUP_TOKEN}',
|
||||
|
||||
// ── 누수 모듈 sensor_id (1번 지점 + 3번 신규 지점) ──
|
||||
// -- leak module sensor_id (Site 1 + new Site 3) --
|
||||
'LEAK_SENSOR_IDS' => [1, 3],
|
||||
|
||||
// ── 온습도 임계 (운영 권장값) ──
|
||||
// -- temperature/humidity thresholds (recommended operational values) --
|
||||
'METRIC_TEMP_HIGH_C' => 27,
|
||||
'METRIC_TEMP_LOW_C' => 10,
|
||||
'METRIC_RH_HIGH' => 60,
|
||||
'METRIC_RH_LOW' => -1, // 저습 경보 비활성
|
||||
'METRIC_DEWPOINT_MARGIN_C' => 0, // 결로 경보 비활성
|
||||
'METRIC_RH_LOW' => -1, // low-humidity alert disabled
|
||||
'METRIC_DEWPOINT_MARGIN_C' => 0, // condensation alert disabled
|
||||
'METRIC_ALERT_COOLDOWN_SEC' => 1800,
|
||||
|
||||
'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).
|
||||
2. **업로드**: `{WEB_DIR}/` 폴더 전체를 `public_html/` 아래로 업로드.
|
||||
3. **config.local.php 의 [CHANGE_ME] 3곳** 채우기: DB 계정 / SMS 계정 / SMS 수신번호.
|
||||
4. **HTTPS 강제**: cafe24 호스팅 패널에서 설정 (필수 — 미설정 시 로그인 불가).
|
||||
5. **관리자 로그인**: `{BASE_URL}/login.php`
|
||||
- ID `admin` / 비밀번호는 `deploy/.secrets/admin_password.txt`
|
||||
- 최초 1회 TOTP 등록: `{BASE_URL}/setup_mfa.php?token=<MFA_SETUP_TOKEN 값>` →
|
||||
앱 등록 → 표시된 시크릿을 `ADMIN_TOTP_SECRET` 에 → `MFA_SETUP_TOKEN` 비우기.
|
||||
6. **cron (1분)**: cafe24 크론탭에 `php /home/<계정>/public_html/{WEB_DIR}/cron_heartbeat.php`
|
||||
(HTTP 방식이면 아래 URL 그대로 — CRON_SECRET 은 이미 계산되어 있음:
|
||||
2. **Upload**: upload the entire `{WEB_DIR}/` folder under `public_html/`.
|
||||
3. **Fill the 3 [CHANGE_ME] areas in config.local.php**: DB account / SMS account / SMS recipient number.
|
||||
4. **Force HTTPS**: set in the cafe24 hosting panel (required - login fails without it).
|
||||
5. **Admin login**: `{BASE_URL}/login.php`
|
||||
- ID `admin` / password is in `deploy/.secrets/admin_password.txt`
|
||||
- First-time TOTP enrollment: `{BASE_URL}/setup_mfa.php?token=<MFA_SETUP_TOKEN value>` ->
|
||||
register in app -> put the shown secret into `ADMIN_TOTP_SECRET` -> clear `MFA_SETUP_TOKEN`.
|
||||
6. **cron (1 min)**: in the cafe24 crontab, `php /home/<account>/public_html/{WEB_DIR}/cron_heartbeat.php`
|
||||
(for the HTTP method, use the URL below as-is - CRON_SECRET is already computed:
|
||||
`{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 방어 ──────────────────────────────────────────
|
||||
write_lf(DEPLOY / "README.md", f'''# deploy/ — 기기별 턴키 배포 번들 (자동 생성: tools/make_deploy_bundles.py)
|
||||
# -- top-level README + gitignore guard ---------------------------------------
|
||||
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`·재클론·폴더 삭제 시 소멸. 재생성하면
|
||||
새 API_KEY 가 발급되어 **이미 배포된 서버·Pi 전부와 불일치**(전 노드 수집 거부)하게 됨.
|
||||
배포 직후 `.secrets/` 를 오프라인 저장소(USB 등)에 복사할 것.
|
||||
Warning: **`.secrets/` must be backed up** - it is lost on `git clean -fdx`, re-clone, or folder deletion. If regenerated,
|
||||
a new API_KEY is issued and **mismatches every already-deployed server & Pi** (all nodes reject ingestion).
|
||||
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회 |
|
||||
| 02_pi_a_leak_id1 | Pi-A 누수+카메라 | 1 | 없음 |
|
||||
| 03_pi_b_sht30_id2 | Pi-B SHT30 온습도 | 2 | 없음 |
|
||||
| 04_pi_c_leak_id3 | Pi-C 누수+카메라(신규 지점) | 3 | 없음 |
|
||||
| 01_server_cafe24 | cafe24 web hosting | - | DB account, SMS account/number, one-time TOTP |
|
||||
| 02_pi_a_leak_id1 | Pi-A leak + camera | 1 | none |
|
||||
| 03_pi_b_sht30_id2 | Pi-B SHT30 temp/humidity | 2 | none |
|
||||
| 04_pi_c_leak_id3 | Pi-C leak + camera (new site) | 3 | none |
|
||||
|
||||
## 순서
|
||||
1. `01_server_cafe24/SETUP.md` — 서버 먼저 (Pi 검증이 서버에 의존)
|
||||
2. Pi 3대 공통 OS: Raspberry Pi Imager → **Bookworm 64-bit**, 사용자 `pi`, SSH 켬, WiFi/유선 설정
|
||||
3. 각 Pi 폴더의 `SETUP.md` (복사 → install.sh → systemctl, 병렬 가능)
|
||||
## Order
|
||||
1. `01_server_cafe24/SETUP.md` - server first (Pi verification depends on the server)
|
||||
2. Common OS for all 3 Pis: Raspberry Pi Imager -> **Bookworm 64-bit**, user `pi`, SSH on, WiFi/wired configured
|
||||
3. Each Pi folder's `SETUP.md` (copy -> install.sh -> systemctl; can be done in parallel)
|
||||
|
||||
## 참고
|
||||
- 서버 주소: `{BASE_URL}` — 도메인은 `tools/make_deploy_bundles.py` 상단 DOMAIN, 경로 폴더명은 `.secrets/web_dir.txt` 수정 후 재실행.
|
||||
- 오프라인(폐쇄망) 설치가 필요하면: 인터넷 되는 Pi 1대에서 각 모듈 `./vendor.sh` 실행 후
|
||||
생성된 `wheelhouse/`+`debs/` 를 다른 Pi 번들 폴더에 복사 (install.sh 가 자동 오프라인 전환).
|
||||
- 전체 배포 계획: `docs/DEPLOYMENT_PLAN_RPI4x3.md`
|
||||
## Notes
|
||||
- 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.
|
||||
- For offline (air-gapped) install: on one internet-connected Pi run each module's `./vendor.sh`, then
|
||||
copy the generated `wheelhouse/`+`debs/` into the other Pi bundle folders (install.sh switches to offline automatically).
|
||||
- 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)
|
||||
|
||||
print("deploy/ 생성 완료")
|
||||
# cp949 콘솔(Windows 기본)에서도 죽지 않도록 em-dash 등 비호환 문자 금지.
|
||||
print("[주의] deploy/.secrets/ 를 오프라인 저장소에 백업할 것 (git clean -fdx 로 소멸).")
|
||||
print(" 재생성 시 새 API_KEY 가 배포된 전 노드와 불일치하게 됨.")
|
||||
print("deploy/ generation complete")
|
||||
# Avoid characters incompatible with a cp949 console (Windows default) - no em-dashes etc.
|
||||
print("[NOTE] Back up deploy/.secrets/ to offline storage (lost on git clean -fdx).")
|
||||
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" admin password : deploy/.secrets/admin_password.txt")
|
||||
print(f" 서버 URL : {BASE_URL}")
|
||||
print(f" server URL : {BASE_URL}")
|
||||
for name, _, _ in BUNDLES:
|
||||
print(f" - deploy/{name}")
|
||||
|
|
|
|||
|
|
@ -1,48 +1,50 @@
|
|||
#!/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·콘솔 자동로그인이 꺼진다. 그래서 이 스크립트는
|
||||
# "현장 물리 콘솔(모니터+키보드)에서 pi 로그인 후" 실행하는 것을 전제로 한다.
|
||||
# Raspberry Pi OS Bookworm 기본 네트워크 관리자는 NetworkManager(nmcli)다.
|
||||
# Background: after harden_pi.sh runs, SSH and console auto-login are disabled. So this
|
||||
# script assumes you run it "after logging in as pi at the on-site physical console
|
||||
# (monitor + keyboard)".
|
||||
# The default network manager on Raspberry Pi OS Bookworm is NetworkManager (nmcli).
|
||||
#
|
||||
# 하드닝 방화벽과의 관계: nftables 는 인바운드만 drop, 아웃바운드는 accept +
|
||||
# established/related 허용. 고정 IP 로 바꿔도 서버 POST(아웃바운드)는 그대로
|
||||
# 나가며 인바운드 규칙 추가가 필요없다. 이 스크립트는 방화벽을 건드리지 않는다.
|
||||
# Relationship to the hardening firewall: nftables drops inbound only; outbound is accept +
|
||||
# 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.
|
||||
#
|
||||
# 사용:
|
||||
# 대화형: sudo bash set_network.sh
|
||||
# 비대화형: sudo bash set_network.sh --ip 192.168.0.50/24 --gw 192.168.0.1 \
|
||||
# Usage:
|
||||
# Interactive: sudo bash set_network.sh
|
||||
# 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
|
||||
# 현재 확인: sudo bash set_network.sh --show
|
||||
# DHCP 복귀: sudo bash set_network.sh --dhcp --yes
|
||||
# Show current: sudo bash set_network.sh --show
|
||||
# Revert to DHCP: sudo bash set_network.sh --dhcp --yes
|
||||
#
|
||||
# 옵션:
|
||||
# --ip A.B.C.D/PREFIX 고정 IP + 서브넷(CIDR). 예: 192.168.0.50/24 (=255.255.255.0)
|
||||
# --gw A.B.C.D 게이트웨이
|
||||
# --dns "A.B.C.D ..." DNS (공백 구분, 여러 개 가능). 서버가 도메인이면 필수.
|
||||
# --iface NAME 인터페이스 (기본: 자동 감지, 보통 eth0)
|
||||
# --con NAME NM 연결 이름 (기본: 유선 연결 자동 감지)
|
||||
# --dhcp 수동 IP 해제 → 자동(DHCP) 복귀
|
||||
# --show 현재 설정만 출력하고 종료
|
||||
# --yes | -y 확인 프롬프트 건너뜀(비대화형)
|
||||
# Options:
|
||||
# --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 gateway
|
||||
# --dns "A.B.C.D ..." DNS (space-separated, multiple allowed). Required if the server is a domain.
|
||||
# --iface NAME interface (default: auto-detect, usually eth0)
|
||||
# --con NAME NM connection name (default: auto-detect wired connection)
|
||||
# --dhcp release manual IP -> revert to automatic (DHCP)
|
||||
# --show print current settings only and exit
|
||||
# --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
|
||||
|
||||
die(){ echo "[ERROR] $*" >&2; exit 1; }
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || die "sudo 로 실행하세요: sudo bash set_network.sh"
|
||||
# 스크립트 폴더 기준으로 test_connection.py 등을 찾도록 cwd 이동.
|
||||
[ "$(id -u)" -eq 0 ] || die "Run with sudo: sudo bash set_network.sh"
|
||||
# Move cwd to the script folder so it can find test_connection.py etc.
|
||||
cd "$(dirname "$0")" 2>/dev/null || true
|
||||
|
||||
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"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
|
|
@ -55,11 +57,11 @@ while [ $# -gt 0 ]; do
|
|||
--show) MODE="show"; shift;;
|
||||
--yes|-y) ASSUME_YES=1; shift;;
|
||||
-h|--help) sed -n '2,40p' "$0"; exit 0;;
|
||||
*) die "알 수 없는 옵션: $1 (도움말: --help)";;
|
||||
*) die "Unknown option: $1 (help: --help)";;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---- 검증 헬퍼 -------------------------------------------------------------
|
||||
# ---- validation helpers ----------------------------------------------------
|
||||
valid_ipv4(){
|
||||
local ip="$1" o
|
||||
[[ "$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 ]
|
||||
}
|
||||
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 ))
|
||||
[ $(( $(ip2int "$1") & m )) -eq $(( $(ip2int "$2") & m )) ]
|
||||
}
|
||||
|
||||
# ---- 유선 연결/인터페이스 자동 감지 ---------------------------------------
|
||||
# ---- auto-detect wired connection/interface --------------------------------
|
||||
if [ -z "$IFACE" ]; then
|
||||
IFACE="$(nmcli -t -f DEVICE,TYPE dev status 2>/dev/null | awk -F: '$2=="ethernet"{print $1; exit}')"
|
||||
IFACE="${IFACE:-eth0}"
|
||||
fi
|
||||
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}')"
|
||||
[ -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"
|
||||
fi
|
||||
|
||||
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 \
|
||||
|| echo " (연결 '$CON' 없음)"
|
||||
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 " (connection '$CON' not found)"
|
||||
echo "-- Actual IP:"; ip -4 addr show "$IFACE" 2>/dev/null | awk '/inet /{print " "$2}' || 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
|
||||
|
||||
# ---- --dhcp : 자동 복귀 ----------------------------------------------------
|
||||
# ---- --dhcp : revert to automatic ------------------------------------------
|
||||
if [ "$MODE" = dhcp ]; then
|
||||
echo "연결 '$CON' → 자동(DHCP) 복귀."
|
||||
[ "$ASSUME_YES" = 1 ] || { read -r -p "진행? [y/N] " a; [[ "$a" =~ ^[Yy]$ ]] || die "취소됨"; }
|
||||
echo "Connection '$CON' -> revert to automatic (DHCP)."
|
||||
[ "$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 down "$CON" >/dev/null 2>&1 || true
|
||||
nmcli con up "$CON" || die "활성화 실패 — 물리 링크/스위치 확인."
|
||||
echo "── 결과 ──"; show_current
|
||||
nmcli con up "$CON" || die "Activation failed — check physical link/switch."
|
||||
echo "── Result ──"; show_current
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---- static : 대화형 프롬프트(값 누락 + TTY 인 경우만) --------------------
|
||||
# ---- static : interactive prompts (only when values are missing and on a TTY) ---
|
||||
if [ -t 0 ]; then
|
||||
[ -n "$IPCIDR" ] || read -r -p "고정 IP/서브넷 (예 192.168.0.50/24): " IPCIDR
|
||||
[ -n "$GW" ] || read -r -p "게이트웨이 (예 192.168.0.1): " GW
|
||||
[ -n "$DNS" ] || read -r -p "DNS (공백구분, 예 '8.8.8.8 1.1.1.1', 없으면 엔터): " DNS
|
||||
[ -n "$IPCIDR" ] || read -r -p "Static IP/subnet (e.g. 192.168.0.50/24): " IPCIDR
|
||||
[ -n "$GW" ] || read -r -p "Gateway (e.g. 192.168.0.1): " GW
|
||||
[ -n "$DNS" ] || read -r -p "DNS (space-separated, e.g. '8.8.8.8 1.1.1.1', press Enter for none): " DNS
|
||||
fi
|
||||
|
||||
# ---- 값 검증 ---------------------------------------------------------------
|
||||
valid_cidr "$IPCIDR" || die "IP/서브넷 형식 오류: '$IPCIDR' (예: 192.168.0.50/24)"
|
||||
valid_ipv4 "$GW" || die "게이트웨이 형식 오류: '$GW'"
|
||||
# ---- value validation ------------------------------------------------------
|
||||
valid_cidr "$IPCIDR" || die "Invalid IP/subnet format: '$IPCIDR' (e.g. 192.168.0.50/24)"
|
||||
valid_ipv4 "$GW" || die "Invalid gateway format: '$GW'"
|
||||
IPADDR="${IPCIDR%/*}"; PREFIX="${IPCIDR#*/}"
|
||||
same_subnet "$IPADDR" "$GW" "$PREFIX" \
|
||||
|| die "게이트웨이 $GW 가 $IPCIDR 서브넷 밖입니다. IP·서브넷·게이트웨이 값을 확인하세요."
|
||||
for d in $DNS; do valid_ipv4 "$d" || die "DNS 형식 오류: '$d'"; done
|
||||
[ -n "$DNS" ] || echo "[WARN] DNS 미지정 — 서버 주소가 도메인이면 이름해석 실패. 서버가 IP면 무방."
|
||||
|| die "Gateway $GW is outside the $IPCIDR subnet. Check the IP/subnet/gateway values."
|
||||
for d in $DNS; do valid_ipv4 "$d" || die "Invalid DNS format: '$d'"; done
|
||||
[ -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."
|
||||
|
||||
# ---- 요약 + 확인 -----------------------------------------------------------
|
||||
echo "───────────── 적용 예정 ─────────────"
|
||||
echo " 인터페이스 : $IFACE"
|
||||
echo " 연결 이름 : $CON"
|
||||
echo " IP/서브넷 : $IPCIDR"
|
||||
echo " 게이트웨이 : $GW"
|
||||
echo " DNS : ${DNS:-(없음)}"
|
||||
# ---- summary + confirmation ------------------------------------------------
|
||||
echo "───────────── To be applied ─────────────"
|
||||
echo " Interface : $IFACE"
|
||||
echo " Connection : $CON"
|
||||
echo " IP/subnet : $IPCIDR"
|
||||
echo " Gateway : $GW"
|
||||
echo " DNS : ${DNS:-(none)}"
|
||||
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"
|
||||
{ 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
|
||||
echo "[INFO] 이전 설정 백업: $BK"
|
||||
echo "[INFO] Previous settings backed up: $BK"
|
||||
|
||||
# ---- 적용 ------------------------------------------------------------------
|
||||
echo "[적용] nmcli 로 고정 IP 설정..."
|
||||
# ---- apply -----------------------------------------------------------------
|
||||
echo "[APPLY] Setting static IP via nmcli..."
|
||||
nmcli con mod "$CON" \
|
||||
ipv4.method manual \
|
||||
ipv4.addresses "$IPCIDR" \
|
||||
ipv4.gateway "$GW" \
|
||||
ipv4.dns "$DNS" \
|
||||
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 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 "───────────── Verification ─────────────"
|
||||
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 "-- 게이트웨이 ping:"
|
||||
if ping -c2 -W2 "$GW" >/dev/null 2>&1; then echo " OK ($GW 도달)"; else \
|
||||
echo " [WARN] $GW 무응답 — 배선/스위치/게이트웨이 값 확인"; fi
|
||||
echo "-- Routing:"; ip route 2>/dev/null | sed 's/^/ /' || true
|
||||
echo "-- Gateway ping:"
|
||||
if ping -c2 -W2 "$GW" >/dev/null 2>&1; then echo " OK ($GW reachable)"; else \
|
||||
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
|
||||
if [ -f "$t" ]; then
|
||||
echo "-- 서버 연결 테스트: $t"
|
||||
echo "-- Server connectivity test: $t"
|
||||
( cd "$(dirname "$t")" && python3 "$(basename "$t")" ) \
|
||||
|| echo " [WARN] 서버 테스트 실패 — DNS/게이트웨이/서버주소 확인"
|
||||
|| echo " [WARN] Server test failed — check DNS/gateway/server address"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "완료. 문제 시 복구: 백업($BK) 참고하거나 sudo bash set_network.sh --dhcp --yes (DHCP 복귀)."
|
||||
echo "설정 확인: sudo bash set_network.sh --show"
|
||||
echo "Done. If there are problems, recover: see backup ($BK) or run sudo bash set_network.sh --dhcp --yes (revert to DHCP)."
|
||||
echo "Check settings: sudo bash set_network.sh --show"
|
||||
|
|
|
|||
Loading…
Reference in a new issue