defined('MQTT_HOST') ? (string)MQTT_HOST : 'localhost', 'port' => defined('MQTT_PORT') ? (int)MQTT_PORT : 1883, 'username' => defined('MQTT_USERNAME') ? (string)MQTT_USERNAME : '', 'password' => defined('MQTT_PASSWORD') ? (string)MQTT_PASSWORD : '', 'topic_prefix' => defined('MQTT_TOPIC_PREFIX') ? trim((string)MQTT_TOPIC_PREFIX) : '', 'topic_suffix' => defined('MQTT_TOPIC_SUFFIX') ? trim((string)MQTT_TOPIC_SUFFIX) : '', ]; } function topicFromFile(string $file): string { $cfg = mqttConfig(); $base = pathinfo($file, PATHINFO_FILENAME); return $cfg['topic_prefix'] . $base . $cfg['topic_suffix']; } function buildMosquittoAuthArgs(array $cfg): string { $parts = []; if($cfg['username'] !== '') $parts[] = '-u ' . escapeshellarg($cfg['username']); if($cfg['password'] !== '') $parts[] = '-P ' . escapeshellarg($cfg['password']); return implode(' ', $parts); } function readDeviceList(string $path): array { $out = []; if(!is_file($path)) return $out; $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; foreach($lines as $line) { $line = trim($line); if($line === '' || (function_exists('str_starts_with') ? str_starts_with($line, '#') : strpos($line, '#') === 0)) continue; // label:file[:lat[:lng]] $parts = explode(':', $line); if(count($parts) < 2) continue; $label = trim($parts[0]); $filename = trim($parts[1]); $lat = isset($parts[2]) ? trim($parts[2]) : null; $lng = isset($parts[3]) ? trim($parts[3]) : null; if($lat !== null && !is_numeric($lat)) $lat = null; if($lng !== null && !is_numeric($lng)) $lng = null; $out[] = [ 'label' => $label, 'file' => $filename, 'lat' => $lat, 'lng' => $lng, ]; } return $out; } function allowedFilename(string $filename, array $deviceList): bool { foreach($deviceList as $d) if($d['file'] === $filename) return true; return false; } function loadJsonFile(string $fullpath): array { if(!is_file($fullpath)) throw new RuntimeException('JSON 파일을 찾을 수 없습니다.'); $raw = file_get_contents($fullpath); if($raw === false) throw new RuntimeException('JSON 파일을 읽을 수 없습니다.'); return json_decode($raw, true, flags: JSON_THROW_ON_ERROR); } function saveJsonFile(string $fullpath, array $data): void { $json = json_encode($data, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR); if(file_put_contents($fullpath, $json) === false) throw new RuntimeException('JSON 파일 저장에 실패했습니다.'); } $deviceList = readDeviceList($DEVICE_LIST_FILE); // ─────────────────────────────────────────────────────────────────────────── // AJAX API // ─────────────────────────────────────────────────────────────────────────── if(isset($_GET['action'])) { header('Content-Type: application/json; charset=utf-8'); try { if($_GET['action'] === 'load') { $file = $_GET['file'] ?? ''; if(!$file || !allowedFilename($file, $deviceList)) { http_response_code(400); echo json_encode(['ok'=>false,'msg'=>'허용되지 않은 파일입니다.']); exit; } $full = $JSON_DIR . '/' . basename($file); $data = loadJsonFile($full); echo json_encode(['ok'=>true,'data'=>$data, 'file'=>$file], JSON_UNESCAPED_UNICODE); exit; } if($_GET['action'] === 'save') { $payload = json_decode(file_get_contents('php://input') ?: '', true); if(!is_array($payload)) { http_response_code(400); echo json_encode(['ok'=>false,'msg'=>'잘못된 요청 본문']); exit; } $file = (string)($payload['file'] ?? ''); $data = $payload['data'] ?? null; if(!$file || !allowedFilename($file, $deviceList)) { http_response_code(400); echo json_encode(['ok'=>false,'msg'=>'허용되지 않은 파일']); exit; } if(!is_array($data)) { http_response_code(400); echo json_encode(['ok'=>false,'msg'=>'데이터가 비어있음']); exit; } $full = $JSON_DIR . '/' . basename($file); $original = loadJsonFile($full); $data['dev_id'] = $original['dev_id'] ?? ($data['dev_id'] ?? ''); $errors = []; foreach(['reset','echo'] as $k) { if(array_key_exists($k, $data)) { $v = (string)$data[$k]; if(!in_array($v, ['0','1'], true)) { $errors[] = "$k 은(는) 0 또는 1 이어야 합니다."; } else { $data[$k] = $v; } } else { $data[$k] = (string)($original[$k] ?? '0'); } } if(!isset($data['motor']) || !is_array($data['motor']) || count($data['motor']) !== 3) { $errors[] = 'motor 형식이 올바르지 않습니다.'; } else { [$mode,$speed,$direction] = array_values($data['motor']); $mode = (string)$mode; $direction = (string)$direction; if(!in_array($mode, ['0','1','2'], true)) $errors[] = 'mode는 0,1,2 중 하나여야 합니다.'; $speedInt = filter_var($speed, FILTER_VALIDATE_INT, ['options'=>['min_range'=>$MOTOR_SPEED_MIN,'max_range'=>$MOTOR_SPEED_MAX]]); if($speedInt === false) { $errors[] = 'speed는 ' . $MOTOR_SPEED_MIN . '~' . $MOTOR_SPEED_MAX . ' 정수여야 합니다.'; } elseif((($speedInt - $MOTOR_SPEED_MIN) % $MOTOR_SPEED_STEP) !== 0) { $errors[] = 'speed는 ' . $MOTOR_SPEED_STEP . ' 단위로 선택해야 합니다.'; } if(!in_array($direction, ['1','2'], true)) $errors[] = 'direction은 1 또는 2여야 합니다.'; if(!$errors) $data['motor'] = [$mode, (string)$speedInt, $direction]; } if($errors) { http_response_code(422); echo json_encode(['ok'=>false,'msg'=>implode(' ', $errors)], JSON_UNESCAPED_UNICODE); exit; } // ★ 저장 직전 서버시간 기록 $data['lastupdate'] = date('Y-m-d H:i:s'); saveJsonFile($full, $data); // MQTT 전송 $topic = topicFromFile($file); $cwd = $BASE_DIR; $cmd = 'cd ' . escapeshellarg($cwd) . ' && /bin/bash ./mqtt/mqtt_pub.sh ' . escapeshellarg($topic) . ' ' . escapeshellarg('get:0') . ' 2>&1'; $output = shell_exec($cmd); $output = $output === null ? '' : (string)$output; echo json_encode(['ok'=>true,'msg'=>'저장 및 전송 완료','shell_output'=>$output], JSON_UNESCAPED_UNICODE); exit; } if($_GET['action'] === 'level_stream') { $file = $_GET['file'] ?? ''; if(!$file || !allowedFilename($file, $deviceList)) { http_response_code(400); echo json_encode(['ok'=>false,'msg'=>'허용되지 않은 파일입니다.']); exit; } $topic = topicFromFile($file); $cfg = mqttConfig(); header('Content-Type: text/event-stream; charset=utf-8'); header('Cache-Control: no-cache, no-transform'); header('X-Accel-Buffering: no'); @ini_set('output_buffering', 'off'); @ini_set('zlib.output_compression', '0'); @set_time_limit(0); while(ob_get_level() > 0) { @ob_end_flush(); } ob_implicit_flush(true); echo ": connected\n\n"; @flush(); $cmd = 'mosquitto_sub -h ' . escapeshellarg($cfg['host']) . ' -p ' . (int)$cfg['port'] . ' ' . buildMosquittoAuthArgs($cfg) . ' -t ' . escapeshellarg($topic) . ' -C 1 2>&1'; $descriptors = [ 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ]; $proc = @proc_open($cmd, $descriptors, $pipes, $BASE_DIR); if(!is_resource($proc)) { echo "event: error\n"; echo 'data: ' . json_encode(['msg'=>'mosquitto_sub 실행 실패'], JSON_UNESCAPED_UNICODE) . "\n\n"; @flush(); exit; } fclose($pipes[0]); stream_set_blocking($pipes[1], false); stream_set_blocking($pipes[2], false); $started = microtime(true); $stdoutBuf = ''; $stderr = ''; while(true) { if(connection_aborted()) break; $status = proc_get_status($proc); $stdoutBuf .= (string)stream_get_contents($pipes[1]); $stderr .= (string)stream_get_contents($pipes[2]); if($stdoutBuf !== '') { $lines = preg_split('/\r\n|\r|\n/', $stdoutBuf); $stdoutBuf = array_pop($lines); foreach($lines as $line) { $line = trim($line); if($line === '') continue; if(preg_match('/^(\d{4})\/(\d{2})\/(\d{2})\/(\d{2})\/(\d{2})\/(\d{2})\/cm\s*:\s*(-?\d+(?:\.\d+)?)$/i', $line, $m)) { $timestamp = sprintf('%s-%s-%s %s:%s:%s', $m[1], $m[2], $m[3], $m[4], $m[5], $m[6]); $value = number_format((float)$m[7], 2, '.', ''); echo "event: level\n"; echo 'data: ' . json_encode([ 'value'=>$value, 'timestamp'=>$timestamp, 'timestamp_raw'=>implode('/', array_slice($m, 1, 6)), 'raw'=>$line, 'topic'=>$topic ], JSON_UNESCAPED_UNICODE) . "\n\n"; @flush(); foreach([1,2] as $i) { if(isset($pipes[$i]) && is_resource($pipes[$i])) fclose($pipes[$i]); } @proc_terminate($proc); @proc_close($proc); exit; } if(preg_match('/^cm\s*:\s*(-?\d+(?:\.\d+)?)$/i', $line, $m)) { $value = number_format((float)$m[1], 2, '.', ''); echo "event: level\n"; echo 'data: ' . json_encode([ 'value'=>$value, 'timestamp'=>null, 'timestamp_raw'=>null, 'raw'=>$line, 'topic'=>$topic ], JSON_UNESCAPED_UNICODE) . "\n\n"; @flush(); foreach([1,2] as $i) { if(isset($pipes[$i]) && is_resource($pipes[$i])) fclose($pipes[$i]); } @proc_terminate($proc); @proc_close($proc); exit; } } } if(!$status['running']) { $err = trim($stderr); if($err !== '') { echo "event: error\n"; echo 'data: ' . json_encode(['msg'=>$err], JSON_UNESCAPED_UNICODE) . "\n\n"; @flush(); } else { echo "event: timeout\n"; echo 'data: ' . json_encode(['msg'=>'수위 수신 대기 중'], JSON_UNESCAPED_UNICODE) . "\n\n"; @flush(); } break; } if((microtime(true) - $started) >= 8.0) { echo "event: timeout\n"; echo 'data: ' . json_encode(['msg'=>'수위 수신 대기 중'], JSON_UNESCAPED_UNICODE) . "\n\n"; @flush(); break; } usleep(200000); } foreach([1,2] as $i) { if(isset($pipes[$i]) && is_resource($pipes[$i])) fclose($pipes[$i]); } if(isset($proc) && is_resource($proc)) { @proc_terminate($proc); @proc_close($proc); } exit; } http_response_code(400); echo json_encode(['ok'=>false,'msg'=>'알 수 없는 action']); exit; } catch (Throwable $e) { http_response_code(500); echo json_encode(['ok'=>false,'msg'=>$e->getMessage()]); exit; } } ?>