- tools/make_deploy_bundles.py: PMS7003·Zero2W 디스플레이 노드 번들 생성 반영(폐쇄망 동봉) - tools/harden_pi.sh: 하드닝 스크립트 갱신 - docs/superpowers: 노드 센서 통합·스모크 파일럿·대시보드 다크 설계/계획 문서 - docs/claude-config: IoT 납품·검증 스킬/룰 참조본 - review/: 프로젝트 리뷰·UI v2 목업
27 KiB
연기(화재) 감지 노드 시범 — 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) | — |
주의:
- LED 애노드 노드 전압을 멀티미터 DC로 먼저 측정(평상시 vs 테스트버튼). V면 직렬저항 = (V−1.2)/2mA. ~2V면 470Ω, 애매하면 1kΩ.
- PC817을 하이사이드(collector→3V3, emitter→GPIO+풀다운)로 배선해야 감지=HIGH → 기존
ACTIVE_LOW=False와 극성 일치(코드 무변경). - 감지기 GND와 Pi GND 공통 접지 필수(옵토 LED 리턴 경로).
- 한계: 배터리死·단선은 무음(시범 감수). 실배포 승격 시 릴레이+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_changekind 분기 (수정)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_recoveryevent_type를periodic으로 강등하지 않고 로깅.$is_smoke_event불리언. smoke 이벤트는is_leak=0강제. -
Step 1: 화이트리스트 확장
php/api/sensor_data.php:208-209 를 아래로 교체:
$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 블록 교체)
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: 커밋
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_typeENUM이 smoke 값 수용. 없으면 Task1의 INSERT가 strict 모드에서 실패/절단. -
Step 1: 신규 설치 스키마 ENUM 확장
sql/schema_sht30.sql:30 의 event_type 컬럼을:
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 생성:
-- 연기 감지 이벤트 타입 추가 (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:
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: 커밋
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
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 정의 줄) 바로 뒤에 추가:
const SMS_TAG_SMOKE_ALARM = '[화재감지]'; // 4번 채널(연기·광전식) 감지
const SMS_TAG_SMOKE_RECOVERY = '[화재복구]'; // 연기 해소(복구)
그리고 누수 쿨다운 상수(LEAK_ALERT_COOLDOWN_SEC) 근처(config.php:205 부근)에 추가:
const SMOKE_ALERT_COOLDOWN_SEC = 300; // 화재 경보 SMS 재발신 최소 간격(초)
- Step 4: config.php — summary SQL에 smoke 집계 추가
sms_summary_select_sql() 의 sms_fail 줄(config.php:186) 앞에 두 줄 삽입:
. " 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) 구조를 그대로 미러(락·쿨다운·에피소드 인지). 태그/쿨다운상수/문구만 교체:
/**
* 화재(연기) 경보 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 다음)
// ── 화재(연기) 경보 / 복구 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: 커밋
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번 추가:
4: {"location": "<설치 위치>", "name": "연기(광전식)"},
(선택) 대시보드 카드용 SMOKE_SENSOR_IDS = [4] 상수를 서버 config 생성부에 추가.
- Step 2: 운영 config.local.php에 수신자 그룹 (수동, git 미추적)
php/config.local.php 의 SENSOR_RECIPIENTS 에:
4 => ['010XXXXYYYY', '010XXXXZZZZ'], // 화재(연기) 수신자
- Step 3: 수신자 해소 검증 (추측 금지 — 실제 출력)
Run: php -r 'require "php/config.php"; var_export(recipients_for_sensor(4));'
Expected: 위 화재 그룹 반환(전역 폴백 아님).
- Step 4: 커밋 (config.local.php는 커밋 대상 아님)
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 생성:
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정의 앞)
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: 커밋
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_recoveryPOST. 누수 센서 경로 불변. -
Step 1: candidate 분기 교체 (leak_sensor.py:434-448)
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: 커밋
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 에 추가:
{"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: 커밋
git add tools/make_deploy_bundles.py
git commit -m "feat(smoke): Pi-C 번들에 연기 채널(id4) + 배포 절차"
Task 8: 하드웨어 조립 + 엔드투엔드 검증
Files: 없음 (물리 작업 + 검증)
- Step 1: 개조
- 감지기 뚜껑 열고 멀티미터 DC로 알람 LED 애노드 확정(평상시 0V, 테스트버튼 ~2V).
- LED노드 → 1kΩ → PC817 1핀, 2핀 → 감지기 GND.
- 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 실제 발송까지)
- 감지기 테스트버튼(또는 연기) → GPIO27 HIGH.
- Pi 로그
[SMOKE DETECTED] sensor4확인. - 서버
leak_sensor_log에event_type='smoke_alarm'행 확인. - 화재팀 휴대폰에
[화재감지]SMS 도착 확인 (HTTP 200만으로 판정 금지). - 해제 →
[화재복구]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은 하드웨어 도착 후.