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) { - -