POSA_LEAKSMS/php/api/status_summary.php
cw2007 6f8bf9413c feat(dashboard): v2 재테마 + status_summary API + 미세먼지 카드
참고 대시보드 미학(대형 숫자 KPI·플랫 카드)으로 재테마하고, 통합 센서(누수+온습도+미세먼지)를
유형 통합 그리드로 표시. PM2.5/PM10 카드·트렌드·이력을 추가.

- php/assets/dash-v2.css: 후행 캐스케이드 오버라이드(기존 파일 무수정 가역 방식)
- php/api/status_summary.php: 상태 요약 폴링 엔드포인트(try/catch·LIMIT·제네릭 에러 JSON)
- dashboard_data.php: PM 카드 데이터·결로/예보 메트릭 정합
- tabs/live·trend·history: 미세먼지 표시·추세·이력 반영
2026-07-19 10:47:27 +09:00

261 lines
13 KiB
PHP

<?php
// =============================================================================
// api/status_summary.php — 미니 상태 표시기(Pi Zero 2 W + ST7789V)용 읽기 전용 요약 JSON
//
// 인증: X-Status-Key 헤더 = hash_hmac('sha256', 'status', API_KEY)
// · cron_heartbeat.php 의 CRON_SECRET 과 동일한 파생 방식(별도 시크릿 배포 불필요,
// 표시기는 API_KEY 로 로컬 계산). 헤더 방식이라 access log 에 키가 남지 않는다.
// · 기본 API_KEY 미교체 상태면 파생키도 예측 가능 → 접근 자체를 차단(fail closed).
// 쓰기 없음(SELECT 전용) · 세션/쿠키 없음 · GET 전용.
//
// 응답(요약): { ts, system:{state,online,total,unrecovered,sms_balance,today_alerts},
// sensors:[{id,name,location,leak_node,metric_node,pm_node,offline,leak,pending,
// th_status,metric_status(=th_status 하위호환 별칭),pm_status,
// temp,hum,pm25,pm10,pm1_0,last_seen,history,pm_history,leak_events}] }
// · state 어휘(우선순위): leak > 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;
}
}