$card):
$is_metric = $card['metric'];
$is_leak_node = $card['leak'];
$s = $card['s'];
// 보고 주기 표기 — 누수 노드(결합)는 10분, 온습도 단독은 1시간
// (config.php heartbeat 주석 기준: raspberry_pi4 REPORT_INTERVAL=600 / sht30=3600)
$cycle_label = $is_leak_node ? '10분 주기' : '1시간 주기';
// 미세먼지 노드 여부(PM_SENSOR_IDS) — 온습도·누수와 독립적으로 카드에 PM 계통 표시.
$is_pm_node = in_array((int)$sid, array_map('intval', PM_SENSOR_IDS), true);
$type_labels = [];
if ($is_leak_node) { $type_labels[] = ['leak', '누수']; }
if ($is_pm_node) { $type_labels[] = ['pm', '미세먼지']; }
if ($is_metric) { $type_labels[] = ['metric', '온습도']; }
// ── 미수신 placeholder ──
if ($s === null): ?>
= (int)$sid ?>번 센서
= $t_txt ?>
데이터 없음
아직 수신된 데이터가 없습니다.
누수 확정 > 확인중(디바운스) > 미세먼지 경보 > 온습도 경보 > 정상 ──
$metric_code = $s['metric_status'] ?? 'normal';
$pm_code = $s['pm_status'] ?? 'normal';
$has_m_alert = $is_metric && !empty($s['is_alerting']);
$has_pm_alert = $is_pm_node && !empty($s['is_pm_alerting']);
$is_leaking = $is_leak_node && (int)($s['leak_confirmed'] ?? $s['is_leak'] ?? 0) === 1;
$is_pending = $is_leak_node && !empty($s['leak_pending']);
if ($s['is_offline']) {
$card_class = 'offline'; $line_class = 'off';
$line_text = '오프라인';
if ($is_leaking) { $line_text .= ' (누수 중 끊김)'; }
elseif ($has_pm_alert) { $line_text .= ' (' . metric_status_label($pm_code) . ' 중 끊김)'; }
elseif ($has_m_alert) { $line_text .= ' (' . metric_status_label($metric_code) . ' 중 끊김)'; }
} elseif ($is_leaking) {
$card_class = 'leak'; $line_class = 'leakst';
$line_text = '누수 감지';
} elseif ($is_pending) {
$card_class = 'pending'; $line_class = 'pend';
$line_text = '확인중(디바운스)';
} elseif ($has_pm_alert) {
$card_class = 'pm'; $line_class = 'pmst';
$line_text = metric_status_label($pm_code) . ' 경보';
} elseif ($has_m_alert) {
$card_class = 'alert'; $line_class = 'err';
$line_text = metric_status_label($metric_code) . ' 경보';
} else {
$card_class = ''; $line_class = 'ok';
$line_text = $is_leak_node ? '정상 (건조)' : '정상';
}
?>
= htmlspecialchars($s['sensor_name']) ?>
= $t_txt ?>
= htmlspecialchars($s['device_location']) ?> / = htmlspecialchars($s['device_id']) ?>
= htmlspecialchars($line_text) ?>
경과 = (int)$lm['elapsed_min'] ?>분
SMS = (int)$lm['sms_count'] ?>건
감지 확정 — 현장 확인 필요
= htmlspecialchars(metric_display($metric['temperature_c'], '')) ?>°C
온도
= htmlspecialchars(metric_display($metric['humidity_percent'], '')) ?>%
습도
= 2):
$sp_series = [];
foreach (['temp' => 'sp-t', 'hum' => 'sp-h'] as $sp_key => $sp_cls) {
$sp_vals = array_map(fn($p) => $p[$sp_key], $sp);
$sp_nums = array_filter($sp_vals, fn($v) => $v !== null);
if (count($sp_nums) < 2) { continue; }
$sp_min = min($sp_nums); $sp_max = max($sp_nums);
$sp_rng = ($sp_max - $sp_min) ?: 1;
$sp_pts = [];
foreach ($sp_vals as $sp_i => $sp_v) {
if ($sp_v === null) { continue; }
$sp_x = $sp_n > 1 ? $sp_i * (300 / ($sp_n - 1)) : 150;
$sp_y = 3 + (1 - ($sp_v - $sp_min) / $sp_rng) * 28;
$sp_pts[] = sprintf('%.1f,%.1f', $sp_x, $sp_y);
}
if (count($sp_pts) >= 2) { $sp_series[$sp_cls] = implode(' ', $sp_pts); }
}
if (!empty($sp_series)): ?>
측정값 없음
= $pmr['pm2_5'] !== null ? (int)$pmr['pm2_5'] : '-' ?>µg/m³
PM2.5
= $pmr['pm10'] !== null ? (int)$pmr['pm10'] : '-' ?>µg/m³
PM10
미세먼지 측정값 없음
near>safe) ──
if ($is_metric):
$fc = (!$s['is_offline'] && isset($forecasts[$sid])) ? $forecasts[$sid] : null;
if ($fc):
$chips = [];
foreach ($fc['headroom'] as $h) {
$chips[] = ['state' => $h['state'], 'text' => $h['label'] . ' 여유 ' . $h['margin'] . $h['unit']];
}
foreach (['temp', 'hum'] as $vk) {
$e = $fc['trend'][$vk]['eta'] ?? null;
if ($e) { $chips[] = ['state' => 'near', 'text' => '↗ ~' . (int)$e['min'] . '분 뒤 ' . metric_status_label($e['key'])]; }
}
foreach ($fc['rate'] as $r) {
$chips[] = ['state' => 'near', 'text' => ($r['spike'] === 'rise' ? '급상승 ' : '급락 ') . ($r['per10'] > 0 ? '+' : '') . $r['per10'] . '/10분'];
}
if (!empty($fc['dew']['risk'])) {
$chips[] = ['state' => 'near', 'text' => '결로주의 (마진 ' . $fc['dew']['margin'] . '℃)'];
}
$chip_rank = ['over' => 0, 'near' => 1, 'safe' => 2];
usort($chips, fn($a, $b) => ($chip_rank[$a['state']] ?? 3) <=> ($chip_rank[$b['state']] ?? 3));
?>
= htmlspecialchars($c['text']) ?>
예보: 임계 여유 충분
아직 수신된 센서 데이터가 없습니다.