pm(미세먼지) > alert(온습도) > offline > ok // · 온습도/미세먼지는 같은 sensor_id 아래 metric_type('sht30' vs 'pms7003')로 분리 — // temp/hum/th_status 는 sht30 행에서만, pm25/pm10/pm1_0/pm_status 는 pms7003 행에서만. // ============================================================================= require_once __DIR__ . '/../config.php'; header('Content-Type: application/json; charset=utf-8'); header('Cache-Control: no-store'); if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'GET') { http_response_code(405); echo json_encode(['error' => 'method_not_allowed']); exit; } // ── 레이트 리밋 (IP별 고정창 60초/30회 — 표시기 정상 폴링은 분당 2회) ───────── // login.php 와 동일하게 php/var JSON + LOCK_EX. 파일 오류 시엔 차단하지 않는다 // (모니터링 표시기가 스로틀 저장소 문제로 죽는 것보다 서비스 지속이 우선). $rl_file = __DIR__ . '/../var/status_rate.json'; $rl_ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; $rl_now = time(); $rl_win = $rl_now - ($rl_now % 60); $rl = @json_decode((string)@file_get_contents($rl_file), true); $rl = is_array($rl) ? $rl : []; $rec = $rl[$rl_ip] ?? ['n' => 0, 'win' => $rl_win]; if (($rec['win'] ?? 0) !== $rl_win) { $rec = ['n' => 0, 'win' => $rl_win]; } $rec['n']++; // 창이 바뀐 다른 IP 기록은 청소(파일 무한 성장 방지) $rl = array_filter($rl, fn($r) => ($r['win'] ?? 0) === $rl_win); $rl[$rl_ip] = $rec; @file_put_contents($rl_file, json_encode($rl), LOCK_EX); if ($rec['n'] > 30) { http_response_code(429); header('Retry-After: ' . (60 - ($rl_now % 60))); echo json_encode(['error' => 'rate_limited']); exit; } // ── 인증 (fail closed) ──────────────────────────────────────────────────────── if (!defined('API_KEY') || API_KEY === '' || API_KEY === 'change-this-api-key') { http_response_code(503); echo json_encode(['error' => 'server_not_configured']); exit; } $status_secret = hash_hmac('sha256', 'status', API_KEY); $given = (string)($_SERVER['HTTP_X_STATUS_KEY'] ?? ''); if ($given === '' || !hash_equals($status_secret, $given)) { http_response_code(403); echo json_encode(['error' => 'forbidden']); exit; } // ── 데이터 수집 (대시보드 판정 규칙과 동일 기준) ───────────────────────────── // DB 예외는 전부 잡아 generic 500 으로 — display_errors=On 호스팅에서도 내부정보 비노출. try { $db = get_db(); $status_rows = $db->query("SELECT * FROM leak_sensor_status ORDER BY sensor_id")->fetchAll(); $sensor_ids = array_map(fn($r) => (int)$r['sensor_id'], $status_rows); // 최신 측정을 (sensor_id, metric_type) 단위로 가져온다 — 누수 노드(1·3)는 온습도(sht30)와 // 미세먼지(pms7003)를 같은 sensor_id 로 보고하므로, metric_type 무시하고 MAX(id) 로 뽑으면 // 두 행이 서로를 덮어써(sht30 행은 pm NULL, pms 행은 temp/hum NULL) 한쪽이 사라진다. // → metric_type 별 MAX(id) 로 뽑아 sht30/pms 버킷에 각각 담는다. 레거시 metric_type='' 은 sht30. $latest_sht30 = []; // sensor_id => 최신 sht30(온습도) 행 $latest_pms = []; // sensor_id => 최신 pms7003(미세먼지) 행 foreach ($db->query(" SELECT sm.* FROM leak_sensor_metric sm INNER JOIN ( SELECT sensor_id, metric_type, MAX(id) AS latest_id FROM leak_sensor_metric GROUP BY sensor_id, metric_type ) latest ON latest.latest_id = sm.id ")->fetchAll() as $mr) { $sid = (int)$mr['sensor_id']; $mt = (string)($mr['metric_type'] ?? ''); if ($mt === 'pms7003') { $latest_pms[$sid] = $mr; } else { // 'sht30' 또는 레거시 '' — 온습도로 취급. sht30·'' 가 둘 다 있으면 최신(id 큰) 것 유지. if (!isset($latest_sht30[$sid]) || (int)$mr['id'] > (int)$latest_sht30[$sid]['id']) { $latest_sht30[$sid] = $mr; } } } // ── 24시간 추세(시간당 평균, 최대 24포인트) — 센서별 쿼리로 // (sensor_id, measured_at) 복합 인덱스를 태워 풀스캔 방지(30초 폴링 엔드포인트) ── $history = []; // sensor_id => [ ['t'=>'HH:MM','temp'=>float|null,'hum'=>float|null], ... ] $pm_history = []; // sensor_id => [ ['t'=>'HH:MM','pm25'=>int], ... ] (pms7003 시간당 평균) // AVG 는 컬럼별로 NULL 을 무시하므로, 같은 시간버킷에 온습도(sht30 행)·미세먼지(pms 행)가 // 섞여 있어도 서로를 오염시키지 않는다(sht30 행은 pm2_5=NULL, pms 행은 temp/hum=NULL). // pm2_5 를 같은 쿼리에 얹어 추가 쿼리 없이 (sensor_id, measured_at) 인덱스를 그대로 태운다. $hist_stmt = $db->prepare(" SELECT DATE_FORMAT(measured_at, '%Y-%m-%d %H:00:00') AS bucket, AVG(temperature_c) AS temp, AVG(humidity_percent) AS hum, AVG(pm2_5) AS pm25 FROM leak_sensor_metric WHERE sensor_id = :sid AND measured_at >= NOW() - INTERVAL 24 HOUR GROUP BY bucket ORDER BY bucket LIMIT 25 "); foreach ($sensor_ids as $sid) { $hist_stmt->execute([':sid' => $sid]); foreach ($hist_stmt->fetchAll() as $h) { $t = substr($h['bucket'], 11, 5); // HH:MM $history[$sid][] = [ 't' => $t, 'temp' => $h['temp'] !== null ? round((float)$h['temp'], 1) : null, 'hum' => $h['hum'] !== null ? round((float)$h['hum'], 0) : null, ]; if ($h['pm25'] !== null) { // 미세먼지 값이 있는 버킷만 $pm_history[$sid][] = ['t' => $t, 'pm25' => (int)round((float)$h['pm25'])]; } } } // 누수 이벤트 타임라인 — 플래핑 센서가 응답을 무한정 키우지 않도록 상한(최신 우선) $leak_events = []; // sensor_id => ['HH:MM', ...] — 최근 24h leak_alert 발생 시각 foreach ($db->query(" SELECT sensor_id, sensor_time FROM leak_sensor_log WHERE event_type = 'leak_alert' AND sensor_time >= NOW() - INTERVAL 24 HOUR ORDER BY sensor_time DESC LIMIT 200 ")->fetchAll() as $e) { $leak_events[(int)$e['sensor_id']][] = substr((string)$e['sensor_time'], 11, 5); } foreach ($leak_events as &$ev_list) { $ev_list = array_reverse($ev_list); } // 시간순 복원 unset($ev_list); $now = time(); $sensors = []; $offline_count = 0; foreach ($status_rows as $s) { $sid = (int)$s['sensor_id']; $elapsed = $now - strtotime($s['last_seen'] ?? '2000-01-01'); $offline = ((int)($s['offline_alerted'] ?? 0) === 1) || ($elapsed >= heartbeat_timeout_for($sid)); if ($offline) { $offline_count++; } // 누수 확정(디바운스) 기준 — 대시보드/SMS 와 동일. 컬럼 부재 구배포는 raw 폴백. $raw_leak = (int)($s['is_leak'] ?? 0); $leak = array_key_exists('leak_confirmed', $s) ? (int)$s['leak_confirmed'] : $raw_leak; $pending = array_key_exists('leak_confirmed', $s) && (($s['leak_pending_since'] ?? null) !== null) && ($raw_leak !== $leak); [$location, $name] = sensor_label($sid, $s['device_location'] ?? null, $s['sensor_name'] ?? null); // 최신 측정을 metric_type 별로 분리 — 온습도(sht30 행)와 미세먼지(pms 행)가 같은 // sensor_id 아래 서로를 덮어쓰지 않게 한다. temp/hum/th_status 는 sht30 행에서만, // pm25/pm10/pm1_0/pm_status 는 pms 행에서만 취한다. $sht = $latest_sht30[$sid] ?? null; $pms = $latest_pms[$sid] ?? null; // 온습도 대표 상태(결로 포함) — sht30 행의 저장 metric_status. $th_status = $sht['metric_status'] ?? 'normal'; // 미세먼지 상태 — pms 행의 저장 metric_status(정의상 pms 행은 temp/hum NULL 이라 // high_pm25/high_pm10/normal 만 나온다). 계약 보장을 위해 PM 값만 허용, 그 외는 normal. $pm_status_raw = $pms['metric_status'] ?? 'normal'; $pm_status = in_array($pm_status_raw, ['high_pm25', 'high_pm10'], true) ? $pm_status_raw : 'normal'; $sensors[] = [ 'id' => $sid, 'name' => $name, 'location' => $location, 'leak_node' => in_array($sid, LEAK_SENSOR_IDS, true), 'metric_node' => in_array($sid, METRIC_SENSOR_IDS, true), 'pm_node' => in_array($sid, PM_SENSOR_IDS, true), 'offline' => $offline, 'leak' => $leak, 'pending' => $pending, 'th_status' => $th_status, // 온습도(+결로) 상태(sht30 행) 'metric_status' => $th_status, // 하위호환 별칭(= th_status) 'pm_status' => $pm_status, // 미세먼지 상태(normal|high_pm25|high_pm10) 'temp' => isset($sht['temperature_c']) && $sht['temperature_c'] !== null ? (float)$sht['temperature_c'] : null, 'hum' => isset($sht['humidity_percent']) && $sht['humidity_percent'] !== null ? (float)$sht['humidity_percent'] : null, 'pm25' => isset($pms['pm2_5']) && $pms['pm2_5'] !== null ? (int)$pms['pm2_5'] : null, 'pm10' => isset($pms['pm10']) && $pms['pm10'] !== null ? (int)$pms['pm10'] : null, 'pm1_0' => isset($pms['pm1_0']) && $pms['pm1_0'] !== null ? (int)$pms['pm1_0'] : null, 'last_seen' => $s['last_seen'] ?? null, 'history' => $history[$sid] ?? [], // 24h 시간당 추세(온/습도) 'pm_history' => $pm_history[$sid] ?? [], // 24h 시간당 추세(PM2.5) 'leak_events' => $leak_events[$sid] ?? [], // 24h 누수 이벤트 시각 ]; } $unrecovered = 0; if (table_exists_api($db, 'leak_incident')) { $unrecovered = (int)$db->query("SELECT COUNT(*) FROM leak_incident WHERE recovered_at IS NULL")->fetchColumn(); } $today_alerts = (int)$db->query(" SELECT COUNT(*) FROM leak_sensor_metric WHERE metric_status <> 'normal' AND measured_at >= CURDATE() ")->fetchColumn() + (int)$db->query(" SELECT COUNT(*) FROM leak_sensor_log WHERE event_type = 'leak_alert' AND sensor_time >= CURDATE() ")->fetchColumn(); // 시스템 종합 상태 — 표시기 배경색 판정용. 5단 우선순위: 누수 > 미세먼지 > 온습도 > 오프라인 > 정상 // (leak > pm > alert(온습도 th) > offline > ok). 오프라인 센서의 경보는 신선하지 않으므로 제외. $leak_active = count(array_filter($sensors, fn($x) => $x['leak'] === 1 && !$x['offline'])); $pm_alert = count(array_filter($sensors, fn($x) => $x['pm_status'] !== 'normal' && !$x['offline'])); $th_alert = count(array_filter($sensors, fn($x) => $x['th_status'] !== 'normal' && !$x['offline'])); if ($leak_active > 0 || $unrecovered > 0) { $state = 'leak'; } elseif ($pm_alert > 0) { $state = 'pm'; } elseif ($th_alert > 0) { $state = 'alert'; } elseif ($offline_count > 0) { $state = 'offline'; } else { $state = 'ok'; } echo json_encode([ 'ts' => date('Y-m-d H:i:s'), 'system' => [ 'state' => $state, 'online' => count($sensors) - $offline_count, 'total' => count($sensors), 'unrecovered' => $unrecovered, 'sms_balance' => latest_sms_balance($db), 'today_alerts' => $today_alerts, ], 'sensors' => $sensors, ], JSON_UNESCAPED_UNICODE); } catch (Throwable $e) { // 내부 오류 상세(쿼리/테이블명 등)는 응답에 싣지 않는다 — 로그로만. error_log('[status_summary] ' . $e->getMessage()); http_response_code(500); echo json_encode(['error' => 'internal']); exit; } // 로컬 헬퍼 — dashboard_data.php(관리 화면 전용)를 끌어오지 않기 위한 최소 복제. function table_exists_api(PDO $db, string $table): bool { try { $st = $db->prepare(" SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = :t "); $st->execute([':t' => $table]); return (int)$st->fetchColumn() > 0; } catch (Throwable $e) { return false; } }