- tools/make_deploy_bundles.py: PMS7003·Zero2W 디스플레이 노드 번들 생성 반영(폐쇄망 동봉) - tools/harden_pi.sh: 하드닝 스크립트 갱신 - docs/superpowers: 노드 센서 통합·스모크 파일럿·대시보드 다크 설계/계획 문서 - docs/claude-config: IoT 납품·검증 스킬/룰 참조본 - review/: 프로젝트 리뷰·UI v2 목업
606 lines
27 KiB
Markdown
606 lines
27 KiB
Markdown
# 연기(화재) 감지 노드 시범 — Implementation Plan
|
||
|
||
> **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:** 저가 광전식 연기 감지기 개조품의 감지 이벤트를 기존 PHP 서버까지 도달시켜 `[화재감지]` SMS를 발송한다.
|
||
|
||
**Architecture:** 기존 Pi4 누수 노드(`leak_sensor.py`)에 연기 채널을 `sensor_id=4`로 추가. 옵토커플러로 절연된 GPIO27 입력을 기존 엣지검출+confirm-read 디바운스가 처리하고, 확정 전이에서 `smoke_alarm`/`smoke_recovery` event_type를 HMAC 서명 POST. 서버(`sensor_data.php`)는 additive로 새 event_type를 수용해 별도 SMS 태그·수신자 그룹으로 라우팅. `is_leak`·누수 로직은 불변.
|
||
|
||
**Tech Stack:** Python 3.11 (RPi.GPIO/rpi-lgpio), PHP 8 + MySQL 8, HMAC-SHA256 raw-body 서명, systemd, 에어갭 배포 번들.
|
||
|
||
## Global Constraints
|
||
|
||
- **에어갭(폐쇄망):** 런타임/설치 시 네트워크 없음. 이 시범은 신규 스택·ADC 없음 → 추가 벤더링 불필요(기존 rpi-lgpio/requests 그대로).
|
||
- **HMAC raw-body 바이트 동일성:** body는 손직렬화 고정 필드순서 문자열. `is_leak`는 0/1 정수, 필드순서 불변. 서버 검증(`verify_signature_raw`, config.php:663)은 event_type 무관.
|
||
- **additive-only:** JSON/DB/응답 스키마는 추가만. 기존 필드 의미 변경 금지(특히 `is_leak`·누수 상태).
|
||
- **event_type 이중 게이트:** `sensor_data.php:208`(PHP 화이트리스트) + `sql/schema_sht30.sql:30`(DB ENUM) 둘 다 넓혀야 함. 하나만 하면 조용히 `periodic` 강등(로그·SMS 없음) 또는 DB insert 실패.
|
||
- **소스가 진실:** `deploy/` 생성물 손편집 금지. `make_deploy_bundles.py` 소스 편집 → 재생성.
|
||
- **커밋 전 전체 테스트 통과 수 명시**(`./run_tests.sh`), 커밋은 사용자 지시 시.
|
||
- **인증 경계:** 개조 저가 감지기는 인증품 아님. 보조 통보용. README/대시보드 배너 유지.
|
||
|
||
---
|
||
|
||
## 하드웨어 — 구매 목록 + 배선도
|
||
|
||
### 구매 (AliExpress, ≈ ₩5,000 + 공구)
|
||
| 부품 | 링크 / 검색 | 수량 | 가격 |
|
||
|---|---|---|---|
|
||
| **광전식 단독형 연기알람** (광전식 명시) | AliExpress item **1005010719685508** ("스마트 광전식 화재 경보기 배터리 85dB") | 2 (1개 실습용) | ₩1,507/개 |
|
||
| 대안 감지기 | item 1005011575755691 (EN14604 CE) / 1005010724878980 (PGST) | — | ₩1,507 |
|
||
| **PC817 옵토커플러** | 검색 `PC817` 10-pack | 1 | ~₩1,000 |
|
||
| 저항 (1kΩ, 10kΩ) | 검색 `resistor kit 1/4W` | 1 | ~₩1,000 |
|
||
| 점퍼선 F-F + 미니 브레드보드 | 검색 `dupont female female` / `mini breadboard 400` | 1 | ~₩2,000 |
|
||
|
||
**필수 공구:** 멀티미터(DC 전압), 인두기+납. 없으면 개조 불가 — 먼저 확보.
|
||
|
||
### 배선도 (개조, PC817 절연, 감지=HIGH)
|
||
|
||
```
|
||
저가 광전식 알람 (배터리 내장, 개조) 라즈베리파이 4 (40핀 헤더)
|
||
┌───────────────────────────┐
|
||
│ [알람 IC] │ 3V3 ── pin 1 ─────┐
|
||
│ │ 알람 시 LED 점등 │ │
|
||
│ LED 애노드 ●──┐ │ ┌────────────────┘
|
||
│ (1kΩ) │ PC817 │4 (collector)
|
||
│ └──────────────► 1핀(A) ▷|─────► │
|
||
│ GND ●──────────────────────► 2핀(K) │ 3핀(E)
|
||
│ │ [PC817 내부 포토트랜지스터] │
|
||
└───────────────────────────┘ └───► pin 13 (GPIO27) ──┬── 감지 입력
|
||
│
|
||
10kΩ 풀다운 ────┤
|
||
│
|
||
GND ── pin 9 ───────┘
|
||
|
||
동작: 평상시 → 알람 LED off → 옵토 off → GPIO27 = LOW (풀다운, 정상)
|
||
연기 → 알람 LED on → 옵토 도통 → GPIO27 = HIGH (감지)
|
||
```
|
||
|
||
**핀 매핑 (BCM/물리):**
|
||
| 신호 | 연결 | Pi 물리핀 |
|
||
|---|---|---|
|
||
| 3.3V (PC817 collector 측) | PC817 4핀 → 3V3 | pin 1 |
|
||
| SMOKE 입력 | PC817 3핀(emitter) → GPIO27 | pin 13 |
|
||
| 풀다운 | GPIO27 → 10kΩ → GND | pin 13 ↔ pin 9 |
|
||
| GND 공통 | 감지기 GND ↔ Pi GND(pin 9) | — |
|
||
|
||
**주의:**
|
||
1. LED 애노드 노드 전압을 멀티미터 DC로 먼저 측정(평상시 vs 테스트버튼). V면 직렬저항 = (V−1.2)/2mA. ~2V면 470Ω, 애매하면 1kΩ.
|
||
2. PC817을 하이사이드(collector→3V3, emitter→GPIO+풀다운)로 배선해야 감지=HIGH → 기존 `ACTIVE_LOW=False`와 극성 일치(코드 무변경).
|
||
3. 감지기 GND와 Pi GND 공통 접지 필수(옵토 LED 리턴 경로).
|
||
4. **한계:** 배터리死·단선은 무음(시범 감수). 실배포 승격 시 릴레이+NC(스펙 §9).
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
**서버(PHP):**
|
||
- `php/config.php` — SMS 태그 2개 + 쿨다운 상수 + summary SQL 2줄 (수정)
|
||
- `php/api/sensor_data.php` — event_type 화이트리스트 + is_leak 가드 + smoke 디스패치 분기 (수정)
|
||
- `php/sms_send.php` — `send_smoke_alert_sms()` / `send_smoke_recovery_sms()` (추가)
|
||
- `sql/schema_sht30.sql` — ENUM 확장 (수정, 신규 설치용)
|
||
- `sql/migration_leak_smoke.sql` — 기존 운영 DB ALTER (신규)
|
||
- `tests/smoke_alert_test.php` — 순수 로직 테스트 (신규)
|
||
|
||
**Pi 노드(Python):**
|
||
- `raspberry_pi4/leak_sensor.py` — 순수 헬퍼 `event_type_for()` + `_confirm_state_change` kind 분기 (수정)
|
||
- `raspberry_pi4/tests/test_smoke_event.py` — 순수 헬퍼 테스트 (신규)
|
||
|
||
**배포:**
|
||
- `tools/make_deploy_bundles.py` — Pi-C PI_META sensors + 서버 `SENSOR_LABELS[4]`/`SMOKE_SENSOR_IDS` (수정)
|
||
|
||
---
|
||
|
||
## Task 1: 서버 — event_type 수용 + is_leak 가드
|
||
|
||
**Files:**
|
||
- Modify: `php/api/sensor_data.php:208-218`
|
||
|
||
**Interfaces:**
|
||
- Produces: 서버가 `smoke_alarm`/`smoke_recovery` event_type를 `periodic`으로 강등하지 않고 로깅. `$is_smoke_event` 불리언. smoke 이벤트는 `is_leak=0` 강제.
|
||
|
||
- [ ] **Step 1: 화이트리스트 확장**
|
||
|
||
`php/api/sensor_data.php:208-209` 를 아래로 교체:
|
||
```php
|
||
$event_type = in_array($data['event_type'], ['startup', 'periodic', 'leak_alert', 'leak_recovery', 'smoke_alarm', 'smoke_recovery'], true)
|
||
? $data['event_type'] : 'periodic';
|
||
```
|
||
|
||
- [ ] **Step 2: is_leak 가드 + is_smoke_event (라인 213-218 블록 교체)**
|
||
|
||
```php
|
||
if ($event_type === 'leak_alert') {
|
||
$is_leak = 1;
|
||
} elseif ($event_type === 'leak_recovery') {
|
||
$is_leak = 0;
|
||
} elseif ($event_type === 'smoke_alarm' || $event_type === 'smoke_recovery') {
|
||
$is_leak = 0; // 연기 이벤트는 누수 상태를 건드리지 않음
|
||
}
|
||
$is_leak_event = ($event_type === 'leak_alert' || $event_type === 'leak_recovery');
|
||
$is_smoke_event = ($event_type === 'smoke_alarm' || $event_type === 'smoke_recovery');
|
||
```
|
||
|
||
- [ ] **Step 3: 커밋**
|
||
|
||
```bash
|
||
git add php/api/sensor_data.php
|
||
git commit -m "feat(smoke): ingest에 smoke_alarm/recovery event_type 수용 (is_leak 불변)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: 서버 — DB ENUM 확장 + 마이그레이션
|
||
|
||
**Files:**
|
||
- Modify: `sql/schema_sht30.sql:30-31`
|
||
- Create: `sql/migration_leak_smoke.sql`
|
||
|
||
**Interfaces:**
|
||
- Produces: `leak_sensor_log.event_type` ENUM이 smoke 값 수용. 없으면 Task1의 INSERT가 strict 모드에서 실패/절단.
|
||
|
||
- [ ] **Step 1: 신규 설치 스키마 ENUM 확장**
|
||
|
||
`sql/schema_sht30.sql:30` 의 event_type 컬럼을:
|
||
```sql
|
||
event_type ENUM('startup','periodic','leak_alert','leak_recovery','smoke_alarm','smoke_recovery') NOT NULL DEFAULT 'periodic',
|
||
```
|
||
|
||
- [ ] **Step 2: 기존 운영 DB용 마이그레이션 작성**
|
||
|
||
`sql/migration_leak_smoke.sql` 생성:
|
||
```sql
|
||
-- 연기 감지 이벤트 타입 추가 (additive). MySQL 8은 ADD IF NOT EXISTS 미지원 —
|
||
-- 재실행 시 동일 정의라 에러 없이 통과(값이 이미 있으면 그대로).
|
||
ALTER TABLE leak_sensor_log
|
||
MODIFY COLUMN event_type
|
||
ENUM('startup','periodic','leak_alert','leak_recovery','smoke_alarm','smoke_recovery')
|
||
NOT NULL DEFAULT 'periodic';
|
||
```
|
||
|
||
- [ ] **Step 3: 로컬/스테이징 DB에 적용 후 insert 검증**
|
||
|
||
Run:
|
||
```bash
|
||
mysql <db> < sql/migration_leak_smoke.sql
|
||
mysql <db> -e "INSERT INTO leak_sensor_log (device_id,sensor_id,sensor_name,event_type,is_leak,sensor_time,created_at) VALUES ('t',4,'연기','smoke_alarm',0,NOW(),NOW()); SELECT event_type FROM leak_sensor_log WHERE sensor_id=4 ORDER BY id DESC LIMIT 1;"
|
||
```
|
||
Expected: `smoke_alarm` 반환(절단/'' 아님).
|
||
|
||
- [ ] **Step 4: 커밋**
|
||
|
||
```bash
|
||
git add sql/schema_sht30.sql sql/migration_leak_smoke.sql
|
||
git commit -m "feat(smoke): leak_sensor_log.event_type ENUM에 smoke 추가 + 마이그레이션"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: 서버 — 화재 SMS 태그·발송 함수·디스패치
|
||
|
||
**Files:**
|
||
- Modify: `php/config.php:157` (태그), `php/config.php:186` (summary SQL), `php/config.php` (쿨다운 상수)
|
||
- Modify: `php/sms_send.php` (함수 2개 추가, `send_leak_recovery_sms` 뒤 ~740)
|
||
- Modify: `php/api/sensor_data.php:496` (디스패치 분기)
|
||
- Create: `tests/smoke_alert_test.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `sms_dispatch_to_recipients()`, `sensor_label()`, `alert_cooldown_suppresses()`, `SMS_TAG_SMOKE_*`.
|
||
- Produces: `send_smoke_alert_sms(array $sensor_data): bool`, `send_smoke_recovery_sms(array $sensor_data): bool`. `sms_summary_select_sql()`가 `smoke_alarm`,`smoke_recovery` 컬럼 포함.
|
||
|
||
- [ ] **Step 1: 실패 테스트 작성 (순수 로직 — 태그·summary SQL 존재)**
|
||
|
||
`tests/smoke_alert_test.php` 생성 (harness는 `recipients_group_test.php` 패턴):
|
||
```php
|
||
<?php
|
||
declare(strict_types=1);
|
||
|
||
$root = dirname(__DIR__);
|
||
require_once $root . '/php/config.php';
|
||
|
||
$failures = [];
|
||
function check(bool $cond, string $msg): void {
|
||
global $failures;
|
||
if (!$cond) { $failures[] = $msg; }
|
||
}
|
||
|
||
// 태그 상수 정의 + 문구
|
||
check(defined('SMS_TAG_SMOKE_ALARM') && SMS_TAG_SMOKE_ALARM === '[화재감지]', 'SMS_TAG_SMOKE_ALARM = [화재감지]');
|
||
check(defined('SMS_TAG_SMOKE_RECOVERY') && SMS_TAG_SMOKE_RECOVERY === '[화재복구]', 'SMS_TAG_SMOKE_RECOVERY = [화재복구]');
|
||
|
||
// summary SQL이 smoke 집계 컬럼 포함(대시보드/월간보고서 SSOT)
|
||
$sql = sms_summary_select_sql();
|
||
check(str_contains($sql, 'AS smoke_alarm'), 'summary SQL has smoke_alarm');
|
||
check(str_contains($sql, 'AS smoke_recovery'), 'summary SQL has smoke_recovery');
|
||
|
||
// 발송 함수 존재(누수 함수와 시그니처 동일)
|
||
check(function_exists('send_smoke_alert_sms'), 'send_smoke_alert_sms() exists');
|
||
check(function_exists('send_smoke_recovery_sms'), 'send_smoke_recovery_sms() exists');
|
||
|
||
if ($failures) {
|
||
fwrite(STDERR, "Smoke alert test failures:\n");
|
||
foreach ($failures as $f) { fwrite(STDERR, "- {$f}\n"); }
|
||
exit(1);
|
||
}
|
||
echo "Smoke alert checks passed.\n";
|
||
```
|
||
|
||
- [ ] **Step 2: 테스트 실패 확인**
|
||
|
||
Run: `php tests/smoke_alert_test.php`
|
||
Expected: FAIL (상수·함수 미정의).
|
||
|
||
- [ ] **Step 3: config.php — 태그 + 쿨다운 상수**
|
||
|
||
`php/config.php:157` (`SMS_TAG_LEAK_RECOVERY` 정의 줄) 바로 뒤에 추가:
|
||
```php
|
||
const SMS_TAG_SMOKE_ALARM = '[화재감지]'; // 4번 채널(연기·광전식) 감지
|
||
const SMS_TAG_SMOKE_RECOVERY = '[화재복구]'; // 연기 해소(복구)
|
||
```
|
||
그리고 누수 쿨다운 상수(`LEAK_ALERT_COOLDOWN_SEC`) 근처(config.php:205 부근)에 추가:
|
||
```php
|
||
const SMOKE_ALERT_COOLDOWN_SEC = 300; // 화재 경보 SMS 재발신 최소 간격(초)
|
||
```
|
||
|
||
- [ ] **Step 4: config.php — summary SQL에 smoke 집계 추가**
|
||
|
||
`sms_summary_select_sql()` 의 `sms_fail` 줄(config.php:186) **앞에** 두 줄 삽입:
|
||
```php
|
||
. " SUM(message LIKE '" . SMS_TAG_SMOKE_ALARM . "%') AS smoke_alarm,\n"
|
||
. " SUM(message LIKE '" . SMS_TAG_SMOKE_RECOVERY . "%') AS smoke_recovery,\n"
|
||
```
|
||
|
||
- [ ] **Step 5: sms_send.php — 발송 함수 2개 (send_leak_recovery_sms 뒤 ~740 라인)**
|
||
|
||
`send_leak_alert_sms`(sms_send.php:625) 구조를 그대로 미러(락·쿨다운·에피소드 인지). 태그/쿨다운상수/문구만 교체:
|
||
```php
|
||
/**
|
||
* 화재(연기) 경보 SMS. send_leak_alert_sms 구조 동일(락+쿨다운+에피소드 인지).
|
||
*/
|
||
function send_smoke_alert_sms(array $sensor_data): bool {
|
||
$db = get_db();
|
||
$sensor_id = (int)$sensor_data['sensor_id'];
|
||
|
||
$lockName = substr("sms_smoke_alert_{$sensor_id}", 0, 64);
|
||
$lockState = 'unsupported';
|
||
try {
|
||
$lk = $db->prepare('SELECT GET_LOCK(:n, 0)');
|
||
$lk->execute([':n' => $lockName]);
|
||
$res = $lk->fetchColumn();
|
||
$lockState = ($res === null || $res === false) ? 'unsupported' : (((int)$res === 1) ? 'acquired' : 'busy');
|
||
} catch (Throwable $e) {
|
||
$lockState = 'unsupported';
|
||
error_log("[SMS] GET_LOCK 사용 불가({$lockName}): " . $e->getMessage());
|
||
}
|
||
if ($lockState === 'busy') {
|
||
error_log("[SMS] " . SMS_TAG_SMOKE_ALARM . " 동시 처리 감지 - 센서{$sensor_id} 중복 발송 생략");
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
$recent = $db->prepare("
|
||
SELECT message, sent_at FROM leak_sms_log
|
||
WHERE sensor_id = :sensor_id AND status = 'success'
|
||
AND (message LIKE :alert OR message LIKE :recovery)
|
||
ORDER BY sent_at DESC, id DESC LIMIT 1
|
||
");
|
||
$recent->execute([
|
||
':sensor_id' => $sensor_id,
|
||
':alert' => SMS_TAG_SMOKE_ALARM . '%',
|
||
':recovery' => SMS_TAG_SMOKE_RECOVERY . '%',
|
||
]);
|
||
$row = $recent->fetch();
|
||
$lastMsg = ($row === false) ? null : (string)$row['message'];
|
||
$lastAt = ($row === false) ? null : (string)$row['sent_at'];
|
||
if (alert_cooldown_suppresses($lastMsg, $lastAt, SMS_TAG_SMOKE_ALARM, SMOKE_ALERT_COOLDOWN_SEC, time())) {
|
||
return false;
|
||
}
|
||
|
||
$dt = date('Y-m-d H:i:s');
|
||
[$loc, $name] = sensor_label($sensor_id, $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
|
||
$message = SMS_TAG_SMOKE_ALARM . "\n"
|
||
. "위치: {$loc}\n"
|
||
. "관리주체: {$name}\n"
|
||
. "연기가 감지되었습니다. 즉시 확인하세요!\n"
|
||
. "시각: {$dt}";
|
||
return sms_dispatch_to_recipients($db, $sensor_data, $message, "화재감지");
|
||
} finally {
|
||
if ($lockState === 'acquired') {
|
||
try { $db->prepare('SELECT RELEASE_LOCK(:n)')->execute([':n' => $lockName]); }
|
||
catch (Throwable $e) { error_log("[SMS] RELEASE_LOCK 실패({$lockName}): " . $e->getMessage()); }
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 화재(연기) 복구 SMS. send_leak_recovery_sms 구조 동일(직전 경보 있을 때만 1회).
|
||
*/
|
||
function send_smoke_recovery_sms(array $sensor_data): bool {
|
||
$db = get_db();
|
||
$sensor_id = (int)$sensor_data['sensor_id'];
|
||
|
||
$stmt = $db->prepare("
|
||
SELECT message FROM leak_sms_log
|
||
WHERE sensor_id = :sid AND status = 'success'
|
||
AND (message LIKE :alert OR message LIKE :recovery)
|
||
ORDER BY sent_at DESC, id DESC LIMIT 1
|
||
");
|
||
$stmt->execute([
|
||
':sid' => $sensor_id,
|
||
':alert' => SMS_TAG_SMOKE_ALARM . '%',
|
||
':recovery' => SMS_TAG_SMOKE_RECOVERY . '%',
|
||
]);
|
||
$last = $stmt->fetchColumn();
|
||
if ($last === false || !str_starts_with((string)$last, SMS_TAG_SMOKE_ALARM)) {
|
||
return false;
|
||
}
|
||
|
||
$dt = date('Y-m-d H:i:s');
|
||
[$loc, $name] = sensor_label($sensor_id, $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
|
||
$message = SMS_TAG_SMOKE_RECOVERY . "\n"
|
||
. "위치: {$loc}\n"
|
||
. "관리주체: {$name}\n"
|
||
. "연기 감지가 해소(복구)되었습니다.\n"
|
||
. "시각: {$dt}";
|
||
return sms_dispatch_to_recipients($db, $sensor_data, $message, "화재복구");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: sensor_data.php — 디스패치 분기 (누수 분기 뒤, 라인 496 다음)**
|
||
|
||
```php
|
||
// ── 화재(연기) 경보 / 복구 SMS (4번 채널) ──
|
||
// Pi가 confirm-read 디바운스로 확정 엣지에서만 보내므로 서버 디바운스 없이 즉시 발송.
|
||
// 재발신 폭주는 send_smoke_alert_sms 내부 쿨다운(SMOKE_ALERT_COOLDOWN_SEC)으로 억제.
|
||
if ($is_smoke_event) {
|
||
if ($event_type === 'smoke_alarm' && function_exists('send_smoke_alert_sms')) {
|
||
if (send_smoke_alert_sms($sensor_info)) { $sms_sent = true; }
|
||
} elseif ($event_type === 'smoke_recovery' && function_exists('send_smoke_recovery_sms')) {
|
||
if (send_smoke_recovery_sms($sensor_info)) { $sms_sent = true; }
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: 테스트 통과 확인 + 전체 스위트**
|
||
|
||
Run: `php tests/smoke_alert_test.php` → Expected: PASS
|
||
Run: `./run_tests.sh` → Expected: 기존 전체 통과 + smoke 1개 추가 (통과 수 명시).
|
||
|
||
- [ ] **Step 8: 커밋**
|
||
|
||
```bash
|
||
git add php/config.php php/sms_send.php php/api/sensor_data.php tests/smoke_alert_test.php
|
||
git commit -m "feat(smoke): [화재감지]/[화재복구] SMS 태그·발송·디스패치 + 테스트"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: 서버 — 라벨·연기 센서 목록 (config)
|
||
|
||
**Files:**
|
||
- Modify: `tools/make_deploy_bundles.py` (생성 소스 — 서버 config.local.php 산출)
|
||
- 운영: `php/config.local.php` (SENSOR_RECIPIENTS[4], git 미추적)
|
||
|
||
**Interfaces:**
|
||
- Produces: 서버가 `sensor_id=4`를 "연기" 라벨로 표시, 4번 SMS를 화재팀 그룹으로 라우팅.
|
||
|
||
- [ ] **Step 1: 생성기에 4번 라벨 + (선택) SMOKE_SENSOR_IDS 추가**
|
||
|
||
`tools/make_deploy_bundles.py` 의 `SENSOR_LABELS`(~344-348) 에 4번 추가:
|
||
```python
|
||
4: {"location": "<설치 위치>", "name": "연기(광전식)"},
|
||
```
|
||
(선택) 대시보드 카드용 `SMOKE_SENSOR_IDS = [4]` 상수를 서버 config 생성부에 추가.
|
||
|
||
- [ ] **Step 2: 운영 config.local.php에 수신자 그룹 (수동, git 미추적)**
|
||
|
||
`php/config.local.php` 의 `SENSOR_RECIPIENTS` 에:
|
||
```php
|
||
4 => ['010XXXXYYYY', '010XXXXZZZZ'], // 화재(연기) 수신자
|
||
```
|
||
|
||
- [ ] **Step 3: 수신자 해소 검증 (추측 금지 — 실제 출력)**
|
||
|
||
Run: `php -r 'require "php/config.php"; var_export(recipients_for_sensor(4));'`
|
||
Expected: 위 화재 그룹 반환(전역 폴백 아님).
|
||
|
||
- [ ] **Step 4: 커밋 (config.local.php는 커밋 대상 아님)**
|
||
|
||
```bash
|
||
git add tools/make_deploy_bundles.py
|
||
git commit -m "feat(smoke): 서버 4번 연기 라벨 + SMOKE_SENSOR_IDS"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Pi — 순수 헬퍼 `event_type_for()` (TDD)
|
||
|
||
**Files:**
|
||
- Modify: `raspberry_pi4/leak_sensor.py` (헬퍼 추가, `_confirm_state_change` 위 ~417)
|
||
- Create: `raspberry_pi4/tests/test_smoke_event.py`
|
||
|
||
**Interfaces:**
|
||
- Produces: `event_type_for(sensor: dict, candidate: bool) -> str` — kind=="smoke"면 smoke_alarm/smoke_recovery, 아니면 leak_alert/leak_recovery. GPIO 불필요한 순수 함수.
|
||
|
||
- [ ] **Step 1: 실패 테스트 작성**
|
||
|
||
`raspberry_pi4/tests/test_smoke_event.py` 생성:
|
||
```python
|
||
from leak_sensor import event_type_for
|
||
|
||
|
||
def test_smoke_kind_maps_to_smoke_events():
|
||
smoke = {"id": 4, "gpio": 27, "name": "연기", "kind": "smoke"}
|
||
assert event_type_for(smoke, True) == "smoke_alarm"
|
||
assert event_type_for(smoke, False) == "smoke_recovery"
|
||
|
||
|
||
def test_default_kind_maps_to_leak_events():
|
||
leak = {"id": 1, "gpio": 17, "name": "누수"}
|
||
assert event_type_for(leak, True) == "leak_alert"
|
||
assert event_type_for(leak, False) == "leak_recovery"
|
||
```
|
||
> 실행 전제: `event_type_for` 는 config/GPIO import 부작용 없는 순수 함수여야 테스트가 import 가능. leak_sensor.py 상단 import가 하드웨어에 의존하면, pytest 실행 시 `conftest.py` 에서 `RPi.GPIO`/`config` 를 스텁하거나(기존 테스트 하네스 확인), 헬퍼를 import-safe한 위치(부작용 전)로 둔다.
|
||
|
||
- [ ] **Step 2: 테스트 실패 확인**
|
||
|
||
Run: `cd raspberry_pi4 && python -m pytest tests/test_smoke_event.py -v`
|
||
Expected: FAIL (`event_type_for` 미정의 / ImportError).
|
||
|
||
- [ ] **Step 3: 헬퍼 구현 (leak_sensor.py, `_confirm_state_change` 정의 앞)**
|
||
|
||
```python
|
||
def event_type_for(sensor: dict, candidate: bool) -> str:
|
||
"""Map a confirmed transition to its event_type by sensor kind.
|
||
kind=='smoke' → smoke_alarm/smoke_recovery; default (leak) → leak_alert/leak_recovery."""
|
||
if sensor.get("kind") == "smoke":
|
||
return "smoke_alarm" if candidate else "smoke_recovery"
|
||
return "leak_alert" if candidate else "leak_recovery"
|
||
```
|
||
|
||
- [ ] **Step 4: 테스트 통과 확인**
|
||
|
||
Run: `cd raspberry_pi4 && python -m pytest tests/test_smoke_event.py -v`
|
||
Expected: PASS (2 passed).
|
||
|
||
- [ ] **Step 5: 커밋**
|
||
|
||
```bash
|
||
git add raspberry_pi4/leak_sensor.py raspberry_pi4/tests/test_smoke_event.py
|
||
git commit -m "feat(smoke): event_type_for() 순수 헬퍼 + 테스트"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Pi — 확정 전이에서 kind 분기
|
||
|
||
**Files:**
|
||
- Modify: `raspberry_pi4/leak_sensor.py:434-448` (`_confirm_state_change` 의 candidate 분기)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `event_type_for()` (Task 5), `post_to_server()`, `_handle_leak_with_photo()`.
|
||
- Produces: 연기 센서(kind=smoke)는 확정 감지 시 `smoke_alarm`(사진 없음), 복구 시 `smoke_recovery` POST. 누수 센서 경로 불변.
|
||
|
||
- [ ] **Step 1: candidate 분기 교체 (leak_sensor.py:434-448)**
|
||
|
||
```python
|
||
if candidate:
|
||
evt = event_type_for(sensor, True)
|
||
if sensor.get("kind") == "smoke":
|
||
log.warning(f"[SMOKE DETECTED] sensor{sensor['id']} ({sensor['name']}) !!!")
|
||
threading.Thread(target=post_to_server, args=(evt, sensor, False), daemon=True).start()
|
||
else:
|
||
log.warning(f"[LEAK DETECTED] sensor{sensor['id']} ({sensor['name']}) !!!")
|
||
threading.Thread(target=_handle_leak_with_photo, args=(sensor,), daemon=True).start()
|
||
else:
|
||
evt = event_type_for(sensor, False)
|
||
log.info(f"[{'SMOKE' if sensor.get('kind')=='smoke' else 'LEAK'} RECOVERED] "
|
||
f"sensor{sensor['id']} ({sensor['name']}) recovered to normal")
|
||
threading.Thread(target=post_to_server, args=(evt, sensor, False), daemon=True).start()
|
||
```
|
||
|
||
- [ ] **Step 2: 회귀 — 기존 Pi 테스트 통과 확인**
|
||
|
||
Run: `cd raspberry_pi4 && python -m pytest tests/ -v`
|
||
Expected: 기존 누수/디바운스 테스트 + smoke 헬퍼 테스트 전부 PASS (통과 수 명시).
|
||
|
||
- [ ] **Step 3: 커밋**
|
||
|
||
```bash
|
||
git add raspberry_pi4/leak_sensor.py
|
||
git commit -m "feat(smoke): 확정 전이 kind 분기 — 연기는 smoke_alarm/recovery(사진 없음)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: 배포 — 번들 재생성 + 마이그레이션 + 재시작
|
||
|
||
**Files:**
|
||
- Modify: `tools/make_deploy_bundles.py` (Pi-C PI_META sensors)
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 1-6 소스 변경, `deploy/.secrets/api_key.txt`.
|
||
- Produces: Pi-C 번들에 연기 채널(id4) config_local override. 서버 config.local.php에 라벨.
|
||
|
||
- [ ] **Step 1: PI_META에 연기 센서 추가**
|
||
|
||
`tools/make_deploy_bundles.py` 의 Pi-C(`04_pi_c_leak_id3`) `sensors` 에 추가:
|
||
```python
|
||
{"id": 4, "gpio": 27, "name": "연기(광전식)", "kind": "smoke"},
|
||
```
|
||
(config.py 검증은 id/gpio/name 필수만 확인 → 추가 키 `kind` 허용됨. 검증 로직 변경 불필요. 실행 전 config.py 검증부에서 추가 키를 거부하지 않는지 1회 확인.)
|
||
|
||
- [ ] **Step 2: 번들 재생성 + API_KEY prefix 불변 확인**
|
||
|
||
Run: `python tools/make_deploy_bundles.py`
|
||
Expected: 실행 로그에 기존 API_KEY prefix 재사용(신규 키 생성 안 됨).
|
||
|
||
- [ ] **Step 3: 서버 반영**
|
||
|
||
- `deploy/01_server_cafe24/<web_dir>/config.local.php` 서버 업로드 (SENSOR_LABELS[4], SENSOR_RECIPIENTS[4]).
|
||
- 서버에서 `mysql <db> < sql/migration_leak_smoke.sql` 실행.
|
||
|
||
- [ ] **Step 4: Pi 반영**
|
||
|
||
- Pi-C 번들 폴더 scp → `sudo systemctl restart leak-sensor` (연기 채널은 기존 leak-sensor 서비스에 편입).
|
||
- harden된 노드는 물리접근/USB 마이그레이션 번들.
|
||
|
||
- [ ] **Step 5: 커밋**
|
||
|
||
```bash
|
||
git add tools/make_deploy_bundles.py
|
||
git commit -m "feat(smoke): Pi-C 번들에 연기 채널(id4) + 배포 절차"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: 하드웨어 조립 + 엔드투엔드 검증
|
||
|
||
**Files:** 없음 (물리 작업 + 검증)
|
||
|
||
- [ ] **Step 1: 개조**
|
||
|
||
1. 감지기 뚜껑 열고 멀티미터 DC로 알람 LED 애노드 확정(평상시 0V, 테스트버튼 ~2V).
|
||
2. LED노드 → 1kΩ → PC817 1핀, 2핀 → 감지기 GND.
|
||
3. PC817 4핀 → Pi 3V3(pin1), 3핀 → GPIO27(pin13) + 10kΩ 풀다운→GND(pin9).
|
||
|
||
- [ ] **Step 2: 유휴 레벨 확인**
|
||
|
||
Run (Pi): `python3 -c "import RPi.GPIO as g; g.setmode(g.BCM); g.setup(27, g.IN, pull_up_down=g.PUD_DOWN); import time; [print(g.input(27)) or time.sleep(1) for _ in range(3)]"`
|
||
Expected: 평상시 `0`(LOW).
|
||
|
||
- [ ] **Step 3: 엔드투엔드 (HTTP 200 아닌 SMS 실제 발송까지)**
|
||
|
||
1. 감지기 테스트버튼(또는 연기) → GPIO27 HIGH.
|
||
2. Pi 로그 `[SMOKE DETECTED] sensor4` 확인.
|
||
3. 서버 `leak_sensor_log` 에 `event_type='smoke_alarm'` 행 확인.
|
||
4. **화재팀 휴대폰에 `[화재감지]` SMS 도착 확인** (HTTP 200만으로 판정 금지).
|
||
5. 해제 → `[화재복구]` 1회 확인.
|
||
|
||
- [ ] **Step 4: 회귀 확인**
|
||
|
||
기존 누수(1/3)·온습도(2) 이벤트 정상 + `leak_sensor_status` 의 누수 `is_leak` 미오염 확인.
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
**Spec 커버리지:**
|
||
- §2 하드웨어/배선 → 하드웨어 섹션 + Task 8. ✓
|
||
- §3 Pi 소프트(SENSORS+kind, 극성) → Task 5·6·7 (극성=감지HIGH, ACTIVE_LOW 불변; 풀다운은 외부 10kΩ=옵션 A). ✓
|
||
- §4 서버 additive(화이트리스트·ENUM·태그·발송·라우팅·라벨) → Task 1·2·3·4. ✓
|
||
- §5 상태모델(CLEAR/ALARM) → 기존 상태머신 재사용, Task 6 전이. ✓
|
||
- §6 배포 → Task 7. ✓
|
||
- §7 테스트 → Task 3(PHP)·5(Py)·8(E2E). ✓
|
||
|
||
**미결(스펙 §8) 반영:** 극성 풀다운은 외부 10kΩ(옵션 A, 코드변경 0)로 확정 — 내부 PUD_DOWN(옵션 B)은 setup_gpio 승격 필요하나 이번 시범은 A 채택. 카메라 OFF(연기 분기에서 `_handle_leak_with_photo` 미호출). LED노드 전압은 Task 8 Step1에서 실측.
|
||
|
||
**Placeholder 스캔:** `<설치 위치>`, `010XXXX…`(운영 비밀값), `<web_dir>`, `<db>`는 운영 환경별 실제값 — 코드 placeholder 아님. 나머지 코드 스텝은 실코드.
|
||
|
||
**타입 일관성:** `event_type_for(sensor, candidate)`(Task5) ↔ Task6 호출 시그니처 일치. `send_smoke_alert_sms/recovery(array): bool`(Task3) ↔ sensor_data.php 호출 일치. 태그 상수명 `SMS_TAG_SMOKE_ALARM/RECOVERY` 전 태스크 동일.
|
||
|
||
---
|
||
|
||
## 진행 순서 메모
|
||
|
||
- Task 1~4(서버) + Task 5(Pi 헬퍼)는 **하드웨어 없이 지금 가능** — 물건 오는 동안 선행.
|
||
- Task 6은 Task 5 의존. Task 7·8은 하드웨어 도착 후.
|