feat: sensor_id 3 신규 누수 지점 노드 지원

3번째 Pi(신규 지점, 누수+카메라)를 코드 수정 없이 배포 가능하게:
- raspberry_pi4/config.py: config_local.py 에서 SENSORS 오버라이드 허용
  + fail-closed 형식 검증(id 1..255/gpio/name, 중복 금지) — 오설정으로
  기본 sensor_id 1 로 조용히 기동해 타 지점 상태를 덮어쓰는 사고 방지.
- REPORT_INTERVAL 3600→600: 서버 HEARTBEAT_TIMEOUT_SEC(1200) 절반 —
  기존 하트비트 오탐(매시간 오프라인/복귀 SMS) 해소.
- php/config.php LEAK_SENSOR_IDS 상수(기본 [1], 1..255 정규화·빈값 복귀).
- php/dashboard.php: 누수 카드 sensor_id===1 하드코딩 → LEAK_SENSOR_IDS 루프.
- php/api/sensor_data.php: periodic 보고 전면 skip_log(누수 노드 증가 시
  이벤트 로그 창 보존) + CORS 헤더 제거(서버간 POST 전용, 도메인 지문 노출 차단).
- test_connection.py(양 모듈): 하드코딩 sensor_id 제거→기기 SENSORS 기반,
  서명 200 실패 시 exit 1(setup.sh 원샷 게이트용).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821XP36Q6t9eWKTPSEumnC
This commit is contained in:
cw2007 2026-07-02 18:51:18 +09:00
parent 9fef7031fc
commit 12b0c74806
8 changed files with 164 additions and 77 deletions

View file

@ -45,10 +45,8 @@
// {"status":"ok","message":"저장 완료", ...} // {"status":"ok","message":"저장 완료", ...}
// ============================================================================= // =============================================================================
// CORS 허용 (기기 → 서버 POST 전용, 브라우저 접근 불필요) // CORS 헤더 없음 — 기기(서버측 cURL/requests) → 서버 POST 전용이라 브라우저
header('Access-Control-Allow-Origin: https://pores.kr'); // same-origin 정책 대상이 아니며, Allow-Origin 헤더는 도메인 지문만 노출한다.
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-Device-Id, X-Signature');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204); http_response_code(204);
@ -109,7 +107,7 @@ function ingest_nonce_claim(PDO $db, string $sig): string {
return 'unavailable'; return 'unavailable';
} }
try { try {
$ins = $db->prepare("INSERT INTO ingest_nonce (signature, seen_at) VALUES (:sig, NOW())"); $ins = $db->prepare("INSERT INTO leak_ingest_nonce (signature, seen_at) VALUES (:sig, NOW())");
$ins->execute([':sig' => substr($sig, 0, 128)]); $ins->execute([':sig' => substr($sig, 0, 128)]);
} catch (PDOException $e) { } catch (PDOException $e) {
$code = $e->getCode(); $code = $e->getCode();
@ -124,7 +122,7 @@ function ingest_nonce_claim(PDO $db, string $sig): string {
// 선점 성공 후, 신선도 창의 2배보다 오래된 nonce 정리(베스트 에포트, 인덱스로 가벼움). // 선점 성공 후, 신선도 창의 2배보다 오래된 nonce 정리(베스트 에포트, 인덱스로 가벼움).
try { try {
$ttl = max(60, INGEST_MAX_CLOCK_SKEW_SEC * 2); $ttl = max(60, INGEST_MAX_CLOCK_SKEW_SEC * 2);
$db->exec("DELETE FROM ingest_nonce WHERE seen_at < (NOW() - INTERVAL " . (int)$ttl . " SECOND)"); $db->exec("DELETE FROM leak_ingest_nonce WHERE seen_at < (NOW() - INTERVAL " . (int)$ttl . " SECOND)");
} catch (Throwable $e) { } catch (Throwable $e) {
/* 정리는 실패해도 무방 */ /* 정리는 실패해도 무방 */
} }
@ -138,7 +136,7 @@ function ingest_nonce_release(PDO $db, string $sig): void {
return; return;
} }
try { try {
$db->prepare("DELETE FROM ingest_nonce WHERE signature = :sig") $db->prepare("DELETE FROM leak_ingest_nonce WHERE signature = :sig")
->execute([':sig' => substr($sig, 0, 128)]); ->execute([':sig' => substr($sig, 0, 128)]);
} catch (Throwable $e) { } catch (Throwable $e) {
error_log("[API] nonce release 오류: " . $e->getMessage()); error_log("[API] nonce release 오류: " . $e->getMessage());
@ -246,7 +244,7 @@ $has_metric_payload = ($metric_type !== '') && ($temperature_c !== null || $humi
// ── 서버측 임계 판정 (config.php METRIC_*) ───────────────── // ── 서버측 임계 판정 (config.php METRIC_*) ─────────────────
// 펌웨어 metric_status 는 물리 plausibility 판정이라 운영 경보로는 신뢰하지 않고, // 펌웨어 metric_status 는 물리 plausibility 판정이라 운영 경보로는 신뢰하지 않고,
// 서버가 운영 임계로 다시 판정하여 sensor_metric.metric_status 에 저장한다. // 서버가 운영 임계로 다시 판정하여 leak_sensor_metric.metric_status 에 저장한다.
$metric_eval = $has_metric_payload $metric_eval = $has_metric_payload
? evaluate_metric_thresholds($temperature_c, $humidity_percent) ? evaluate_metric_thresholds($temperature_c, $humidity_percent)
: ['breaches' => [], 'status' => 'normal']; : ['breaches' => [], 'status' => 'normal'];
@ -255,11 +253,12 @@ $metric_status = $metric_eval['status'];
$metric_saved = false; $metric_saved = false;
// ── DB 저장 ─────────────────────────────────────────────── // ── DB 저장 ───────────────────────────────────────────────
// 주기(periodic) 온습도 측정 보고는 sensor_log 에 남기지 않고 sensor_metric/상태만 갱신 // 주기(periodic) 보고는 순수 하트비트 — leak_sensor_log 에 남기지 않고 상태 upsert
// (대시보드 로그 정리). startup 은 1회 sensor_log 기록. // (+온습도면 leak_sensor_metric)만 갱신한다. 누수 노드가 10분 주기로 늘어나면
// 누수 이벤트(leak_alert/leak_recovery)는 항상 sensor_log 에 기록한다 // periodic 행이 이벤트 로그 창을 밀어내므로(지점 2개 = 일 288행) 전부 스킵.
// startup 은 1회 기록. 누수 이벤트(leak_alert/leak_recovery)는 항상 기록한다
// (leak_alert 는 log_id 가 사진 업로드에 필요하고, 누수 이력 추적 대상). // (leak_alert 는 log_id 가 사진 업로드에 필요하고, 누수 이력 추적 대상).
$skip_log = ($event_type === 'periodic' && $has_metric_payload); $skip_log = ($event_type === 'periodic');
$log_id = null; $log_id = null;
try { try {
@ -280,7 +279,7 @@ try {
if (!$skip_log) { if (!$skip_log) {
$stmt = $db->prepare(" $stmt = $db->prepare("
INSERT INTO sensor_log INSERT INTO leak_sensor_log
(device_id, device_location, sensor_id, sensor_name, (device_id, device_location, sensor_id, sensor_name,
event_type, is_leak, sensor_time, created_at) event_type, is_leak, sensor_time, created_at)
VALUES VALUES
@ -302,7 +301,7 @@ try {
// ── 오프라인 복구 감지: UPSERT 전에 offline_alerted 확인 ── // ── 오프라인 복구 감지: UPSERT 전에 offline_alerted 확인 ──
$was_offline = false; $was_offline = false;
$chk = $db->prepare("SELECT offline_alerted FROM sensor_status WHERE sensor_id = :sensor_id"); $chk = $db->prepare("SELECT offline_alerted FROM leak_sensor_status WHERE sensor_id = :sensor_id");
$chk->execute([':sensor_id' => $sensor_id]); $chk->execute([':sensor_id' => $sensor_id]);
$offline_flag = $chk->fetchColumn(); $offline_flag = $chk->fetchColumn();
if ($offline_flag === '1' || $offline_flag === 1) { if ($offline_flag === '1' || $offline_flag === 1) {
@ -314,7 +313,7 @@ try {
// (startup/periodic)는 누수 상태를 건드리지 않도록 기존 값을 보존한다(통합 서버에서 // (startup/periodic)는 누수 상태를 건드리지 않도록 기존 값을 보존한다(통합 서버에서
// 2번 모듈 보고가 1번 모듈 누수 상태를 덮어쓰지 않게). // 2번 모듈 보고가 1번 모듈 누수 상태를 덮어쓰지 않게).
$stmt2 = $db->prepare(" $stmt2 = $db->prepare("
INSERT INTO sensor_status INSERT INTO leak_sensor_status
(sensor_id, device_id, sensor_name, device_location, (sensor_id, device_id, sensor_name, device_location,
last_event_type, is_leak, last_seen, offline_alerted) last_event_type, is_leak, last_seen, offline_alerted)
VALUES VALUES
@ -339,9 +338,9 @@ try {
':is_leak_event' => $is_leak_event ? 1 : 0, ':is_leak_event' => $is_leak_event ? 1 : 0,
]); ]);
if ($has_metric_payload && api_table_exists($db, 'sensor_metric')) { if ($has_metric_payload && api_table_exists($db, 'leak_sensor_metric')) {
$metric_stmt = $db->prepare(" $metric_stmt = $db->prepare("
INSERT INTO sensor_metric INSERT INTO leak_sensor_metric
(device_id, device_location, sensor_id, sensor_name, (device_id, device_location, sensor_id, sensor_name,
metric_type, temperature_c, humidity_percent, metric_status, metric_type, temperature_c, humidity_percent, metric_status,
measured_at, created_at) measured_at, created_at)

View file

@ -41,6 +41,10 @@ return [
// Temporary bootstrap token for setup_mfa.php. Remove or empty after MFA enrollment. // Temporary bootstrap token for setup_mfa.php. Remove or empty after MFA enrollment.
'MFA_SETUP_TOKEN' => 'replace-with-temporary-random-token', 'MFA_SETUP_TOKEN' => 'replace-with-temporary-random-token',
// 누수 모듈 sensor_id 목록 — 대시보드 누수 상태 카드 표시 대상 (기본 [1]).
// 신규 누수 지점(예: Pi-C, sensor_id 3) 투입 시: [1, 3]
'LEAK_SENSOR_IDS' => [1],
// 온습도 임계 경보 (서버 판정 → SMS). 운영 환경에 맞게 조정. // 온습도 임계 경보 (서버 판정 → SMS). 운영 환경에 맞게 조정.
'METRIC_TEMP_HIGH_C' => 27, // 고온 임계(℃) 'METRIC_TEMP_HIGH_C' => 27, // 고온 임계(℃)
'METRIC_TEMP_LOW_C' => 10, // 저온 임계(℃) 'METRIC_TEMP_LOW_C' => 10, // 저온 임계(℃)

View file

@ -51,7 +51,7 @@ define('INGEST_MAX_BODY_BYTES', (int)cfg('INGEST_MAX_BODY_BYTES', 8192));
// 재전송(replay) 멱등 방어. 서명(X-Signature)을 nonce 로 기록해, 시계오차 창 // 재전송(replay) 멱등 방어. 서명(X-Signature)을 nonce 로 기록해, 시계오차 창
// (INGEST_MAX_CLOCK_SKEW_SEC) 안에서 동일 서명 본문이 재수신되면 멱등 처리(재저장/ // (INGEST_MAX_CLOCK_SKEW_SEC) 안에서 동일 서명 본문이 재수신되면 멱등 처리(재저장/
// 재발송 생략)한다. 펌웨어 재시도(같은 본문)·캡처 재생 모두 한 번만 반영된다. // 재발송 생략)한다. 펌웨어 재시도(같은 본문)·캡처 재생 모두 한 번만 반영된다.
// ingest_nonce 테이블이 없는 기존 배포에서는 자동으로 비활성(graceful degrade). // leak_ingest_nonce 테이블이 없는 기존 배포에서는 자동으로 비활성(graceful degrade).
define('INGEST_NONCE_ENABLED', (bool)cfg('INGEST_NONCE_ENABLED', true)); define('INGEST_NONCE_ENABLED', (bool)cfg('INGEST_NONCE_ENABLED', true));
// 레거시 본문 필드 서명(verify_signature: 키-접두 sha256) 수용 여부. 기본 비활성. // 레거시 본문 필드 서명(verify_signature: 키-접두 sha256) 수용 여부. 기본 비활성.
@ -78,7 +78,7 @@ define('SMS_RECIPIENTS', $sms_recipients);
define('SMS_MAX_EUCKR_BYTES', (int)cfg('SMS_MAX_EUCKR_BYTES', 90)); define('SMS_MAX_EUCKR_BYTES', (int)cfg('SMS_MAX_EUCKR_BYTES', 90));
// ─── SMS 메시지 태그 (경보 상태머신의 단일 출처) ────────────────────────────── // ─── SMS 메시지 태그 (경보 상태머신의 단일 출처) ──────────────────────────────
// sms_log.message 의 접두 태그로 쿨다운/복구/대시보드/월간보고서가 경보 상태를 // leak_sms_log.message 의 접두 태그로 쿨다운/복구/대시보드/월간보고서가 경보 상태를
// 재구성한다. 별도 상태 테이블을 두지 않으므로 이 태그 문자열이 사실상의 상태 키다. // 재구성한다. 별도 상태 테이블을 두지 않으므로 이 태그 문자열이 사실상의 상태 키다.
// 여러 파일(sms_send/dashboard/monthly_report)의 LIKE 문자열이 서로 어긋나 무성 // 여러 파일(sms_send/dashboard/monthly_report)의 LIKE 문자열이 서로 어긋나 무성
// 장애가 나는 것을 막기 위해, 태그는 반드시 여기서만 정의하고 상수로 참조한다. // 장애가 나는 것을 막기 위해, 태그는 반드시 여기서만 정의하고 상수로 참조한다.
@ -107,7 +107,7 @@ const SMS_METRIC_TAGS = [
]; ];
/** /**
* sms_log 태그별 건수 집계 SELECT 절(단일 출처). 대시보드/월간보고서가 공유한다. * leak_sms_log 태그별 건수 집계 SELECT 절(단일 출처). 대시보드/월간보고서가 공유한다.
* 태그 문자열은 위 SMS_TAG_* 상수에서만 가져오므로, 태그를 바꿔도 모든 집계가 한 * 태그 문자열은 위 SMS_TAG_* 상수에서만 가져오므로, 태그를 바꿔도 모든 집계가 한
* 곳에서 따라온다(여러 파일의 LIKE 문자열이 어긋나 무성 장애 나는 것 방지). * 곳에서 따라온다(여러 파일의 LIKE 문자열이 어긋나 무성 장애 나는 것 방지).
* 반환 컬럼: high_temp, low_temp, high_humidity, low_humidity, recovery, * 반환 컬럼: high_temp, low_temp, high_humidity, low_humidity, recovery,
@ -192,12 +192,30 @@ define('STUCK_ALERT_COOLDOWN_SEC', (int)cfg('STUCK_ALERT_COOLDOWN_SEC', 21600));
define('SMS_FORCE_FAIL', (bool)cfg('SMS_FORCE_FAIL', false)); define('SMS_FORCE_FAIL', (bool)cfg('SMS_FORCE_FAIL', false));
// ─── Heartbeat (장비 오프라인 감지) ──────────────────────── // ─── Heartbeat (장비 오프라인 감지) ────────────────────────
// SHT30 측정 보고 주기는 5분(펌웨어 APP_SHT30_REPORT_INTERVAL_SEC=300). 기본 // SHT30 측정 보고 주기는 5분(펌웨어 APP_SHT30_REPORT_INTERVAL_SEC=300), 누수 모듈
// 오프라인 판정은 약 4회 누락(20분)으로 둔다. config.local.php 로 조정 가능. // 정상 보고 주기는 10분(raspberry_pi4/config.py REPORT_INTERVAL=600). 기본 오프라인
// 판정 20분은 SHT30 4회 / 누수 2회 누락에 해당한다. config.local.php 로 조정 가능.
// ⚠️ 이 타임아웃은 전 센서 공통 — 어떤 모듈의 보고 주기보다도 길어야 오탐이 없다.
define('HEARTBEAT_TIMEOUT_SEC', (int)cfg('HEARTBEAT_TIMEOUT_SEC', 1200)); define('HEARTBEAT_TIMEOUT_SEC', (int)cfg('HEARTBEAT_TIMEOUT_SEC', 1200));
define('HEARTBEAT_CHECK_INTERVAL_SEC', (int)cfg('HEARTBEAT_CHECK_INTERVAL_SEC', 300)); // 오프라인 체크 실행 주기 define('HEARTBEAT_CHECK_INTERVAL_SEC', (int)cfg('HEARTBEAT_CHECK_INTERVAL_SEC', 300)); // 오프라인 체크 실행 주기
define('CRON_SECRET', hash_hmac('sha256', 'cron', API_KEY)); // cron HTTP 접근용 시크릿(API_KEY 파생, HMAC) define('CRON_SECRET', hash_hmac('sha256', 'cron', API_KEY)); // cron HTTP 접근용 시크릿(API_KEY 파생, HMAC)
// ─── 누수 모듈 sensor_id 목록 ────────────────────────────
// 대시보드 누수 상태 카드 표시 대상. 신규 누수 지점 추가 시 config.local.php 로
// 오버라이드한다. 예: 'LEAK_SENSOR_IDS' => [1, 3] (문자열 "1,3" 도 허용)
// 유효 범위는 1..255 정수(api/sensor_data.php 의 sensor_id 검증과 동일). 범위 밖·
// 비숫자 항목은 버리고, 결과가 비면 기본 [1] 로 되돌린다(오설정으로 누수 카드
// 전체가 사라지는 것 방지).
$leak_sensor_ids = cfg('LEAK_SENSOR_IDS', [1]);
if (is_string($leak_sensor_ids)) {
$leak_sensor_ids = explode(',', $leak_sensor_ids);
}
$leak_sensor_ids = array_values(array_filter(
array_map('intval', (array)$leak_sensor_ids),
static fn($id) => $id >= 1 && $id <= 255
));
define('LEAK_SENSOR_IDS', $leak_sensor_ids !== [] ? $leak_sensor_ids : [1]);
// ─── 대시보드 세션 인증 ────────────────────────────────── // ─── 대시보드 세션 인증 ──────────────────────────────────
define('ADMIN_USER', cfg('ADMIN_USER', 'admin')); define('ADMIN_USER', cfg('ADMIN_USER', 'admin'));
define('ADMIN_PASSWORD_HASH', cfg('ADMIN_PASSWORD_HASH', '')); // bcrypt 해시 define('ADMIN_PASSWORD_HASH', cfg('ADMIN_PASSWORD_HASH', '')); // bcrypt 해시
@ -244,7 +262,7 @@ function json_response(array $data, int $http_code = 200): void {
// ─── 개인정보(수신번호) 마스킹 ───────────────────────────── // ─── 개인정보(수신번호) 마스킹 ─────────────────────────────
// 화면/보고서에 수신번호를 노출할 때 가운데를 가린다(010-****-1234). 발송 기록 // 화면/보고서에 수신번호를 노출할 때 가운데를 가린다(010-****-1234). 발송 기록
// 자체(sms_log)는 보존하되 표시 단계에서만 최소화한다(개인정보 최소표시 원칙). // 자체(leak_sms_log)는 보존하되 표시 단계에서만 최소화한다(개인정보 최소표시 원칙).
// 쉼표로 여러 번호가 들어오면 각각 마스킹한다. // 쉼표로 여러 번호가 들어오면 각각 마스킹한다.
function mask_phone(string $value): string { function mask_phone(string $value): string {
if (strpos($value, ',') !== false) { if (strpos($value, ',') !== false) {
@ -266,7 +284,7 @@ function mask_phone(string $value): string {
function latest_sms_balance(PDO $db): ?int { function latest_sms_balance(PDO $db): ?int {
try { try {
$rm = $db->query(" $rm = $db->query("
SELECT result_message FROM sms_log SELECT result_message FROM leak_sms_log
WHERE status = 'success' AND result_message LIKE '%잔여%' WHERE status = 'success' AND result_message LIKE '%잔여%'
ORDER BY id DESC LIMIT 1 ORDER BY id DESC LIMIT 1
")->fetchColumn(); ")->fetchColumn();

View file

@ -6,7 +6,7 @@
// 온습도 임계 경보(고온/저온/고습/저습)와 장비 오프라인/복구를 모니터링한다. // 온습도 임계 경보(고온/저온/고습/저습)와 장비 오프라인/복구를 모니터링한다.
// 임계 판정과 SMS // 임계 판정과 SMS
// 발송은 서버(api/sensor_data.php + config.php METRIC_* 상수)에서 수행하고, // 발송은 서버(api/sensor_data.php + config.php METRIC_* 상수)에서 수행하고,
// 이 대시보드는 그 결과(sensor_metric/sensor_status/sms_log)를 표시한다. // 이 대시보드는 그 결과(leak_sensor_metric/leak_sensor_status/leak_sms_log)를 표시한다.
// ============================================================================= // =============================================================================
require_once __DIR__ . '/config.php'; require_once __DIR__ . '/config.php';
@ -84,16 +84,16 @@ function table_exists(PDO $db, string $table): bool {
} }
// ── 센서별 현재 상태 + 최신 측정값 ────────────────────────── // ── 센서별 현재 상태 + 최신 측정값 ──────────────────────────
$statuses = $db->query("SELECT * FROM sensor_status ORDER BY sensor_id")->fetchAll(); $statuses = $db->query("SELECT * FROM leak_sensor_status ORDER BY sensor_id")->fetchAll();
// 센서별 최신 sensor_metric (최대 id 기준) // 센서별 최신 leak_sensor_metric (최대 id 기준)
$latest_metrics = []; $latest_metrics = [];
$metric_rows = $db->query(" $metric_rows = $db->query("
SELECT sm.* SELECT sm.*
FROM sensor_metric sm FROM leak_sensor_metric sm
INNER JOIN ( INNER JOIN (
SELECT sensor_id, MAX(id) AS latest_id SELECT sensor_id, MAX(id) AS latest_id
FROM sensor_metric FROM leak_sensor_metric
GROUP BY sensor_id GROUP BY sensor_id
) latest ON latest.latest_id = sm.id ) latest ON latest.latest_id = sm.id
ORDER BY sm.sensor_id ORDER BY sm.sensor_id
@ -140,7 +140,7 @@ $chart_points = [];
if ($chart_sensor_id > 0) { if ($chart_sensor_id > 0) {
$chart_stmt = $db->prepare(" $chart_stmt = $db->prepare("
SELECT measured_at, temperature_c, humidity_percent SELECT measured_at, temperature_c, humidity_percent
FROM sensor_metric FROM leak_sensor_metric
WHERE sensor_id = :sid WHERE sensor_id = :sid
ORDER BY id DESC ORDER BY id DESC
LIMIT 100 LIMIT 100
@ -158,36 +158,36 @@ if ($chart_sensor_id > 0) {
} }
} }
// ── 최근 측정 이력 (sensor_metric 30건) ───────────────────── // ── 최근 측정 이력 (leak_sensor_metric 30건) ─────────────────────
$recent_metrics = $db->query(" $recent_metrics = $db->query("
SELECT sensor_id, sensor_name, device_location, SELECT sensor_id, sensor_name, device_location,
temperature_c, humidity_percent, metric_status, measured_at temperature_c, humidity_percent, metric_status, measured_at
FROM sensor_metric FROM leak_sensor_metric
ORDER BY id DESC ORDER BY id DESC
LIMIT 30 LIMIT 30
")->fetchAll(); ")->fetchAll();
// ── 최근 센서 이벤트 로그 (sensor_log: startup/periodic/leak_alert/leak_recovery) ── // ── 최근 센서 이벤트 로그 (leak_sensor_log: startup/periodic/leak_alert/leak_recovery) ──
$logs = $db->query(" $logs = $db->query("
SELECT id, sensor_name, device_location, event_type, is_leak, sensor_time SELECT id, sensor_name, device_location, event_type, is_leak, sensor_time
FROM sensor_log FROM leak_sensor_log
ORDER BY id DESC ORDER BY id DESC
LIMIT 30 LIMIT 30
")->fetchAll(); ")->fetchAll();
// ── 최근 SMS 발송 이력 ───────────────────────────────────── // ── 최근 SMS 발송 이력 ─────────────────────────────────────
$sms_logs = $db->query(" $sms_logs = $db->query("
SELECT * FROM sms_log SELECT * FROM leak_sms_log
ORDER BY id DESC ORDER BY id DESC
LIMIT 20 LIMIT 20
")->fetchAll(); ")->fetchAll();
// ── 30일 운영 요약 ───────────────────────────────────────── // ── 30일 운영 요약 ─────────────────────────────────────────
// 종류별 임계 경보 SMS 건수 (sms_log message prefix 기준) // 종류별 임계 경보 SMS 건수 (leak_sms_log message prefix 기준)
$sms_summary_rows = $db->query(" $sms_summary_rows = $db->query("
SELECT SELECT
" . sms_summary_select_sql() . " " . sms_summary_select_sql() . "
FROM sms_log FROM leak_sms_log
WHERE sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) WHERE sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
")->fetch(); ")->fetch();
@ -209,7 +209,7 @@ $summary['alert_total'] = $summary['high_temp'] + $summary['low_temp']
// 30일 평균 온습도 + 최근 온습도(차트 센서 기준 최신값) // 30일 평균 온습도 + 최근 온습도(차트 센서 기준 최신값)
$metric_avg = $db->query(" $metric_avg = $db->query("
SELECT AVG(temperature_c) AS avg_temp, AVG(humidity_percent) AS avg_hum SELECT AVG(temperature_c) AS avg_temp, AVG(humidity_percent) AS avg_hum
FROM sensor_metric FROM leak_sensor_metric
WHERE measured_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) WHERE measured_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
")->fetch(); ")->fetch();
$avg_temp = $metric_avg['avg_temp'] !== null ? round((float)$metric_avg['avg_temp'], 1) : null; $avg_temp = $metric_avg['avg_temp'] !== null ? round((float)$metric_avg['avg_temp'], 1) : null;
@ -239,7 +239,7 @@ $normal_count = count($statuses) - $offline_count - $alert_count;
// ── 경보 SMS 발송 실패 / 수집 거부 사유 (신뢰성·관측성) ───── // ── 경보 SMS 발송 실패 / 수집 거부 사유 (신뢰성·관측성) ─────
// 최근 1시간 내 경보/오프라인/에스컬레이션 SMS 가 실패했는가(미수신 가능 경고). // 최근 1시간 내 경보/오프라인/에스컬레이션 SMS 가 실패했는가(미수신 가능 경고).
$recent_alert_fail = (int)$db->query(" $recent_alert_fail = (int)$db->query("
SELECT COUNT(*) FROM sms_log SELECT COUNT(*) FROM leak_sms_log
WHERE status = 'fail' WHERE status = 'fail'
AND sent_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR) AND sent_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR)
AND (message LIKE '" . SMS_TAG_HIGH_TEMP . "%' OR message LIKE '" . SMS_TAG_LOW_TEMP . "%' AND (message LIKE '" . SMS_TAG_HIGH_TEMP . "%' OR message LIKE '" . SMS_TAG_LOW_TEMP . "%'
@ -252,14 +252,19 @@ $recent_alert_fail = (int)$db->query("
$ingest_rejections = read_ingest_rejections(1); $ingest_rejections = read_ingest_rejections(1);
$rejection_total = array_sum($ingest_rejections); $rejection_total = array_sum($ingest_rejections);
// ── 누수(1번 모듈) 현재 상태 + 사고/사진 ─────────────────── // ── 누수 모듈 현재 상태 + 사고/사진 ───────────────────
// 누수 센서(sensor_id=1)의 sensor_status.is_leak 로 누수/정상을 표시한다. 오프라인 // 누수 센서(LEAK_SENSOR_IDS, config.php — 기본 [1])의 leak_sensor_status.is_leak 로
// 판정은 위 $statuses 계산과 동일한 로직을 따른다(행이 없으면 '데이터 없음'). // 누수/정상을 표시한다. 오프라인 판정은 위 $statuses 계산과 동일한 로직을
$leak_status = null; // 따른다(행이 없으면 '데이터 없음'). 신규 누수 지점은 config.local.php 의
// 'LEAK_SENSOR_IDS' => [1, 3] 오버라이드로 카드가 추가된다.
$leak_statuses = []; // sensor_id => leak_sensor_status 행 (미수신이면 null)
foreach (LEAK_SENSOR_IDS as $leak_sid) {
$leak_statuses[$leak_sid] = null;
}
foreach ($statuses as $s) { foreach ($statuses as $s) {
if ((int)$s['sensor_id'] === 1) { $sid = (int)$s['sensor_id'];
$leak_status = $s; if (array_key_exists($sid, $leak_statuses)) {
break; $leak_statuses[$sid] = $s;
} }
} }
@ -689,9 +694,10 @@ if ($alert_count > 0) {
<?php endif; ?> <?php endif; ?>
</div> </div>
<!-- 누수(1번 모듈) 상태 + 사고 이력 + 사진 --> <!-- 누수 모듈 상태 + 사고 이력 + 사진 -->
<h2 id="leak-section">누수 감지 (1번 모듈)</h2> <h2 id="leak-section">누수 감지</h2>
<?php <div class="sensor-grid">
<?php foreach ($leak_statuses as $leak_sid => $leak_status):
// 누수 센서 상태 표시: 오프라인 > 누수 > 정상 우선순위 // 누수 센서 상태 표시: 오프라인 > 누수 > 정상 우선순위
if ($leak_status === null) { if ($leak_status === null) {
$leak_card_class = ''; $leak_card_class = '';
@ -711,10 +717,9 @@ if ($alert_count > 0) {
$leak_badge_text = '정상'; $leak_badge_text = '정상';
} }
?> ?>
<div class="sensor-grid">
<div class="sensor-card leak-status-card <?= $leak_card_class ?>"> <div class="sensor-card leak-status-card <?= $leak_card_class ?>">
<?php if ($leak_status === null): ?> <?php if ($leak_status === null): ?>
<strong>1번 센서 (누수)</strong><br> <strong><?= (int)$leak_sid ?>번 센서 (누수)</strong><br>
<div style="margin-top:10px"> <div style="margin-top:10px">
<span class="badge <?= $leak_badge_class ?>"><?= htmlspecialchars($leak_badge_text) ?></span> <span class="badge <?= $leak_badge_class ?>"><?= htmlspecialchars($leak_badge_text) ?></span>
</div> </div>
@ -733,6 +738,7 @@ if ($alert_count > 0) {
</div> </div>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php endforeach; ?>
</div> </div>
<details class="mobile-detail" data-mobile-collapsed open> <details class="mobile-detail" data-mobile-collapsed open>

View file

@ -32,7 +32,9 @@ USE_INTERNAL_PULL = False # True = PUD_DOWN, False = PUD_OFF
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# 주기 설정 # 주기 설정
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
REPORT_INTERVAL = 3600 # 정상 상태 주기 보고 간격 (초) = 1시간 REPORT_INTERVAL = 600 # 정상 상태 주기 보고 간격 (초) = 10분
# 서버 HEARTBEAT_TIMEOUT_SEC(기본 1200초)보다 짧아야
# 오프라인 오탐이 없다 (600초 = 2회 누락 시 오프라인 판정).
LEAK_REPORT_INTERVAL = 10 # 누수 감지 시 재보고 간격 (초) LEAK_REPORT_INTERVAL = 10 # 누수 감지 시 재보고 간격 (초)
DEBOUNCE_MS = 500 # 바운싱 방지 시간 (ms) — 고임피던스라 넉넉히 설정 DEBOUNCE_MS = 500 # 바운싱 방지 시간 (ms) — 고임피던스라 넉넉히 설정
@ -80,3 +82,32 @@ except ImportError as e:
"`cp config_local.example.py config_local.py` 후 실제 서버 URL / API_KEY / " "`cp config_local.example.py config_local.py` 후 실제 서버 URL / API_KEY / "
"기기 정보를 채우세요. (비밀값은 절대 커밋하지 마세요.)" "기기 정보를 채우세요. (비밀값은 절대 커밋하지 마세요.)"
) from e ) from e
# ─────────────────────────────────────────────
# 선택 오버라이드 — 기기별 값을 config_local.py 에서 덮어쓸 수 있다.
# 추적 파일(config.py)을 기기마다 수정하면 배포 드리프트가 생기므로,
# 신규 지점 노드(예: sensor_id 3)는 config_local.py 에 SENSORS 를 정의한다.
# ─────────────────────────────────────────────
try:
from config_local import SENSORS # noqa: F811 — 기기별 센서 목록 오버라이드
except ImportError:
pass # config_local 에 SENSORS 가 없으면 위 기본값 사용
# SENSORS 형식 검증 (fail closed) — 오버라이드 오타/형식 오류로 잘못된 sensor_id
# (예: 기본값 1)로 조용히 기동해 다른 지점 상태를 덮어쓰는 사고를 막는다.
if not isinstance(SENSORS, list) or not SENSORS:
raise RuntimeError("SENSORS 는 비어있지 않은 list 여야 합니다 (config_local.py 확인).")
_ids, _gpios = set(), set()
for _s in SENSORS:
if not (isinstance(_s, dict)
and isinstance(_s.get("id"), int) and 1 <= _s["id"] <= 255
and isinstance(_s.get("gpio"), int)
and isinstance(_s.get("name"), str) and _s["name"]):
raise RuntimeError(
f"SENSORS 항목 형식 오류: {_s!r} — id(int 1..255)/gpio(int)/name(str) 필수."
)
if _s["id"] in _ids or _s["gpio"] in _gpios:
raise RuntimeError(f"SENSORS id/gpio 중복: {_s!r}")
_ids.add(_s["id"])
_gpios.add(_s["gpio"])
del _ids, _gpios

View file

@ -11,7 +11,7 @@
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=요청본문 바이트)) # X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=요청본문 바이트))
# 사진 업로드 canonical = device_id|sensor_id|log_id|timestamp (공백 없음) # 사진 업로드 canonical = device_id|sensor_id|log_id|timestamp (공백 없음)
# → API_KEY 는 서버 config.local.php 의 API_KEY 와 "정확히 동일"해야 한다. # → API_KEY 는 서버 config.local.php 의 API_KEY 와 "정확히 동일"해야 한다.
# 서버에서 sql/migration_add_leak.sql 을 1회 실행하면(가산·멱등) 누수 수신이 활성화되고, # 서버 DB 는 sql/schema_sht30.sql 하나로 설치되며(누수 요소 포함),
# 누수 상태·사진·온습도가 한 대시보드(dashboard.php)에 함께 표시된다. # 누수 상태·사진·온습도가 한 대시보드(dashboard.php)에 함께 표시된다.
# (구버전의 sha256(API_KEY + 정렬 JSON) body-field 서명은 폐기됨 — 서버가 거부한다.) # (구버전의 sha256(API_KEY + 정렬 JSON) body-field 서명은 폐기됨 — 서버가 거부한다.)
# ============================================================================= # =============================================================================
@ -33,3 +33,17 @@ API_KEY = "CHANGE_ME"
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
DEVICE_ID = "rpi4-leak-01" DEVICE_ID = "rpi4-leak-01"
DEVICE_LOCATION = "서버실" DEVICE_LOCATION = "서버실"
# ─────────────────────────────────────────────
# (선택) 센서 목록 오버라이드 — 신규 지점 노드용
# 미정의 시 config.py 기본값(SENSORS = [{"id": 1, ...}]) 사용.
# 신규 지점(예: Pi-C, sensor_id 3) 배포 시 이 파일에서만 정의하면 되고,
# 추적 파일(config.py)은 수정할 필요 없다. 서버 대시보드 카드 표시는
# 서버 config.local.php 의 'LEAK_SENSOR_IDS' => [1, 3] 도 함께 갱신할 것.
# ⚠️ sensor_id 는 지점마다 고유해야 함 — 1/2 재사용 시 기존 지점 상태를
# 조용히 덮어쓴다 (leak_sensor_status 는 sensor_id 단독 키).
# ─────────────────────────────────────────────
# SENSORS = [
# {"id": 3, "gpio": 17, "name": "3번 센서 (신규 지점)"},
# ]
# (위를 켤 때 DEVICE_ID 예: "rpi4-leak-02", DEVICE_LOCATION 도 지점명으로 변경)

View file

@ -4,10 +4,18 @@
import hashlib import hashlib
import hmac import hmac
import sys
import time import time
import subprocess import subprocess
from config import API_URL, API_KEY, DEVICE_ID, DEVICE_LOCATION from config import API_URL, API_KEY, DEVICE_ID, DEVICE_LOCATION, SENSORS
signed_ok = False # 서명 POST 200 여부 — setup.sh 가 이 exit 코드로 하드닝을 게이트한다
# 이 기기에 설정된 첫 센서로 테스트한다 — sensor_id 를 하드코딩하면(예: 1)
# 다른 지점 노드에서 테스트할 때 그 지점의 leak_sensor_status 를 오염시킨다.
TEST_SENSOR_ID = SENSORS[0]["id"]
TEST_SENSOR_NAME = SENSORS[0]["name"]
# API_URL 에서 베이스 경로 추출 (.../api/sensor_data.php → .../api) # API_URL 에서 베이스 경로 추출 (.../api/sensor_data.php → .../api)
API_BASE = API_URL.rsplit("/", 1)[0] API_BASE = API_URL.rsplit("/", 1)[0]
@ -46,7 +54,7 @@ try:
"-A", "LeakSensor/2.0", "-A", "LeakSensor/2.0",
"-X", "POST", API_URL, "-X", "POST", API_URL,
"-H", "Content-Type: application/json", "-H", "Content-Type: application/json",
"-d", '{"device_id":"test","sensor_id":1,"event_type":"startup","is_leak":0,"timestamp":1234567890}'], "-d", f'{{"device_id":"test","sensor_id":{TEST_SENSOR_ID},"event_type":"startup","is_leak":0,"timestamp":1234567890}}'],
capture_output=True, text=True, timeout=10 capture_output=True, text=True, timeout=10
) )
lines = result.stdout.strip().split("\n") lines = result.stdout.strip().split("\n")
@ -69,8 +77,8 @@ try:
'{' '{'
f'"device_id":"{DEVICE_ID}",' f'"device_id":"{DEVICE_ID}",'
f'"device_location":"{DEVICE_LOCATION}",' f'"device_location":"{DEVICE_LOCATION}",'
f'"sensor_id":1,' f'"sensor_id":{TEST_SENSOR_ID},'
f'"sensor_name":"테스트 센서",' f'"sensor_name":"{TEST_SENSOR_NAME} (연결테스트)",'
f'"event_type":"startup",' f'"event_type":"startup",'
f'"is_leak":0,' f'"is_leak":0,'
f'"timestamp":{ts}' f'"timestamp":{ts}'
@ -95,6 +103,7 @@ try:
print(f" 응답 내용: {resp.text[:200]}") print(f" 응답 내용: {resp.text[:200]}")
if resp.status_code == 200: if resp.status_code == 200:
signed_ok = True
print(" → 성공! 모든 연결 정상") print(" → 성공! 모든 연결 정상")
elif resp.status_code == 403: elif resp.status_code == 403:
print(" → 403: requests 라이브러리가 차단됨 또는 서명 불일치") print(" → 403: requests 라이브러리가 차단됨 또는 서명 불일치")
@ -108,5 +117,6 @@ except Exception as e:
print(f" → 실패: {e}") print(f" → 실패: {e}")
print("\n" + "=" * 50) print("\n" + "=" * 50)
print(" 테스트 완료") print(" 테스트 완료" + ("" if signed_ok else " — 서명 POST 실패(위 로그 확인)"))
print("=" * 50) print("=" * 50)
sys.exit(0 if signed_ok else 1)

View file

@ -4,11 +4,14 @@
wire.py 로 본문을 만들고 raw-body HMAC(X-Signature)을 붙여 실제로 한 번 wire.py 로 본문을 만들고 raw-body HMAC(X-Signature)을 붙여 실제로 한 번
서명된 POST 를 보낸다. 비밀값(API_URL/API_KEY)은 config_local.py 에서 로드한다. 서명된 POST 를 보낸다. 비밀값(API_URL/API_KEY)은 config_local.py 에서 로드한다.
""" """
import sys
import time import time
import subprocess import subprocess
import requests import requests
signed_ok = False # 서명 POST 200 여부 — setup.sh 가 이 exit 코드로 하드닝을 게이트한다
from config import ( from config import (
API_URL, API_KEY, DEVICE_ID, DEVICE_LOCATION, API_URL, API_KEY, DEVICE_ID, DEVICE_LOCATION,
SENSOR_ID, SENSOR_NAME, APP_VERSION, SENSOR_ID, SENSOR_NAME, APP_VERSION,
@ -68,6 +71,7 @@ try:
print(f" HTTP 응답코드: {resp.status_code}") print(f" HTTP 응답코드: {resp.status_code}")
print(f" 응답 내용: {resp.text[:200]}") print(f" 응답 내용: {resp.text[:200]}")
if resp.status_code == 200: if resp.status_code == 200:
signed_ok = True
print(" → 성공! 서명 검증 통과, 측정 저장됨 (대시보드 sensor_id=2 확인)") print(" → 성공! 서명 검증 통과, 측정 저장됨 (대시보드 sensor_id=2 확인)")
elif resp.status_code == 403: elif resp.status_code == 403:
print(" → 403 인증 실패: API_KEY 가 서버 config.local.php 와 다르거나 서명 불일치") print(" → 403 인증 실패: API_KEY 가 서버 config.local.php 와 다르거나 서명 불일치")
@ -81,5 +85,6 @@ except Exception as e:
print(f" → 실패: {e}") print(f" → 실패: {e}")
print("\n" + "=" * 50) print("\n" + "=" * 50)
print(" 테스트 완료") print(" 테스트 완료" + ("" if signed_ok else " — 서명 POST 실패(위 로그 확인)"))
print("=" * 50) print("=" * 50)
sys.exit(0 if signed_ok else 1)