feat: enable HTTPS for API and update MCU Web Serial connection UI
This commit is contained in:
@@ -39,3 +39,6 @@ Desktop.ini
|
||||
|
||||
# Agent Skills
|
||||
.agents/
|
||||
|
||||
# SSL certs (private keys)
|
||||
certs/*.pem
|
||||
|
||||
@@ -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
@@ -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
@@ -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 Serial。MCU 寫值功能需使用 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
@@ -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;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+3
-2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user