POSA_LEAKSMS/docs/superpowers/plans/2026-07-15-leak-node-sht30-integration.md
cw2007 0b1e86c84a feat(sht30): 누수 노드에 온습도 동일-id 통합 (소스·서버예시·테스트·문서)
누수 노드(Pi-A=sensor_id 1, Pi-C=sensor_id 3)에 SHT30 온습도를 "같은
sensor_id"로 보고하는 구성을 반영. 노드는 기존 sht30_sensor.py를 별도
systemd 서비스로 얹고(불안정한 누수 코드 무변경), config_local로 SENSOR_ID만 지정.

- raspberry_pi4_sht30/config.py: config_local로 SENSOR_ID/SENSOR_NAME 오버라이드
  지원(미지정 시 기본 2 유지 — Pi-B 하위호환)
- php/config.local.example.php: METRIC_SENSOR_IDS=[1,2,3] 예시 +
  SENSOR_HEARTBEAT_TIMEOUT_SEC[1,3]=1800(1·3이 온습도 목록에 들어가도 누수
  오프라인 감지 속도 유지)
- tests/heartbeat_timeout_test.php: 동일-id 통합 시 1·3 누수 타임아웃 유지 검증
- docs/superpowers/{specs,plans}: 설계 문서 + 구현 계획

설치 패키지(deploy/* — gitignored)는 온디스크 산출물로 별도 반영(sht30 서브폴더,
install.sh 체이닝, WIRING/SETUP/README, 서버 SETUP 절차).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nd7QvbgEWCjSQtUYnGNvvV
2026-07-15 10:16:49 +09:00

445 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 누수 노드 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 섹션 삽입**
`</body>` 직전(또는 마지막 섹션 뒤)에 삽입:
```html
<section>
<h2>SHT30 온습도 (동일 노드, sensor_id 1)</h2>
<p>누수 센서와 <b>같은 Pi</b>에 SHT30(P4422-3)을 I2C로 추가. 온습도는 누수와 <b>같은 sensor_id(1)</b>로 보고한다. 누수 GPIO17(핀11)과 <b>핀 충돌 없음</b>.</p>
<table>
<thead><tr><th>SHT30</th><th>Pi 40핀</th><th>비고</th></tr></thead>
<tbody>
<tr><td>VCC</td><td>3.3V (핀 1)</td><td>★ 반드시 3.3V. 5V(핀 2·4) 금지 — I2C 라인 5V로 끌려 Pi 손상</td></tr>
<tr><td>SDA</td><td>GPIO2 / SDA1 (핀 3)</td><td>온보드 풀업 없으면 4.7kΩ→3.3V</td></tr>
<tr><td>SCL</td><td>GPIO3 / SCL1 (핀 5)</td><td>온보드 풀업 없으면 4.7kΩ→3.3V</td></tr>
<tr><td>GND</td><td>GND (핀 6 또는 9)</td><td></td></tr>
<tr><td>ADDR</td><td>GND</td><td>0x44 (config.py I2C_ADDR 와 일치)</td></tr>
</tbody>
</table>
</section>
```
- [ ] **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 는 예시·문서만, 실값 라인은 제시(에이전트 미커밋). ✓