센서1·3(누수 노드)에 PMS7003 를 온습도와 동일 sensor_id 로 통합하고 metric_type='pms7003'
로 구분해 수집한다. LED 는 스위칭 없이 상시 점등 방식으로 확정.
- raspberry_pi4_pms7003/: PMS7003 노드 풀세트(드라이버·센서·install·systemd·vendor·배선·연결테스트·프레임 단위테스트)
- 서버: evaluate_metric_thresholds() 에 pm25/pm10 선택 인자 추가(온습도 전용 호출 후방호환),
sensor_data.php 수집 경로에 pms7003 metric 반영, config.php/예시에 PM 임계 추가
- SQL: migration_pms7003.sql · schema_sht30.sql 정합, tools/migration_leak_add_{pms7003,sht30} 마이그레이터
- tests/pm_threshold_test.php + run_tests.sh 러너 등록
- .gitignore: 노드별 config_local.py 커밋 차단
540 lines
27 KiB
PHP
540 lines
27 KiB
PHP
<?php
|
|
// =============================================================================
|
|
// api/sensor_data.php - 센서 보드로부터 측정/이벤트 데이터를 받는 통합 수집 API
|
|
// (2번 모듈=SHT30 온습도 sensor_id=2, 1번 모듈=누수+카메라 sensor_id=1)
|
|
//
|
|
// [요청 형식 — 온습도(SHT30, 2번 모듈)] POST application/json
|
|
// {
|
|
// "device_id": "rpi4-sht30-01", // 배포=Raspberry Pi 4 (STM32 펌웨어면 "stm32-sht30-01")
|
|
// "device_location": "서버실",
|
|
// "sensor_id": 2,
|
|
// "sensor_name": "2번 센서 (SHT30)",
|
|
// "event_type": "periodic" | "startup",
|
|
// "timestamp": 1700000000,
|
|
// "metric_type": "sht30",
|
|
// "temperature_c": 24.50,
|
|
// "humidity_percent": 48.30,
|
|
// "metric_status": "normal", // 펌웨어 plausibility 판정(참고용; 서버가 재판정)
|
|
// "app_version": "v2606-sht30-rpi4" // Pi 배포 (STM32 펌웨어면 "v2606-sht30"); 서버는 검증 안 함
|
|
// }
|
|
//
|
|
// [요청 형식 — 누수(1번 모듈)] POST application/json
|
|
// {
|
|
// "device_id": "rpi4-leak-01",
|
|
// "device_location": "서버실",
|
|
// "sensor_id": 1,
|
|
// "sensor_name": "1번 센서 (누수)",
|
|
// "event_type": "leak_alert" | "leak_recovery" | "periodic" | "startup",
|
|
// "is_leak": 1, // 누수 여부(0/1)
|
|
// "timestamp": 1700000000
|
|
// }
|
|
// - leak_alert 응답에 log_id 를 실어 보내면, 1번 모듈이 그 log_id 로 사진을
|
|
// api/photo_upload.php 에 업로드한다.
|
|
//
|
|
// [인증 방식 2가지]
|
|
// 1) 신규(STM32 펌웨어): HTTP 헤더 X-Signature = HMAC-SHA256(key=API_KEY, msg=raw_body)
|
|
// - 본문 바이트 그대로 서명 → JSON 재직렬화/부동소수 포맷 호환 문제 없음
|
|
// - timestamp 신선도(INGEST_MAX_CLOCK_SKEW_SEC)로 재전송(replay) 방어
|
|
// 2) 레거시: 본문 "signature" 필드 = sha256(API_KEY . ksort(json)) (하위호환, deprecated)
|
|
//
|
|
// [온습도 임계 경보] config.php 의 METRIC_* 임계로 서버가 고온/저온/고습/저습을
|
|
// 판정하고 종류별 쿨다운으로 SMS를 발송한다. 정상 복귀 시 1회 복귀 SMS를 보낸다.
|
|
// (판정/발송 로직은 sms_send.php)
|
|
//
|
|
// [응답 형식] 200 OK application/json
|
|
// {"status":"ok","message":"저장 완료", ...}
|
|
// =============================================================================
|
|
|
|
// CORS 헤더 없음 — 기기(서버측 cURL/requests) → 서버 POST 전용이라 브라우저
|
|
// same-origin 정책 대상이 아니며, Allow-Origin 헤더는 도메인 지문만 노출한다.
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
|
http_response_code(204);
|
|
exit;
|
|
}
|
|
|
|
// POST만 허용
|
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
http_response_code(405);
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
echo json_encode(['status' => 'error', 'message' => 'Method Not Allowed']);
|
|
exit;
|
|
}
|
|
|
|
require_once dirname(__DIR__) . '/config.php';
|
|
require_once dirname(__DIR__) . '/sms_send.php';
|
|
require_once dirname(__DIR__) . '/cron_heartbeat.php';
|
|
|
|
// ── 운영 비밀값 강제: 기본 API_KEY 로는 수신을 거부(fail-closed) ──────────
|
|
// config.local.php 미설치/미설정 상태에서 공개된 기본 키로 위조 데이터가
|
|
// 수신되는 것을 차단한다. (security: 시작 시 비밀값 존재 검증)
|
|
if (api_key_is_default()) {
|
|
error_log("[API] 거부: API_KEY 가 기본값입니다. config.local.php 에 운영 키를 설정하세요.");
|
|
record_ingest_rejection('key_default');
|
|
json_response(['status' => 'error', 'message' => '서버 구성 오류'], 503);
|
|
}
|
|
|
|
// 디바이스가 보낸 문자열에서 제어문자(개행 등)를 제거해 SMS/로그 라인 주입을 막는다.
|
|
// (서명은 raw 본문에 대해 이미 검증되었으므로 파싱 후 정제해도 안전하다.)
|
|
function api_sanitize_str(string $s): string {
|
|
return preg_replace('/[[:cntrl:]]+/', ' ', $s);
|
|
}
|
|
|
|
function api_table_exists(PDO $db, string $table): bool {
|
|
try {
|
|
$stmt = $db->prepare("
|
|
SELECT COUNT(*)
|
|
FROM information_schema.TABLES
|
|
WHERE TABLE_SCHEMA = DATABASE()
|
|
AND TABLE_NAME = :table
|
|
");
|
|
$stmt->execute([':table' => $table]);
|
|
return (int)$stmt->fetchColumn() > 0;
|
|
} catch (Throwable $e) {
|
|
error_log("[API] table check failed: {$table} " . $e->getMessage());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ── 재전송(replay) 멱등 선점 ──────────────────────────────────
|
|
// 인증·신선도(timestamp)를 통과한 요청의 서명을 nonce 로 원자적 기록한다. 시계오차
|
|
// 창 안에서 같은 서명 본문이 다시 오면(펌웨어 재시도/캡처 재생) 한 번만 반영된다.
|
|
// 'claimed' : 이번에 선점 → 계속 처리(저장/발송)
|
|
// 'duplicate' : 이미 처리됨 → 멱등 200 으로 종료(재저장/재발송 없음)
|
|
// 'unavailable' : nonce 비활성/테이블 없음/오류 → 종전대로 진행(graceful degrade)
|
|
function ingest_nonce_claim(PDO $db, string $sig): string {
|
|
if (!INGEST_NONCE_ENABLED || $sig === '') {
|
|
return 'unavailable';
|
|
}
|
|
try {
|
|
$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();
|
|
if ($code === '23000') {
|
|
return 'duplicate'; // PK 충돌 = 동일 서명 재수신
|
|
}
|
|
if ($code !== '42S02') { // 42S02 = 테이블 없음(기존 배포) → 조용히 비활성
|
|
error_log("[API] nonce claim 오류({$code}): " . $e->getMessage());
|
|
}
|
|
return 'unavailable';
|
|
}
|
|
// 선점 성공 후, 신선도 창의 2배보다 오래된 nonce 정리(베스트 에포트, 인덱스로 가벼움).
|
|
try {
|
|
$ttl = max(60, INGEST_MAX_CLOCK_SKEW_SEC * 2);
|
|
$db->exec("DELETE FROM leak_ingest_nonce WHERE seen_at < (NOW() - INTERVAL " . (int)$ttl . " SECOND)");
|
|
} catch (Throwable $e) {
|
|
/* 정리는 실패해도 무방 */
|
|
}
|
|
return 'claimed';
|
|
}
|
|
|
|
// 처리 중 저장이 실패하면(예: DB 오류) 선점한 nonce 를 되돌려, 디바이스 재시도가
|
|
// 다시 처리될 수 있게 한다(데이터 유실 방지).
|
|
function ingest_nonce_release(PDO $db, string $sig): void {
|
|
if ($sig === '') {
|
|
return;
|
|
}
|
|
try {
|
|
$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());
|
|
}
|
|
}
|
|
|
|
// ── 요청 파싱 ─────────────────────────────────────────────
|
|
$raw = file_get_contents('php://input');
|
|
|
|
// 본문 크기 상한: 인증 전 대용량 본문을 json_decode/HMAC 에 태우는 증폭형 DoS 차단.
|
|
if (strlen($raw) > INGEST_MAX_BODY_BYTES) {
|
|
error_log("[API] 본문 과대(" . strlen($raw) . "B > " . INGEST_MAX_BODY_BYTES . "B) from "
|
|
. ($_SERVER['REMOTE_ADDR'] ?? '?'));
|
|
record_ingest_rejection('body_too_large');
|
|
json_response(['status' => 'error', 'message' => '본문이 너무 큽니다'], 413);
|
|
}
|
|
|
|
$data = json_decode($raw, true);
|
|
|
|
if (!$data) {
|
|
record_ingest_rejection('invalid_json');
|
|
json_response(['status' => 'error', 'message' => '잘못된 JSON 형식'], 400);
|
|
}
|
|
|
|
// ── 서명 검증 ─────────────────────────────────────────────
|
|
// X-Signature 헤더가 있으면 raw-body HMAC 서명(STM32 펌웨어)으로 검증한다.
|
|
// 레거시 본문 필드 서명(키-접두 sha256)은 기본 비활성(ALLOW_LEGACY_BODY_SIGNATURE).
|
|
// 운영 클라이언트(STM32)는 HMAC 만 쓰므로 약한 다운그레이드 경로를 닫아 둔다.
|
|
$has_raw_sig = isset($_SERVER['HTTP_X_SIGNATURE']) && $_SERVER['HTTP_X_SIGNATURE'] !== '';
|
|
if ($has_raw_sig) {
|
|
$sig_ok = verify_signature_raw($raw);
|
|
$sig_mode = 'raw';
|
|
} elseif (ALLOW_LEGACY_BODY_SIGNATURE) {
|
|
$sig_ok = verify_signature($data);
|
|
$sig_mode = 'legacy';
|
|
} else {
|
|
$sig_ok = false; // X-Signature 헤더 필수 (레거시 비활성)
|
|
$sig_mode = 'none';
|
|
}
|
|
if (!$sig_ok) {
|
|
error_log("[API] 서명 불일치 from " . ($_SERVER['REMOTE_ADDR'] ?? '?') . " (mode={$sig_mode})");
|
|
// 출처 식별: 서명모드(raw=키불일치 / none=헤더스트립)와 본문 device_id(신뢰불가)를 함께
|
|
// 링에 남긴다 → 대시보드에서 '우리 노드 오설정 vs 외부 스캐너' 판별.
|
|
record_ingest_rejection('signature', ['mode' => $sig_mode, 'device_id' => (string)($data['device_id'] ?? '')]);
|
|
json_response(['status' => 'error', 'message' => '인증 실패'], 403);
|
|
}
|
|
|
|
// ── 필수 필드 검증 ────────────────────────────────────────
|
|
$required = ['device_id', 'sensor_id', 'event_type', 'timestamp'];
|
|
foreach ($required as $field) {
|
|
if (!isset($data[$field])) {
|
|
record_ingest_rejection('missing_field');
|
|
json_response(['status' => 'error', 'message' => "필드 누락: {$field}"], 400);
|
|
}
|
|
}
|
|
|
|
// ── 값 추출 및 타입 변환 ──────────────────────────────────
|
|
$device_id = api_sanitize_str(substr(trim($data['device_id']), 0, 64));
|
|
$device_location = api_sanitize_str(substr(trim($data['device_location'] ?? ''), 0, 100));
|
|
$sensor_id = (int)$data['sensor_id'];
|
|
// sensor_id 범위 검증(스키마 TINYINT UNSIGNED: 1..255). 범위를 벗어나면 DB INSERT
|
|
// 단계에서 500(DB 저장 실패)이 되므로, 경계에서 깔끔하게 400 으로 거른다.
|
|
if ($sensor_id < 1 || $sensor_id > 255) {
|
|
record_ingest_rejection('sensor_id_range');
|
|
json_response(['status' => 'error', 'message' => 'sensor_id 범위 오류(1..255)'], 400);
|
|
}
|
|
$sensor_name = api_sanitize_str(substr(trim($data['sensor_name'] ?? "센서{$sensor_id}"), 0, 100));
|
|
// 통합 수집: 온습도(startup/periodic) + 누수(leak_alert/leak_recovery) 이벤트 허용.
|
|
$event_type = in_array($data['event_type'], ['startup', 'periodic', 'leak_alert', 'leak_recovery'], true)
|
|
? $data['event_type'] : 'periodic';
|
|
// 누수 여부(0/1). 1번 모듈만 보낸다. leak_alert 는 항상 누수=1, leak_recovery 는 0 으로
|
|
// 강제해 이벤트와 상태의 일관성을 보장한다(필드 누락/오값 방어).
|
|
$is_leak = !empty($data['is_leak']) ? 1 : 0;
|
|
if ($event_type === 'leak_alert') {
|
|
$is_leak = 1;
|
|
} elseif ($event_type === 'leak_recovery') {
|
|
$is_leak = 0;
|
|
}
|
|
$is_leak_event = ($event_type === 'leak_alert' || $event_type === 'leak_recovery');
|
|
$ts = (int)$data['timestamp'];
|
|
|
|
// ── 재전송(replay) 방어: timestamp 신선도 검증 ────────────
|
|
// 캡처한 유효 서명 요청의 무한 재사용을 막는다. 펌웨어는 SNTP 동기 후 정확한
|
|
// Unix 시각을 싣는다(reporter.c). 허용 오차를 벗어나면 거부.
|
|
$now = time();
|
|
if ($ts <= 0 || abs($now - $ts) > INGEST_MAX_CLOCK_SKEW_SEC) {
|
|
error_log("[API] timestamp 신선도 위반 (ts={$ts}, now={$now}, max=" . INGEST_MAX_CLOCK_SKEW_SEC . "s) from "
|
|
. ($_SERVER['REMOTE_ADDR'] ?? '?'));
|
|
record_ingest_rejection('timestamp');
|
|
json_response(['status' => 'error', 'message' => '요청 시각이 허용 범위를 벗어났습니다'], 400);
|
|
}
|
|
|
|
$sensor_dt = date('Y-m-d H:i:s', $ts);
|
|
$metric_type = api_sanitize_str(substr(trim($data['metric_type'] ?? ''), 0, 30));
|
|
$temperature_c = isset($data['temperature_c']) && is_numeric($data['temperature_c']) ? round((float)$data['temperature_c'], 2) : null;
|
|
$humidity_percent = isset($data['humidity_percent']) && is_numeric($data['humidity_percent']) ? round((float)$data['humidity_percent'], 2) : null;
|
|
|
|
// 센서 물리 범위를 벗어난 값은 결측(null)으로 처리(센서 오류 방어)
|
|
if ($temperature_c !== null && ($temperature_c < -40 || $temperature_c > 125)) {
|
|
$temperature_c = null;
|
|
}
|
|
if ($humidity_percent !== null && ($humidity_percent < 0 || $humidity_percent > 100)) {
|
|
$humidity_percent = null;
|
|
}
|
|
|
|
// PMS7003 미세먼지(µg/m³ 대기환산 정수). 누수 노드에 온습도와 동일 sensor_id 로 통합되며
|
|
// metric_type='pms7003' 로 구분된다(온습도 행에서는 NULL, PM 행에서는 온습도가 NULL).
|
|
// 물리 범위(0..1000)를 벗어난 값은 결측(null)으로 처리한다(온도와 동일한 방어).
|
|
$pm1_0 = isset($data['pm1_0']) && is_numeric($data['pm1_0']) ? (int)$data['pm1_0'] : null;
|
|
$pm2_5 = isset($data['pm2_5']) && is_numeric($data['pm2_5']) ? (int)$data['pm2_5'] : null;
|
|
$pm10 = isset($data['pm10']) && is_numeric($data['pm10']) ? (int)$data['pm10'] : null;
|
|
if ($pm1_0 !== null && ($pm1_0 < 0 || $pm1_0 > 1000)) { $pm1_0 = null; }
|
|
if ($pm2_5 !== null && ($pm2_5 < 0 || $pm2_5 > 1000)) { $pm2_5 = null; }
|
|
if ($pm10 !== null && ($pm10 < 0 || $pm10 > 1000)) { $pm10 = null; }
|
|
|
|
$has_metric_payload = ($metric_type !== '')
|
|
&& ($temperature_c !== null || $humidity_percent !== null
|
|
|| $pm1_0 !== null || $pm2_5 !== null || $pm10 !== null);
|
|
|
|
// ── 서버측 임계 판정 (config.php METRIC_*) ─────────────────
|
|
// 펌웨어 metric_status 는 물리 plausibility 판정이라 운영 경보로는 신뢰하지 않고,
|
|
// 서버가 운영 임계로 다시 판정하여 leak_sensor_metric.metric_status 에 저장한다.
|
|
$metric_eval = $has_metric_payload
|
|
? evaluate_metric_thresholds($temperature_c, $humidity_percent, $pm2_5, $pm10)
|
|
: ['breaches' => [], 'status' => 'normal'];
|
|
$metric_status = $metric_eval['status'];
|
|
|
|
$metric_saved = false;
|
|
|
|
// ── DB 저장 ───────────────────────────────────────────────
|
|
// 주기(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');
|
|
$log_id = null;
|
|
|
|
try {
|
|
$db = get_db();
|
|
|
|
// 누수 디바운스 사용 여부(마스터 스위치 + 컬럼 존재). 컬럼 없는 구배포는 레거시
|
|
// 즉시 발송 경로로 graceful degrade. 아래 사고추적/누수 SMS 분기에서 참조.
|
|
$use_debounce = LEAK_DEBOUNCE_ENABLED && leak_status_has_debounce_cols($db);
|
|
|
|
// ── 재전송(replay) 멱등 선점 ──
|
|
// 인증·신선도를 통과한 서명을 nonce 로 원자적 기록한다. 같은 서명 본문이 시계오차
|
|
// 창 안에서 다시 오면(펌웨어 재시도/캡처 재생) 중복 저장·중복 SMS 없이 멱등 200.
|
|
$req_sig = $has_raw_sig ? (string)$_SERVER['HTTP_X_SIGNATURE'] : (string)($data['signature'] ?? '');
|
|
$nonce_state = ingest_nonce_claim($db, $req_sig);
|
|
if ($nonce_state === 'duplicate') {
|
|
json_response([
|
|
'status' => 'ok',
|
|
'message' => '이미 처리된 요청(멱등)',
|
|
'duplicate' => true,
|
|
]);
|
|
}
|
|
|
|
if (!$skip_log) {
|
|
$stmt = $db->prepare("
|
|
INSERT INTO leak_sensor_log
|
|
(device_id, device_location, sensor_id, sensor_name,
|
|
event_type, is_leak, sensor_time, created_at)
|
|
VALUES
|
|
(:device_id, :device_location, :sensor_id, :sensor_name,
|
|
:event_type, :is_leak, :sensor_time, NOW())
|
|
");
|
|
$stmt->execute([
|
|
':device_id' => $device_id,
|
|
':device_location' => $device_location,
|
|
':sensor_id' => $sensor_id,
|
|
':sensor_name' => $sensor_name,
|
|
':event_type' => $event_type,
|
|
':is_leak' => $is_leak,
|
|
':sensor_time' => $sensor_dt,
|
|
]);
|
|
|
|
$log_id = $db->lastInsertId();
|
|
}
|
|
|
|
// ── 오프라인 복구 감지: UPSERT 전에 offline_alerted 확인 ──
|
|
$was_offline = false;
|
|
$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) {
|
|
$was_offline = true;
|
|
}
|
|
|
|
// ── 최신 센서 상태 업서트 (항상 갱신, 보고 = 온라인) ──
|
|
// is_leak 은 누수 이벤트(leak_alert/leak_recovery)일 때만 갱신한다. 온습도 보고
|
|
// (startup/periodic)는 누수 상태를 건드리지 않도록 기존 값을 보존한다(통합 서버에서
|
|
// 2번 모듈 보고가 1번 모듈 누수 상태를 덮어쓰지 않게).
|
|
$stmt2 = $db->prepare("
|
|
INSERT INTO leak_sensor_status
|
|
(sensor_id, device_id, sensor_name, device_location,
|
|
last_event_type, is_leak, last_seen, offline_alerted)
|
|
VALUES
|
|
(:sensor_id, :device_id, :sensor_name, :device_location,
|
|
:event_type, :is_leak_ins, NOW(), 0)
|
|
ON DUPLICATE KEY UPDATE
|
|
device_id = VALUES(device_id),
|
|
sensor_name = VALUES(sensor_name),
|
|
device_location = VALUES(device_location),
|
|
last_event_type = VALUES(last_event_type),
|
|
is_leak = IF(:is_leak_event, VALUES(is_leak), is_leak),
|
|
last_seen = NOW(),
|
|
offline_alerted = 0
|
|
");
|
|
$stmt2->execute([
|
|
':sensor_id' => $sensor_id,
|
|
':device_id' => $device_id,
|
|
':sensor_name' => $sensor_name,
|
|
':device_location' => $device_location,
|
|
':event_type' => $event_type,
|
|
':is_leak_ins' => $is_leak,
|
|
':is_leak_event' => $is_leak_event ? 1 : 0,
|
|
]);
|
|
|
|
if ($has_metric_payload && api_table_exists($db, 'leak_sensor_metric')) {
|
|
$metric_stmt = $db->prepare("
|
|
INSERT INTO leak_sensor_metric
|
|
(device_id, device_location, sensor_id, sensor_name,
|
|
metric_type, temperature_c, humidity_percent,
|
|
pm1_0, pm2_5, pm10, metric_status,
|
|
measured_at, created_at)
|
|
VALUES
|
|
(:device_id, :device_location, :sensor_id, :sensor_name,
|
|
:metric_type, :temperature_c, :humidity_percent,
|
|
:pm1_0, :pm2_5, :pm10, :metric_status,
|
|
:measured_at, NOW())
|
|
");
|
|
$metric_stmt->execute([
|
|
':device_id' => $device_id,
|
|
':device_location' => $device_location,
|
|
':sensor_id' => $sensor_id,
|
|
':sensor_name' => $sensor_name,
|
|
':metric_type' => $metric_type,
|
|
':temperature_c' => $temperature_c,
|
|
':humidity_percent' => $humidity_percent,
|
|
':pm1_0' => $pm1_0,
|
|
':pm2_5' => $pm2_5,
|
|
':pm10' => $pm10,
|
|
':metric_status' => $metric_status,
|
|
':measured_at' => $sensor_dt,
|
|
]);
|
|
$metric_saved = true;
|
|
}
|
|
|
|
// ── 누수 사고(에피소드) 추적 (leak_incident) — 레거시(디바운스 미사용) 경로 ──
|
|
// leak_alert: 미복구 사고가 없으면 새 사고를 연다(중복 leak_alert 는 새로 열지 않음).
|
|
// leak_recovery: 같은 센서의 가장 최근 미복구 사고를 닫는다(recovered_at/복구 log_id 채움).
|
|
// leak_incident 테이블이 없으면(누수 미통합 서버) graceful 하게 건너뛴다.
|
|
// 디바운스 사용 시엔 사고 open/close 를 '확정 엣지'(apply_leak_debounce)로 옮겨 처리하므로
|
|
// 여기서는 건너뛴다(raw 이벤트마다 사고가 열려 월간보고서가 부풀지 않게).
|
|
if (!$use_debounce && $is_leak_event && api_table_exists($db, 'leak_incident')) {
|
|
if ($event_type === 'leak_alert') {
|
|
$open = $db->prepare("
|
|
SELECT id FROM leak_incident
|
|
WHERE sensor_id = :sensor_id AND recovered_at IS NULL
|
|
ORDER BY started_at DESC, id DESC LIMIT 1
|
|
");
|
|
$open->execute([':sensor_id' => $sensor_id]);
|
|
if ($open->fetchColumn() === false) {
|
|
$ins_inc = $db->prepare("
|
|
INSERT INTO leak_incident
|
|
(sensor_id, device_id, sensor_name, device_location,
|
|
started_at, log_id_alert, created_at)
|
|
VALUES
|
|
(:sensor_id, :device_id, :sensor_name, :device_location,
|
|
:started_at, :log_id_alert, NOW())
|
|
");
|
|
$ins_inc->execute([
|
|
':sensor_id' => $sensor_id,
|
|
':device_id' => $device_id,
|
|
':sensor_name' => $sensor_name,
|
|
':device_location' => $device_location,
|
|
':started_at' => $sensor_dt,
|
|
':log_id_alert' => $log_id !== null ? (int)$log_id : null,
|
|
]);
|
|
}
|
|
} else { // leak_recovery
|
|
$close = $db->prepare("
|
|
UPDATE leak_incident
|
|
SET recovered_at = :recovered_at, log_id_recovery = :log_id_recovery
|
|
WHERE sensor_id = :sensor_id AND recovered_at IS NULL
|
|
ORDER BY started_at DESC, id DESC
|
|
LIMIT 1
|
|
");
|
|
$close->execute([
|
|
':recovered_at' => $sensor_dt,
|
|
':log_id_recovery' => $log_id !== null ? (int)$log_id : null,
|
|
':sensor_id' => $sensor_id,
|
|
]);
|
|
}
|
|
}
|
|
|
|
// ── 오프라인→온라인 복구 SMS 발송 ─────────────────────
|
|
if ($was_offline) {
|
|
$online_info = [
|
|
'sensor_id' => $sensor_id,
|
|
'sensor_name' => $sensor_name,
|
|
'device_id' => $device_id,
|
|
'device_location' => $device_location,
|
|
];
|
|
send_device_online_sms($online_info);
|
|
error_log("[API] 장비 온라인 복구 - 센서{$sensor_id}");
|
|
}
|
|
|
|
} catch (PDOException $e) {
|
|
error_log("[API] DB 오류: " . $e->getMessage());
|
|
// 저장 실패 시 선점한 nonce 를 되돌려, 디바이스 재시도가 재처리되도록 한다(유실 방지).
|
|
if (isset($nonce_state, $req_sig) && $nonce_state === 'claimed') {
|
|
ingest_nonce_release($db, $req_sig);
|
|
}
|
|
json_response(['status' => 'error', 'message' => 'DB 저장 실패'], 500);
|
|
}
|
|
|
|
// ── 온습도 임계 경보 / 정상 복귀 SMS ───────────────────────
|
|
$sms_sent = false;
|
|
$sensor_info = [
|
|
'sensor_id' => $sensor_id,
|
|
'sensor_name' => $sensor_name,
|
|
'device_id' => $device_id,
|
|
'device_location' => $device_location,
|
|
];
|
|
|
|
if ($has_metric_payload) {
|
|
if (!empty($metric_eval['breaches'])) {
|
|
// 임계 초과: 종류별로 SMS 발송(각자 쿨다운 적용)
|
|
foreach ($metric_eval['breaches'] as $kind) {
|
|
if ($kind === 'condensation_risk') {
|
|
$value = condensation_margin_c($temperature_c, $humidity_percent); // 표시값=결로마진(℃)
|
|
} elseif (in_array($kind, ['high_temp', 'low_temp'], true)) {
|
|
$value = $temperature_c;
|
|
} elseif ($kind === 'high_pm25') {
|
|
$value = $pm2_5; // 표시값=PM2.5(µg/m³)
|
|
} elseif ($kind === 'high_pm10') {
|
|
$value = $pm10; // 표시값=PM10(µg/m³)
|
|
} else {
|
|
$value = $humidity_percent;
|
|
}
|
|
if ($value !== null && send_metric_alert_sms($sensor_info, $kind, (float)$value)) {
|
|
$sms_sent = true;
|
|
}
|
|
}
|
|
} elseif (($temperature_c !== null || $humidity_percent !== null)
|
|
&& metric_was_alerting($db, $sensor_id)
|
|
&& metric_is_recovered($temperature_c, $humidity_percent)) {
|
|
// 직전까지 경보 상태였고 이제 (히스테리시스 포함) 정상으로 복귀 → 복귀 SMS 1회.
|
|
// 복귀 판정/메시지는 온습도(SHT30)에 한정한다. PMS 보고(온습도 NULL)는 이 분기를
|
|
// 타면 안 된다 — 온습도 결측을 '정상 복귀'로 오인해 온습도 경보 중에도 거짓 복귀
|
|
// SMS 가 나가는 것을 막는다(같은 sensor_id 에 PMS·SHT30 이 별도 행으로 공존하므로).
|
|
// 미세먼지 경보에는 별도 복귀 SMS 가 없다(값이 임계 아래로 내려가면 재발송이 멈출 뿐).
|
|
$sms_sent = send_metric_recovery_sms(
|
|
$sensor_info,
|
|
(float)($temperature_c ?? 0.0),
|
|
(float)($humidity_percent ?? 0.0)
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── 누수 경보 / 복구 SMS (1번 모듈) ────────────────────────
|
|
// [디바운스 경로] 감지/복구 전이를 확인창 동안 지속돼야 확정하고, 확정 엣지에서만 SMS 1회.
|
|
// 감지↔복구 채터링(플래핑)이 SMS 폭탄으로 번지는 것을 원천 차단한다. 누수 센서의 주기
|
|
// 보고(periodic, is_leak 포함)도 확정 타이머를 전진시키므로 이벤트/주기 모두에서 호출한다.
|
|
// [레거시 경로] 디바운스 컬럼이 없거나 비활성이면 종전대로 즉시 발송(쿨다운/복구 판정).
|
|
if ($use_debounce) {
|
|
if ($is_leak_event || in_array($sensor_id, LEAK_SENSOR_IDS, true)) {
|
|
if (apply_leak_debounce($db, $sensor_id, $is_leak, $sensor_info)) {
|
|
$sms_sent = true;
|
|
}
|
|
}
|
|
} elseif ($is_leak_event) {
|
|
if ($event_type === 'leak_alert' && function_exists('send_leak_alert_sms')) {
|
|
if (send_leak_alert_sms($sensor_info)) {
|
|
$sms_sent = true;
|
|
}
|
|
} elseif ($event_type === 'leak_recovery' && function_exists('send_leak_recovery_sms')) {
|
|
if (send_leak_recovery_sms($sensor_info)) {
|
|
$sms_sent = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Heartbeat: 다른 센서 오프라인 여부 체크 (5분 쓰로틀) ──
|
|
check_offline_sensors_throttled();
|
|
|
|
// ── 응답 ──────────────────────────────────────────────────
|
|
json_response([
|
|
'status' => 'ok',
|
|
'message' => $skip_log ? '상태 업데이트 완료' : '저장 완료',
|
|
'log_id' => $log_id ? (int)$log_id : null,
|
|
'metric_saved' => $metric_saved,
|
|
'metric_status' => $metric_status,
|
|
'sms_sent' => $sms_sent,
|
|
// 서버 현재 시각(Unix). 펌웨어가 RTC 드리프트 보정/시계 헬스 점검에 사용할 수 있도록
|
|
// 응답에 싣는다(폐쇄망에서 SNTP 가 간헐적일 때 서버시각으로 RTC 를 미세보정하는 후속
|
|
// 펌웨어 작업의 입력). 보안 영향 없음(인증된 200 응답에만 포함).
|
|
'server_now' => $now,
|
|
]);
|