|
|
@@ -0,0 +1,875 @@ |
|
|
|
|
|
<!DOCTYPE html> |
|
|
|
|
|
<html lang="en"> |
|
|
|
|
|
<head> |
|
|
|
|
|
<meta charset="utf-8"> |
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1"> |
|
|
|
|
|
<title>fm-rds-tx</title> |
|
|
|
|
|
<style> |
|
|
|
|
|
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Archivo+Black&display=swap'); |
|
|
|
|
|
|
|
|
|
|
|
:root { |
|
|
|
|
|
--bg: #0a0a0c; |
|
|
|
|
|
--surface: #111116; |
|
|
|
|
|
--surface2: #18181e; |
|
|
|
|
|
--border: #2a2a35; |
|
|
|
|
|
--text: #d4d4dc; |
|
|
|
|
|
--text-dim: #6a6a78; |
|
|
|
|
|
--accent: #ff3b30; |
|
|
|
|
|
--accent-glow: #ff3b3044; |
|
|
|
|
|
--green: #30d158; |
|
|
|
|
|
--green-glow: #30d15844; |
|
|
|
|
|
--amber: #ff9f0a; |
|
|
|
|
|
--amber-glow: #ff9f0a44; |
|
|
|
|
|
--blue: #0a84ff; |
|
|
|
|
|
--mono: 'JetBrains Mono', monospace; |
|
|
|
|
|
--display: 'Archivo Black', sans-serif; |
|
|
|
|
|
--radius: 6px; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
* { box-sizing: border-box; margin: 0; padding: 0; } |
|
|
|
|
|
|
|
|
|
|
|
body { |
|
|
|
|
|
background: var(--bg); |
|
|
|
|
|
color: var(--text); |
|
|
|
|
|
font-family: var(--mono); |
|
|
|
|
|
font-size: 13px; |
|
|
|
|
|
line-height: 1.5; |
|
|
|
|
|
min-height: 100vh; |
|
|
|
|
|
overflow-x: hidden; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/* Scan lines overlay */ |
|
|
|
|
|
body::before { |
|
|
|
|
|
content: ''; |
|
|
|
|
|
position: fixed; inset: 0; |
|
|
|
|
|
background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.03) 2px, rgba(0,0,0,0.03) 4px); |
|
|
|
|
|
pointer-events: none; z-index: 1000; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.app { |
|
|
|
|
|
max-width: 900px; |
|
|
|
|
|
margin: 0 auto; |
|
|
|
|
|
padding: 16px; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/* Header */ |
|
|
|
|
|
.header { |
|
|
|
|
|
display: flex; |
|
|
|
|
|
align-items: center; |
|
|
|
|
|
justify-content: space-between; |
|
|
|
|
|
padding: 16px 0 24px; |
|
|
|
|
|
border-bottom: 1px solid var(--border); |
|
|
|
|
|
margin-bottom: 20px; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.header h1 { |
|
|
|
|
|
font-family: var(--display); |
|
|
|
|
|
font-size: 22px; |
|
|
|
|
|
letter-spacing: 2px; |
|
|
|
|
|
text-transform: uppercase; |
|
|
|
|
|
color: var(--accent); |
|
|
|
|
|
text-shadow: 0 0 20px var(--accent-glow), 0 0 40px var(--accent-glow); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.header-status { |
|
|
|
|
|
display: flex; |
|
|
|
|
|
align-items: center; |
|
|
|
|
|
gap: 12px; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/* LED indicator */ |
|
|
|
|
|
.led { |
|
|
|
|
|
width: 10px; height: 10px; |
|
|
|
|
|
border-radius: 50%; |
|
|
|
|
|
background: #333; |
|
|
|
|
|
box-shadow: none; |
|
|
|
|
|
transition: all 0.3s; |
|
|
|
|
|
} |
|
|
|
|
|
.led.on-green { |
|
|
|
|
|
background: var(--green); |
|
|
|
|
|
box-shadow: 0 0 8px var(--green), 0 0 20px var(--green-glow); |
|
|
|
|
|
} |
|
|
|
|
|
.led.on-red { |
|
|
|
|
|
background: var(--accent); |
|
|
|
|
|
box-shadow: 0 0 8px var(--accent), 0 0 20px var(--accent-glow); |
|
|
|
|
|
} |
|
|
|
|
|
.led.on-amber { |
|
|
|
|
|
background: var(--amber); |
|
|
|
|
|
box-shadow: 0 0 8px var(--amber), 0 0 20px var(--amber-glow); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/* TX control bar */ |
|
|
|
|
|
.tx-bar { |
|
|
|
|
|
display: flex; |
|
|
|
|
|
gap: 10px; |
|
|
|
|
|
align-items: center; |
|
|
|
|
|
background: var(--surface); |
|
|
|
|
|
border: 1px solid var(--border); |
|
|
|
|
|
border-radius: var(--radius); |
|
|
|
|
|
padding: 12px 16px; |
|
|
|
|
|
margin-bottom: 16px; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.tx-bar .freq-display { |
|
|
|
|
|
font-family: var(--display); |
|
|
|
|
|
font-size: 32px; |
|
|
|
|
|
color: var(--green); |
|
|
|
|
|
text-shadow: 0 0 15px var(--green-glow); |
|
|
|
|
|
letter-spacing: 1px; |
|
|
|
|
|
min-width: 200px; |
|
|
|
|
|
} |
|
|
|
|
|
.tx-bar .freq-display .unit { |
|
|
|
|
|
font-family: var(--mono); |
|
|
|
|
|
font-size: 14px; |
|
|
|
|
|
color: var(--text-dim); |
|
|
|
|
|
margin-left: 4px; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.tx-btn { |
|
|
|
|
|
padding: 8px 20px; |
|
|
|
|
|
border: 1px solid var(--border); |
|
|
|
|
|
border-radius: var(--radius); |
|
|
|
|
|
background: var(--surface2); |
|
|
|
|
|
color: var(--text); |
|
|
|
|
|
font-family: var(--mono); |
|
|
|
|
|
font-size: 12px; |
|
|
|
|
|
font-weight: 600; |
|
|
|
|
|
cursor: pointer; |
|
|
|
|
|
text-transform: uppercase; |
|
|
|
|
|
letter-spacing: 1px; |
|
|
|
|
|
transition: all 0.15s; |
|
|
|
|
|
} |
|
|
|
|
|
.tx-btn:hover { border-color: var(--text-dim); } |
|
|
|
|
|
.tx-btn.start { border-color: var(--green); color: var(--green); } |
|
|
|
|
|
.tx-btn.start:hover { background: var(--green); color: var(--bg); } |
|
|
|
|
|
.tx-btn.stop { border-color: var(--accent); color: var(--accent); } |
|
|
|
|
|
.tx-btn.stop:hover { background: var(--accent); color: #fff; } |
|
|
|
|
|
|
|
|
|
|
|
.tx-state { |
|
|
|
|
|
font-size: 11px; |
|
|
|
|
|
text-transform: uppercase; |
|
|
|
|
|
letter-spacing: 2px; |
|
|
|
|
|
color: var(--text-dim); |
|
|
|
|
|
margin-left: auto; |
|
|
|
|
|
} |
|
|
|
|
|
.tx-state.running { color: var(--green); } |
|
|
|
|
|
.tx-state.idle { color: var(--text-dim); } |
|
|
|
|
|
|
|
|
|
|
|
/* Telemetry strip */ |
|
|
|
|
|
.telem { |
|
|
|
|
|
display: flex; |
|
|
|
|
|
gap: 1px; |
|
|
|
|
|
background: var(--border); |
|
|
|
|
|
border-radius: var(--radius); |
|
|
|
|
|
overflow: hidden; |
|
|
|
|
|
margin-bottom: 16px; |
|
|
|
|
|
} |
|
|
|
|
|
.telem-cell { |
|
|
|
|
|
flex: 1; |
|
|
|
|
|
background: var(--surface); |
|
|
|
|
|
padding: 10px 12px; |
|
|
|
|
|
text-align: center; |
|
|
|
|
|
} |
|
|
|
|
|
.telem-cell .label { |
|
|
|
|
|
font-size: 9px; |
|
|
|
|
|
text-transform: uppercase; |
|
|
|
|
|
letter-spacing: 1.5px; |
|
|
|
|
|
color: var(--text-dim); |
|
|
|
|
|
margin-bottom: 4px; |
|
|
|
|
|
} |
|
|
|
|
|
.telem-cell .value { |
|
|
|
|
|
font-size: 16px; |
|
|
|
|
|
font-weight: 700; |
|
|
|
|
|
color: var(--text); |
|
|
|
|
|
} |
|
|
|
|
|
.telem-cell .value.warn { color: var(--amber); } |
|
|
|
|
|
.telem-cell .value.err { color: var(--accent); } |
|
|
|
|
|
|
|
|
|
|
|
/* Section panels */ |
|
|
|
|
|
.panel { |
|
|
|
|
|
background: var(--surface); |
|
|
|
|
|
border: 1px solid var(--border); |
|
|
|
|
|
border-radius: var(--radius); |
|
|
|
|
|
margin-bottom: 12px; |
|
|
|
|
|
overflow: hidden; |
|
|
|
|
|
} |
|
|
|
|
|
.panel-head { |
|
|
|
|
|
display: flex; |
|
|
|
|
|
align-items: center; |
|
|
|
|
|
gap: 8px; |
|
|
|
|
|
padding: 10px 14px; |
|
|
|
|
|
border-bottom: 1px solid var(--border); |
|
|
|
|
|
background: var(--surface2); |
|
|
|
|
|
cursor: pointer; |
|
|
|
|
|
user-select: none; |
|
|
|
|
|
} |
|
|
|
|
|
.panel-head h2 { |
|
|
|
|
|
font-family: var(--mono); |
|
|
|
|
|
font-size: 11px; |
|
|
|
|
|
font-weight: 600; |
|
|
|
|
|
text-transform: uppercase; |
|
|
|
|
|
letter-spacing: 2px; |
|
|
|
|
|
color: var(--text-dim); |
|
|
|
|
|
} |
|
|
|
|
|
.panel-head .chevron { |
|
|
|
|
|
margin-left: auto; |
|
|
|
|
|
color: var(--text-dim); |
|
|
|
|
|
transition: transform 0.2s; |
|
|
|
|
|
font-size: 10px; |
|
|
|
|
|
} |
|
|
|
|
|
.panel-head.collapsed .chevron { transform: rotate(-90deg); } |
|
|
|
|
|
.panel-body { padding: 14px; } |
|
|
|
|
|
.panel-body.collapsed { display: none; } |
|
|
|
|
|
|
|
|
|
|
|
/* Form controls */ |
|
|
|
|
|
.ctrl-row { |
|
|
|
|
|
display: flex; |
|
|
|
|
|
align-items: center; |
|
|
|
|
|
gap: 12px; |
|
|
|
|
|
padding: 6px 0; |
|
|
|
|
|
border-bottom: 1px solid #1a1a22; |
|
|
|
|
|
} |
|
|
|
|
|
.ctrl-row:last-child { border-bottom: none; } |
|
|
|
|
|
|
|
|
|
|
|
.ctrl-label { |
|
|
|
|
|
font-size: 11px; |
|
|
|
|
|
color: var(--text-dim); |
|
|
|
|
|
min-width: 110px; |
|
|
|
|
|
text-transform: uppercase; |
|
|
|
|
|
letter-spacing: 0.5px; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.ctrl-input { |
|
|
|
|
|
flex: 1; |
|
|
|
|
|
display: flex; |
|
|
|
|
|
align-items: center; |
|
|
|
|
|
gap: 8px; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
input[type="range"] { |
|
|
|
|
|
-webkit-appearance: none; |
|
|
|
|
|
appearance: none; |
|
|
|
|
|
flex: 1; |
|
|
|
|
|
height: 4px; |
|
|
|
|
|
background: var(--border); |
|
|
|
|
|
border-radius: 2px; |
|
|
|
|
|
outline: none; |
|
|
|
|
|
} |
|
|
|
|
|
input[type="range"]::-webkit-slider-thumb { |
|
|
|
|
|
-webkit-appearance: none; |
|
|
|
|
|
width: 14px; height: 14px; |
|
|
|
|
|
border-radius: 50%; |
|
|
|
|
|
background: var(--text); |
|
|
|
|
|
border: 2px solid var(--bg); |
|
|
|
|
|
cursor: pointer; |
|
|
|
|
|
transition: background 0.15s; |
|
|
|
|
|
} |
|
|
|
|
|
input[type="range"]::-webkit-slider-thumb:hover { background: var(--accent); } |
|
|
|
|
|
|
|
|
|
|
|
input[type="number"], input[type="text"] { |
|
|
|
|
|
background: var(--bg); |
|
|
|
|
|
border: 1px solid var(--border); |
|
|
|
|
|
border-radius: 4px; |
|
|
|
|
|
color: var(--text); |
|
|
|
|
|
font-family: var(--mono); |
|
|
|
|
|
font-size: 13px; |
|
|
|
|
|
padding: 5px 8px; |
|
|
|
|
|
width: 80px; |
|
|
|
|
|
outline: none; |
|
|
|
|
|
transition: border-color 0.15s; |
|
|
|
|
|
} |
|
|
|
|
|
input[type="text"] { width: 100%; } |
|
|
|
|
|
input:focus { border-color: var(--accent); } |
|
|
|
|
|
|
|
|
|
|
|
.val-display { |
|
|
|
|
|
font-size: 12px; |
|
|
|
|
|
font-weight: 600; |
|
|
|
|
|
min-width: 55px; |
|
|
|
|
|
text-align: right; |
|
|
|
|
|
color: var(--text); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/* Toggle switch */ |
|
|
|
|
|
.toggle { |
|
|
|
|
|
position: relative; |
|
|
|
|
|
width: 36px; height: 20px; |
|
|
|
|
|
background: var(--border); |
|
|
|
|
|
border-radius: 10px; |
|
|
|
|
|
cursor: pointer; |
|
|
|
|
|
transition: background 0.2s; |
|
|
|
|
|
flex-shrink: 0; |
|
|
|
|
|
} |
|
|
|
|
|
.toggle.on { background: var(--green); } |
|
|
|
|
|
.toggle::after { |
|
|
|
|
|
content: ''; |
|
|
|
|
|
position: absolute; |
|
|
|
|
|
top: 2px; left: 2px; |
|
|
|
|
|
width: 16px; height: 16px; |
|
|
|
|
|
background: var(--text); |
|
|
|
|
|
border-radius: 50%; |
|
|
|
|
|
transition: transform 0.2s; |
|
|
|
|
|
} |
|
|
|
|
|
.toggle.on::after { transform: translateX(16px); } |
|
|
|
|
|
|
|
|
|
|
|
/* RDS section */ |
|
|
|
|
|
.rds-input { |
|
|
|
|
|
width: 100%; |
|
|
|
|
|
background: var(--bg); |
|
|
|
|
|
border: 1px solid var(--border); |
|
|
|
|
|
border-radius: 4px; |
|
|
|
|
|
color: var(--green); |
|
|
|
|
|
font-family: var(--mono); |
|
|
|
|
|
font-size: 15px; |
|
|
|
|
|
font-weight: 700; |
|
|
|
|
|
padding: 8px 10px; |
|
|
|
|
|
outline: none; |
|
|
|
|
|
letter-spacing: 2px; |
|
|
|
|
|
text-transform: uppercase; |
|
|
|
|
|
transition: border-color 0.15s; |
|
|
|
|
|
} |
|
|
|
|
|
.rds-input:focus { border-color: var(--accent); } |
|
|
|
|
|
.rds-input.rt { |
|
|
|
|
|
font-size: 12px; |
|
|
|
|
|
font-weight: 400; |
|
|
|
|
|
letter-spacing: 0.5px; |
|
|
|
|
|
text-transform: none; |
|
|
|
|
|
color: var(--text); |
|
|
|
|
|
} |
|
|
|
|
|
.rds-charcount { |
|
|
|
|
|
font-size: 10px; |
|
|
|
|
|
color: var(--text-dim); |
|
|
|
|
|
text-align: right; |
|
|
|
|
|
margin-top: 2px; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/* Apply button */ |
|
|
|
|
|
.apply-btn { |
|
|
|
|
|
display: block; |
|
|
|
|
|
width: 100%; |
|
|
|
|
|
padding: 10px; |
|
|
|
|
|
margin-top: 8px; |
|
|
|
|
|
background: var(--accent); |
|
|
|
|
|
border: none; |
|
|
|
|
|
border-radius: var(--radius); |
|
|
|
|
|
color: #fff; |
|
|
|
|
|
font-family: var(--mono); |
|
|
|
|
|
font-size: 12px; |
|
|
|
|
|
font-weight: 700; |
|
|
|
|
|
text-transform: uppercase; |
|
|
|
|
|
letter-spacing: 2px; |
|
|
|
|
|
cursor: pointer; |
|
|
|
|
|
transition: all 0.15s; |
|
|
|
|
|
opacity: 0; |
|
|
|
|
|
transform: translateY(-4px); |
|
|
|
|
|
pointer-events: none; |
|
|
|
|
|
} |
|
|
|
|
|
.apply-btn.visible { |
|
|
|
|
|
opacity: 1; |
|
|
|
|
|
transform: translateY(0); |
|
|
|
|
|
pointer-events: auto; |
|
|
|
|
|
} |
|
|
|
|
|
.apply-btn:hover { filter: brightness(1.2); } |
|
|
|
|
|
.apply-btn.sending { |
|
|
|
|
|
opacity: 0.6; |
|
|
|
|
|
pointer-events: none; |
|
|
|
|
|
} |
|
|
|
|
|
.apply-btn.ok { |
|
|
|
|
|
background: var(--green); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/* Toast notification */ |
|
|
|
|
|
.toast { |
|
|
|
|
|
position: fixed; |
|
|
|
|
|
bottom: 20px; |
|
|
|
|
|
right: 20px; |
|
|
|
|
|
padding: 10px 16px; |
|
|
|
|
|
border-radius: var(--radius); |
|
|
|
|
|
font-size: 12px; |
|
|
|
|
|
font-weight: 600; |
|
|
|
|
|
z-index: 2000; |
|
|
|
|
|
transform: translateY(60px); |
|
|
|
|
|
opacity: 0; |
|
|
|
|
|
transition: all 0.3s; |
|
|
|
|
|
} |
|
|
|
|
|
.toast.show { transform: translateY(0); opacity: 1; } |
|
|
|
|
|
.toast.ok { background: var(--green); color: var(--bg); } |
|
|
|
|
|
.toast.err { background: var(--accent); color: #fff; } |
|
|
|
|
|
|
|
|
|
|
|
/* Log */ |
|
|
|
|
|
.log { |
|
|
|
|
|
background: var(--bg); |
|
|
|
|
|
border: 1px solid var(--border); |
|
|
|
|
|
border-radius: 4px; |
|
|
|
|
|
padding: 8px 10px; |
|
|
|
|
|
font-size: 10px; |
|
|
|
|
|
color: var(--text-dim); |
|
|
|
|
|
max-height: 120px; |
|
|
|
|
|
overflow-y: auto; |
|
|
|
|
|
white-space: pre-wrap; |
|
|
|
|
|
word-break: break-all; |
|
|
|
|
|
} |
|
|
|
|
|
.log .entry { padding: 1px 0; } |
|
|
|
|
|
.log .entry.err { color: var(--accent); } |
|
|
|
|
|
.log .entry.ok { color: var(--green); } |
|
|
|
|
|
|
|
|
|
|
|
/* Responsive */ |
|
|
|
|
|
@media (max-width: 600px) { |
|
|
|
|
|
.tx-bar { flex-wrap: wrap; } |
|
|
|
|
|
.tx-bar .freq-display { font-size: 24px; min-width: auto; } |
|
|
|
|
|
.telem { flex-wrap: wrap; } |
|
|
|
|
|
.telem-cell { flex: 1 1 30%; } |
|
|
|
|
|
.ctrl-row { flex-wrap: wrap; } |
|
|
|
|
|
.ctrl-label { min-width: auto; width: 100%; } |
|
|
|
|
|
} |
|
|
|
|
|
</style> |
|
|
|
|
|
</head> |
|
|
|
|
|
<body> |
|
|
|
|
|
<div class="app" id="app"> |
|
|
|
|
|
|
|
|
|
|
|
<!-- Header --> |
|
|
|
|
|
<div class="header"> |
|
|
|
|
|
<h1>FM-RDS-TX</h1> |
|
|
|
|
|
<div class="header-status"> |
|
|
|
|
|
<div class="led" id="led-conn"></div> |
|
|
|
|
|
<span style="font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:1px" id="conn-label">connecting</span> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
<!-- TX Control Bar --> |
|
|
|
|
|
<div class="tx-bar"> |
|
|
|
|
|
<div class="freq-display" id="freq-display">---.--<span class="unit">MHz</span></div> |
|
|
|
|
|
<button class="tx-btn start" id="btn-start" onclick="txStart()">TX ON</button> |
|
|
|
|
|
<button class="tx-btn stop" id="btn-stop" onclick="txStop()">TX OFF</button> |
|
|
|
|
|
<div class="tx-state" id="tx-state">--</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
<!-- Telemetry --> |
|
|
|
|
|
<div class="telem" id="telem"> |
|
|
|
|
|
<div class="telem-cell"><div class="label">Chunks</div><div class="value" id="t-chunks">--</div></div> |
|
|
|
|
|
<div class="telem-cell"><div class="label">Samples</div><div class="value" id="t-samples">--</div></div> |
|
|
|
|
|
<div class="telem-cell"><div class="label">Underruns</div><div class="value" id="t-underruns">0</div></div> |
|
|
|
|
|
<div class="telem-cell"><div class="label">Uptime</div><div class="value" id="t-uptime">--</div></div> |
|
|
|
|
|
<div class="telem-cell"><div class="label">Rate</div><div class="value" id="t-rate">--</div></div> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
<!-- Frequency --> |
|
|
|
|
|
<div class="panel"> |
|
|
|
|
|
<div class="panel-head" onclick="togglePanel(this)"> |
|
|
|
|
|
<div class="led on-green" style="width:6px;height:6px"></div> |
|
|
|
|
|
<h2>Frequency</h2> |
|
|
|
|
|
<span class="chevron">▼</span> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="panel-body"> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<span class="ctrl-label">TX Freq</span> |
|
|
|
|
|
<div class="ctrl-input"> |
|
|
|
|
|
<input type="range" min="87.5" max="108.0" step="0.1" id="freq-slider" |
|
|
|
|
|
oninput="onFreqSlider(this.value)"> |
|
|
|
|
|
<input type="number" min="65" max="110" step="0.1" id="freq-num" |
|
|
|
|
|
onchange="onFreqNum(this.value)"> |
|
|
|
|
|
<span class="val-display">MHz</span> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<button class="apply-btn" id="freq-apply" onclick="applyFreq()">Apply Frequency</button> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
<!-- Levels --> |
|
|
|
|
|
<div class="panel"> |
|
|
|
|
|
<div class="panel-head" onclick="togglePanel(this)"> |
|
|
|
|
|
<div class="led on-amber" style="width:6px;height:6px"></div> |
|
|
|
|
|
<h2>Levels</h2> |
|
|
|
|
|
<span class="chevron">▼</span> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="panel-body"> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<span class="ctrl-label">Output Drive</span> |
|
|
|
|
|
<div class="ctrl-input"> |
|
|
|
|
|
<input type="range" min="0" max="3" step="0.01" id="drive-slider" |
|
|
|
|
|
oninput="onSlider('outputDrive', this.value, 'drive-val')"> |
|
|
|
|
|
<span class="val-display" id="drive-val">--</span> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<span class="ctrl-label">Pilot Level</span> |
|
|
|
|
|
<div class="ctrl-input"> |
|
|
|
|
|
<input type="range" min="0" max="0.2" step="0.001" id="pilot-slider" |
|
|
|
|
|
oninput="onSlider('pilotLevel', this.value, 'pilot-val')"> |
|
|
|
|
|
<span class="val-display" id="pilot-val">--</span> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<span class="ctrl-label">RDS Inject</span> |
|
|
|
|
|
<div class="ctrl-input"> |
|
|
|
|
|
<input type="range" min="0" max="0.15" step="0.001" id="rds-inj-slider" |
|
|
|
|
|
oninput="onSlider('rdsInjection', this.value, 'rds-inj-val')"> |
|
|
|
|
|
<span class="val-display" id="rds-inj-val">--</span> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<span class="ctrl-label">Limiter Ceil</span> |
|
|
|
|
|
<div class="ctrl-input"> |
|
|
|
|
|
<input type="range" min="0" max="2" step="0.01" id="ceil-slider" |
|
|
|
|
|
oninput="onSlider('limiterCeiling', this.value, 'ceil-val')"> |
|
|
|
|
|
<span class="val-display" id="ceil-val">--</span> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<button class="apply-btn" id="levels-apply" onclick="applyLevels()">Apply Levels</button> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
<!-- Switches --> |
|
|
|
|
|
<div class="panel"> |
|
|
|
|
|
<div class="panel-head" onclick="togglePanel(this)"> |
|
|
|
|
|
<div class="led on-green" style="width:6px;height:6px"></div> |
|
|
|
|
|
<h2>Switches</h2> |
|
|
|
|
|
<span class="chevron">▼</span> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="panel-body"> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<span class="ctrl-label">Stereo</span> |
|
|
|
|
|
<div class="ctrl-input"> |
|
|
|
|
|
<div class="toggle" id="tog-stereo" onclick="applyToggle('stereoEnabled', this)"></div> |
|
|
|
|
|
<span class="val-display" id="stereo-label">--</span> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<span class="ctrl-label">RDS</span> |
|
|
|
|
|
<div class="ctrl-input"> |
|
|
|
|
|
<div class="toggle" id="tog-rds" onclick="applyToggle('rdsEnabled', this)"></div> |
|
|
|
|
|
<span class="val-display" id="rds-label">--</span> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-row"> |
|
|
|
|
|
<span class="ctrl-label">Limiter</span> |
|
|
|
|
|
<div class="ctrl-input"> |
|
|
|
|
|
<div class="toggle" id="tog-limiter" onclick="applyToggle('limiterEnabled', this)"></div> |
|
|
|
|
|
<span class="val-display" id="limiter-label">--</span> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
<!-- RDS --> |
|
|
|
|
|
<div class="panel"> |
|
|
|
|
|
<div class="panel-head" onclick="togglePanel(this)"> |
|
|
|
|
|
<div class="led on-amber" style="width:6px;height:6px"></div> |
|
|
|
|
|
<h2>RDS</h2> |
|
|
|
|
|
<span class="chevron">▼</span> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="panel-body"> |
|
|
|
|
|
<div class="ctrl-row" style="flex-direction:column;align-items:stretch;gap:4px"> |
|
|
|
|
|
<span class="ctrl-label">Program Service (PS)</span> |
|
|
|
|
|
<input type="text" class="rds-input" id="rds-ps" maxlength="8" placeholder="STATION"> |
|
|
|
|
|
<div class="rds-charcount"><span id="ps-count">0</span>/8</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="ctrl-row" style="flex-direction:column;align-items:stretch;gap:4px;margin-top:8px"> |
|
|
|
|
|
<span class="ctrl-label">RadioText (RT)</span> |
|
|
|
|
|
<input type="text" class="rds-input rt" id="rds-rt" maxlength="64" placeholder="Now playing..."> |
|
|
|
|
|
<div class="rds-charcount"><span id="rt-count">0</span>/64</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
<button class="apply-btn" id="rds-apply" onclick="applyRDS()">Apply RDS Text</button> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
<!-- Log --> |
|
|
|
|
|
<div class="panel"> |
|
|
|
|
|
<div class="panel-head" onclick="togglePanel(this)"> |
|
|
|
|
|
<h2>Log</h2> |
|
|
|
|
|
<span class="chevron">▼</span> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="panel-body"> |
|
|
|
|
|
<div class="log" id="log"></div> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
<!-- Toast --> |
|
|
|
|
|
<div class="toast" id="toast"></div> |
|
|
|
|
|
|
|
|
|
|
|
<script> |
|
|
|
|
|
const $ = id => document.getElementById(id); |
|
|
|
|
|
|
|
|
|
|
|
// State |
|
|
|
|
|
let cfg = {}; |
|
|
|
|
|
let pending = {}; |
|
|
|
|
|
let pollTimer = null; |
|
|
|
|
|
|
|
|
|
|
|
// --- API --- |
|
|
|
|
|
async function api(path, opts) { |
|
|
|
|
|
try { |
|
|
|
|
|
const r = await fetch(path, opts); |
|
|
|
|
|
const text = await r.text(); |
|
|
|
|
|
if (!r.ok) throw new Error(text.trim() || `HTTP ${r.status}`); |
|
|
|
|
|
try { return JSON.parse(text); } |
|
|
|
|
|
catch(e) { return {ok: true}; } |
|
|
|
|
|
} catch(e) { |
|
|
|
|
|
throw e; |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
async function loadConfig() { |
|
|
|
|
|
try { |
|
|
|
|
|
cfg = await api('/config'); |
|
|
|
|
|
$('led-conn').className = 'led on-green'; |
|
|
|
|
|
$('conn-label').textContent = 'connected'; |
|
|
|
|
|
syncUI(); |
|
|
|
|
|
} catch(e) { |
|
|
|
|
|
$('led-conn').className = 'led on-red'; |
|
|
|
|
|
$('conn-label').textContent = 'offline'; |
|
|
|
|
|
log('config load failed: ' + e.message, 'err'); |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
async function loadRuntime() { |
|
|
|
|
|
try { |
|
|
|
|
|
const rt = await api('/runtime'); |
|
|
|
|
|
const eng = rt.engine || {}; |
|
|
|
|
|
const drv = rt.driver || {}; |
|
|
|
|
|
|
|
|
|
|
|
// TX state |
|
|
|
|
|
const state = eng.state || 'idle'; |
|
|
|
|
|
const el = $('tx-state'); |
|
|
|
|
|
el.textContent = state.toUpperCase(); |
|
|
|
|
|
el.className = 'tx-state ' + state; |
|
|
|
|
|
|
|
|
|
|
|
// Telemetry |
|
|
|
|
|
$('t-chunks').textContent = fmt(eng.chunksProduced || 0); |
|
|
|
|
|
$('t-samples').textContent = fmt(eng.totalSamples || 0); |
|
|
|
|
|
const ur = eng.underruns || 0; |
|
|
|
|
|
const urEl = $('t-underruns'); |
|
|
|
|
|
urEl.textContent = ur; |
|
|
|
|
|
urEl.className = 'value' + (ur > 0 ? ' err' : ''); |
|
|
|
|
|
$('t-uptime').textContent = fmtTime(eng.uptimeSeconds || 0); |
|
|
|
|
|
$('t-rate').textContent = drv.effectiveSampleRateHz ? (drv.effectiveSampleRateHz/1000).toFixed(0) + 'k' : '--'; |
|
|
|
|
|
|
|
|
|
|
|
$('led-conn').className = 'led on-green'; |
|
|
|
|
|
$('conn-label').textContent = 'connected'; |
|
|
|
|
|
} catch(e) { |
|
|
|
|
|
// Silent on poll errors |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
async function txStart() { |
|
|
|
|
|
try { |
|
|
|
|
|
await api('/tx/start', {method:'POST'}); |
|
|
|
|
|
toast('TX started', 'ok'); |
|
|
|
|
|
log('TX started', 'ok'); |
|
|
|
|
|
} catch(e) { toast(e.message, 'err'); log('TX start failed: ' + e.message, 'err'); } |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
async function txStop() { |
|
|
|
|
|
try { |
|
|
|
|
|
await api('/tx/stop', {method:'POST'}); |
|
|
|
|
|
toast('TX stopped', 'ok'); |
|
|
|
|
|
log('TX stopped', 'ok'); |
|
|
|
|
|
} catch(e) { toast(e.message, 'err'); log('TX stop failed: ' + e.message, 'err'); } |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
async function sendPatch(patch, btnId) { |
|
|
|
|
|
const btn = btnId ? $(btnId) : null; |
|
|
|
|
|
if (btn) btn.classList.add('sending'); |
|
|
|
|
|
try { |
|
|
|
|
|
const r = await api('/config', { |
|
|
|
|
|
method: 'POST', |
|
|
|
|
|
headers: {'Content-Type':'application/json'}, |
|
|
|
|
|
body: JSON.stringify(patch) |
|
|
|
|
|
}); |
|
|
|
|
|
Object.assign(cfg, flatCfg(patch)); |
|
|
|
|
|
toast('Applied' + (r.live ? ' (live)' : ''), 'ok'); |
|
|
|
|
|
log('PATCH ' + JSON.stringify(patch) + (r.live ? ' [live]' : ''), 'ok'); |
|
|
|
|
|
if (btn) { btn.classList.remove('sending'); btn.classList.add('ok'); setTimeout(()=>{btn.classList.remove('ok','visible')}, 800); } |
|
|
|
|
|
pending = {}; |
|
|
|
|
|
} catch(e) { |
|
|
|
|
|
toast(e.message, 'err'); |
|
|
|
|
|
log('PATCH failed: ' + e.message, 'err'); |
|
|
|
|
|
if (btn) btn.classList.remove('sending'); |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// --- UI sync --- |
|
|
|
|
|
function syncUI() { |
|
|
|
|
|
// Frequency |
|
|
|
|
|
const freq = cfg.fm?.frequencyMHz || 100; |
|
|
|
|
|
$('freq-display').innerHTML = freq.toFixed(1) + '<span class="unit">MHz</span>'; |
|
|
|
|
|
$('freq-slider').value = freq; |
|
|
|
|
|
$('freq-num').value = freq; |
|
|
|
|
|
|
|
|
|
|
|
// Levels |
|
|
|
|
|
setSlider('drive-slider', 'drive-val', cfg.fm?.outputDrive, 2); |
|
|
|
|
|
setSlider('pilot-slider', 'pilot-val', cfg.fm?.pilotLevel, 3); |
|
|
|
|
|
setSlider('rds-inj-slider', 'rds-inj-val', cfg.fm?.rdsInjection, 3); |
|
|
|
|
|
setSlider('ceil-slider', 'ceil-val', cfg.fm?.limiterCeiling, 2); |
|
|
|
|
|
|
|
|
|
|
|
// Toggles |
|
|
|
|
|
setToggle('tog-stereo', 'stereo-label', cfg.fm?.stereoEnabled); |
|
|
|
|
|
setToggle('tog-rds', 'rds-label', cfg.rds?.enabled); |
|
|
|
|
|
setToggle('tog-limiter', 'limiter-label', cfg.fm?.limiterEnabled); |
|
|
|
|
|
|
|
|
|
|
|
// RDS text |
|
|
|
|
|
$('rds-ps').value = cfg.rds?.ps || ''; |
|
|
|
|
|
$('rds-rt').value = cfg.rds?.radioText || ''; |
|
|
|
|
|
$('ps-count').textContent = ($('rds-ps').value || '').length; |
|
|
|
|
|
$('rt-count').textContent = ($('rds-rt').value || '').length; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function setSlider(sliderId, valId, value, decimals) { |
|
|
|
|
|
const v = value ?? 0; |
|
|
|
|
|
$(sliderId).value = v; |
|
|
|
|
|
$(valId).textContent = v.toFixed(decimals || 2); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function setToggle(togId, labelId, on) { |
|
|
|
|
|
$(togId).className = 'toggle' + (on ? ' on' : ''); |
|
|
|
|
|
$(labelId).textContent = on ? 'ON' : 'OFF'; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// --- Handlers --- |
|
|
|
|
|
function onFreqSlider(v) { |
|
|
|
|
|
v = parseFloat(v); |
|
|
|
|
|
$('freq-num').value = v.toFixed(1); |
|
|
|
|
|
$('freq-display').innerHTML = v.toFixed(1) + '<span class="unit">MHz</span>'; |
|
|
|
|
|
pending.frequencyMHz = v; |
|
|
|
|
|
showApply('freq-apply'); |
|
|
|
|
|
} |
|
|
|
|
|
function onFreqNum(v) { |
|
|
|
|
|
v = parseFloat(v); |
|
|
|
|
|
if (isNaN(v)) return; |
|
|
|
|
|
$('freq-slider').value = v; |
|
|
|
|
|
$('freq-display').innerHTML = v.toFixed(1) + '<span class="unit">MHz</span>'; |
|
|
|
|
|
pending.frequencyMHz = v; |
|
|
|
|
|
showApply('freq-apply'); |
|
|
|
|
|
} |
|
|
|
|
|
function applyFreq() { |
|
|
|
|
|
if (pending.frequencyMHz != null) sendPatch({frequencyMHz: pending.frequencyMHz}, 'freq-apply'); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function onSlider(key, v, valId) { |
|
|
|
|
|
v = parseFloat(v); |
|
|
|
|
|
$(valId).textContent = v.toFixed(key === 'outputDrive' || key === 'limiterCeiling' ? 2 : 3); |
|
|
|
|
|
pending[key] = v; |
|
|
|
|
|
showApply('levels-apply'); |
|
|
|
|
|
} |
|
|
|
|
|
function applyLevels() { |
|
|
|
|
|
const patch = {}; |
|
|
|
|
|
for (const k of ['outputDrive','pilotLevel','rdsInjection','limiterCeiling']) { |
|
|
|
|
|
if (pending[k] != null) patch[k] = pending[k]; |
|
|
|
|
|
} |
|
|
|
|
|
if (Object.keys(patch).length) sendPatch(patch, 'levels-apply'); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function applyToggle(key, el) { |
|
|
|
|
|
const isOn = el.classList.contains('on'); |
|
|
|
|
|
const newVal = !isOn; |
|
|
|
|
|
const patch = {}; |
|
|
|
|
|
patch[key] = newVal; |
|
|
|
|
|
sendPatch(patch); |
|
|
|
|
|
// Optimistic UI |
|
|
|
|
|
el.classList.toggle('on'); |
|
|
|
|
|
const labelId = el.id.replace('tog-', '') + '-label'; |
|
|
|
|
|
const lbl = document.getElementById(labelId); |
|
|
|
|
|
if (lbl) lbl.textContent = newVal ? 'ON' : 'OFF'; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function applyRDS() { |
|
|
|
|
|
const ps = $('rds-ps').value; |
|
|
|
|
|
const rt = $('rds-rt').value; |
|
|
|
|
|
const patch = {}; |
|
|
|
|
|
if (ps !== (cfg.rds?.ps || '')) patch.ps = ps; |
|
|
|
|
|
if (rt !== (cfg.rds?.radioText || '')) patch.radioText = rt; |
|
|
|
|
|
if (Object.keys(patch).length) sendPatch(patch, 'rds-apply'); |
|
|
|
|
|
else toast('No changes', 'ok'); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// RDS char counters |
|
|
|
|
|
$('rds-ps').addEventListener('input', function() { |
|
|
|
|
|
$('ps-count').textContent = this.value.length; |
|
|
|
|
|
showApply('rds-apply'); |
|
|
|
|
|
}); |
|
|
|
|
|
$('rds-rt').addEventListener('input', function() { |
|
|
|
|
|
$('rt-count').textContent = this.value.length; |
|
|
|
|
|
showApply('rds-apply'); |
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
// --- Panel toggle --- |
|
|
|
|
|
function togglePanel(head) { |
|
|
|
|
|
head.classList.toggle('collapsed'); |
|
|
|
|
|
head.nextElementSibling.classList.toggle('collapsed'); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// --- Apply button visibility --- |
|
|
|
|
|
function showApply(btnId) { |
|
|
|
|
|
$(btnId).classList.add('visible'); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// --- Toast --- |
|
|
|
|
|
function toast(msg, type) { |
|
|
|
|
|
const t = $('toast'); |
|
|
|
|
|
t.textContent = msg; |
|
|
|
|
|
t.className = 'toast ' + type + ' show'; |
|
|
|
|
|
clearTimeout(t._timer); |
|
|
|
|
|
t._timer = setTimeout(() => t.classList.remove('show'), 2500); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// --- Log --- |
|
|
|
|
|
function log(msg, type) { |
|
|
|
|
|
const el = $('log'); |
|
|
|
|
|
const ts = new Date().toLocaleTimeString(); |
|
|
|
|
|
const d = document.createElement('div'); |
|
|
|
|
|
d.className = 'entry ' + (type || ''); |
|
|
|
|
|
d.textContent = ts + ' ' + msg; |
|
|
|
|
|
el.appendChild(d); |
|
|
|
|
|
el.scrollTop = el.scrollHeight; |
|
|
|
|
|
// Keep max 200 entries |
|
|
|
|
|
while (el.children.length > 200) el.removeChild(el.firstChild); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// --- Helpers --- |
|
|
|
|
|
function fmt(n) { |
|
|
|
|
|
if (n >= 1e9) return (n/1e9).toFixed(2) + 'G'; |
|
|
|
|
|
if (n >= 1e6) return (n/1e6).toFixed(2) + 'M'; |
|
|
|
|
|
if (n >= 1e3) return (n/1e3).toFixed(1) + 'k'; |
|
|
|
|
|
return n.toString(); |
|
|
|
|
|
} |
|
|
|
|
|
function fmtTime(s) { |
|
|
|
|
|
if (!s || s <= 0) return '--'; |
|
|
|
|
|
const h = Math.floor(s/3600); |
|
|
|
|
|
const m = Math.floor((s%3600)/60); |
|
|
|
|
|
const sec = Math.floor(s%60); |
|
|
|
|
|
if (h > 0) return h + 'h ' + m + 'm'; |
|
|
|
|
|
if (m > 0) return m + 'm ' + sec + 's'; |
|
|
|
|
|
return sec + 's'; |
|
|
|
|
|
} |
|
|
|
|
|
function flatCfg(patch) { |
|
|
|
|
|
// Update local cfg mirror from patch keys |
|
|
|
|
|
const map = { |
|
|
|
|
|
frequencyMHz: (v) => { if(!cfg.fm) cfg.fm={}; cfg.fm.frequencyMHz=v; }, |
|
|
|
|
|
outputDrive: (v) => { if(!cfg.fm) cfg.fm={}; cfg.fm.outputDrive=v; }, |
|
|
|
|
|
stereoEnabled: (v) => { if(!cfg.fm) cfg.fm={}; cfg.fm.stereoEnabled=v; }, |
|
|
|
|
|
pilotLevel: (v) => { if(!cfg.fm) cfg.fm={}; cfg.fm.pilotLevel=v; }, |
|
|
|
|
|
rdsInjection: (v) => { if(!cfg.fm) cfg.fm={}; cfg.fm.rdsInjection=v; }, |
|
|
|
|
|
rdsEnabled: (v) => { if(!cfg.rds) cfg.rds={}; cfg.rds.enabled=v; }, |
|
|
|
|
|
limiterEnabled: (v) => { if(!cfg.fm) cfg.fm={}; cfg.fm.limiterEnabled=v; }, |
|
|
|
|
|
limiterCeiling: (v) => { if(!cfg.fm) cfg.fm={}; cfg.fm.limiterCeiling=v; }, |
|
|
|
|
|
ps: (v) => { if(!cfg.rds) cfg.rds={}; cfg.rds.ps=v; }, |
|
|
|
|
|
radioText: (v) => { if(!cfg.rds) cfg.rds={}; cfg.rds.radioText=v; }, |
|
|
|
|
|
}; |
|
|
|
|
|
for (const [k,v] of Object.entries(patch)) { if (map[k]) map[k](v); } |
|
|
|
|
|
return {}; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// --- Init --- |
|
|
|
|
|
async function init() { |
|
|
|
|
|
log('fm-rds-tx web control initializing'); |
|
|
|
|
|
await loadConfig(); |
|
|
|
|
|
// Poll runtime every 500ms |
|
|
|
|
|
setInterval(loadRuntime, 500); |
|
|
|
|
|
// Reload config every 5s (catch external changes) |
|
|
|
|
|
setInterval(loadConfig, 5000); |
|
|
|
|
|
log('polling active', 'ok'); |
|
|
|
|
|
} |
|
|
|
|
|
init(); |
|
|
|
|
|
</script> |
|
|
|
|
|
</body> |
|
|
|
|
|
</html> |