feat: enable HTTPS for API and update MCU Web Serial connection UI

This commit is contained in:
ws50529
2026-05-14 17:28:33 +08:00
parent 11e8ff8533
commit 68b003fd80
7 changed files with 98 additions and 72 deletions
+3
View File
@@ -39,3 +39,6 @@ Desktop.ini
# Agent Skills
.agents/
# SSL certs (private keys)
certs/*.pem
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# 產生自簽 SSL 憑證供 Uvicorn HTTPS 使用
# 用法: bash certs/generate_cert.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CERT_FILE="${SCRIPT_DIR}/cert.pem"
KEY_FILE="${SCRIPT_DIR}/key.pem"
DAYS=3650
if [ -f "$CERT_FILE" ] && [ -f "$KEY_FILE" ]; then
echo "憑證已存在: ${CERT_FILE}, ${KEY_FILE}"
echo "如需重新產生,請先刪除後再執行。"
exit 0
fi
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout "$KEY_FILE" \
-out "$CERT_FILE" \
-days "$DAYS" \
-subj "/CN=$(hostname)" \
-addext "subjectAltName=DNS:$(hostname),IP:0.0.0.0"
echo "已產生自簽憑證(有效 ${DAYS} 天):"
echo " cert: ${CERT_FILE}"
echo " key: ${KEY_FILE}"
+1 -1
View File
@@ -6,7 +6,7 @@ After=network.target
User=wisetop
WorkingDirectory=/home/wisetop/diff-eq-analyzer
Environment="PATH=/home/wisetop/diff-eq-analyzer/.venv/bin"
ExecStart=/home/wisetop/diff-eq-analyzer/.venv/bin/uvicorn dea_api:app --host 0.0.0.0 --port 8000
ExecStart=/home/wisetop/diff-eq-analyzer/.venv/bin/uvicorn dea_api:app --host 0.0.0.0 --port 8000 --ssl-keyfile=/home/wisetop/diff-eq-analyzer/certs/key.pem --ssl-certfile=/home/wisetop/diff-eq-analyzer/certs/cert.pem
Restart=always
RestartSec=3
+18 -15
View File
@@ -74,7 +74,7 @@
</div>
<div class="flex items-center gap-2">
<button @click="writeToMCU"
:disabled="writingMCU"
:disabled="writingMCU || !mcuConnected"
class="primary-action text-[10px] font-bold role-bg-primary role-hover-primary role-text-on-fill role-border-primary border px-2 py-1 rounded transition-colors uppercase disabled:opacity-50 disabled:cursor-not-allowed">
{{ writingMCU ? '寫入中' : '寫值到 MCU' }}
</button>
@@ -83,22 +83,25 @@
</div>
</h2>
<div class="mb-3 space-y-2">
<div class="flex items-center gap-2">
<select v-model="mcuPort"
class="flex-1 bg-slate-50 dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-2 text-xs role-focus-primary focus:ring-1 role-focus-ring-primary outline-none transition-all text-slate-900 dark:text-gray-100 truncate">
<option v-if="mcuPorts.length === 0" :value="mcuPort">
{{ loadingMCUPorts ? '偵測COM Port中...' : (mcuPort || '無可用COM Port') }}
</option>
<option v-for="port in mcuPorts" :key="port.device" :value="port.device">
{{ port.device }} - {{ port.description || 'Unknown' }}
</option>
</select>
<button @click="fetchMCUPorts"
:disabled="loadingMCUPorts"
class="text-[10px] font-bold bg-slate-200 dark:bg-gray-800 text-slate-600 dark:text-gray-300 hover:bg-slate-300 dark:hover:bg-gray-700 px-2 py-2 rounded transition-colors uppercase disabled:opacity-50 disabled:cursor-not-allowed">
更新
<div v-if="webSerialSupported" class="flex items-center gap-2">
<button v-if="!mcuConnected" @click="connectMCUPort"
class="flex-1 text-xs font-bold role-bg-primary role-hover-primary role-text-on-fill role-border-primary border px-3 py-2 rounded transition-colors uppercase">
選擇連接埠並連線
</button>
<template v-else>
<span class="flex items-center gap-1.5 flex-1 text-xs font-semibold text-emerald-600 dark:text-emerald-400">
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
MCU 已連線
</span>
<button @click="disconnectMCUPort"
class="text-[10px] font-bold bg-slate-200 dark:bg-gray-800 text-slate-600 dark:text-gray-300 hover:bg-slate-300 dark:hover:bg-gray-700 px-2 py-2 rounded transition-colors uppercase">
斷線
</button>
</template>
</div>
<p v-if="!webSerialSupported" class="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg px-3 py-2">
此瀏覽器不支援 Web SerialMCU 寫值功能需使用 Chrome Edge且透過 HTTPS 連線
</p>
<p v-if="mcuStatus" class="text-xs text-slate-500 dark:text-gray-400">{{ mcuStatus }}</p>
</div>
<select v-model="filterType" @change="onFilterTypeChange"
+45 -53
View File
@@ -1,4 +1,4 @@
const DEFAULT_FILTER_TYPE = "Lowpass (低通)";
const DEFAULT_FILTER_TYPE = "Lowpass (低通)";
const MANUAL_FILTER_TYPE = "(無) 手動自訂";
const DEFAULT_FS = 100000.0;
const DEFAULT_B_STR = "0.5, 0.5, 0.0, 0.0";
@@ -64,9 +64,9 @@ export default {
fixedAFineRepeatTimer: null,
isTouchInput: false,
activeCoeffAdjustment: null,
mcuPorts: [],
mcuPort: '',
loadingMCUPorts: false,
mcuSerialPort: null,
mcuConnected: false,
webSerialSupported: false,
writingMCU: false,
mcuStatus: '',
}
@@ -214,7 +214,10 @@ export default {
} else {
document.documentElement.classList.remove('dark');
}
this.fetchMCUPorts();
this.webSerialSupported = ('serial' in navigator);
if (!this.webSerialSupported) {
this.mcuStatus = '此瀏覽器不支援 Web Serial(需使用 Chrome 或 Edge,且透過 HTTPS 連線)';
}
this.applyFilterDesign();
},
beforeUnmount() {
@@ -1110,42 +1113,37 @@ export default {
} catch (e) { this.globalError = e.message; }
},
async fetchMCUPorts() {
this.loadingMCUPorts = true;
async connectMCUPort() {
if (!this.webSerialSupported) return;
this.mcuStatus = '';
try {
const res = await fetch('/api/mcu/ports');
if (!res.ok) {
let err = { detail: '無法取得 MCU COM Port清單' };
try {
err = await res.json();
} catch {
// Keep the default message when the server did not return JSON.
}
throw new Error(err.detail || '無法取得 MCU COM Port清單');
}
const data = await res.json();
this.mcuPorts = Array.isArray(data.ports) ? data.ports : [];
const devices = this.mcuPorts.map(port => port.device);
if (!this.mcuPort || !devices.includes(this.mcuPort)) {
if (data.default_port && devices.includes(data.default_port)) {
this.mcuPort = data.default_port;
} else if (devices.length > 0) {
this.mcuPort = devices[0];
} else {
this.mcuPort = data.default_port || '';
}
}
this.mcuStatus = devices.length > 0 ? `已偵測 ${devices.length} 個COM Port` : '未偵測到COM Port,將使用預設COM Port';
const port = await navigator.serial.requestPort();
await port.open({ baudRate: 115200 });
this.mcuSerialPort = port;
this.mcuConnected = true;
const info = port.getInfo();
const vid = info.usbVendorId ? `VID:0x${info.usbVendorId.toString(16)}` : '';
this.mcuStatus = `已連線${vid ? ' (' + vid + ')' : ''}`;
} catch (e) {
this.mcuPorts = [];
this.mcuStatus = e.message;
} finally {
this.loadingMCUPorts = false;
if (e.name === 'NotFoundError') {
this.mcuStatus = '未選擇連接埠';
} else {
this.mcuStatus = `連線失敗: ${e.message}`;
}
}
},
async disconnectMCUPort() {
if (this.mcuSerialPort) {
try {
await this.mcuSerialPort.close();
} catch { /* ignore close errors */ }
this.mcuSerialPort = null;
}
this.mcuConnected = false;
this.mcuStatus = '已斷線';
},
mcuCommandFromFixedCoeffs() {
const bVals = this.fixedPointCoeffs.b.map(item => item.val);
const aVals = this.fixedPointCoeffs.a.map(item => item.val);
@@ -1158,33 +1156,27 @@ export default {
},
async writeToMCU() {
if (!this.mcuSerialPort || !this.mcuConnected) {
this.mcuStatus = '請先連線 MCU 連接埠';
return;
}
this.writingMCU = true;
this.globalError = null;
this.mcuStatus = '';
const command = this.mcuCommandFromFixedCoeffs();
try {
const res = await fetch('/api/mcu/write', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command, port: this.mcuPort || null }),
});
if (!res.ok) {
let err = { detail: 'MCU 寫入失敗' };
try {
err = await res.json();
} catch {
// Keep the default message when the server did not return JSON.
}
throw new Error(err.detail || 'MCU 寫入失敗');
const encoder = new TextEncoder();
const writer = this.mcuSerialPort.writable.getWriter();
try {
await writer.write(encoder.encode(command + '\r\n'));
} finally {
writer.releaseLock();
}
const data = await res.json();
this.mcuStatus = `已送出 ${data.command}${data.port}`;
this.mcuStatus = `已送出 ${command}`;
} catch (e) {
this.globalError = e.message;
this.mcuStatus = e.message;
this.mcuStatus = `寫入失敗: ${e.message}`;
} finally {
this.writingMCU = false;
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
File diff suppressed because one or more lines are too long