Files
SatMaster/frontend/src/components/SettingsPanel.svelte
2026-03-24 23:24:36 +01:00

387 lines
16 KiB
Svelte
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { settings, flexConnected, rotorConnected, soundEnabled } from '../stores/satstore.js'
import { Go } from '../lib/wails.js'
import { maidenheadToLatLon, latLonToMaidenhead } from '../lib/maidenhead.js'
let saving = false
let flexStatus = ''
let rotorStatus = ''
let tleStatus = ''
let qraError = ''
let qraInput = ''
let rxSlice = 0 // Slice A = RX downlink
let txSlice = 1 // Slice B = TX uplink
// Voice selection
let availableVoices = []
let selectedVoiceName = ''
// Load slice config on mount
import { onMount } from 'svelte'
onMount(async () => {
// Always load from persisted settings
rxSlice = $settings.rxSlice ?? 0
txSlice = $settings.txSlice ?? 1
// Load available TTS voices
const loadVoices = () => {
const voices = window.speechSynthesis?.getVoices() || []
availableVoices = voices.filter(v => v.lang.startsWith('en'))
// Also include all voices if no English ones found
if (availableVoices.length === 0) availableVoices = voices
selectedVoiceName = $settings.ttsVoiceName || ''
}
loadVoices()
// Chrome/Wails loads voices async
if (window.speechSynthesis) {
window.speechSynthesis.onvoiceschanged = loadVoices
}
// Apply to Go if already connected
if ($flexConnected) {
await Go.SetSliceConfig(rxSlice, txSlice)
}
})
async function applySlices() {
await Go.SetSliceConfig(rxSlice, txSlice)
settings.update(s => ({ ...s, rxSlice, txSlice }))
flexStatus = `Slices: RX=Slice ${'ABCDEFGH'[rxSlice]}, TX=Slice ${'ABCDEFGH'[txSlice]}`
}
function saveVoice() {
settings.update(s => ({ ...s, ttsVoiceName: selectedVoiceName }))
}
// Load once - do NOT subscribe reactively (would overwrite user edits)
let local = { ...$settings }
$: qraFromCoords = (local.qthLat && local.qthLon)
? latLonToMaidenhead(local.qthLat, local.qthLon)
: ''
function applyQRA() {
qraError = ''
const result = maidenheadToLatLon(qraInput)
if (!result) { qraError = 'Invalid locator (e.g. JN03ef)'; return }
local.qthLat = result.lat
local.qthLon = result.lon
}
async function saveQTH() {
saving = true
settings.update(s => ({ ...s, qthLat: local.qthLat, qthLon: local.qthLon, qthAlt: local.qthAlt }))
await Go.SetObserverLocation(local.qthLat, local.qthLon, local.qthAlt)
saving = false
}
async function connectFlex() {
flexStatus = 'Connecting…'
// Persist host/port before connecting
settings.update(s => ({ ...s, flexHost: local.flexHost, flexPort: local.flexPort }))
const res = await Go.ConnectFlexRadio(local.flexHost, local.flexPort)
if (res === 'OK') {
flexConnected.set(true)
flexStatus = 'Connected ✓'
await Go.SetSatelliteFrequencies(local.downlinkHz, local.uplinkHz)
} else { flexStatus = res || 'Error' }
}
async function disconnectFlex() {
await Go.DisconnectFlexRadio()
flexConnected.set(false)
flexStatus = 'Disconnected'
}
async function connectRotor() {
rotorStatus = 'Connecting…'
settings.update(s => ({ ...s, rotorHost: local.rotorHost, rotorPort: local.rotorPort, rotorAzOnly: local.rotorAzOnly }))
const res = await Go.ConnectRotor(local.rotorHost, local.rotorPort)
if (res === 'OK') {
rotorConnected.set(true); rotorStatus = '✓ Connected'
await Go.SetRotorAzOnly(local.rotorAzOnly ?? true)
}
else { rotorStatus = res || 'Error' }
}
async function disconnectRotor() {
await Go.DisconnectRotor()
rotorConnected.set(false)
rotorStatus = 'Disconnected'
}
async function refreshTLE() {
tleStatus = 'Downloading…'
const res = await Go.RefreshTLE()
tleStatus = res === 'OK' ? 'Updated ✓' : res
setTimeout(() => tleStatus = '', 3000)
}
function toggleAutoFlex() {
local.autoConnectFlex = !local.autoConnectFlex
settings.update(s => ({ ...s, autoConnectFlex: local.autoConnectFlex }))
}
function toggleAutoRotor() {
local.autoConnectRotor = !local.autoConnectRotor
settings.update(s => ({ ...s, autoConnectRotor: local.autoConnectRotor }))
}
</script>
<div class="sp">
<div class="sp-scroll">
<!-- QTH -->
<section class="card">
<h3>📍 QTH / Locator</h3>
<div class="qra-row">
<div class="field-col">
<label>Maidenhead Locator</label>
<div class="inp-btn">
<input type="text" bind:value={qraInput} placeholder="JN03ef"
maxlength="8" class="mono upper"
on:keydown={e => e.key==='Enter' && applyQRA()} />
<button class="btn-sm" on:click={applyQRA}>Apply</button>
</div>
{#if qraError}<span class="err">{qraError}</span>{/if}
</div>
<div class="qra-display">
<label>Current QRA</label>
<span class="qra-big">{qraFromCoords || '—'}</span>
</div>
</div>
<div class="sep">or enter coordinates manually</div>
<div class="fg">
<label>Latitude</label>
<input type="number" bind:value={local.qthLat} step="0.0001" min="-90" max="90" />
<label>Longitude</label>
<input type="number" bind:value={local.qthLon} step="0.0001" min="-180" max="180" />
<label>Altitude (m)</label>
<input type="number" bind:value={local.qthAlt} step="1" min="0" />
</div>
<button class="btn-primary" on:click={saveQTH} disabled={saving}>
{saving ? 'Saving…' : '💾 Save QTH'}
</button>
</section>
<!-- FlexRadio -->
<section class="card">
<div class="card-head">
<h3>🔌 FlexRadio</h3>
<span class="badge {$flexConnected ? 'ok' : 'off'}">{$flexConnected ? 'CONNECTED' : 'OFFLINE'}</span>
</div>
<div class="fg">
<label>Host / IP</label>
<input type="text" bind:value={local.flexHost} placeholder="192.168.1.x" />
<label>TCP Port</label>
<input type="number" bind:value={local.flexPort} />
</div>
<div class="toggle-row">
<button class="toggle {local.autoConnectFlex ? 'toggle-on' : ''}" on:click={toggleAutoFlex}>
{local.autoConnectFlex ? '✓' : '○'} Auto-connect on startup
</button>
</div>
<div class="btn-row">
{#if $flexConnected}
<button class="btn-danger" on:click={disconnectFlex}>Disconnect</button>
{:else}
<button class="btn-primary" on:click={connectFlex}>Connect</button>
{/if}
{#if flexStatus}
<span class="status {flexStatus.includes('✓') || flexStatus.includes('Slice') ? 'ok-txt' : 'err-txt'}">{flexStatus}</span>
{/if}
</div>
<!-- Satellite Slice Configuration -->
<div class="slice-config">
<div class="slice-label">SATELLITE SLICES</div>
<div class="slice-row">
<span class="slice-lbl">↓ RX Downlink</span>
<select bind:value={rxSlice} class="slice-sel">
{#each ['A','B','C','D'] as l, i}
<option value={i}>Slice {l}</option>
{/each}
</select>
</div>
<div class="slice-row">
<span class="slice-lbl">↑ TX Uplink</span>
<select bind:value={txSlice} class="slice-sel">
{#each ['A','B','C','D'] as l, i}
<option value={i}>Slice {l}</option>
{/each}
</select>
</div>
<button class="btn-secondary btn-sm" on:click={applySlices}>Apply slices</button>
<p class="hint">Default: Slice A = RX downlink, Slice B = TX uplink</p>
</div>
</section>
<!-- PstRotator -->
<section class="card">
<div class="card-head">
<h3>🔄 PstRotator</h3>
<span class="badge {$rotorConnected ? 'ok' : 'off'}">{$rotorConnected ? 'CONNECTED' : 'OFFLINE'}</span>
</div>
<div class="fg">
<label>Host / IP</label>
<input type="text" bind:value={local.rotorHost} placeholder="127.0.0.1" />
<label>UDP Port</label>
<input type="number" bind:value={local.rotorPort} />
</div>
<div class="toggle-row">
<button class="toggle {local.autoConnectRotor ? 'toggle-on' : ''}" on:click={toggleAutoRotor}>
{local.autoConnectRotor ? '✓' : '○'} Auto-connect on startup
</button>
</div>
<div class="btn-row">
{#if $rotorConnected}
<button class="btn-danger" on:click={disconnectRotor}>Disconnect</button>
{:else}
<button class="btn-primary" on:click={connectRotor}>Connect</button>
{/if}
{#if rotorStatus}
<span class="status {rotorStatus.includes('✓') ? 'ok-txt' : 'err-txt'}">{rotorStatus}</span>
{/if}
</div>
<div class="slice-config">
<div class="slice-label">ROTOR MODE</div>
<div class="slice-row">
<span class="slice-lbl">Axes</span>
<select bind:value={local.rotorAzOnly} class="slice-sel" on:change={() => Go.SetRotorAzOnly(local.rotorAzOnly ?? true)}>
<option value={true}>Azimuth only</option>
<option value={false}>Azimuth + Elevation</option>
</select>
</div>
<p class="hint">Select "Azimuth only" if you don't have an elevation rotor</p>
</div>
</section>
<!-- Voice Alerts -->
<section class="card">
<div class="card-head">
<h3>🔊 Voice Alerts</h3>
<button class="toggle {$soundEnabled ? 'toggle-on' : ''}" on:click={() => soundEnabled.update(v => !v)}>
{$soundEnabled ? '✓ Enabled' : '○ Disabled'}
</button>
</div>
<div class="fg">
<label>TTS Voice</label>
<select bind:value={selectedVoiceName} on:change={saveVoice} disabled={!$soundEnabled}>
<option value="">— System default —</option>
{#each availableVoices as v}
<option value={v.name}>{v.name} ({v.lang})</option>
{/each}
</select>
</div>
{#if availableVoices.length === 0}
<p class="hint">No voices detected. Install English voices via Windows Settings → Speech.</p>
{:else}
<p class="hint">{availableVoices.length} English voice{availableVoices.length > 1 ? 's' : ''} available. Recommended: Microsoft David or Zira.</p>
{/if}
<div class="btn-row">
<button class="btn-secondary btn-sm" disabled={!$soundEnabled} on:click={() => {
if (!window.speechSynthesis) return
const utt = new SpeechSynthesisUtterance('AOS ISS is rising. Pass will last for 8 minutes.')
utt.lang = 'en-US'
if (selectedVoiceName) {
const v = availableVoices.find(v => v.name === selectedVoiceName)
if (v) utt.voice = v
}
window.speechSynthesis.cancel()
window.speechSynthesis.speak(utt)
}}>▶ Test voice</button>
</div>
</section>
<!-- TLE -->
<section class="card">
<h3>🛰️ TLE Data</h3>
<p class="hint">Source: Celestrak amateur · Fallback: PE0SAT</p>
<div class="btn-row">
<button class="btn-secondary" on:click={refreshTLE}>↻ Refresh TLE</button>
{#if tleStatus}
<span class="status {tleStatus.includes('✓') ? 'ok-txt' : 'err-txt'}">{tleStatus}</span>
{/if}
</div>
</section>
<!-- About -->
<section class="card">
<h3> About</h3>
<p><strong>SatMaster v1.0</strong> — F4BPO</p>
<p>Amateur satellite tracking · FlexRadio Doppler correction · PstRotator control</p>
<p class="hint">SGP4 via akhenakh/sgp4 · Go + Wails + Svelte</p>
</section>
</div>
</div>
<style>
.sp { height:100%; background:#f0f2f5; overflow:hidden; font-family:'Inter',sans-serif; }
.sp-scroll { height:100%; overflow-y:auto; padding:16px 20px; display:flex; flex-direction:column; gap:14px; max-width:680px; margin:0 auto; }
.sp-scroll::-webkit-scrollbar { width:5px; }
.sp-scroll::-webkit-scrollbar-thumb { background:#c0ccd8; border-radius:3px; }
.card { background:white; border:1px solid #e0e8f0; border-radius:12px; padding:16px 18px; display:flex; flex-direction:column; gap:10px; box-shadow:0 1px 3px rgba(0,0,0,0.05); }
.card-head { display:flex; align-items:center; justify-content:space-between; }
h3 { font-size:14px; font-weight:600; color:#1a2a3a; margin:0; }
.badge { font-size:10px; font-weight:700; padding:3px 10px; border-radius:20px; letter-spacing:.04em; }
.badge.ok { background:#e0f7ee; color:#1a7a40; }
.badge.off { background:#fef0f0; color:#c03030; }
.qra-row { display:flex; gap:16px; align-items:flex-start; }
.field-col { flex:1; display:flex; flex-direction:column; gap:5px; }
.inp-btn { display:flex; gap:6px; }
.qra-display { display:flex; flex-direction:column; gap:4px; min-width:100px; }
.qra-big { font-size:22px; font-weight:700; color:#1a5a9a; font-family:'Inter',sans-serif; letter-spacing:.1em; }
.mono { font-family:'Inter',sans-serif; letter-spacing:.08em; text-transform:uppercase; }
.err { font-size:11px; color:#c03030; }
.sep { font-size:11px; color:#9ab0c8; text-align:center; padding:4px 0; border-top:1px solid #e8edf2; margin-top:2px; }
.fg { display:grid; grid-template-columns:130px 1fr; gap:8px 12px; align-items:center; }
label { font-size:12px; color:#5a7a9a; font-weight:500; }
input, select {
background:#f8fafc; border:1.5px solid #d0dce8; color:#1a2a3a;
padding:8px 10px; border-radius:8px; font-size:13px;
font-family:'Inter',sans-serif; outline:none; width:100%;
transition:border-color .15s, box-shadow .15s;
}
input:focus, select:focus { border-color:#1a5a9a; box-shadow:0 0 0 3px rgba(26,90,154,0.1); }
.freq-row { display:flex; gap:8px; align-items:center; }
.freq-tag { font-size:12px; color:#1a5a9a; font-weight:700; white-space:nowrap; min-width:90px; font-family:'Inter',sans-serif; }
.toggle-row { display:flex; }
.toggle { background:#f8fafc; border:1.5px solid #d0dce8; color:#5a7a9a; font-size:12px; padding:7px 12px; border-radius:8px; cursor:pointer; font-family:'Inter',sans-serif; transition:all .15s; }
.toggle:hover { border-color:#1a5a9a; }
.toggle.toggle-on { background:#e8f0fa; border-color:#1a5a9a; color:#1a5a9a; font-weight:600; }
.btn-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
button { padding:8px 18px; border-radius:8px; font-size:13px; font-weight:600; cursor:pointer; border:none; transition:all .15s; font-family:'Inter',sans-serif; }
button:disabled { opacity:.4; cursor:not-allowed; }
button:hover:not(:disabled) { filter:brightness(.95); }
.btn-sm { padding:6px 12px; font-size:12px; }
.btn-primary { background:#1a5a9a; color:white; }
.btn-secondary { background:#f0f4f8; color:#1a5a9a; border:1.5px solid #d0dce8; }
.btn-apply { background:#f0f4f8; color:#1a5a9a; border:1.5px solid #d0dce8; padding:7px 12px; }
.btn-danger { background:#fff0f0; color:#c03030; border:1.5px solid #f0c0c0; }
.status { font-size:12px; font-weight:600; }
.ok-txt { color:#1a7a40; }
.err-txt { color:#c03030; }
.hint { font-size:11px; color:#9ab0c8; }
p { font-size:13px; color:#5a7a9a; line-height:1.6; }
strong { color:#1a2a3a; }
/* Slice config */
.slice-config { display:flex; flex-direction:column; gap:8px; padding:12px; background:#f8fafc; border:1.5px solid #e0e8f0; border-radius:8px; }
.slice-label { font-size:10px; font-weight:600; color:#9ab0c8; text-transform:uppercase; letter-spacing:.08em; }
.slice-row { display:flex; align-items:center; gap:10px; }
.slice-lbl { font-size:12px; color:#5a7a9a; font-weight:500; width:110px; }
.slice-sel { background:#f8fafc; border:1.5px solid #d0dce8; color:#1a2a3a; padding:6px 10px; border-radius:6px; font-family:'Inter',sans-serif; font-size:12px; outline:none; cursor:pointer; width:auto; }
</style>