POSA_LEAKSMS/docs/superpowers/plans/2026-07-18-smoke-node-pilot.md
cw2007 90b93f818c chore(deploy+docs): 노드 번들 생성기·하드닝 갱신 + 설계문서
- tools/make_deploy_bundles.py: PMS7003·Zero2W 디스플레이 노드 번들 생성 반영(폐쇄망 동봉)
- tools/harden_pi.sh: 하드닝 스크립트 갱신
- docs/superpowers: 노드 센서 통합·스모크 파일럿·대시보드 다크 설계/계획 문서
- docs/claude-config: IoT 납품·검증 스킬/룰 참조본
- review/: 프로젝트 리뷰·UI v2 목업
2026-07-19 10:48:09 +09:00

27 KiB
Raw Blame History

연기(화재) 감지 노드 시범 — 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 를 아래로 교체:

$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_type ENUM이 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_recovery POST. 누수 센서 경로 불변.

  • 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: 개조
  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은 하드웨어 도착 후.