feat(deploy-fab): auto-reset Arduino when starting serial monitor
This commit is contained in:
parent
f6b57c08d1
commit
87364da0c5
|
|
@ -0,0 +1,677 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { BLEHardwareDeployer } from '$services/ble-deployer';
|
||||
import {
|
||||
SUPPORTED_BAUD_RATES,
|
||||
DEFAULT_BAUD_RATE,
|
||||
type DeployState,
|
||||
type DeployProgress
|
||||
} from '$types/deployer';
|
||||
|
||||
let {
|
||||
velxioIframe,
|
||||
onPushSerial,
|
||||
onSerialStart,
|
||||
onStopSerial
|
||||
}: {
|
||||
velxioIframe: HTMLIFrameElement | null;
|
||||
onPushSerial: (text: string) => void;
|
||||
onSerialStart: () => void;
|
||||
onStopSerial: () => void;
|
||||
} = $props();
|
||||
|
||||
let deployer = $state<BLEHardwareDeployer | null>(null);
|
||||
let isPaired = $state(false);
|
||||
let cancelled = $state(false);
|
||||
let deployState = $state<DeployState>('idle');
|
||||
let deployMessage = $state('');
|
||||
let totalChunks = $state(0);
|
||||
let completedChunks = $state(0);
|
||||
let progressPercent = $derived(totalChunks > 0 ? Math.round((completedChunks / totalChunks) * 100) : 0);
|
||||
let bleSupported = $state(true);
|
||||
let errorMessage = $state('');
|
||||
let panelOpen = $state(false);
|
||||
let pendingHex = $state<string | null>(null);
|
||||
let serialActive = $state(false);
|
||||
let serialPendingBaud: number | null = null;
|
||||
let baudRate = $state(DEFAULT_BAUD_RATE);
|
||||
|
||||
export function setHex(hex: string | null) {
|
||||
pendingHex = hex;
|
||||
}
|
||||
|
||||
function initDeployer() {
|
||||
deployer = new BLEHardwareDeployer();
|
||||
bleSupported = deployer.checkSupport();
|
||||
if (!bleSupported) {
|
||||
deployState = 'error';
|
||||
errorMessage = 'Web Bluetooth tidak didukung. Gunakan Chrome Android.';
|
||||
}
|
||||
deployer.onDisconnected(() => {
|
||||
deployState = 'error';
|
||||
errorMessage = 'Koneksi BLE terputus.';
|
||||
serialActive = false;
|
||||
});
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
deployState = 'idle';
|
||||
deployMessage = '';
|
||||
totalChunks = 0;
|
||||
completedChunks = 0;
|
||||
errorMessage = '';
|
||||
serialActive = false;
|
||||
isPaired = false;
|
||||
}
|
||||
|
||||
function handlePair() {
|
||||
try {
|
||||
deployState = 'pairing';
|
||||
deployMessage = 'Pilih perangkat Velxio Deployer...';
|
||||
deployer!.pair().then(() => {
|
||||
isPaired = true;
|
||||
deployState = 'idle';
|
||||
deployMessage = 'Perangkat terhubung';
|
||||
}).catch((err: any) => {
|
||||
deployState = 'error';
|
||||
errorMessage = err.message || 'Gagal pairing';
|
||||
});
|
||||
} catch (err: any) {
|
||||
deployState = 'error';
|
||||
errorMessage = err.message || 'Gagal pairing';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeploy() {
|
||||
if (!deployer || !isPaired) {
|
||||
errorMessage = 'Hubungkan perangkat dulu';
|
||||
return;
|
||||
}
|
||||
if (!pendingHex) {
|
||||
errorMessage = 'Tidak ada HEX untuk di-deploy. Compile dulu di editor.';
|
||||
return;
|
||||
}
|
||||
|
||||
serialActive = false;
|
||||
|
||||
cancelled = false;
|
||||
try {
|
||||
deployState = 'transferring';
|
||||
deployMessage = 'Mengirim data ke Velxio Deployer...';
|
||||
|
||||
const onProgress = (p: DeployProgress) => {
|
||||
deployState = p.state;
|
||||
deployMessage = p.message;
|
||||
totalChunks = p.totalChunks;
|
||||
completedChunks = p.completedChunks;
|
||||
};
|
||||
|
||||
await deployer.deployHex(pendingHex, onProgress);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
deployState = 'success';
|
||||
deployMessage = 'Upload berhasil! LED Hijau.';
|
||||
handleOpenSerial();
|
||||
} catch (err: any) {
|
||||
if (cancelled) return;
|
||||
deployState = 'error';
|
||||
errorMessage = err.message || 'Deploy gagal';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOpenSerial() {
|
||||
if (!deployer || !isPaired) return;
|
||||
|
||||
try {
|
||||
// Auto-reset Arduino before opening serial monitor
|
||||
console.log('[DEPLOY-FAB] Auto-resetting Arduino before serial monitor...');
|
||||
await deployer.resetArduino();
|
||||
|
||||
if (serialPendingBaud !== null) {
|
||||
await deployer.setBaudRate(serialPendingBaud);
|
||||
baudRate = serialPendingBaud;
|
||||
serialPendingBaud = null;
|
||||
}
|
||||
serialActive = true;
|
||||
onSerialStart();
|
||||
await deployer.startSerialMonitor((text) => {
|
||||
onPushSerial(text);
|
||||
});
|
||||
} catch (err: any) {
|
||||
serialActive = false;
|
||||
errorMessage = 'Gagal membuka Serial Monitor: ' + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCloseSerial() {
|
||||
deployer?.stopSerialMonitor();
|
||||
serialActive = false;
|
||||
onStopSerial();
|
||||
}
|
||||
|
||||
async function handleBaudChange(e: Event) {
|
||||
const newBaud = parseInt((e.target as HTMLSelectElement).value);
|
||||
if (serialActive && deployer) {
|
||||
try {
|
||||
await deployer.setBaudRate(newBaud);
|
||||
baudRate = newBaud;
|
||||
} catch (err: any) {
|
||||
errorMessage = 'Gagal mengubah baud: ' + err.message;
|
||||
}
|
||||
} else {
|
||||
serialPendingBaud = newBaud;
|
||||
baudRate = newBaud;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDisconnect() {
|
||||
deployer?.disconnect();
|
||||
resetState();
|
||||
}
|
||||
|
||||
function handleRetry() {
|
||||
resetState();
|
||||
pendingHex = null;
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
cancelled = true;
|
||||
deployState = 'idle';
|
||||
deployMessage = '';
|
||||
errorMessage = 'Deploy dibatalkan';
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
panelOpen = !panelOpen;
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
panelOpen = false;
|
||||
}
|
||||
|
||||
initDeployer();
|
||||
|
||||
onDestroy(() => {
|
||||
if (isPaired) {
|
||||
deployer?.disconnect();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- FAB trigger button -->
|
||||
<button
|
||||
class="fab-trigger"
|
||||
class:panel-open={panelOpen}
|
||||
onclick={togglePanel}
|
||||
aria-label={panelOpen ? 'Tutup panel deploy' : 'Buka panel deploy'}
|
||||
title="BLE Deployer"
|
||||
>
|
||||
<svg class="fab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M6 9a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V9z"/>
|
||||
<path d="M12 15v3"/>
|
||||
<path d="M8 21h8"/>
|
||||
<path d="M12 3L9 6h6l-3-3z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Deploy panel -->
|
||||
{#if panelOpen}
|
||||
<div class="fab-panel-backdrop" onclick={closePanel}></div>
|
||||
<div class="fab-panel">
|
||||
<div class="fab-panel-header">
|
||||
<h3 class="panel-title">BLE Deployer</h3>
|
||||
<div class="panel-status" class:connected={isPaired}>
|
||||
<span class="status-dot"></span>
|
||||
{isPaired ? 'Terhubung' : 'Terputus'}
|
||||
</div>
|
||||
<button class="panel-close" onclick={closePanel} aria-label="Tutup">×</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<!-- Connection -->
|
||||
<div class="panel-section">
|
||||
{#if !isPaired}
|
||||
<button class="btn btn-primary btn-full" onclick={handlePair} disabled={deployState === 'pairing'}>
|
||||
{deployState === 'pairing' ? 'Menghubungkan...' : 'Pair Device'}
|
||||
</button>
|
||||
{:else}
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-success btn-full" onclick={handleDeploy}
|
||||
disabled={deployState === 'transferring' || deployState === 'flashing' || !pendingHex}>
|
||||
{deployState === 'transferring' ? 'Mengirim...' : deployState === 'flashing' ? 'Flashing...' : 'Deploy'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
{#if deployState === 'transferring' || deployState === 'flashing'}
|
||||
<button class="btn btn-outline btn-sm" onclick={handleCancel}>Batal</button>
|
||||
{:else}
|
||||
<button class="btn btn-outline btn-sm" onclick={handleDisconnect}>Putuskan</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Hex status -->
|
||||
<div class="hex-status">
|
||||
<span class="hex-label">HEX:</span>
|
||||
<span class="hex-value" class:hex-loaded={!!pendingHex}>
|
||||
{pendingHex ? `${Math.round(pendingHex.length / 1024)} KB siap` : 'Belum ada (compile di editor)'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress -->
|
||||
{#if deployState !== 'idle' && deployState !== 'error'}
|
||||
<div class="panel-section">
|
||||
<div class="progress-message">{deployMessage}</div>
|
||||
{#if totalChunks > 0}
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar-fill" style="width: {progressPercent}%"></div>
|
||||
</div>
|
||||
<div class="progress-text">
|
||||
Chunk {completedChunks}/{totalChunks} ({progressPercent}%)
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Serial controls -->
|
||||
{#if isPaired}
|
||||
<div class="panel-section">
|
||||
<div class="serial-header">
|
||||
<span>Serial Monitor</span>
|
||||
<div class="serial-controls">
|
||||
<select class="baud-select" bind:value={baudRate} onchange={handleBaudChange}>
|
||||
{#each SUPPORTED_BAUD_RATES as b}
|
||||
<option value={b}>{b}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="baud-label">baud</span>
|
||||
<button class="btn btn-sm btn-outline" onclick={serialActive ? handleCloseSerial : handleOpenSerial}>
|
||||
{serialActive ? 'Tutup' : 'Mulai'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if !serialActive}
|
||||
<div class="serial-hint">Serial data akan dikirim ke editor ({baudRate} baud).</div>
|
||||
{:else}
|
||||
<div class="serial-active-hint">Serial monitor aktif — data dikirim ke editor.</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Info / Cancel message -->
|
||||
{#if deployState === 'idle' && errorMessage}
|
||||
<div class="panel-info">
|
||||
<div class="info-message">{errorMessage}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error -->
|
||||
{#if deployState === 'error'}
|
||||
<div class="panel-error">
|
||||
<div class="error-icon">⚠</div>
|
||||
<div class="error-message">{errorMessage}</div>
|
||||
<button class="btn btn-outline btn-sm" onclick={handleRetry}>Coba Lagi</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !bleSupported && deployState !== 'error'}
|
||||
<div class="panel-error">
|
||||
<div class="error-message">Web Bluetooth tidak didukung di browser ini. Gunakan Chrome Android.</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* ─── FAB Button ─── */
|
||||
.fab-trigger {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.4);
|
||||
z-index: 1000;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.fab-trigger:hover {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
.fab-trigger:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.fab-trigger.panel-open {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.fab-icon {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
/* ─── Panel Backdrop ─── */
|
||||
.fab-panel-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
z-index: 998;
|
||||
}
|
||||
|
||||
/* ─── Panel ─── */
|
||||
.fab-panel {
|
||||
position: fixed;
|
||||
bottom: 84px;
|
||||
right: 20px;
|
||||
width: 320px;
|
||||
max-height: 80vh;
|
||||
background: #1e1e2e;
|
||||
color: #e0e0e0;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
z-index: 999;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: panelSlideUp 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes panelSlideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fab-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: #181825;
|
||||
border-bottom: 1px solid #313244;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.panel-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.panel-status.connected {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #888;
|
||||
}
|
||||
|
||||
.panel-status.connected .status-dot {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.panel-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
font-size: 1.3rem;
|
||||
cursor: pointer;
|
||||
padding: 0 0 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.panel-close:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 12px 16px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ─── Buttons ─── */
|
||||
.btn {
|
||||
font-size: 0.85rem;
|
||||
padding: 8px 14px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, opacity 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 5px 10px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #22c55e;
|
||||
color: #fff;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-success:hover:not(:disabled) {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #555;
|
||||
}
|
||||
|
||||
.btn-outline:hover:not(:disabled) {
|
||||
background: #313244;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ─── Hex Status ─── */
|
||||
.hex-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.78rem;
|
||||
padding: 6px 10px;
|
||||
background: #11111b;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.hex-label {
|
||||
color: #888;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hex-value {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.hex-value.hex-loaded {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
/* ─── Progress ─── */
|
||||
.progress-message {
|
||||
font-size: 0.8rem;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #313244;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #3b82f6, #22c55e);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 0.72rem;
|
||||
color: #888;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.progress-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid #313244;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin: 6px auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ─── Serial ─── */
|
||||
.serial-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.serial-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.baud-select {
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
background: #313244;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.baud-label {
|
||||
font-size: 0.7rem;
|
||||
color: #888;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.serial-hint,
|
||||
.serial-active-hint {
|
||||
font-size: 0.72rem;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.serial-active-hint {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
/* ─── Info / Cancel message ─── */
|
||||
.panel-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #3b3b6e;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.info-message {
|
||||
font-size: 0.8rem;
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
/* ─── Error ─── */
|
||||
.panel-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
background: #2e1a1a;
|
||||
border: 1px solid #5c2a2a;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 1.2rem;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 0.8rem;
|
||||
color: #fca5a5;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue