From 12b0c748063e6d2303bf2267e1775588b14b5866 Mon Sep 17 00:00:00 2001 From: cw2007 Date: Thu, 2 Jul 2026 18:51:18 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20sensor=5Fid=203=20=EC=8B=A0=EA=B7=9C=20?= =?UTF-8?q?=EB=88=84=EC=88=98=20=EC=A7=80=EC=A0=90=20=EB=85=B8=EB=93=9C=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3번째 Pi(신규 지점, 누수+카메라)를 코드 수정 없이 배포 가능하게: - raspberry_pi4/config.py: config_local.py 에서 SENSORS 오버라이드 허용 + fail-closed 형식 검증(id 1..255/gpio/name, 중복 금지) — 오설정으로 기본 sensor_id 1 로 조용히 기동해 타 지점 상태를 덮어쓰는 사고 방지. - REPORT_INTERVAL 3600→600: 서버 HEARTBEAT_TIMEOUT_SEC(1200) 절반 — 기존 하트비트 오탐(매시간 오프라인/복귀 SMS) 해소. - php/config.php LEAK_SENSOR_IDS 상수(기본 [1], 1..255 정규화·빈값 복귀). - php/dashboard.php: 누수 카드 sensor_id===1 하드코딩 → LEAK_SENSOR_IDS 루프. - php/api/sensor_data.php: periodic 보고 전면 skip_log(누수 노드 증가 시 이벤트 로그 창 보존) + CORS 헤더 제거(서버간 POST 전용, 도메인 지문 노출 차단). - test_connection.py(양 모듈): 하드코딩 sensor_id 제거→기기 SENSORS 기반, 서명 200 실패 시 exit 1(setup.sh 원샷 게이트용). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01821XP36Q6t9eWKTPSEumnC --- php/api/sensor_data.php | 33 +++++---- php/config.local.example.php | 4 ++ php/config.php | 32 +++++++-- php/dashboard.php | 96 ++++++++++++++------------ raspberry_pi4/config.py | 33 ++++++++- raspberry_pi4/config_local.example.py | 16 ++++- raspberry_pi4/test_connection.py | 20 ++++-- raspberry_pi4_sht30/test_connection.py | 7 +- 8 files changed, 164 insertions(+), 77 deletions(-) diff --git a/php/api/sensor_data.php b/php/api/sensor_data.php index 64cc2ab..c8fb443 100644 --- a/php/api/sensor_data.php +++ b/php/api/sensor_data.php @@ -45,10 +45,8 @@ // {"status":"ok","message":"저장 완료", ...} // ============================================================================= -// CORS 허용 (기기 → 서버 POST 전용, 브라우저 접근 불필요) -header('Access-Control-Allow-Origin: https://pores.kr'); -header('Access-Control-Allow-Methods: POST, OPTIONS'); -header('Access-Control-Allow-Headers: Content-Type, X-Device-Id, X-Signature'); +// CORS 헤더 없음 — 기기(서버측 cURL/requests) → 서버 POST 전용이라 브라우저 +// same-origin 정책 대상이 아니며, Allow-Origin 헤더는 도메인 지문만 노출한다. if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); @@ -109,7 +107,7 @@ function ingest_nonce_claim(PDO $db, string $sig): string { return 'unavailable'; } try { - $ins = $db->prepare("INSERT INTO ingest_nonce (signature, seen_at) VALUES (:sig, NOW())"); + $ins = $db->prepare("INSERT INTO leak_ingest_nonce (signature, seen_at) VALUES (:sig, NOW())"); $ins->execute([':sig' => substr($sig, 0, 128)]); } catch (PDOException $e) { $code = $e->getCode(); @@ -124,7 +122,7 @@ function ingest_nonce_claim(PDO $db, string $sig): string { // 선점 성공 후, 신선도 창의 2배보다 오래된 nonce 정리(베스트 에포트, 인덱스로 가벼움). try { $ttl = max(60, INGEST_MAX_CLOCK_SKEW_SEC * 2); - $db->exec("DELETE FROM ingest_nonce WHERE seen_at < (NOW() - INTERVAL " . (int)$ttl . " SECOND)"); + $db->exec("DELETE FROM leak_ingest_nonce WHERE seen_at < (NOW() - INTERVAL " . (int)$ttl . " SECOND)"); } catch (Throwable $e) { /* 정리는 실패해도 무방 */ } @@ -138,7 +136,7 @@ function ingest_nonce_release(PDO $db, string $sig): void { return; } try { - $db->prepare("DELETE FROM ingest_nonce WHERE signature = :sig") + $db->prepare("DELETE FROM leak_ingest_nonce WHERE signature = :sig") ->execute([':sig' => substr($sig, 0, 128)]); } catch (Throwable $e) { error_log("[API] nonce release 오류: " . $e->getMessage()); @@ -246,7 +244,7 @@ $has_metric_payload = ($metric_type !== '') && ($temperature_c !== null || $humi // ── 서버측 임계 판정 (config.php METRIC_*) ───────────────── // 펌웨어 metric_status 는 물리 plausibility 판정이라 운영 경보로는 신뢰하지 않고, -// 서버가 운영 임계로 다시 판정하여 sensor_metric.metric_status 에 저장한다. +// 서버가 운영 임계로 다시 판정하여 leak_sensor_metric.metric_status 에 저장한다. $metric_eval = $has_metric_payload ? evaluate_metric_thresholds($temperature_c, $humidity_percent) : ['breaches' => [], 'status' => 'normal']; @@ -255,11 +253,12 @@ $metric_status = $metric_eval['status']; $metric_saved = false; // ── DB 저장 ─────────────────────────────────────────────── -// 주기(periodic) 온습도 측정 보고는 sensor_log 에 남기지 않고 sensor_metric/상태만 갱신 -// (대시보드 로그 정리). startup 은 1회 sensor_log 기록. -// 누수 이벤트(leak_alert/leak_recovery)는 항상 sensor_log 에 기록한다 +// 주기(periodic) 보고는 순수 하트비트 — leak_sensor_log 에 남기지 않고 상태 upsert +// (+온습도면 leak_sensor_metric)만 갱신한다. 누수 노드가 10분 주기로 늘어나면 +// periodic 행이 이벤트 로그 창을 밀어내므로(지점 2개 = 일 288행) 전부 스킵. +// startup 은 1회 기록. 누수 이벤트(leak_alert/leak_recovery)는 항상 기록한다 // (leak_alert 는 log_id 가 사진 업로드에 필요하고, 누수 이력 추적 대상). -$skip_log = ($event_type === 'periodic' && $has_metric_payload); +$skip_log = ($event_type === 'periodic'); $log_id = null; try { @@ -280,7 +279,7 @@ try { if (!$skip_log) { $stmt = $db->prepare(" - INSERT INTO sensor_log + INSERT INTO leak_sensor_log (device_id, device_location, sensor_id, sensor_name, event_type, is_leak, sensor_time, created_at) VALUES @@ -302,7 +301,7 @@ try { // ── 오프라인 복구 감지: UPSERT 전에 offline_alerted 확인 ── $was_offline = false; - $chk = $db->prepare("SELECT offline_alerted FROM sensor_status WHERE sensor_id = :sensor_id"); + $chk = $db->prepare("SELECT offline_alerted FROM leak_sensor_status WHERE sensor_id = :sensor_id"); $chk->execute([':sensor_id' => $sensor_id]); $offline_flag = $chk->fetchColumn(); if ($offline_flag === '1' || $offline_flag === 1) { @@ -314,7 +313,7 @@ try { // (startup/periodic)는 누수 상태를 건드리지 않도록 기존 값을 보존한다(통합 서버에서 // 2번 모듈 보고가 1번 모듈 누수 상태를 덮어쓰지 않게). $stmt2 = $db->prepare(" - INSERT INTO sensor_status + INSERT INTO leak_sensor_status (sensor_id, device_id, sensor_name, device_location, last_event_type, is_leak, last_seen, offline_alerted) VALUES @@ -339,9 +338,9 @@ try { ':is_leak_event' => $is_leak_event ? 1 : 0, ]); - if ($has_metric_payload && api_table_exists($db, 'sensor_metric')) { + if ($has_metric_payload && api_table_exists($db, 'leak_sensor_metric')) { $metric_stmt = $db->prepare(" - INSERT INTO sensor_metric + INSERT INTO leak_sensor_metric (device_id, device_location, sensor_id, sensor_name, metric_type, temperature_c, humidity_percent, metric_status, measured_at, created_at) diff --git a/php/config.local.example.php b/php/config.local.example.php index ed149a9..7d0beb3 100644 --- a/php/config.local.example.php +++ b/php/config.local.example.php @@ -41,6 +41,10 @@ return [ // Temporary bootstrap token for setup_mfa.php. Remove or empty after MFA enrollment. 'MFA_SETUP_TOKEN' => 'replace-with-temporary-random-token', + // 누수 모듈 sensor_id 목록 — 대시보드 누수 상태 카드 표시 대상 (기본 [1]). + // 신규 누수 지점(예: Pi-C, sensor_id 3) 투입 시: [1, 3] + 'LEAK_SENSOR_IDS' => [1], + // 온습도 임계 경보 (서버 판정 → SMS). 운영 환경에 맞게 조정. 'METRIC_TEMP_HIGH_C' => 27, // 고온 임계(℃) 'METRIC_TEMP_LOW_C' => 10, // 저온 임계(℃) diff --git a/php/config.php b/php/config.php index b737d9e..b90631f 100644 --- a/php/config.php +++ b/php/config.php @@ -51,7 +51,7 @@ define('INGEST_MAX_BODY_BYTES', (int)cfg('INGEST_MAX_BODY_BYTES', 8192)); // 재전송(replay) 멱등 방어. 서명(X-Signature)을 nonce 로 기록해, 시계오차 창 // (INGEST_MAX_CLOCK_SKEW_SEC) 안에서 동일 서명 본문이 재수신되면 멱등 처리(재저장/ // 재발송 생략)한다. 펌웨어 재시도(같은 본문)·캡처 재생 모두 한 번만 반영된다. -// ingest_nonce 테이블이 없는 기존 배포에서는 자동으로 비활성(graceful degrade). +// leak_ingest_nonce 테이블이 없는 기존 배포에서는 자동으로 비활성(graceful degrade). define('INGEST_NONCE_ENABLED', (bool)cfg('INGEST_NONCE_ENABLED', true)); // 레거시 본문 필드 서명(verify_signature: 키-접두 sha256) 수용 여부. 기본 비활성. @@ -78,7 +78,7 @@ define('SMS_RECIPIENTS', $sms_recipients); define('SMS_MAX_EUCKR_BYTES', (int)cfg('SMS_MAX_EUCKR_BYTES', 90)); // ─── SMS 메시지 태그 (경보 상태머신의 단일 출처) ────────────────────────────── -// sms_log.message 의 접두 태그로 쿨다운/복구/대시보드/월간보고서가 경보 상태를 +// leak_sms_log.message 의 접두 태그로 쿨다운/복구/대시보드/월간보고서가 경보 상태를 // 재구성한다. 별도 상태 테이블을 두지 않으므로 이 태그 문자열이 사실상의 상태 키다. // 여러 파일(sms_send/dashboard/monthly_report)의 LIKE 문자열이 서로 어긋나 무성 // 장애가 나는 것을 막기 위해, 태그는 반드시 여기서만 정의하고 상수로 참조한다. @@ -107,7 +107,7 @@ const SMS_METRIC_TAGS = [ ]; /** - * sms_log 태그별 건수 집계 SELECT 절(단일 출처). 대시보드/월간보고서가 공유한다. + * leak_sms_log 태그별 건수 집계 SELECT 절(단일 출처). 대시보드/월간보고서가 공유한다. * 태그 문자열은 위 SMS_TAG_* 상수에서만 가져오므로, 태그를 바꿔도 모든 집계가 한 * 곳에서 따라온다(여러 파일의 LIKE 문자열이 어긋나 무성 장애 나는 것 방지). * 반환 컬럼: high_temp, low_temp, high_humidity, low_humidity, recovery, @@ -192,12 +192,30 @@ define('STUCK_ALERT_COOLDOWN_SEC', (int)cfg('STUCK_ALERT_COOLDOWN_SEC', 21600)); define('SMS_FORCE_FAIL', (bool)cfg('SMS_FORCE_FAIL', false)); // ─── Heartbeat (장비 오프라인 감지) ──────────────────────── -// SHT30 측정 보고 주기는 5분(펌웨어 APP_SHT30_REPORT_INTERVAL_SEC=300). 기본 -// 오프라인 판정은 약 4회 누락(20분)으로 둔다. config.local.php 로 조정 가능. +// SHT30 측정 보고 주기는 5분(펌웨어 APP_SHT30_REPORT_INTERVAL_SEC=300), 누수 모듈 +// 정상 보고 주기는 10분(raspberry_pi4/config.py REPORT_INTERVAL=600). 기본 오프라인 +// 판정 20분은 SHT30 4회 / 누수 2회 누락에 해당한다. config.local.php 로 조정 가능. +// ⚠️ 이 타임아웃은 전 센서 공통 — 어떤 모듈의 보고 주기보다도 길어야 오탐이 없다. define('HEARTBEAT_TIMEOUT_SEC', (int)cfg('HEARTBEAT_TIMEOUT_SEC', 1200)); define('HEARTBEAT_CHECK_INTERVAL_SEC', (int)cfg('HEARTBEAT_CHECK_INTERVAL_SEC', 300)); // 오프라인 체크 실행 주기 define('CRON_SECRET', hash_hmac('sha256', 'cron', API_KEY)); // cron HTTP 접근용 시크릿(API_KEY 파생, HMAC) +// ─── 누수 모듈 sensor_id 목록 ──────────────────────────── +// 대시보드 누수 상태 카드 표시 대상. 신규 누수 지점 추가 시 config.local.php 로 +// 오버라이드한다. 예: 'LEAK_SENSOR_IDS' => [1, 3] (문자열 "1,3" 도 허용) +// 유효 범위는 1..255 정수(api/sensor_data.php 의 sensor_id 검증과 동일). 범위 밖· +// 비숫자 항목은 버리고, 결과가 비면 기본 [1] 로 되돌린다(오설정으로 누수 카드 +// 전체가 사라지는 것 방지). +$leak_sensor_ids = cfg('LEAK_SENSOR_IDS', [1]); +if (is_string($leak_sensor_ids)) { + $leak_sensor_ids = explode(',', $leak_sensor_ids); +} +$leak_sensor_ids = array_values(array_filter( + array_map('intval', (array)$leak_sensor_ids), + static fn($id) => $id >= 1 && $id <= 255 +)); +define('LEAK_SENSOR_IDS', $leak_sensor_ids !== [] ? $leak_sensor_ids : [1]); + // ─── 대시보드 세션 인증 ────────────────────────────────── define('ADMIN_USER', cfg('ADMIN_USER', 'admin')); define('ADMIN_PASSWORD_HASH', cfg('ADMIN_PASSWORD_HASH', '')); // bcrypt 해시 @@ -244,7 +262,7 @@ function json_response(array $data, int $http_code = 200): void { // ─── 개인정보(수신번호) 마스킹 ───────────────────────────── // 화면/보고서에 수신번호를 노출할 때 가운데를 가린다(010-****-1234). 발송 기록 -// 자체(sms_log)는 보존하되 표시 단계에서만 최소화한다(개인정보 최소표시 원칙). +// 자체(leak_sms_log)는 보존하되 표시 단계에서만 최소화한다(개인정보 최소표시 원칙). // 쉼표로 여러 번호가 들어오면 각각 마스킹한다. function mask_phone(string $value): string { if (strpos($value, ',') !== false) { @@ -266,7 +284,7 @@ function mask_phone(string $value): string { function latest_sms_balance(PDO $db): ?int { try { $rm = $db->query(" - SELECT result_message FROM sms_log + SELECT result_message FROM leak_sms_log WHERE status = 'success' AND result_message LIKE '%잔여%' ORDER BY id DESC LIMIT 1 ")->fetchColumn(); diff --git a/php/dashboard.php b/php/dashboard.php index 74f0906..f6bc1b9 100644 --- a/php/dashboard.php +++ b/php/dashboard.php @@ -6,7 +6,7 @@ // 온습도 임계 경보(고온/저온/고습/저습)와 장비 오프라인/복구를 모니터링한다. // 임계 판정과 SMS // 발송은 서버(api/sensor_data.php + config.php METRIC_* 상수)에서 수행하고, -// 이 대시보드는 그 결과(sensor_metric/sensor_status/sms_log)를 표시한다. +// 이 대시보드는 그 결과(leak_sensor_metric/leak_sensor_status/leak_sms_log)를 표시한다. // ============================================================================= require_once __DIR__ . '/config.php'; @@ -84,16 +84,16 @@ function table_exists(PDO $db, string $table): bool { } // ── 센서별 현재 상태 + 최신 측정값 ────────────────────────── -$statuses = $db->query("SELECT * FROM sensor_status ORDER BY sensor_id")->fetchAll(); +$statuses = $db->query("SELECT * FROM leak_sensor_status ORDER BY sensor_id")->fetchAll(); -// 센서별 최신 sensor_metric (최대 id 기준) +// 센서별 최신 leak_sensor_metric (최대 id 기준) $latest_metrics = []; $metric_rows = $db->query(" SELECT sm.* - FROM sensor_metric sm + FROM leak_sensor_metric sm INNER JOIN ( SELECT sensor_id, MAX(id) AS latest_id - FROM sensor_metric + FROM leak_sensor_metric GROUP BY sensor_id ) latest ON latest.latest_id = sm.id ORDER BY sm.sensor_id @@ -140,7 +140,7 @@ $chart_points = []; if ($chart_sensor_id > 0) { $chart_stmt = $db->prepare(" SELECT measured_at, temperature_c, humidity_percent - FROM sensor_metric + FROM leak_sensor_metric WHERE sensor_id = :sid ORDER BY id DESC LIMIT 100 @@ -158,36 +158,36 @@ if ($chart_sensor_id > 0) { } } -// ── 최근 측정 이력 (sensor_metric 30건) ───────────────────── +// ── 최근 측정 이력 (leak_sensor_metric 30건) ───────────────────── $recent_metrics = $db->query(" SELECT sensor_id, sensor_name, device_location, temperature_c, humidity_percent, metric_status, measured_at - FROM sensor_metric + FROM leak_sensor_metric ORDER BY id DESC LIMIT 30 ")->fetchAll(); -// ── 최근 센서 이벤트 로그 (sensor_log: startup/periodic/leak_alert/leak_recovery) ── +// ── 최근 센서 이벤트 로그 (leak_sensor_log: startup/periodic/leak_alert/leak_recovery) ── $logs = $db->query(" SELECT id, sensor_name, device_location, event_type, is_leak, sensor_time - FROM sensor_log + FROM leak_sensor_log ORDER BY id DESC LIMIT 30 ")->fetchAll(); // ── 최근 SMS 발송 이력 ───────────────────────────────────── $sms_logs = $db->query(" - SELECT * FROM sms_log + SELECT * FROM leak_sms_log ORDER BY id DESC LIMIT 20 ")->fetchAll(); // ── 30일 운영 요약 ───────────────────────────────────────── -// 종류별 임계 경보 SMS 건수 (sms_log message prefix 기준) +// 종류별 임계 경보 SMS 건수 (leak_sms_log message prefix 기준) $sms_summary_rows = $db->query(" SELECT " . sms_summary_select_sql() . " - FROM sms_log + FROM leak_sms_log WHERE sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) ")->fetch(); @@ -209,7 +209,7 @@ $summary['alert_total'] = $summary['high_temp'] + $summary['low_temp'] // 30일 평균 온습도 + 최근 온습도(차트 센서 기준 최신값) $metric_avg = $db->query(" SELECT AVG(temperature_c) AS avg_temp, AVG(humidity_percent) AS avg_hum - FROM sensor_metric + FROM leak_sensor_metric WHERE measured_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) ")->fetch(); $avg_temp = $metric_avg['avg_temp'] !== null ? round((float)$metric_avg['avg_temp'], 1) : null; @@ -239,7 +239,7 @@ $normal_count = count($statuses) - $offline_count - $alert_count; // ── 경보 SMS 발송 실패 / 수집 거부 사유 (신뢰성·관측성) ───── // 최근 1시간 내 경보/오프라인/에스컬레이션 SMS 가 실패했는가(미수신 가능 경고). $recent_alert_fail = (int)$db->query(" - SELECT COUNT(*) FROM sms_log + SELECT COUNT(*) FROM leak_sms_log WHERE status = 'fail' AND sent_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR) AND (message LIKE '" . SMS_TAG_HIGH_TEMP . "%' OR message LIKE '" . SMS_TAG_LOW_TEMP . "%' @@ -252,14 +252,19 @@ $recent_alert_fail = (int)$db->query(" $ingest_rejections = read_ingest_rejections(1); $rejection_total = array_sum($ingest_rejections); -// ── 누수(1번 모듈) 현재 상태 + 사고/사진 ─────────────────── -// 누수 센서(sensor_id=1)의 sensor_status.is_leak 로 누수/정상을 표시한다. 오프라인 -// 판정은 위 $statuses 계산과 동일한 로직을 따른다(행이 없으면 '데이터 없음'). -$leak_status = null; +// ── 누수 모듈 현재 상태 + 사고/사진 ─────────────────── +// 누수 센서(LEAK_SENSOR_IDS, config.php — 기본 [1])의 leak_sensor_status.is_leak 로 +// 누수/정상을 표시한다. 오프라인 판정은 위 $statuses 계산과 동일한 로직을 +// 따른다(행이 없으면 '데이터 없음'). 신규 누수 지점은 config.local.php 의 +// 'LEAK_SENSOR_IDS' => [1, 3] 오버라이드로 카드가 추가된다. +$leak_statuses = []; // sensor_id => leak_sensor_status 행 (미수신이면 null) +foreach (LEAK_SENSOR_IDS as $leak_sid) { + $leak_statuses[$leak_sid] = null; +} foreach ($statuses as $s) { - if ((int)$s['sensor_id'] === 1) { - $leak_status = $s; - break; + $sid = (int)$s['sensor_id']; + if (array_key_exists($sid, $leak_statuses)) { + $leak_statuses[$sid] = $s; } } @@ -689,32 +694,32 @@ if ($alert_count > 0) { - -

누수 감지 (1번 모듈)

- 누수 > 정상 우선순위 - if ($leak_status === null) { - $leak_card_class = ''; - $leak_badge_class = 'offline'; - $leak_badge_text = '데이터 없음'; - } elseif ($leak_status['is_offline']) { - $leak_card_class = 'offline'; - $leak_badge_class = 'offline'; - $leak_badge_text = ((int)($leak_status['is_leak'] ?? 0) === 1) ? '오프라인 (누수 중 끊김)' : '오프라인'; - } elseif ((int)($leak_status['is_leak'] ?? 0) === 1) { - $leak_card_class = 'leak'; - $leak_badge_class = 'leak'; - $leak_badge_text = '누수'; - } else { - $leak_card_class = ''; - $leak_badge_class = 'ok'; - $leak_badge_text = '정상'; - } - ?> + +

누수 감지

+ $leak_status): + // 누수 센서 상태 표시: 오프라인 > 누수 > 정상 우선순위 + if ($leak_status === null) { + $leak_card_class = ''; + $leak_badge_class = 'offline'; + $leak_badge_text = '데이터 없음'; + } elseif ($leak_status['is_offline']) { + $leak_card_class = 'offline'; + $leak_badge_class = 'offline'; + $leak_badge_text = ((int)($leak_status['is_leak'] ?? 0) === 1) ? '오프라인 (누수 중 끊김)' : '오프라인'; + } elseif ((int)($leak_status['is_leak'] ?? 0) === 1) { + $leak_card_class = 'leak'; + $leak_badge_class = 'leak'; + $leak_badge_text = '누수'; + } else { + $leak_card_class = ''; + $leak_badge_class = 'ok'; + $leak_badge_text = '정상'; + } + ?>
- 1번 센서 (누수)
+ 번 센서 (누수)
@@ -733,6 +738,7 @@ if ($alert_count > 0) {
+
diff --git a/raspberry_pi4/config.py b/raspberry_pi4/config.py index b46c5dc..2f17f4e 100644 --- a/raspberry_pi4/config.py +++ b/raspberry_pi4/config.py @@ -32,7 +32,9 @@ USE_INTERNAL_PULL = False # True = PUD_DOWN, False = PUD_OFF # ───────────────────────────────────────────── # 주기 설정 # ───────────────────────────────────────────── -REPORT_INTERVAL = 3600 # 정상 상태 주기 보고 간격 (초) = 1시간 +REPORT_INTERVAL = 600 # 정상 상태 주기 보고 간격 (초) = 10분 + # 서버 HEARTBEAT_TIMEOUT_SEC(기본 1200초)보다 짧아야 + # 오프라인 오탐이 없다 (600초 = 2회 누락 시 오프라인 판정). LEAK_REPORT_INTERVAL = 10 # 누수 감지 시 재보고 간격 (초) DEBOUNCE_MS = 500 # 바운싱 방지 시간 (ms) — 고임피던스라 넉넉히 설정 @@ -80,3 +82,32 @@ except ImportError as e: "`cp config_local.example.py config_local.py` 후 실제 서버 URL / API_KEY / " "기기 정보를 채우세요. (비밀값은 절대 커밋하지 마세요.)" ) from e + +# ───────────────────────────────────────────── +# 선택 오버라이드 — 기기별 값을 config_local.py 에서 덮어쓸 수 있다. +# 추적 파일(config.py)을 기기마다 수정하면 배포 드리프트가 생기므로, +# 신규 지점 노드(예: sensor_id 3)는 config_local.py 에 SENSORS 를 정의한다. +# ───────────────────────────────────────────── +try: + from config_local import SENSORS # noqa: F811 — 기기별 센서 목록 오버라이드 +except ImportError: + pass # config_local 에 SENSORS 가 없으면 위 기본값 사용 + +# SENSORS 형식 검증 (fail closed) — 오버라이드 오타/형식 오류로 잘못된 sensor_id +# (예: 기본값 1)로 조용히 기동해 다른 지점 상태를 덮어쓰는 사고를 막는다. +if not isinstance(SENSORS, list) or not SENSORS: + raise RuntimeError("SENSORS 는 비어있지 않은 list 여야 합니다 (config_local.py 확인).") +_ids, _gpios = set(), set() +for _s in SENSORS: + if not (isinstance(_s, dict) + and isinstance(_s.get("id"), int) and 1 <= _s["id"] <= 255 + and isinstance(_s.get("gpio"), int) + and isinstance(_s.get("name"), str) and _s["name"]): + raise RuntimeError( + f"SENSORS 항목 형식 오류: {_s!r} — id(int 1..255)/gpio(int)/name(str) 필수." + ) + if _s["id"] in _ids or _s["gpio"] in _gpios: + raise RuntimeError(f"SENSORS id/gpio 중복: {_s!r}") + _ids.add(_s["id"]) + _gpios.add(_s["gpio"]) +del _ids, _gpios diff --git a/raspberry_pi4/config_local.example.py b/raspberry_pi4/config_local.example.py index 434a0aa..21cfad2 100644 --- a/raspberry_pi4/config_local.example.py +++ b/raspberry_pi4/config_local.example.py @@ -11,7 +11,7 @@ # X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=요청본문 바이트)) # 사진 업로드 canonical = device_id|sensor_id|log_id|timestamp (공백 없음) # → API_KEY 는 서버 config.local.php 의 API_KEY 와 "정확히 동일"해야 한다. -# 서버에서 sql/migration_add_leak.sql 을 1회 실행하면(가산·멱등) 누수 수신이 활성화되고, +# 서버 DB 는 sql/schema_sht30.sql 하나로 설치되며(누수 요소 포함), # 누수 상태·사진·온습도가 한 대시보드(dashboard.php)에 함께 표시된다. # (구버전의 sha256(API_KEY + 정렬 JSON) body-field 서명은 폐기됨 — 서버가 거부한다.) # ============================================================================= @@ -33,3 +33,17 @@ API_KEY = "CHANGE_ME" # ───────────────────────────────────────────── DEVICE_ID = "rpi4-leak-01" DEVICE_LOCATION = "서버실" + +# ───────────────────────────────────────────── +# (선택) 센서 목록 오버라이드 — 신규 지점 노드용 +# 미정의 시 config.py 기본값(SENSORS = [{"id": 1, ...}]) 사용. +# 신규 지점(예: Pi-C, sensor_id 3) 배포 시 이 파일에서만 정의하면 되고, +# 추적 파일(config.py)은 수정할 필요 없다. 서버 대시보드 카드 표시는 +# 서버 config.local.php 의 'LEAK_SENSOR_IDS' => [1, 3] 도 함께 갱신할 것. +# ⚠️ sensor_id 는 지점마다 고유해야 함 — 1/2 재사용 시 기존 지점 상태를 +# 조용히 덮어쓴다 (leak_sensor_status 는 sensor_id 단독 키). +# ───────────────────────────────────────────── +# SENSORS = [ +# {"id": 3, "gpio": 17, "name": "3번 센서 (신규 지점)"}, +# ] +# (위를 켤 때 DEVICE_ID 예: "rpi4-leak-02", DEVICE_LOCATION 도 지점명으로 변경) diff --git a/raspberry_pi4/test_connection.py b/raspberry_pi4/test_connection.py index e004885..ab00dc3 100644 --- a/raspberry_pi4/test_connection.py +++ b/raspberry_pi4/test_connection.py @@ -4,10 +4,18 @@ import hashlib import hmac +import sys import time import subprocess -from config import API_URL, API_KEY, DEVICE_ID, DEVICE_LOCATION +from config import API_URL, API_KEY, DEVICE_ID, DEVICE_LOCATION, SENSORS + +signed_ok = False # 서명 POST 200 여부 — setup.sh 가 이 exit 코드로 하드닝을 게이트한다 + +# 이 기기에 설정된 첫 센서로 테스트한다 — sensor_id 를 하드코딩하면(예: 1) +# 다른 지점 노드에서 테스트할 때 그 지점의 leak_sensor_status 를 오염시킨다. +TEST_SENSOR_ID = SENSORS[0]["id"] +TEST_SENSOR_NAME = SENSORS[0]["name"] # API_URL 에서 베이스 경로 추출 (.../api/sensor_data.php → .../api) API_BASE = API_URL.rsplit("/", 1)[0] @@ -46,7 +54,7 @@ try: "-A", "LeakSensor/2.0", "-X", "POST", API_URL, "-H", "Content-Type: application/json", - "-d", '{"device_id":"test","sensor_id":1,"event_type":"startup","is_leak":0,"timestamp":1234567890}'], + "-d", f'{{"device_id":"test","sensor_id":{TEST_SENSOR_ID},"event_type":"startup","is_leak":0,"timestamp":1234567890}}'], capture_output=True, text=True, timeout=10 ) lines = result.stdout.strip().split("\n") @@ -69,8 +77,8 @@ try: '{' f'"device_id":"{DEVICE_ID}",' f'"device_location":"{DEVICE_LOCATION}",' - f'"sensor_id":1,' - f'"sensor_name":"테스트 센서",' + f'"sensor_id":{TEST_SENSOR_ID},' + f'"sensor_name":"{TEST_SENSOR_NAME} (연결테스트)",' f'"event_type":"startup",' f'"is_leak":0,' f'"timestamp":{ts}' @@ -95,6 +103,7 @@ try: print(f" 응답 내용: {resp.text[:200]}") if resp.status_code == 200: + signed_ok = True print(" → 성공! 모든 연결 정상") elif resp.status_code == 403: print(" → 403: requests 라이브러리가 차단됨 또는 서명 불일치") @@ -108,5 +117,6 @@ except Exception as e: print(f" → 실패: {e}") print("\n" + "=" * 50) -print(" 테스트 완료") +print(" 테스트 완료" + ("" if signed_ok else " — 서명 POST 실패(위 로그 확인)")) print("=" * 50) +sys.exit(0 if signed_ok else 1) diff --git a/raspberry_pi4_sht30/test_connection.py b/raspberry_pi4_sht30/test_connection.py index 95eeb68..f956307 100644 --- a/raspberry_pi4_sht30/test_connection.py +++ b/raspberry_pi4_sht30/test_connection.py @@ -4,11 +4,14 @@ wire.py 로 본문을 만들고 raw-body HMAC(X-Signature)을 붙여 실제로 한 번 서명된 POST 를 보낸다. 비밀값(API_URL/API_KEY)은 config_local.py 에서 로드한다. """ +import sys import time import subprocess import requests +signed_ok = False # 서명 POST 200 여부 — setup.sh 가 이 exit 코드로 하드닝을 게이트한다 + from config import ( API_URL, API_KEY, DEVICE_ID, DEVICE_LOCATION, SENSOR_ID, SENSOR_NAME, APP_VERSION, @@ -68,6 +71,7 @@ try: print(f" HTTP 응답코드: {resp.status_code}") print(f" 응답 내용: {resp.text[:200]}") if resp.status_code == 200: + signed_ok = True print(" → 성공! 서명 검증 통과, 측정 저장됨 (대시보드 sensor_id=2 확인)") elif resp.status_code == 403: print(" → 403 인증 실패: API_KEY 가 서버 config.local.php 와 다르거나 서명 불일치") @@ -81,5 +85,6 @@ except Exception as e: print(f" → 실패: {e}") print("\n" + "=" * 50) -print(" 테스트 완료") +print(" 테스트 완료" + ("" if signed_ok else " — 서명 POST 실패(위 로그 확인)")) print("=" * 50) +sys.exit(0 if signed_ok else 1)