POSA_LEAKSMS/php/tabs/live.php
cw2007 2a469dc3cc feat(alert): 누수 감지/복구 디바운스 · 온습도 1시간 주기 · 센서별 수신자 그룹
누수 감지↔복구 플래핑 SMS 폭탄 방지, 온습도 측정 주기 1시간화(부수 오탐
차단), 센서별 SMS 수신자 그룹 지정 3종을 함께 반영한다.

1) 누수 디바운스 (서버 전용, 펌웨어 무변경)
   - leak_sensor_status 에 leak_confirmed / leak_pending_since 컬럼 추가.
   - leak_debounce_decide(): 단일 대칭 타이머 순수 함수. raw 상태가 확인창
     동안 지속돼야 확정하고 확정 엣지에서만 SMS 1회. dry 블립<복구창이면
     복구 미확정 → 확정 유지 → 재감지·복구 SMS 둘 다 차단(폭탄 원천 봉쇄).
   - apply_leak_debounce()/sweep_pending_leak_transitions(): 센서 네임드 락으로
     직렬화, cron sweep 으로 보고 끊겨도 확정 해소. 사고(leak_incident)
     open/close 도 확정 엣지로 이동(월간보고 사고건수 정합). raw 이벤트는
     leak_sensor_log 에 그대로 보존(플래핑 진단).
   - 컬럼 없는 구배포는 레거시 즉시 발송으로 graceful degrade.
   - 확인창 기본: 감지 60s / 복구 180s (config.local.php 조정).

2) 온습도 1시간 주기 + 센서별 히트비트 타임아웃
   - raspberry_pi4_sht30/config.py REPORT_INTERVAL 300→3600.
   - heartbeat_timeout_for(): 명시 오버라이드 > 온습도 기본(7500s) > 전역(1200s).
     cron_heartbeat / dashboard 오프라인 판정을 센서별로. 매시간 오탐 제거,
     누수 오프라인은 20분 유지.
   - STUCK_DETECT_SAMPLES 6→3(1시간 주기 ≈3시간), 관련 주석 정정.

3) 센서별 수신자 그룹
   - SENSOR_RECIPIENTS 맵 + recipients_for_sensor() 전역 폴백.
   - sms_dispatch_to_recipients 가 센서별로 수신자 해소(에스컬레이션 TIER2
     명시 전달은 유지, 헬스/잔액은 전역).

대시보드: 누수 카드 '확인중(디바운스)' 배지, 시스템 탭에 확인창·센서별
타임아웃·수신자 그룹 표시.

마이그레이션: sql/migration_leak_debounce.sql (컬럼 2개), schema_sht30.sql 갱신.
테스트: leak_debounce(12) / recipients_group / heartbeat_timeout 순수 단위 추가.
설계: docs/superpowers/specs/2026-07-13-leak-debounce-sensor-config-design.md
2026-07-13 20:57:17 +09:00

192 lines
11 KiB
PHP

<?php
// tabs/live.php — [실시간 현황] 탭. dashboard.php 셸에서 include 된다.
// 사용 변수: load_global_data() 결과(extract) — $metric_statuses, $leak_statuses,
// $unrecovered_incidents, $forecasts, $sms_* , $today_* 등.
if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
// 온습도·누수 센서를 하나의 그리드로 통합한다(센서 수가 적어도 한 행을 균등하게
// 채우도록 — 섹션마다 카드가 1개씩 흩어지는 것 방지). sensor_id 순서, 유형은 칩으로 구분.
$sensor_cards = [];
foreach ($metric_statuses as $sid => $s) { $sensor_cards[(int)$sid] = ['type' => 'metric', 's' => $s]; }
foreach ($leak_statuses as $sid => $s) {
if (!isset($sensor_cards[(int)$sid])) { $sensor_cards[(int)$sid] = ['type' => 'leak', 's' => $s]; }
}
ksort($sensor_cards);
?>
<div class="zone" id="tab-live">
<div class="zone-label"><b>실시간 상태</b> <span class="en">Live</span></div>
<h2 id="sensors-section">센서 현황 <span class="h2-sub">· <?= count($sensor_cards) ?>개소</span></h2>
<div class="sensor-grid" id="leak-section">
<?php foreach ($sensor_cards as $sid => $card):
$type = $card['type'];
$s = $card['s'];
$type_label = $type === 'metric' ? '온습도' : '누수';
// 미수신 placeholder
if ($s === null): ?>
<div class="sensor-card">
<div class="sc-head">
<strong><?= (int)$sid ?>번 센서</strong>
<span class="sc-type <?= $type ?>"><?= $type_label ?></span>
</div>
<div class="badge-row"><span class="badge offline">데이터 없음</span></div>
<div class="metric-time">아직 수신된 <?= $type_label ?> 데이터가 없습니다.</div>
</div>
<?php continue; endif;
// 상태 우선순위: 오프라인 > (온습도 경보 | 누수) > 정상
if ($type === 'metric') {
$status_code = $s['metric_status'] ?? 'normal';
if ($s['is_offline']) {
$card_class = 'offline'; $badge_class = 'offline';
$badge_text = $s['is_alerting'] ? ('오프라인 (' . metric_status_label($status_code) . ' 중 끊김)') : '오프라인';
} elseif ($s['is_alerting']) {
$card_class = 'alert'; $badge_class = 'alert';
$badge_text = metric_status_label($status_code) . ' 경보!';
} else {
$card_class = ''; $badge_class = 'ok'; $badge_text = '정상';
}
} else {
// 디바운스 확정 상태 기준(SMS 와 일치). leak_pending: 확인창 대기 중(블립 평가).
$is_leaking = (int)($s['leak_confirmed'] ?? $s['is_leak'] ?? 0) === 1;
$is_pending = !empty($s['leak_pending']);
if ($s['is_offline']) {
$card_class = 'offline'; $badge_class = 'offline';
$badge_text = $is_leaking ? '오프라인 (누수 중 끊김)' : '오프라인';
} elseif ($is_leaking) {
$card_class = 'leak'; $badge_class = 'leak'; $badge_text = '누수';
} elseif ($is_pending) {
// raw 감지됐으나 확인창 미충족 → 확정 전(SMS 아직 안 나감)임을 명시
$card_class = 'pending'; $badge_class = 'pending'; $badge_text = '확인중(디바운스)';
} else {
$card_class = ''; $badge_class = 'ok'; $badge_text = '정상';
}
}
?>
<div class="sensor-card <?= $type === 'leak' ? 'leak-status-card ' : '' ?><?= $card_class ?>">
<div class="sc-head">
<strong><?= htmlspecialchars($s['sensor_name']) ?></strong>
<span class="sc-type <?= $type ?>"><?= $type_label ?></span>
</div>
<small><?= htmlspecialchars($s['device_location']) ?> / <?= htmlspecialchars($s['device_id']) ?></small>
<div class="badge-row">
<span class="badge <?= $badge_class ?>"><?= htmlspecialchars($badge_text) ?></span>
</div>
<?php if ($type === 'metric'):
$status_code = $s['metric_status'] ?? 'normal';
$temp_chip_class = ''; $hum_chip_class = '';
if ($status_code === 'high_temp') { $temp_chip_class = 'hot'; }
elseif ($status_code === 'low_temp') { $temp_chip_class = 'cold'; }
elseif ($status_code === 'high_humidity') { $hum_chip_class = 'humid'; }
elseif ($status_code === 'low_humidity') { $hum_chip_class = 'dry'; }
if (!empty($s['latest_metric'])):
$metric = $s['latest_metric']; ?>
<div class="metric-line">
<span class="metric-chip <?= $temp_chip_class ?>">온도 <?= htmlspecialchars(metric_display($metric['temperature_c'], '°C')) ?></span>
<span class="metric-chip <?= $hum_chip_class ?>">습도 <?= htmlspecialchars(metric_display($metric['humidity_percent'], '%')) ?></span>
</div>
<div class="metric-time">측정: <?= htmlspecialchars($metric['measured_at']) ?></div>
<?php else: ?>
<div class="metric-time">측정값 없음</div>
<?php endif; ?>
<?php endif; ?>
<div class="metric-time<?= $s['is_offline'] ? ' stale' : '' ?>">
최종확인: <?= htmlspecialchars($s['last_seen']) ?><?php if ($s['is_offline']): ?> (<?= $s['offline_elapsed_min'] ?>분 전)<?php endif; ?>
</div>
<?php
// 온습도 카드 예보줄: 온라인 + 예보 있으면 임계 여유 상시 표시(over>near>safe)
if ($type === '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));
?>
<div class="forecast">
<?php if ($chips): foreach ($chips as $c): ?>
<span class="forecast-chip <?= htmlspecialchars($c['state']) ?>"><?= htmlspecialchars($c['text']) ?></span>
<?php endforeach; else: ?>
<span class="forecast-note">예보: 임계 여유 충분</span>
<?php endif; ?>
</div>
<?php endif; endif; ?>
</div>
<?php endforeach; ?>
<?php if (empty($sensor_cards)): ?>
<p class="empty-note">아직 수신된 센서 데이터가 없습니다.</p>
<?php endif; ?>
</div>
<?php if (!empty($unrecovered_incidents)): ?>
<!-- 미복구 누수 사고 (전체 이력·사진은 '이력 조회' 탭) -->
<h2>미복구 누수 사고 <span class="h2-sub">· <?= count($unrecovered_incidents) ?>건</span></h2>
<div class="table-wrap">
<table>
<thead>
<tr><th class="col-hide-xs">#</th><th>센서</th><th>위치</th><th>감지 시각</th><th>상태</th></tr>
</thead>
<tbody>
<?php foreach ($unrecovered_incidents as $inc): ?>
<tr class="alert-row">
<td class="col-hide-xs"><?= (int)$inc['id'] ?></td>
<td><?= htmlspecialchars($inc['sensor_name']) ?></td>
<td><?= htmlspecialchars($inc['device_location']) ?></td>
<td><?= htmlspecialchars($inc['started_at']) ?></td>
<td><span class="pill leak">미복구</span></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<p class="panel-note">사진·복구 시각 등 전체 사고 이력은 <a href="<?= htmlspecialchars(dash_url(['tab' => 'history'])) ?>">이력 조회 탭</a>에서 확인하세요.</p>
<?php endif; ?>
<!-- 채널·오늘 요약 -->
<h2>채널·오늘 요약</h2>
<div class="mini-panels">
<section class="panel mini-panel" aria-label="SMS 채널 상태">
<h3>SMS 채널 상태</h3>
<dl class="mini-rows">
<div><dt>잔여 건수</dt>
<dd class="<?= ($sms_balance !== null && $sms_balance <= SMS_BALANCE_WARN_THRESHOLD) ? 'err-color' : '' ?>">
<?= $sms_balance !== null ? (int)$sms_balance . '건' : '확인 전' ?>
</dd></div>
<div><dt>마지막 성공 발송</dt>
<dd><?= $sms_last_success_at !== null ? htmlspecialchars($sms_last_success_at) : '기록 없음' ?></dd></div>
<div><dt>오늘 발송 / 실패</dt>
<dd><?= (int)$sms_today_total ?>건 / <span class="<?= $sms_today_fail > 0 ? 'err-color' : '' ?>"><?= (int)$sms_today_fail ?>건</span></dd></div>
</dl>
<p class="panel-note">SMS는 유일한 경보 채널입니다. 잔여 건수가 <?= (int)SMS_BALANCE_WARN_THRESHOLD ?>건 이하면 충전하세요.</p>
</section>
<section class="panel mini-panel" aria-label="오늘 경보 요약">
<h3>오늘 경보 요약</h3>
<dl class="mini-rows">
<div><dt>오늘 경보 횟수</dt>
<dd class="<?= $today_alert_count > 0 ? 'warn-color' : 'ok-color' ?>"><?= (int)$today_alert_count ?>건</dd></div>
<div><dt>오늘 최근 경보</dt>
<dd><?= $today_last_alert_at !== null ? htmlspecialchars($today_last_alert_at) : '오늘 없음' ?></dd></div>
<div><dt>가동률</dt>
<dd><?= (int)$uptime_pct ?>% (<?= (int)$online_count ?>/<?= (int)$sensor_total ?> 온라인)</dd></div>
</dl>
<p class="panel-note">7일·30일 추이는 <a href="<?= htmlspecialchars(dash_url(['tab' => 'trend'])) ?>">추이·통계 탭</a>에서 확인하세요.</p>
</section>
</div>
</div>