first commit

This commit is contained in:
2026-03-24 23:24:36 +01:00
commit a69394a05b
1638 changed files with 891299 additions and 0 deletions

View File

@@ -0,0 +1,387 @@
<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>