diff --git a/docs/superpowers/plans/2026-07-15-leak-node-sht30-integration.md b/docs/superpowers/plans/2026-07-15-leak-node-sht30-integration.md new file mode 100644 index 0000000..5bb0090 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-leak-node-sht30-integration.md @@ -0,0 +1,445 @@ +# 누수 노드 SHT30 온습도 통합 구현 계획 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 기존 누수 노드(Pi-A=id1, Pi-C=id3)에 SHT30 온습도 센서를 물리적으로 추가하고, 온습도를 같은 sensor_id(1,3)로 서버에 보고 — 대시보드·경보·설치 패키지 전부 반영. + +**Architecture:** 노드마다 기존 누수 서비스는 무변경, `sht30_sensor.py`를 두 번째 systemd 서비스로 얹는다(별도 디렉터리 `/home/pi/sht30`). sht30 코드는 그대로 재사용하고 config_local로 `SENSOR_ID`만 1/3로 지정. 서버는 코드 변경 없이 config.local 값(METRIC_SENSOR_IDS·heartbeat override)만 조정 — 대시보드/보고서는 이미 `METRIC_SENSOR_IDS` 동적 순회. + +**Tech Stack:** Python 3.11(라즈베리 파이 4, Bookworm), smbus2(I2C), PHP(cafe24 서버), systemd, bash 설치 스크립트(폐쇄망 오프라인 번들). + +## Global Constraints + +- **폐쇄망(air-gapped):** 모든 설치 산출물은 자립형. 네트워크 미사용, 번들 없으면 명확히 FLAG. wheel/deb는 build 머신에서 vendor.sh로 사전 다운로드. (기존 install.sh/vendor.sh 패턴 유지) +- **동일 sensor_id:** 온습도는 새 id 없이 누수와 같은 id(Pi-A=1, Pi-C=3). Pi-B(id=2)는 절대 변경 없음. +- **누수 코드 무변경:** `leak_sensor.py` 및 누수 런타임 경로(`/home/pi/raspberry_pi4`) 손대지 않음. +- **라벨 일치:** 각 노드에서 sht30 config_local의 `DEVICE_LOCATION`은 같은 노드 누수 config_local의 `DEVICE_LOCATION`과 **문자열 동일**(같은 sensor_id upsert 라벨 플랩 방지). +- **HMAC 서명:** sht30 config_local의 `API_KEY`는 서버 config.local.php의 `API_KEY`와 완전 동일. +- **비밀값:** `config_local.py`/`config.local.php`(실값)는 에이전트가 직접 수정/커밋하지 않음. 예시(`*.example`)와 문서만 반영, 실값 라인은 응답/문서에 제시. + +--- + +### Task 1: sht30 config.py — config_local로 SENSOR_ID/SENSOR_NAME 오버라이드 지원 + +**목적:** 누수 노드의 sht30가 tracked `config.py`(SENSOR_ID=2 기본) 편집 없이 config_local만으로 id=1/3 보고. 누수 config.py의 SENSORS 오버라이드 패턴과 동일. Pi-B는 config_local에 SENSOR_ID 미지정 → 기본 2 유지(하위호환). + +**Files:** +- Modify: `raspberry_pi4_sht30/config.py` (끝에 오버라이드 블록 추가) +- Modify: `deploy/03_pi_b_sht30_id2/config.py` (동일 블록 — 소스와 동기화) + +**Interfaces:** +- Produces: `config.SENSOR_ID` / `config.SENSOR_NAME` 가 config_local 값으로 대체 가능(검증 포함). `sht30_sensor.py`가 `from config import SENSOR_ID, SENSOR_NAME` 로 소비(무변경). + +- [ ] **Step 1: `raspberry_pi4_sht30/config.py` 끝(현재 마지막 줄 = config_local import 블록 이후)에 추가** + +```python + +# ───────────────────────────────────────────── +# Optional override — per-device values from config_local.py. +# Editing the tracked config.py per device causes deployment drift, so a co-located +# node (e.g. leak node reporting temp/humidity under the SAME sensor_id 1 or 3) +# overrides SENSOR_ID / SENSOR_NAME here instead. If config_local has neither, +# the defaults above (SENSOR_ID=2) are kept — backward compatible for Pi-B. +# ───────────────────────────────────────────── +try: + from config_local import SENSOR_ID # noqa: F811 — per-device sensor_id override +except ImportError: + pass +try: + from config_local import SENSOR_NAME # noqa: F811 — per-device display name override +except ImportError: + pass + +# Validate (fail closed) — a typo must not silently start with a wrong/plausible id. +if not (isinstance(SENSOR_ID, int) and 1 <= SENSOR_ID <= 255): + raise RuntimeError(f"SENSOR_ID must be int 1..255 (check config_local.py): {SENSOR_ID!r}") +if not (isinstance(SENSOR_NAME, str) and SENSOR_NAME): + raise RuntimeError("SENSOR_NAME must be a non-empty string (check config_local.py).") +``` + +- [ ] **Step 2: 같은 블록을 `deploy/03_pi_b_sht30_id2/config.py` 끝에도 추가** (소스와 동일 — 두 파일 내용 일치 확인) + +- [ ] **Step 3: 오버라이드 동작 검증(수동 스모크 — 이 repo의 파이썬 테스트 관례는 수동 스크립트)** + +Run (임시 config_local 생성해 import 확인): +```bash +cd raspberry_pi4_sht30 +python3 - <<'PY' +# 오버라이드 로직 단위 확인(실 config_local 없이): 값 대입+검증 규칙만. +ns = {'SENSOR_ID': 2, 'SENSOR_NAME': 'Sensor 2 (SHT30)'} +ns['SENSOR_ID'] = 1 # config_local override 흉내 +assert isinstance(ns['SENSOR_ID'], int) and 1 <= ns['SENSOR_ID'] <= 255 +assert isinstance(ns['SENSOR_NAME'], str) and ns['SENSOR_NAME'] +print("override ok:", ns['SENSOR_ID']) +PY +``` +Expected: `override ok: 1` + +- [ ] **Step 4: Commit** + +```bash +git add raspberry_pi4_sht30/config.py deploy/03_pi_b_sht30_id2/config.py +git commit -m "feat(sht30): config_local 로 SENSOR_ID/SENSOR_NAME 오버라이드(동일-id 공용 노드 대비)" +``` + +--- + +### Task 2: deploy/02 (Pi-A) 에 sht30 서브폴더 추가 — SENSOR_ID=1 + +**Files:** +- Create dir: `deploy/02_pi_a_leak_id1/sht30/` +- Create(복사): `sht30/sht30_sensor.py`, `sht30/sht30_driver.py`, `sht30/wire.py`, `sht30/config.py`, `sht30/test_sensor.py`, `sht30/test_connection.py`, `sht30/requirements.txt`, `sht30/vendor.sh`, `sht30/install.sh`, `sht30/systemd/sht30-sensor.service` — 전부 `deploy/03_pi_b_sht30_id2/`에서 **verbatim 복사** (config.py는 Task 1 반영본) +- Create: `sht30/config_local.example.py`, `sht30/config_local.py` + +**Interfaces:** +- Produces: `/home/pi/sht30` 로 설치될 자립형 sht30 installable. Task 4(install.sh 체이닝)가 소비. + +- [ ] **Step 1: deploy/03 sht30 파일 verbatim 복사** + +```bash +cd deploy/02_pi_a_leak_id1 +mkdir -p sht30/systemd +cp ../03_pi_b_sht30_id2/sht30_sensor.py sht30/ +cp ../03_pi_b_sht30_id2/sht30_driver.py sht30/ +cp ../03_pi_b_sht30_id2/wire.py sht30/ +cp ../03_pi_b_sht30_id2/config.py sht30/ +cp ../03_pi_b_sht30_id2/test_sensor.py sht30/ +cp ../03_pi_b_sht30_id2/test_connection.py sht30/ +cp ../03_pi_b_sht30_id2/requirements.txt sht30/ +cp ../03_pi_b_sht30_id2/vendor.sh sht30/ +cp ../03_pi_b_sht30_id2/install.sh sht30/ +cp ../03_pi_b_sht30_id2/systemd/sht30-sensor.service sht30/systemd/ +``` + +- [ ] **Step 2: `sht30/systemd/sht30-sensor.service` 의 경로를 `/home/pi/sht30` 로 수정** + +`WorkingDirectory` 와 `ExecStart` 두 줄을 다음으로 변경: +```ini +WorkingDirectory=/home/pi/sht30 +ExecStart=/home/pi/sht30/venv/bin/python sht30_sensor.py +``` +(나머지 sandbox/그룹 설정은 그대로 — `SupplementaryGroups=i2c` 유지) + +- [ ] **Step 3: `sht30/config_local.example.py` 작성** + +```python +# ============================================================================= +# config_local.example.py — 누수 노드(Pi-A, sensor_id=1)에 얹는 SHT30 온습도 서비스 설정 +# cp config_local.example.py config_local.py 후 실값 입력. config_local.py 는 커밋 금지. +# +# ★ 동일 sensor_id: 이 온습도는 같은 노드 누수 센서와 "같은" sensor_id(=1)로 보고한다. +# ★ 라벨 일치: DEVICE_LOCATION 은 같은 노드 누수 config_local.py 의 DEVICE_LOCATION 과 +# "문자열 동일"해야 한다(같은 id 를 두 서비스가 upsert → 라벨 플랩 방지). +# ★ API_KEY 는 서버 config.local.php 의 API_KEY 와 완전 동일(HMAC 서명). +# ============================================================================= +API_URL = "https://YOUR_SERVER/raspi_leck_detecter/api/sensor_data.php" +API_KEY = "CHANGE_ME" + +DEVICE_ID = "rpi4-a-sht30" +DEVICE_LOCATION = "Server Room" # ★ 누수(id=1) DEVICE_LOCATION 과 동일하게 + +SENSOR_ID = 1 # ★ 누수와 동일 id 로 온습도 보고 +SENSOR_NAME = "Sensor 1 (SHT30)" +``` + +- [ ] **Step 4: `sht30/config_local.py` 작성(실배포 기본값 — 실값은 현장에서 교체, chmod 600은 install.sh가)** + +Step 3 내용과 동일하게 `config_local.py` 생성. `.gitignore` 가 `config_local.py` 를 제외하는지 확인 — 제외되면 예시만 커밋 대상(실값 파일 미커밋). + +- [ ] **Step 5: Commit** + +```bash +git add deploy/02_pi_a_leak_id1/sht30 +git commit -m "feat(pi-a): 누수 노드에 SHT30 온습도 서비스 서브폴더 추가(sensor_id=1)" +``` + +--- + +### Task 3: deploy/04 (Pi-C) 에 sht30 서브폴더 추가 — SENSOR_ID=3 + +Task 2와 동일 절차, `deploy/04_pi_c_leak_id3/sht30/` 로. 차이는 config_local(.example).py: + +- [ ] **Step 1: 복사** — Task 2 Step 1을 `cd deploy/04_pi_c_leak_id3` 로 동일 수행. +- [ ] **Step 2: service 경로** — Task 2 Step 2와 동일(`/home/pi/sht30`). +- [ ] **Step 3+4: config_local(.example).py** — Task 2와 동일하되: + +```python +DEVICE_ID = "rpi4-c-sht30" +DEVICE_LOCATION = "New Site" # ★ 누수(id=3) DEVICE_LOCATION 과 동일하게 +SENSOR_ID = 3 # ★ 누수와 동일 id +SENSOR_NAME = "Sensor 3 (SHT30)" +``` +(API_URL/API_KEY는 Task 2와 동일 형식) + +- [ ] **Step 5: Commit** + +```bash +git add deploy/04_pi_c_leak_id3/sht30 +git commit -m "feat(pi-c): 누수 노드에 SHT30 온습도 서비스 서브폴더 추가(sensor_id=3)" +``` + +--- + +### Task 4: deploy/02 install.sh — sht30 서비스 설치 체이닝 + 서비스 enable + +**Files:** +- Modify: `deploy/02_pi_a_leak_id1/install.sh` (마지막 완료 메시지 직전에 단계 추가) + +**Interfaces:** +- Consumes: Task 2의 `sht30/` (자립형 install.sh 포함). +- Produces: `install.sh` 1회 실행으로 누수 + 온습도 두 서비스 설치·기동. + +- [ ] **Step 1: `deploy/02_pi_a_leak_id1/install.sh` 의 최종 `echo "==... Installation complete"` 블록 바로 앞에 삽입** + +```bash +# 5) SHT30 온습도 co-센서 설치 (동일 sensor_id=1, /home/pi/sht30 로 설치) +if [ -d sht30 ]; then + echo "[5/5] Installing SHT30 temp/humidity co-sensor (sensor_id 1)..." + SHT30_DEST="/home/pi/sht30" + sudo mkdir -p "${SHT30_DEST}" + # 코드/번들 배치(설치 대상 디렉터리로 복사 — 폐쇄망: 네트워크 미사용) + sudo cp -r sht30/. "${SHT30_DEST}"/ + sudo chown -R "${SERVICE_USER}:${SERVICE_USER}" "${SHT30_DEST}" + # sht30 자체 install.sh 로 위임(venv + I2C 활성화 + 로그 + 서비스 설치). OFFLINE 전파. + ( cd "${SHT30_DEST}" && SERVICE_USER="${SERVICE_USER}" OFFLINE="${OFFLINE}" bash install.sh ) + # systemd 서비스 등록/기동 + sudo cp "${SHT30_DEST}/systemd/sht30-sensor.service" /etc/systemd/system/ + sudo systemctl daemon-reload + sudo systemctl enable sht30-sensor + sudo systemctl restart sht30-sensor + echo " sht30-sensor 서비스 등록/기동 완료" +else + echo "[5/5] sht30/ 폴더 없음 — 온습도 co-센서 설치 건너뜀" +fi +``` + +- [ ] **Step 2: 문법 검증** + +Run: `bash -n deploy/02_pi_a_leak_id1/install.sh` +Expected: (출력 없음 = 문법 OK) + +- [ ] **Step 3: Commit** + +```bash +git add deploy/02_pi_a_leak_id1/install.sh +git commit -m "feat(pi-a): install.sh 에 SHT30 온습도 서비스 설치 체이닝(누수+온습도 1회 설치)" +``` + +--- + +### Task 5: deploy/04 install.sh — 동일 체이닝 (sensor_id 3) + +Task 4와 동일. 삽입 스니펫에서 주석 "sensor_id 1"→"sensor_id 3"만 변경. + +- [ ] **Step 1:** `deploy/04_pi_c_leak_id3/install.sh` 에 Task 4 Step 1 스니펫 삽입(주석 id 3). +- [ ] **Step 2:** `bash -n deploy/04_pi_c_leak_id3/install.sh` → 문법 OK. +- [ ] **Step 3: Commit** + +```bash +git add deploy/04_pi_c_leak_id3/install.sh +git commit -m "feat(pi-c): install.sh 에 SHT30 온습도 서비스 설치 체이닝(sensor_id=3)" +``` + +--- + +### Task 6: deploy/02 배선/문서 — WIRING.html + SETUP.md + README.md + +**Files:** +- Modify: `deploy/02_pi_a_leak_id1/WIRING.html` (SHT30 I2C 배선 섹션 추가) +- Modify: `deploy/02_pi_a_leak_id1/SETUP.md` (이중 서비스 설치·확인 절차) +- Modify: `deploy/02_pi_a_leak_id1/README.md` (개요에 온습도 추가) + +- [ ] **Step 1: `WIRING.html` — 누수 배선 다음에 SHT30 I2C 섹션 삽입** + +`` 직전(또는 마지막 섹션 뒤)에 삽입: +```html +
+

SHT30 온습도 (동일 노드, sensor_id 1)

+

누수 센서와 같은 Pi에 SHT30(P4422-3)을 I2C로 추가. 온습도는 누수와 같은 sensor_id(1)로 보고한다. 누수 GPIO17(핀11)과 핀 충돌 없음.

+ + + + + + + + + +
SHT30Pi 40핀비고
VCC3.3V (핀 1)★ 반드시 3.3V. 5V(핀 2·4) 금지 — I2C 라인 5V로 끌려 Pi 손상
SDAGPIO2 / SDA1 (핀 3)온보드 풀업 없으면 4.7kΩ→3.3V
SCLGPIO3 / SCL1 (핀 5)온보드 풀업 없으면 4.7kΩ→3.3V
GNDGND (핀 6 또는 9)
ADDRGND0x44 (config.py I2C_ADDR 와 일치)
+
+``` + +- [ ] **Step 2: `SETUP.md` — 온습도 설치/확인 절차 추가** + +다음 섹션을 문서 말미에 추가: +```markdown +## SHT30 온습도 co-센서 (동일 sensor_id 1) + +이 노드는 누수 + 온습도 두 서비스를 함께 돌린다. + +1. 배선: WIRING.html 의 "SHT30 온습도" 표대로(VCC=3.3V, SDA=핀3, SCL=핀5, GND=핀6, ADDR=GND). +2. 온습도 설정: `sht30/config_local.py` 에서 API_URL/API_KEY/DEVICE_LOCATION 을 실값으로. + - ★ `DEVICE_LOCATION` 은 누수 `config_local.py` 의 값과 문자열 동일. + - `SENSOR_ID=1` 은 그대로(누수와 동일 id). +3. 설치: `bash install.sh` 1회 → 누수 + 온습도 서비스 모두 설치·기동(SHT30는 /home/pi/sht30). +4. 확인: + - `i2cdetect -y 1` → 0x44 표시 + - `sudo systemctl status leak-sensor sht30-sensor` → 둘 다 active + - 서버 대시보드 1번 카드에 누수 상태 + 온습도 표시 +5. I2C 활성화 직후면 `sudo reboot` 필요할 수 있음(install.sh 안내). +``` + +- [ ] **Step 3: `README.md`** — 개요 문단에 "이 노드는 누수(id 1) + SHT30 온습도(같은 id 1)를 함께 보고한다" 한 줄 추가. + +- [ ] **Step 4: Commit** + +```bash +git add deploy/02_pi_a_leak_id1/WIRING.html deploy/02_pi_a_leak_id1/SETUP.md deploy/02_pi_a_leak_id1/README.md +git commit -m "docs(pi-a): SHT30 온습도 배선/설치 문서 반영" +``` + +--- + +### Task 7: deploy/04 배선/문서 — 동일 (sensor_id 3) + +Task 6과 동일하되 모든 "sensor_id 1"→"3", DEVICE_LOCATION 안내는 누수(id=3) 값 기준. + +- [ ] **Step 1~3:** Task 6 Step 1~3을 `deploy/04_pi_c_leak_id3/` 파일에 id=3으로 반영. +- [ ] **Step 4: Commit** + +```bash +git add deploy/04_pi_c_leak_id3/WIRING.html deploy/04_pi_c_leak_id3/SETUP.md deploy/04_pi_c_leak_id3/README.md +git commit -m "docs(pi-c): SHT30 온습도 배선/설치 문서 반영(sensor_id=3)" +``` + +--- + +### Task 8: 서버 — config.local.example.php + deploy/01 SETUP 반영 + +**Files:** +- Modify: `php/config.local.example.php` +- Modify: `deploy/01_server_cafe24/asmdkwqdmqwertn/config.local.php`(시크릿 — **에이전트 미수정**, 값만 SETUP·응답에 제시) +- Modify: `deploy/01_server_cafe24/SETUP.md` + +**Interfaces:** +- Produces: 대시보드가 1·3을 온습도 카드로도 표시(METRIC_SENSOR_IDS), 누수 오프라인 감지 속도 유지(heartbeat override). + +- [ ] **Step 1: `php/config.local.example.php` 의 `LEAK_SENSOR_IDS` 아래 `METRIC_SENSOR_IDS` 예시 추가 + heartbeat override 주석 갱신** + +`'LEAK_SENSOR_IDS' => [1],` 다음에 삽입: +```php + // 온습도(SHT30) sensor_id 목록 — 대시보드 온습도 카드 표시 대상 (기본 [2]). + // 누수 노드(1·3)에 SHT30 을 얹어 "같은 sensor_id" 로 온습도를 보고하는 구성에서는 + // 1·3 도 포함한다: [1, 2, 3]. (누수 카드 + 온습도 카드가 같은 지점에 함께 표시됨) + 'METRIC_SENSOR_IDS' => [1, 2, 3], +``` +그리고 기존 `SENSOR_HEARTBEAT_TIMEOUT_SEC` 주석 예시를 실효 오버라이드로 교체: +```php + // 센서별 오프라인 판정 타임아웃(무수신 초). ★ 1·3 은 온습도 목록에도 들어가지만 + // 누수 노드이므로 metric 기본(7500s)으로 느슨해지면 안 된다 → 누수 타임아웃 유지 위해 + // 명시 오버라이드한다(30분). (미지정 시 온습도로 간주되어 오프라인 감지가 늦어짐) + 'SENSOR_HEARTBEAT_TIMEOUT_SEC' => [1 => 1800, 3 => 1800], +``` + +- [ ] **Step 2: `deploy/01_server_cafe24/SETUP.md` 에 온서버 config.local.php 변경 절차 추가** + +```markdown +## 누수 노드 온습도(SHT30) 통합 반영 (동일 sensor_id) + +누수 노드(1·3)에 SHT30 를 얹어 같은 sensor_id 로 온습도를 보고하면, 서버 config.local.php 에 +아래를 반영한다(코드 변경 없음 — 설정만): + + 'METRIC_SENSOR_IDS' => [1, 2, 3], + 'SENSOR_HEARTBEAT_TIMEOUT_SEC' => [1 => 1800, 3 => 1800], + +- METRIC_SENSOR_IDS: 1·3 을 온습도 카드 대상에 포함(대시보드/월간보고 자동 반영). +- SENSOR_HEARTBEAT_TIMEOUT_SEC: 1·3 을 누수 타임아웃(30분)으로 고정(온습도 완화 방지). +- 온습도 임계 경보 수신자는 기존 SENSOR_RECIPIENTS[1]/[3] 그대로 사용. +반영 후 대시보드에서 1·3 카드에 누수 + 온습도가 함께 뜨는지 확인. +``` + +- [ ] **Step 3: Commit** + +```bash +git add php/config.local.example.php deploy/01_server_cafe24/SETUP.md +git commit -m "docs(server): 누수 노드 동일-id 온습도 통합 — METRIC_SENSOR_IDS/heartbeat 예시·SETUP 반영" +``` + +--- + +### Task 9: 서버 테스트 — heartbeat override(1·3) 케이스 추가 + +**Files:** +- Modify: `tests/heartbeat_timeout_test.php` + +**Interfaces:** +- Consumes: `resolve_heartbeat_timeout($sid, $overrides, $metricIds, $metricDefault, $globalDefault)` (config.php 순수 함수). + +- [ ] **Step 1: 기존 테스트 파일 문법 확인** + +Run: `php -l tests/heartbeat_timeout_test.php` +Expected: `No syntax errors detected` + +- [ ] **Step 2: 실패 테스트 추가 — 1·3 이 METRIC 목록에 있어도 override 로 누수 타임아웃 반환** + +`tests/heartbeat_timeout_test.php` 에 다음 검증 추가(파일의 assert 패턴에 맞춰 삽입): +```php +// 동일-id 온습도 통합: 1·3 이 METRIC_SENSOR_IDS 에 포함돼도, 명시 오버라이드가 +// metric 기본(7500)보다 우선하여 누수 타임아웃(1800)을 반환해야 한다. +$overrides = [1 => 1800, 3 => 1800]; +$metricIds = [1, 2, 3]; +assert(resolve_heartbeat_timeout(1, $overrides, $metricIds, 7500, 1200) === 1800); // 누수 유지 +assert(resolve_heartbeat_timeout(3, $overrides, $metricIds, 7500, 1200) === 1800); // 누수 유지 +assert(resolve_heartbeat_timeout(2, $overrides, $metricIds, 7500, 1200) === 7500); // 순수 온습도 +echo "[OK] heartbeat override(1,3) with metric-membership\n"; +``` + +- [ ] **Step 3: 실행 → 통과 확인** + +Run: `php tests/heartbeat_timeout_test.php` +Expected: 기존 출력 + `[OK] heartbeat override(1,3) with metric-membership`, 종료코드 0 + +- [ ] **Step 4: Commit** + +```bash +git add tests/heartbeat_timeout_test.php +git commit -m "test(server): 동일-id 온습도 통합 시 1·3 누수 heartbeat 타임아웃 유지 검증" +``` + +--- + +### Task 10: 통합 검증 (대시보드 중복집합 + 라벨/스키마) — 코드 수정은 발견 시만 + +**Files:** +- Verify: `php/dashboard_data.php`(≈208–223, 503–506), `php/monthly_report_print.php`, `php/forecast.php` +- Modify(발견 시만): 위 파일 중 동일 id가 LEAK∩METRIC 일 때 깨지는 부분 + +- [ ] **Step 1: 카드 맵 로직 확인 — sensor_id 가 LEAK·METRIC 양쪽에 있어도 정상** + +`php/dashboard_data.php` 208–230행을 읽고, `LEAK_SENSOR_IDS`와 `METRIC_SENSOR_IDS` 순회가 서로 독립적으로 카드를 만드는지(중복 집합 1·3에서 예외/누락 없는지) 확인. 누수 카드와 온습도 카드가 **각각** 생성되면 정상. + +- [ ] **Step 2: 월간보고/예보에서 동일 id 취급 확인** + +`php/monthly_report_print.php`(LEAK_SENSOR_IDS·METRIC_SENSOR_IDS 사용부), `php/forecast.php`(METRIC 순회)에서 1·3이 온습도로도 집계될 때 누수 데이터와 충돌/중복 표기가 없는지 확인. + +- [ ] **Step 3: 문제 발견 시에만 최소 수정** — 없으면 수정 없이 다음. + +- [ ] **Step 4: 통합 스모크(로컬 서버 있으면) — sensor_id=1 metric POST → 저장·표시·임계 경로** + +가능하면 로컬/스테이징에서 sensor_id=1 로 sht30 periodic POST(서명 포함) → `leak_sensor_metric`에 1행 저장 + `leak_sensor_status`의 is_leak 보존 + 대시보드 1번 카드에 온습도 표시 확인. (운영 서버 직접 검증은 사용자 몫 — SETUP 절차로 안내.) + +- [ ] **Step 5: Commit(수정 있었을 때만)** + +```bash +git add -A +git commit -m "fix(server): 동일-id LEAK∩METRIC 대시보드/보고 표기 정합(발견 시)" +``` + +--- + +## Self-Review (작성자 체크) + +- **Spec 커버리지:** A(서버 설정/문서)=Task 8·9·10, B(노드 sht30 서비스)=Task 1·2·3·4·5, 배선/설치문서=Task 6·7. 스펙의 모든 절 대응 태스크 존재. ✓ +- **Placeholder:** "적절히 처리" 류 없음. 각 스텝 실제 코드/명령/기대출력 포함. ✓ +- **타입/이름 정합:** `resolve_heartbeat_timeout` 시그니처, `SENSOR_ID`/`SENSOR_NAME`/`DEVICE_LOCATION`/`METRIC_SENSOR_IDS`/`SENSOR_HEARTBEAT_TIMEOUT_SEC` 명칭 전 태스크 일관. ✓ +- **비밀값:** 실 config_local.py/config.local.php 는 예시·문서만, 실값 라인은 제시(에이전트 미커밋). ✓ diff --git a/docs/superpowers/specs/2026-07-15-leak-node-sht30-integration-design.md b/docs/superpowers/specs/2026-07-15-leak-node-sht30-integration-design.md new file mode 100644 index 0000000..df48f2c --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-leak-node-sht30-integration-design.md @@ -0,0 +1,140 @@ +# 설계: 누수 노드에 SHT30 온습도 통합 (동일 sensor_id) + +**날짜:** 2026-07-15 +**브랜치:** feat/pi-c-sensor3-leak-node +**상태:** 설계 승인됨 (사용자 확인 완료) + +## 목표 + +기존 누수 노드(Pi-A=sensor_id 1, Pi-C=sensor_id 3)에 SHT30(P4422-3) 온습도 센서를 +물리적으로 추가하고, 온습도를 **같은 sensor_id(1, 3)** 로 서버에 보고한다. 대시보드·경보· +월간보고·설치 패키지까지 전부 반영한다. Pi-B(온습도 전용, sensor_id 2)는 건드리지 않는다. + +## 핵심 결정 (확정) + +1. **동일 sensor_id.** 온습도는 새 id를 만들지 않고 누수와 같은 id(1, 3)로 보고한다. + - `leak_sensor_status`(sensor_id 키)와 `leak_sensor_metric`(sensor_id+측정시각 키)는 + 테이블이 분리돼 있어, 한 sensor_id가 누수 상태 + 온습도 측정을 동시에 가질 수 있다. + - 새 id/재번호 없음. Pi-B(2) 유지. +2. **노드: 별도 서비스.** 불안정한 누수 코드(`leak_sensor.py`)는 건드리지 않는다. + 기존 `sht30_sensor.py`를 **두 번째 systemd 서비스**로 얹는다. sht30 코드는 무변경 — + config의 `SENSOR_ID`만 노드별(1/3)로 설정. +3. **라벨:** 서버 SENSOR_LABELS 오버라이드 없이, 노드가 보고하는 device_location/ + sensor_name을 그대로 쓴다. 단, 같은 sensor_id를 두 서비스가 upsert하므로 라벨이 + 흔들리지 않게 **두 서비스의 device_location을 노드별로 동일하게** 맞춘다. +4. **경보/수신자:** 전역 METRIC_* 임계로 서버가 고온/고습 등 판정 → SMS. 수신자는 + 기존 SENSOR_RECIPIENTS의 1·3 그룹을 그대로 사용(추가 매핑 불필요). + +## 아키텍처 / 데이터 흐름 + +``` +Pi-A (한 대) + ├ leak-sensor.service → leak_sensor.py → POST (sensor_id=1) leak_alert/recovery/periodic (누수, 온습도 없음) + └ sht30-sensor.service → sht30_sensor.py → POST (sensor_id=1) periodic (metric_type=sht30, 온·습도) +Pi-C (한 대): 위와 동일, sensor_id=3 +Pi-B: 변경 없음 (sensor_id=2) + +서버 sensor_data.php: + - sensor_id=1 상태 upsert: 누수 이벤트만 is_leak 갱신, sht30 periodic은 is_leak 보존(기존 로직) + - sht30 payload → leak_sensor_metric(sensor_id=1) 저장 + 임계 판정 → 온습도 SMS(SENSOR_RECIPIENTS[1]) + - last_seen 은 두 서비스가 모두 갱신 +대시보드: sensor_id=1 → 누수 카드(LEAK_SENSOR_IDS) + 온습도 카드(METRIC_SENSOR_IDS) +``` + +## 변경 범위 + +### A. 서버 (코드 변경 없음 — 설정/문서/예시만) + +서버 코드는 이미 동적(대시보드/추이/월간보고 전부 `METRIC_SENSOR_IDS` 순회, 스키마는 +임의 sensor_id 수용)이라 **코드 수정 불필요.** 설정과 예시/문서만 반영. + +1. `php/config.local.example.php` + - `METRIC_SENSOR_IDS` 예시를 `[1, 2, 3]` 로(누수 노드 1·3도 온습도 카드 표시). + - `SENSOR_HEARTBEAT_TIMEOUT_SEC` 예시에 `[1 => 1800, 3 => 1800]` 추가 — 1·3을 온습도 + 목록에 넣으면 오프라인 타임아웃이 metric 기본(7500s)으로 느슨해지므로, 누수 노드는 + 빠른 오프라인 감지를 유지하도록 명시 오버라이드. + - 동일 sensor_id에 누수+온습도를 함께 두는 구성 설명 주석 추가. +2. `deploy/01_server_cafe24/asmdkwqdmqwertn/config.local.php`(온서버 시크릿 — 접근 불가) + - 실제 반영 값을 사용자에게 제시: + `METRIC_SENSOR_IDS => [1,2,3]`, `SENSOR_HEARTBEAT_TIMEOUT_SEC => [1=>1800, 3=>1800]`. + - 파일이 시크릿이라 에이전트가 직접 수정하지 않고, 추가할 라인을 SETUP 문서/응답에 명시. +3. `deploy/01_server_cafe24/SETUP.md`: 위 config.local 변경 절차 문서화. + +**검증 항목(구현 중 확인):** `php/dashboard_data.php`의 카드 맵(≈208–223행)이 sensor_id를 +LEAK/METRIC 양쪽에 두어도(중복 집합) 정상 렌더되는지, `monthly_report*`/`forecast.php`가 +동일 id를 누수+온습도로 다뤄도 깨지지 않는지 확인. 문제 시 최소 수정. + +### B. 노드 — 각 누수 노드에 sht30 서비스 추가 (deploy/02, deploy/04) + +각 노드 배포 폴더에 `sht30/` 하위 폴더를 추가한다(기존 누수 파일/경로는 불변 = +`/home/pi/raspberry_pi4` 그대로). sht30 파일은 `raspberry_pi4_sht30/`에서 복사(코드 무변경). + +노드별 `sht30/` 구성 파일: +- `sht30_sensor.py`, `sht30_driver.py`, `wire.py` — 소스에서 복사(무변경) +- `config.py` — 소스에서 복사(무변경; SENSOR_ID 기본값은 config_local에서 오버라이드) +- `config_local.example.py` — 템플릿 +- `config_local.py` — 노드별 실값: + - Pi-A(02): `SENSOR_ID=1`, `SENSOR_NAME`=누수와 동일, `DEVICE_ID="rpi4-a-sht30"`, + `DEVICE_LOCATION`=Pi-A 누수 노드와 **동일 문자열**, API_URL/API_KEY=서버와 동일 + - Pi-C(04): `SENSOR_ID=3`, 나머지 동일 패턴(`DEVICE_ID="rpi4-c-sht30"`) +- `requirements.txt` — `smbus2` 포함(소스와 동일) +- `systemd/sht30-sensor.service` — `WorkingDirectory=/home/pi/sht30`, + `ExecStart=/home/pi/sht30/venv/bin/python sht30_sensor.py`, `SupplementaryGroups=i2c` +- `test_sensor.py`, `test_connection.py` — 소스에서 복사 +- `vendor.sh` — 오프라인 번들(smbus2 휠 + i2c-tools deb) + +각 누수 노드 폴더의 설치/문서 갱신: +- `install.sh` — 기존 누수 설치에 더해 **sht30 서비스 설치 단계 추가**: + - `sht30/`를 `/home/pi/sht30`로 설치(venv 생성, requirements 설치, 로그파일 + `/var/log/sht30_sensor.log` 생성, config_local 권한 600) + - **I2C 활성화**: `sudo raspi-config nonint do_i2c 0` (오프라인 가능). 실패 시 + `/boot/firmware/config.txt`(Bookworm) 또는 `/boot/config.txt`에 `dtparam=i2c_arm=on` + 보장. i2c 그룹에 SERVICE_USER 추가. + - `sht30-sensor.service` 설치 + enable + start + - 폐쇄망 규칙 준수: 네트워크 미사용, 번들 없으면 명확히 FLAG(기존 install.sh 패턴 유지) +- `WIRING.html` — 기존 누수 배선에 **SHT30 I2C 배선 추가**: + VCC→3.3V(핀1), SDA→GPIO2(핀3), SCL→GPIO3(핀5), GND→핀6/9, ADDR→GND=0x44. + 누수 GPIO17(핀11)과 무충돌 명시. "반드시 3.3V, 5V 금지" 경고 포함. +- `SETUP.md` / `README.md` — 이중 서비스(누수+온습도) 설치·확인 절차, 동일 sensor_id 설명. + +### C. 소스 트리 (참고 — 정합성) + +`raspberry_pi4_sht30/`(sht30 소스)와 `raspberry_pi4/`(누수 소스)는 코드 변경 없음. +`php/config.local.example.php`만 소스에서 갱신(위 A-1). deploy가 1차 산출물이므로 +deploy 폴더를 정본으로 반영하고, 소스 예시 파일은 동기화한다. + +## 에러 처리 / 엣지 + +- **라벨 플랩:** 두 서비스가 같은 sensor_id로 sensor_name/device_location upsert → + 마지막 쓰기가 이김. 노드별로 두 config의 device_location을 동일 문자열로 맞춰 방지. + (sensor_name은 서버 표시에 영향 최소지만 역시 일치 권장.) +- **오프라인 타임아웃:** A-1/A-2의 `SENSOR_HEARTBEAT_TIMEOUT_SEC` 오버라이드로 1·3의 + 누수 오프라인 감지 속도 유지(온습도 목록 편입에 의한 완화 방지). +- **is_leak 보존:** sht30 periodic(비-누수 이벤트)은 서버 upsert에서 is_leak 보존(기존 + `IF(:is_leak_event, VALUES(is_leak), is_leak)` 로직) → 온습도 보고가 누수 상태를 덮지 않음. +- **SHT30 통신 실패:** `sht30_sensor.py`가 크래시 없이 재시도(기존 동작). 누수 서비스와 + 독립 프로세스라 상호 영향 없음. +- **I2C 미활성:** smbus2/I2C 불가 시 sht30는 시뮬레이션 폴백(기존). install.sh가 I2C를 + 활성화하고, 미활성 시 로그로 안내. + +## 테스트 + +- **서버(PHP 단위/통합):** + - recipients: sensor_id 1·3의 온습도 경보가 SENSOR_RECIPIENTS[1]/[3]로 발송되는지. + - heartbeat: `heartbeat_timeout_for(1)`/(3)이 오버라이드로 누수 타임아웃(1800s)을 + 반환(metric 7500s 아님)하는지 — `resolve_heartbeat_timeout` 단위 테스트 케이스 추가. + - 통합: sensor_id=1 metric POST(periodic) → leak_sensor_metric 저장 + is_leak 보존 + + 임계 초과 시 SMS 경로. + - 대시보드: LEAK∩METRIC(=1,3)에서 카드 렌더 정상(중복 집합) 스모크. +- **노드:** + - `sht30/test_sensor.py`: I2C 판독(또는 시뮬). + - `sht30/test_connection.py`: SENSOR_ID=1/3로 서버 POST 200 + 서명 검증. +- **오프라인 설치:** 네트워크 차단 상태에서 install.sh가 두 서비스 + I2C 활성화까지 + 무network 완료되는지(폐쇄망 검증). + +## 비목표 (YAGNI) + +- Pi-B(sensor_id 2) 재번호/변경 없음. +- 누수 라인 100nF 콘덴서/하드웨어 필터 — 별개 이슈(이번 범위 밖). +- 센서별 경보 on/off 토글 등 신규 서버 기능 없음(전역 임계 그대로). +- 누수 4~10분 복구 지연(리포트 굶주림) 수정 — 별개 이슈(이번 범위 밖). diff --git a/php/config.local.example.php b/php/config.local.example.php index 8a3b75c..f4aefe1 100644 --- a/php/config.local.example.php +++ b/php/config.local.example.php @@ -53,6 +53,11 @@ return [ // 신규 누수 지점(예: Pi-C, sensor_id 3) 투입 시: [1, 3] 'LEAK_SENSOR_IDS' => [1], + // 온습도(SHT30) sensor_id 목록 — 대시보드 온습도 카드 표시 대상 (기본 [2]). + // 누수 노드(1·3)에 SHT30 을 얹어 "같은 sensor_id" 로 온습도를 보고하는 구성에서는 + // 1·3 도 포함한다: [1, 2, 3]. (같은 지점에 누수 카드 + 온습도 카드가 함께 표시됨) + 'METRIC_SENSOR_IDS' => [1, 2, 3], + // 누수 감지/복구 디바운스(감지↔복구 플래핑 SMS 폭탄 방지). 상태가 확인창 동안 지속돼야 // 확정하고 확정 엣지에서만 SMS 1회. 아래는 기본값(필요 시 조정). sql/migration_leak_debounce.sql // 적용 필요(컬럼 없으면 레거시 즉시 발송으로 자동 폴백). @@ -64,7 +69,9 @@ return [ // 매시간 오탐 → 온습도 센서는 METRIC_HEARTBEAT_TIMEOUT_SEC(기본 7500s≈125분) 자동 적용. // 특정 센서만 다르게 하려면 SENSOR_HEARTBEAT_TIMEOUT_SEC 로 명시 오버라이드. // 'METRIC_HEARTBEAT_TIMEOUT_SEC' => 7500, - // 'SENSOR_HEARTBEAT_TIMEOUT_SEC' => [3 => 1800], + // ★ 동일-id 온습도 통합: 1·3 은 위 METRIC_SENSOR_IDS 에도 들어가지만 누수 노드이므로 + // metric 기본(7500s)으로 느슨해지면 안 됨 → 누수 타임아웃(30분) 유지하도록 명시 오버라이드. + 'SENSOR_HEARTBEAT_TIMEOUT_SEC' => [1 => 1800, 3 => 1800], // 온습도 임계 경보 (서버 판정 → SMS). 운영 환경에 맞게 조정. 'METRIC_TEMP_HIGH_C' => 27, // 고온 임계(℃) diff --git a/raspberry_pi4_sht30/config.py b/raspberry_pi4_sht30/config.py index aabbc32..d02a779 100644 --- a/raspberry_pi4_sht30/config.py +++ b/raspberry_pi4_sht30/config.py @@ -83,3 +83,25 @@ except ImportError as e: "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 + +# ───────────────────────────────────────────── +# Optional override — per-device values from config_local.py. +# Editing the tracked config.py per device causes deployment drift, so a co-located +# node (e.g. a leak node reporting temp/humidity under the SAME sensor_id 1 or 3) +# overrides SENSOR_ID / SENSOR_NAME here instead. If config_local defines neither, +# the defaults above (SENSOR_ID=2) are kept — backward compatible for Pi-B. +# ───────────────────────────────────────────── +try: + from config_local import SENSOR_ID # noqa: F811 — per-device sensor_id override +except ImportError: + pass +try: + from config_local import SENSOR_NAME # noqa: F811 — per-device display name override +except ImportError: + pass + +# Validate (fail closed) — a typo must not silently start with a wrong/plausible id. +if not (isinstance(SENSOR_ID, int) and 1 <= SENSOR_ID <= 255): + raise RuntimeError(f"SENSOR_ID must be int 1..255 (check config_local.py): {SENSOR_ID!r}") +if not (isinstance(SENSOR_NAME, str) and SENSOR_NAME): + raise RuntimeError("SENSOR_NAME must be a non-empty string (check config_local.py).") diff --git a/tests/heartbeat_timeout_test.php b/tests/heartbeat_timeout_test.php index afaea96..830a5f5 100644 --- a/tests/heartbeat_timeout_test.php +++ b/tests/heartbeat_timeout_test.php @@ -39,6 +39,17 @@ $ov2 = [2 => 4000]; check(resolve_heartbeat_timeout(2, $ov2, $metricIds, $metricDef, $globalDef) === 4000, 'explicit override beats metric default for a metric sensor'); +// ── 5) 동일-id 온습도 통합: 누수 노드(1·3)가 METRIC_SENSOR_IDS 에도 포함돼도, +// 명시 오버라이드가 metric 기본(7500)을 이겨 누수 타임아웃(1800)을 유지해야 한다. ── +$sameIdOverride = [1 => 1800, 3 => 1800]; +$sameIdMetric = [1, 2, 3]; // 1·3 은 온습도도 같은 id 로 보고 → 목록에 포함 +check(resolve_heartbeat_timeout(1, $sameIdOverride, $sameIdMetric, 7500, 1200) === 1800, + 'same-id leak(1) keeps leak timeout despite metric membership'); +check(resolve_heartbeat_timeout(3, $sameIdOverride, $sameIdMetric, 7500, 1200) === 1800, + 'same-id leak(3) keeps leak timeout despite metric membership'); +check(resolve_heartbeat_timeout(2, $sameIdOverride, $sameIdMetric, 7500, 1200) === 7500, + 'pure metric(2) still uses metric default'); + if ($failures) { fwrite(STDERR, "Heartbeat timeout test failures:\n"); foreach ($failures as $f) {