참고 대시보드 미학(대형 숫자 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: 미세먼지 표시·추세·이력 반영
261 lines
13 KiB
PHP
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;
|
|
}
|
|
}
|