누수 감지↔복구 플래핑 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
192 lines
11 KiB
PHP
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>
|