387 lines
16 KiB
Svelte
387 lines
16 KiB
Svelte
<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> |