這篇記錄我如何把 HDPV 的 HDMI 矩陣切換器(8 進 8 出)從「依賴 Windows 控制軟體」,
1. 設備與目標
- 品牌:HDPV
- 型號/描述:「8進8出矩陣主機:高清音視頻拼接屏切換器(16路 HDMI 螢幕中控處理終端)」
- 控制方式:網路 TCP(示例端口:5050),控制命令為 ASCII 字串,常見以「
.」結尾 - 目標:不用 EXE,改用 XAMPP 掛 PHP,讓多設備透過瀏覽器控制切換
2. 連接方式:LAN 模式與 AP 模式
2.1 LAN 模式(接入路由器/交換器)
- 矩陣主機 LAN 口接到路由器或交換器。
- Windows 電腦也接到同一個網段(有線或 Wi‑Fi)。
- 確認矩陣主機 IP 與控制端口(我這次示例 IP 為
192.168.1.244,端口5050)。
優點是穩定、同網段設備都能訪問;缺點是需要知道設備 IP(DHCP 可能會變,建議固定 IP 或做 DHCP 綁定)。
2.2 AP 模式(設備自己開 Wi‑Fi)
- 矩陣主機切到 AP 模式並廣播 Wi‑Fi。
- 手機/平板/電腦連上該 Wi‑Fi(在同網段內)。
- 直接用瀏覽器控制(後面會用 XAMPP 把控制頁提供給多設備)。
優點是不依賴路由器;缺點是 Wi‑Fi 穩定性/覆蓋取決於設備本身。
3. 抓包逆向:用 Windows + Wireshark 找出「真正的控制 data」
3.1 抓包步驟
- 在 Windows 安裝並打開 Wireshark,選擇正確的網卡介面開始 Capture。
- 打開原廠/配套控制軟體,執行一個明確操作(例如一鍵切換/全切)。
- 在 Wireshark 用顯示過濾器縮小範圍,例如:
tcp.port == 5050或ip.addr == 192.168.1.244。

3.2 data 與數據包的關係(為什麼要看 payload)
網路傳輸中,「數據包(packet)」是承載單位;「data」是你真正要送的內容。
以 TCP 控制協議來說:
- IP/TCP Header:來源/目的 IP、端口、序號、ACK、旗標等,讓封包能被正確轉送、確認、重傳。
- TCP Payload:真正的控制命令內容(也就是我們要逆向的 data)。
3.3 例子:抓到 payload 為 8All.
在封包分析中可以看到這類特徵:
- 目的端口:
5050/TCP - TCP Flags:
PSH, ACK(表示在既有連線中推送一段應用層資料) - TCP payload 很短,例如 5 bytes
- payload 十六進位:
38 41 6c 6c 2e→ ASCII:8All.
只要能穩定對應「某個 UI 操作 → 某個 payload」,就能把命令搬到自己的系統裡重放。
這也是後面用 PHP 直接控制矩陣主機的基礎。


4. 不做 EXE:用 XAMPP + PHP 做跨設備控制
4.1 為什麼這樣做
- 跨設備:同網段的手機/平板/電腦都能用瀏覽器操作。
- 部署簡單:Windows 裝 XAMPP(Apache + PHP)即可。
- 更可維護:命令映射、場景、記錄、權限,都能逐步加上去。
4.2 系統結構(重點是把「抓到的 payload」產品化)
- 前端 UI:提供輸入/輸出選擇、快捷按鈕、命名管理。
- 後端 PHP:用 TCP 連到矩陣主機(IP:Port),把 ASCII 命令寫入 socket。
- 紀錄與鎖:避免多人同時亂切;保留操作 log 便於追查。
5. 我的 PHP One-file 程式:優點與工程化亮點(基於你提供的代碼)
我採用「單檔 PHP」同時提供 UI + API 的做法,方便在現場快速部署。
這份代碼不只是能送出 8All.,而是把控制做成一個可維運的小系統。
5.1 協議抽象:從樣本命令整理出規則
- 用
build_command($in, $out)生成命令:$in . 'All.'(全切)與$in . 'V' . $out . '.'(定向切換,如8V4.)。 - 配合 UI 的端口範圍驗證(1..8),讓命令可控且可預期。
5.2 可靠性:超時、重試、回顯讀取
- 連線/讀寫超時:避免設備不回應時卡住。
- 重試機制:失敗後短暫等待再嘗試,提高現場網路波動下的成功率。
- 可選讀回顯:把設備的回覆帶回 UI / log,方便除錯。
5.3 併發控制:命令鎖 + UI 單人鎖(非常適合會議室/展廳)
- 命令鎖(switch.cmd.lock):任何人按按鈕都要排隊,避免 TCP 寫入互相穿插造成設備狀態混亂。
- UI 單人鎖(switch.ui.lock):同時間只允許一位操作者開啟控制介面;其他人會看到「介面使用中」頁面。
- Presence + Idle timeout:心跳只維持在線狀態,但若超過 60 秒沒有真正操作就自動釋放,避免佔用不放。
- Logout 冷卻:登出後短時間不自動重新 claim,避免使用者誤操作導致鎖立刻被自己搶回。
5.4 可用性:端口命名不靠資料庫(JSON 持久化)
- 輸入/輸出命名:可在 UI 設定「Input 1 = Apple TV」這類易讀名稱。
- 名稱存到
switch.names.json,無需 MySQL,適合快速部署與備份遷移。 - 有基本清洗(去換行、縮短到 40 字)避免 UI 被破壞。
5.5 可觀測性:JSONL 操作日誌
- 每次切換、命名保存都寫入
switch.log,格式為「一行一筆 JSON」。 - 記錄包含來源 IP、UA、cmd、耗時、嘗試次數、回顯等,後續分析非常方便。
6. 部署方式(XAMPP)與多設備訪問
- 在 Windows 安裝 XAMPP,啟動 Apache。
- 把你的 PHP 檔放到
xampp/htdocs/(例如htdocs/hdpv-switch.php)。 - 修改配置:
$DEVICE_IP指向矩陣主機的實際 IP(如192.168.1.244)。 - 手機/平板連到同一個 LAN/AP,瀏覽器開:
http://你的電腦IP/hdpv-switch.php
常見問題排查
- 打不開頁面:檢查 Apache 是否啟動、Windows 防火牆是否允許 80 端口。
- 頁面開了但切換失敗:檢查電腦能否連到設備
5050(同網段、無阻擋)。 - 多人同時操作:你已做 UI 鎖與命令鎖,這是設計預期;另一位會看到 busy 頁面。
7. 可選加強
- CSRF Token:避免同網段惡意頁面誘導發送切換指令。
- 命令白名單/角色權限:如果未來加入「自訂命令」欄位,建議強制白名單或權限控制。
- 更精準的回應解析:若設備回覆有固定結尾符,可改為讀到終止符再回傳,提升診斷能力。
#HDPV HDMI矩陣控制, HDPV 8進8出矩陣切換器, HDMI matrix switcher 網路控制, HDMI矩陣切換器 TCP 5050, Wireshark 抓包 HDMI矩陣, Wireshark 分析 TCP payload, 8All. 指令, 8V4. 指令, PHP 控制 HDMI矩陣切換器, XAMPP PHP socket, fsockopen TCP 發送命令, 網頁控制面板 HDMI矩陣, 手機平板控制 HDMI切換器, LAN模式 AP模式 連接, JSONL 日誌 設備控制, 命令鎖 flock, UI單人鎖 TTL heartbeat idle timeout, HDMI拼接屏 中控處理終端
index.php | XAMPP
<?php
declare(strict_types=1);
/**
* One-file HDMI Matrix switcher UI+API (TCP ASCII)
* Features:
* - Send ASCII command ending with "." to DEVICE_IP:5050 (e.g. "8All." / "8V4.")
* - Input validation (1..N)
* - Logging + retry + command lock
* - UI single-user lock + TTL heartbeat
* - Idle timeout: > 60s no "action" => auto release UI lock
* - Logout to release lock immediately; prevents auto re-claim until user clicks Enter
* - Port naming (inputs/outputs): editable in UI, saved to a JSON file (no DB)
*
* Files created next to this script:
* - switch.ui.lock
* - switch.cmd.lock
* - switch.log
* - switch.names.json <-- your port names here
*/
session_start();
/* ===================== Config ===================== */
$DEVICE_IP = '192.168.1.244'; // TODO change
$DEVICE_PORT = 5050;
$MAX_PORT = 8;
$TIMEOUT_SEC = 2.0;
$RETRIES = 2;
$READ_ECHO = true;
$CMD_LOCK_FILE = __DIR__ . '/switch.cmd.lock';
$UI_LOCK_FILE = __DIR__ . '/switch.ui.lock';
$UI_TTL_SEC = 60;
$IDLE_TIMEOUT_SEC = 60;
$LOG_FILE = __DIR__ . '/switch.log';
// Logout behavior
$LOGOUT_COOLDOWN_SEC = 15;
// Port names storage (editable from UI)
$NAMES_FILE = __DIR__ . '/switch.names.json';
/* ================================================== */
function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); }
function wants_json(): bool {
$accept = $_SERVER['HTTP_ACCEPT'] ?? '';
return str_contains($accept, 'application/json') || (($_GET['format'] ?? '') === 'json');
}
function log_line(string $file, array $data): void {
$data['ts'] = date('c');
$line = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
@file_put_contents($file, $line . PHP_EOL, FILE_APPEND | LOCK_EX);
}
function as_int($v): ?int {
if ($v === null) return null;
if (is_string($v) && trim($v) === '') return null;
if (!is_numeric($v)) return null;
return (int)$v;
}
function build_command(int $in, ?int $out): string {
return ($out === null) ? ($in . 'All.') : ($in . 'V' . $out . '.');
}
/* ============ UI disabled flag (avoid auto re-claim after logout) ============ */
function ui_is_disabled(): bool {
$until = $_SESSION['ui_disabled_until'] ?? 0;
return is_numeric($until) ? (time() < (int)$until) : false;
}
function ui_disable_for(int $sec): void { $_SESSION['ui_disabled_until'] = time() + $sec; }
function ui_enable(): void { unset($_SESSION['ui_disabled_until']); }
/* ===================== UI lock with presence + idle timeout ===================== */
function ui_claim_lock(string $file, int $ttlSec, int $idleTimeoutSec): array {
$now = time();
$me = [
'id' => session_id(),
'ip' => $_SERVER['REMOTE_ADDR'] ?? '',
'ua' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 200),
'ts' => $now,
];
$fp = @fopen($file, 'c+');
if (!$fp) return ['ok' => false, 'reason' => 'cannot_open_lockfile'];
if (!@flock($fp, LOCK_EX)) { @fclose($fp); return ['ok' => false, 'reason' => 'cannot_lock']; }
rewind($fp);
$raw = stream_get_contents($fp);
$cur = $raw ? json_decode($raw, true) : null;
$expiredByTtl = true;
if (is_array($cur) && isset($cur['ts'])) $expiredByTtl = ($now - (int)$cur['ts']) > $ttlSec;
$expiredByIdle = false;
if (is_array($cur)) {
$la = (int)($cur['last_action_ts'] ?? 0);
if ($la > 0) $expiredByIdle = ($now - $la) > $idleTimeoutSec;
}
$same = is_array($cur) && (($cur['id'] ?? '') === $me['id']);
if (is_array($cur) && $expiredByIdle) $cur = null; // auto-release on idle
if ($same || $expiredByTtl || !$cur) {
$owner = $me;
$owner['last_action_ts'] = $now; // start idle timer from entry
ftruncate($fp, 0); rewind($fp);
fwrite($fp, json_encode($owner, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
fflush($fp);
@flock($fp, LOCK_UN); @fclose($fp);
return ['ok' => true, 'owner' => $owner, 'reason' => ($same ? 'same_owner' : 'claimed')];
}
@flock($fp, LOCK_UN); @fclose($fp);
return ['ok' => false, 'reason' => 'busy', 'owner' => $cur];
}
function ui_heartbeat(string $file, int $idleTimeoutSec): array {
$now = time();
$fp = @fopen($file, 'c+');
if (!$fp) return ['ok' => false, 'reason' => 'cannot_open_lockfile'];
if (!@flock($fp, LOCK_EX)) { @fclose($fp); return ['ok' => false, 'reason' => 'cannot_lock']; }
rewind($fp);
$raw = stream_get_contents($fp);
$cur = $raw ? json_decode($raw, true) : null;
if (!is_array($cur) || !isset($cur['id'])) {
@flock($fp, LOCK_UN); @fclose($fp);
return ['ok' => false, 'reason' => 'no_owner'];
}
if (($cur['id'] ?? '') !== session_id()) {
@flock($fp, LOCK_UN); @fclose($fp);
return ['ok' => false, 'reason' => 'not_owner', 'owner' => $cur];
}
$la = (int)($cur['last_action_ts'] ?? 0);
if ($la > 0 && ($now - $la) > $idleTimeoutSec) {
ftruncate($fp, 0); fflush($fp);
@flock($fp, LOCK_UN); @fclose($fp);
return ['ok' => false, 'reason' => 'idle_released'];
}
$cur['ts'] = $now; // presence only
ftruncate($fp, 0); rewind($fp);
fwrite($fp, json_encode($cur, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
fflush($fp);
@flock($fp, LOCK_UN); @fclose($fp);
return ['ok' => true, 'reason' => 'hb', 'owner' => $cur];
}
function ui_touch_action(string $file): void {
$now = time();
$fp = @fopen($file, 'c+');
if (!$fp) return;
if (!@flock($fp, LOCK_EX)) { @fclose($fp); return; }
rewind($fp);
$raw = stream_get_contents($fp);
$cur = $raw ? json_decode($raw, true) : null;
if (is_array($cur) && (($cur['id'] ?? '') === session_id())) {
$cur['ts'] = $now;
$cur['last_action_ts'] = $now;
ftruncate($fp, 0); rewind($fp);
fwrite($fp, json_encode($cur, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
fflush($fp);
}
@flock($fp, LOCK_UN); @fclose($fp);
}
function ui_release_lock(string $file): bool {
$fp = @fopen($file, 'c+');
if (!$fp) return false;
if (!@flock($fp, LOCK_EX)) { @fclose($fp); return false; }
rewind($fp);
$raw = stream_get_contents($fp);
$cur = $raw ? json_decode($raw, true) : null;
$released = false;
if (is_array($cur) && (($cur['id'] ?? '') === session_id())) {
ftruncate($fp, 0);
fflush($fp);
$released = true;
}
@flock($fp, LOCK_UN); @fclose($fp);
return $released;
}
/* ===================== Names storage ===================== */
function names_default(int $maxPort): array {
$in = []; $out = [];
for ($i=1; $i<=$maxPort; $i++) {
$in[(string)$i] = "Input {$i}";
$out[(string)$i] = "Output {$i}";
}
return ['inputs' => $in, 'outputs' => $out];
}
function names_load(string $file, int $maxPort): array {
$base = names_default($maxPort);
if (!is_file($file)) return $base;
$raw = @file_get_contents($file);
if ($raw === false || trim($raw) === '') return $base;
$j = json_decode($raw, true);
if (!is_array($j)) return $base;
foreach (['inputs','outputs'] as $k) {
if (!isset($j[$k]) || !is_array($j[$k])) $j[$k] = [];
for ($i=1; $i<=$maxPort; $i++) {
$key = (string)$i;
$val = $j[$k][$key] ?? $base[$k][$key];
if (!is_string($val)) $val = $base[$k][$key];
$val = trim($val);
$j[$k][$key] = ($val === '') ? $base[$k][$key] : mb_substr($val, 0, 40);
}
}
return ['inputs' => $j['inputs'], 'outputs' => $j['outputs']];
}
function names_save(string $file, array $names): bool {
$tmp = $file . '.tmp';
$json = json_encode($names, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) return false;
$ok = @file_put_contents($tmp, $json . PHP_EOL, LOCK_EX);
if ($ok === false) return false;
return @rename($tmp, $file);
}
function sanitize_name(string $s): string {
$s = trim($s);
$s = preg_replace('/\s+/u', ' ', $s);
$s = str_replace(["\r","\n","\t"], ' ', $s);
$s = trim($s);
if ($s === '') return '';
return mb_substr($s, 0, 40);
}
/* ===================== TCP send ===================== */
function tcp_send_once(string $ip, int $port, string $cmd, float $timeout, bool $readEcho): array {
$t0 = microtime(true);
$errno = 0; $errstr = '';
$fp = @fsockopen($ip, $port, $errno, $errstr, $timeout);
if (!$fp) return ['ok' => false, 'error' => "connect_failed($errno): $errstr", 'echo' => '', 'ms' => (int)((microtime(true) - $t0) * 1000)];
stream_set_timeout($fp, (int)$timeout, (int)(($timeout - (int)$timeout) * 1_000_000));
$written = @fwrite($fp, $cmd);
@fflush($fp);
$echo = '';
if ($readEcho) $echo = (string)@fread($fp, 1024);
@fclose($fp);
$ms = (int)((microtime(true) - $t0) * 1000);
if ($written === false || $written === 0) return ['ok' => false, 'error' => 'write_failed', 'echo' => $echo, 'ms' => $ms];
return ['ok' => true, 'error' => '', 'echo' => $echo, 'ms' => $ms];
}
function tcp_send_with_lock(string $cmdLockFile, string $ip, int $port, string $cmd, float $timeout, bool $readEcho, int $retries): array {
$lockFp = @fopen($cmdLockFile, 'c+');
if (!$lockFp) return ['ok' => false, 'error' => "cannot_open_cmd_lock", 'echo' => '', 'ms' => 0, 'attempts' => 0];
if (!@flock($lockFp, LOCK_EX)) { @fclose($lockFp); return ['ok' => false, 'error' => "cannot_lock_cmd", 'echo' => '', 'ms' => 0, 'attempts' => 0]; }
$attempts = 0;
$last = null;
for ($i = 0; $i <= $retries; $i++) {
$attempts++;
$last = tcp_send_once($ip, $port, $cmd, $timeout, $readEcho);
if ($last['ok']) break;
usleep(150_000);
}
@flock($lockFp, LOCK_UN); @fclose($lockFp);
$last['attempts'] = $attempts;
return $last;
}
/* ===================== Routing ===================== */
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
/** Enter UI (after logout) */
if ($method === 'GET' && isset($_GET['enter'])) {
ui_enable();
header('Location: ' . strtok($_SERVER['REQUEST_URI'], '?'));
exit;
}
/** Logout */
if ($method === 'GET' && isset($_GET['logout'])) {
$released = ui_release_lock($UI_LOCK_FILE);
ui_disable_for($LOGOUT_COOLDOWN_SEC);
header('Location: ' . strtok($_SERVER['REQUEST_URI'], '?') . '?loggedout=' . ($released ? '1' : '0'));
exit;
}
/** Heartbeat */
if ($method === 'GET' && isset($_GET['hb'])) {
header('Content-Type: application/json; charset=utf-8');
if (ui_is_disabled()) { echo json_encode(['ok' => false, 'reason' => 'ui_disabled'], JSON_UNESCAPED_UNICODE); exit; }
$hb = ui_heartbeat($UI_LOCK_FILE, $IDLE_TIMEOUT_SEC);
echo json_encode($hb, JSON_UNESCAPED_UNICODE);
exit;
}
/** Save names */
$names = names_load($NAMES_FILE, $MAX_PORT);
if ($method === 'POST' && (($_POST['action'] ?? '') === 'save_names')) {
ui_touch_action($UI_LOCK_FILE);
$new = ['inputs' => [], 'outputs' => []];
for ($i=1; $i<=$MAX_PORT; $i++) {
$k = (string)$i;
$new['inputs'][$k] = sanitize_name((string)($_POST['in_name'][$k] ?? ''));
$new['outputs'][$k] = sanitize_name((string)($_POST['out_name'][$k] ?? ''));
if ($new['inputs'][$k] === '') $new['inputs'][$k] = "Input {$i}";
if ($new['outputs'][$k] === '') $new['outputs'][$k] = "Output {$i}";
}
$ok = names_save($NAMES_FILE, $new);
log_line($LOG_FILE, [
'remote' => $_SERVER['REMOTE_ADDR'] ?? '',
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
'event' => 'save_names',
'ok' => $ok,
]);
if (wants_json()) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => $ok, 'names' => $new], JSON_UNESCAPED_UNICODE);
exit;
}
// reload
$names = $new;
}
/** If UI disabled, show landing without claiming lock */
if ($method === 'GET' && !wants_json() && ui_is_disabled()) {
$secondsLeft = max(0, (int)($_SESSION['ui_disabled_until'] ?? time()) - time());
$released = (($_GET['loggedout'] ?? '') === '1');
echo "<!doctype html><html lang='zh-Hant'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>"
."<title>已登出</title><style>
body{margin:0;font-family:system-ui;background:linear-gradient(180deg,#0b1220,#070b14);color:#e8eefc}
.wrap{max-width:860px;margin:40px auto;padding:0 16px}
.card{background:rgba(18,26,43,.92);border:1px solid #22304a;border-radius:14px;padding:18px;box-shadow:0 12px 40px rgba(0,0,0,.35)}
.muted{color:#a9b7d0} a{color:#8fb0ff;text-decoration:none} a:hover{text-decoration:underline}
.btn{display:inline-block;margin-top:10px;padding:10px 12px;border-radius:12px;border:1px solid rgba(79,124,255,.5);
background:linear-gradient(180deg,rgba(79,124,255,.95),rgba(79,124,255,.75));color:white;font-weight:650}
</style></head><body><div class='wrap'><div class='card'>"
."<h2 style='margin:0 0 10px'>已登出</h2>"
."<p class='muted' style='margin:0 0 8px'>".($released ? "已釋放 UI 鎖。" : "已登出(鎖可能不是你持有或已過期)。")."</p>"
."<p class='muted' style='margin:0'>暫停自動進入:剩餘 {$secondsLeft} 秒。</p>"
."<a class='btn' href='?enter=1'>重新進入控制介面</a>"
."</div></div></body></html>";
exit;
}
/** Enforce single-user UI on GET: claim lock */
if ($method === 'GET' && !wants_json()) {
$lock = ui_claim_lock($UI_LOCK_FILE, $UI_TTL_SEC, $IDLE_TIMEOUT_SEC);
if (!$lock['ok']) {
$owner = $lock['owner'] ?? [];
http_response_code(423);
echo "<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>"
."<title>介面使用中</title><style>
body{font-family:system-ui;margin:0;background:#0b1220;color:#e8eefc}
.wrap{max-width:860px;margin:40px auto;padding:0 16px}
.card{background:#121a2b;border:1px solid #22304a;border-radius:14px;padding:18px}
a{color:#4f7cff;text-decoration:none}
pre{background:#0d1424;border:1px solid #22304a;border-radius:10px;padding:12px;overflow:auto;color:#a9b7d0}
</style></head><body><div class='wrap'><div class='card'>"
."<h2 style='margin:0 0 10px'>介面使用中(只能一人開啟)</h2>"
."<p style='margin:0 0 12px;color:#a9b7d0'>目前已有其他使用者正在操作,請稍後再試。</p>"
."<pre>".h(json_encode($owner, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE))."</pre>"
."<p style='margin:12px 0 0'><a href='?'>重新整理</a></p>"
."</div></div></body></html>";
exit;
}
}
/* ===================== Handle POST (switch) ===================== */
$result = null;
$error = '';
$cmd = '';
$tab = $_GET['tab'] ?? 'switch';
if ($method === 'POST' && (($_POST['action'] ?? '') === 'switch')) {
ui_touch_action($UI_LOCK_FILE);
$in = as_int($_POST['in'] ?? null);
$out = as_int($_POST['out'] ?? null);
if ($in === null || $in < 1 || $in > $MAX_PORT) {
$error = "輸入端口 in 必須是 1~{$MAX_PORT}";
} elseif ($out !== null && ($out < 1 || $out > $MAX_PORT)) {
$error = "輸出端口 out 必須是 1~{$MAX_PORT}(或留空表示 All)";
} else {
$cmd = build_command($in, $out);
$send = tcp_send_with_lock($CMD_LOCK_FILE, $DEVICE_IP, $DEVICE_PORT, $cmd, $TIMEOUT_SEC, $READ_ECHO, $RETRIES);
$result = $send + ['cmd' => $cmd, 'in' => $in, 'out' => $out, 'device' => "{$DEVICE_IP}:{$DEVICE_PORT}"];
log_line($LOG_FILE, [
'remote' => $_SERVER['REMOTE_ADDR'] ?? '',
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
'device' => $result['device'],
'cmd' => $cmd,
'ok' => $result['ok'],
'error' => $result['error'],
'echo' => $result['echo'],
'ms' => $result['ms'],
'attempts' => $result['attempts'] ?? 1,
]);
if (!$result['ok']) $error = "送出失敗:{$result['error']}";
}
if (wants_json()) {
header('Content-Type: application/json; charset=utf-8');
if ($error !== '') { http_response_code(400); echo json_encode(['ok' => false, 'error' => $error], JSON_UNESCAPED_UNICODE); }
else echo json_encode($result, JSON_UNESCAPED_UNICODE);
exit;
}
$tab = 'switch';
}
/* ===================== HTML UI ===================== */
function port_label(array $names, string $type, int $n): string {
$k = (string)$n;
$v = $names[$type][$k] ?? ($type === 'inputs' ? "Input $n" : "Output $n");
return "{$n}: {$v}";
}
?>
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HDMI 切換控制</title>
<style>
:root{--bg:#0b1220;--card:#121a2b;--txt:#e8eefc;--muted:#a9b7d0;--pri:#4f7cff;--err:#ff5a6a;--ok:#3ddc97;--bd:#22304a;}
body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,Noto Sans,"PingFang TC","Microsoft JhengHei",sans-serif;background:linear-gradient(180deg,#0b1220,#070b14);color:var(--txt);}
.wrap{max-width:980px;margin:40px auto;padding:0 16px;}
.card{background:rgba(18,26,43,.92);border:1px solid var(--bd);border-radius:14px;padding:18px 18px 14px;box-shadow:0 12px 40px rgba(0,0,0,.35);}
h1{font-size:18px;margin:0;}
.p{color:var(--muted);font-size:13px;line-height:1.6;margin:10px 0 14px;}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;}
label{display:block;font-size:12px;color:var(--muted);margin:0 0 6px;}
select,input{width:100%;padding:10px 12px;border-radius:10px;border:1px solid var(--bd);background:#0d1424;color:var(--txt);}
.btn{margin-top:12px;width:100%;padding:11px 12px;border-radius:12px;border:1px solid rgba(79,124,255,.5);background:linear-gradient(180deg,rgba(79,124,255,.95),rgba(79,124,255,.75));color:white;font-weight:650;cursor:pointer;}
.btnrow{display:flex;gap:10px;flex-wrap:wrap;margin-top:10px}
.btn2{display:inline-block;padding:10px 12px;border-radius:12px;border:1px solid var(--bd);background:#0d1424;color:var(--txt);text-decoration:none}
.btn2:hover{border-color:rgba(79,124,255,.6)}
.notice{margin-top:12px;padding:10px 12px;border-radius:12px;border:1px solid var(--bd);background:#0d1424;font-size:13px;}
.notice.ok{border-color:rgba(61,220,151,.45);}
.notice.err{border-color:rgba(255,90,106,.45);}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;}
.footer{margin-top:10px;color:var(--muted);font-size:12px;display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap}
hr{border:none;border-top:1px solid var(--bd);margin:12px 0;}
a{color:#8fb0ff;text-decoration:none;}
a:hover{text-decoration:underline;}
.topbar{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;flex-wrap:wrap}
.badge{font-size:12px;color:var(--muted)}
.logout{display:inline-block;padding:8px 10px;border-radius:10px;border:1px solid var(--bd);background:#0d1424;color:var(--txt);}
.tabs{display:flex;gap:8px;margin-top:10px;flex-wrap:wrap}
.tab{padding:8px 10px;border-radius:10px;border:1px solid var(--bd);background:#0d1424;color:var(--txt);text-decoration:none}
.tab.active{border-color:rgba(79,124,255,.6);background:rgba(79,124,255,.12)}
.table{width:100%;border-collapse:separate;border-spacing:0 8px}
.table td{padding:6px 8px}
.small{font-size:12px;color:var(--muted)}
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<div class="topbar">
<div>
<h1>HDMI 切換控制</h1>
<div class="badge">
設備:<span class="mono"><?=h($DEVICE_IP . ':' . $DEVICE_PORT)?></span>
|閒置自動退出:<?=$IDLE_TIMEOUT_SEC?> 秒
|命令:ASCII(結尾「.」)
</div>
<div class="tabs">
<a class="tab <?=($tab==='switch'?'active':'')?>" href="?tab=switch">切換</a>
<a class="tab <?=($tab==='names'?'active':'')?>" href="?tab=names">命名</a>
</div>
</div>
<div>
<a class="logout" href="?logout=1">Logout(釋放鎖)</a>
</div>
</div>
<?php if ($tab === 'switch'): ?>
<p class="p">
例:<span class="mono">1All.</span>(輸入1到全部輸出)、
<span class="mono">8V4.</span>(輸入8到輸出4)
</p>
<form method="post" action="?tab=switch">
<input type="hidden" name="action" value="switch">
<div class="grid">
<div>
<label>輸入</label>
<select name="in" required>
<?php for($i=1;$i<=$MAX_PORT;$i++): ?>
<option value="<?=$i?>" <?=isset($_POST['in']) && (int)$_POST['in']===$i?'selected':''?>>
<?=h(port_label($names, 'inputs', $i))?>
</option>
<?php endfor; ?>
</select>
</div>
<div>
<label>輸出</label>
<select name="out">
<option value="" <?=empty($_POST['out'])?'selected':''?>>全部 (All)</option>
<?php for($i=1;$i<=$MAX_PORT;$i++): ?>
<option value="<?=$i?>" <?=isset($_POST['out']) && $_POST['out']!=='' && (int)$_POST['out']===$i?'selected':''?>>
<?=h(port_label($names, 'outputs', $i))?>
</option>
<?php endfor; ?>
</select>
</div>
</div>
<button class="btn" type="submit">執行切換</button>
</form>
<?php if (($method ?? '') === 'POST' && (($_POST['action'] ?? '') === 'switch')): ?>
<?php if ($error !== ''): ?>
<div class="notice err"><b>失敗:</b><?=h($error)?></div>
<?php else: ?>
<div class="notice <?=($result && $result['ok'])?'ok':'err'?>">
<div><b>指令:</b><span class="mono"><?=h($cmd)?></span></div>
<div><b>結果:</b><?=($result['ok']?'OK':'FAIL')?>(<?=$result['ms']?> ms,嘗試 <?=$result['attempts'] ?? 1?> 次)</div>
<?php if (!empty($result['echo'])): ?>
<div><b>回顯:</b><span class="mono"><?=h($result['echo'])?></span></div>
<?php endif; ?>
<?php if (!$result['ok']): ?>
<div><b>錯誤:</b><?=h($result['error'])?></div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php endif; ?>
<?php else: /* names */ ?>
<p class="p">
你可以在此設定 1~<?=$MAX_PORT?> 的輸入/輸出名稱(例如:<span class="mono">1: Apple TV</span>)。
會寫入 <span class="mono"><?=h(basename($NAMES_FILE))?></span>,下次開啟仍保留。
</p>
<form method="post" action="?tab=names">
<input type="hidden" name="action" value="save_names">
<div class="grid">
<div>
<label>輸入端口命名</label>
<table class="table">
<?php for($i=1;$i<=$MAX_PORT;$i++): $k=(string)$i; ?>
<tr>
<td style="width:60px" class="small">IN <?=$i?></td>
<td><input name="in_name[<?=$k?>]" value="<?=h($names['inputs'][$k] ?? '')?>" placeholder="例如:Apple TV"></td>
</tr>
<?php endfor; ?>
</table>
</div>
<div>
<label>輸出端口命名</label>
<table class="table">
<?php for($i=1;$i<=$MAX_PORT;$i++): $k=(string)$i; ?>
<tr>
<td style="width:60px" class="small">OUT <?=$i?></td>
<td><input name="out_name[<?=$k?>]" value="<?=h($names['outputs'][$k] ?? '')?>" placeholder="例如:Meeting Room TV"></td>
</tr>
<?php endfor; ?>
</table>
</div>
</div>
<button class="btn" type="submit">儲存命名</button>
<div class="small" style="margin-top:8px">
提示:名稱最多 40 字元;空白會自動回復預設(Input/Output N)。
</div>
</form>
<?php endif; ?>
<hr>
<div class="footer">
<div>Log:<span class="mono"><?=h(basename($LOG_FILE))?></span></div>
<div class="small">命名檔:<span class="mono"><?=h(basename($NAMES_FILE))?></span></div>
</div>
</div>
</div>
<script>
// Presence heartbeat (does NOT reset idle timer). Server will auto-release after idle timeout.
setInterval(async () => {
try {
const r = await fetch('?hb=1', {cache:'no-store'});
const j = await r.json();
if (j && j.reason === 'idle_released') {
alert('已超過 60 秒未操作,系統已自動退出佔用。');
location.href = '?logout=1';
}
} catch (e) {}
}, 20000);
</script>
</body>
</html>