feat: WebUI settings panel (Issue #614) #622
343
ui/settings_panel.css
Normal file
343
ui/settings_panel.css
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
/* settings_panel.css — Saltybot Settings (Issue #614) */
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg0: #050510;
|
||||||
|
--bg1: #070712;
|
||||||
|
--bg2: #0a0a1a;
|
||||||
|
--bd: #0c2a3a;
|
||||||
|
--bd2: #1e3a5f;
|
||||||
|
--dim: #374151;
|
||||||
|
--mid: #6b7280;
|
||||||
|
--base: #9ca3af;
|
||||||
|
--hi: #d1d5db;
|
||||||
|
--cyan: #06b6d4;
|
||||||
|
--green: #22c55e;
|
||||||
|
--amber: #f59e0b;
|
||||||
|
--red: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--bg0);
|
||||||
|
color: var(--base);
|
||||||
|
height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
#header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 5px 12px;
|
||||||
|
background: var(--bg1);
|
||||||
|
border-bottom: 1px solid var(--bd);
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.logo { color: #f97316; font-weight: bold; letter-spacing: .15em; font-size: 13px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
#conn-dot {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%;
|
||||||
|
background: var(--dim); flex-shrink: 0; transition: background .3s;
|
||||||
|
}
|
||||||
|
#conn-dot.connected { background: var(--green); }
|
||||||
|
#conn-dot.error { background: var(--red); animation: blink 1s infinite; }
|
||||||
|
|
||||||
|
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.3} }
|
||||||
|
@keyframes fadeout { 0%{opacity:1} 70%{opacity:1} 100%{opacity:0} }
|
||||||
|
|
||||||
|
.save-ind {
|
||||||
|
font-size: 10px; font-weight: bold; letter-spacing: .1em;
|
||||||
|
color: var(--green); padding: 2px 8px;
|
||||||
|
border: 1px solid var(--green); border-radius: 3px;
|
||||||
|
animation: fadeout 2s forwards;
|
||||||
|
}
|
||||||
|
.save-ind.hidden { display: none; }
|
||||||
|
|
||||||
|
#ws-input {
|
||||||
|
background: var(--bg2); border: 1px solid var(--bd2); border-radius: 4px;
|
||||||
|
color: #67e8f9; padding: 2px 7px; font-family: monospace; font-size: 11px; width: 185px;
|
||||||
|
}
|
||||||
|
#ws-input:focus { outline: none; border-color: var(--cyan); }
|
||||||
|
|
||||||
|
.hbtn {
|
||||||
|
padding: 2px 8px; border-radius: 4px; border: 1px solid var(--bd2);
|
||||||
|
background: var(--bg2); color: #67e8f9; font-family: monospace;
|
||||||
|
font-size: 10px; font-weight: bold; cursor: pointer; transition: background .15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.hbtn:hover { background: #0e4f69; }
|
||||||
|
.apply-btn { border-color: #14532d; color: #86efac; }
|
||||||
|
.apply-btn:hover { background: #052010; }
|
||||||
|
.del-btn { border-color: #7f1d1d; color: #fca5a5; }
|
||||||
|
.del-btn:hover { background: #1c0505; }
|
||||||
|
|
||||||
|
/* ── Tab bar ── */
|
||||||
|
#tab-bar {
|
||||||
|
display: flex;
|
||||||
|
background: var(--bg1);
|
||||||
|
border-bottom: 1px solid var(--bd);
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0 12px;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.tab-btn {
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--mid);
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: .1em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color .15s, border-color .15s;
|
||||||
|
}
|
||||||
|
.tab-btn:hover { color: var(--base); }
|
||||||
|
.tab-btn.active { color: var(--cyan); border-bottom-color: var(--cyan); }
|
||||||
|
|
||||||
|
/* ── Main ── */
|
||||||
|
#main {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tab panels ── */
|
||||||
|
.tab-panel { display: none; }
|
||||||
|
.tab-panel.active { display: block; }
|
||||||
|
|
||||||
|
.panel-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section card ── */
|
||||||
|
.section-card {
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--bd2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.sec-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.sec-title {
|
||||||
|
font-size: 11px; font-weight: bold; letter-spacing: .1em;
|
||||||
|
color: #67e8f9; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.sec-node {
|
||||||
|
font-size: 9px; color: var(--mid); margin-top: 2px;
|
||||||
|
}
|
||||||
|
.sec-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* ── Parameter fields ── */
|
||||||
|
.param-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px 1fr 80px 50px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 5px 0;
|
||||||
|
border-bottom: 1px solid var(--bd);
|
||||||
|
}
|
||||||
|
.param-row:last-child { border-bottom: none; }
|
||||||
|
.param-label { font-size: 10px; color: var(--mid); }
|
||||||
|
.param-unit { font-size: 9px; color: var(--dim); text-align: right; }
|
||||||
|
|
||||||
|
/* Slider */
|
||||||
|
.param-slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 100%; height: 4px; border-radius: 2px;
|
||||||
|
background: var(--bd2); outline: none; cursor: pointer;
|
||||||
|
}
|
||||||
|
.param-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 12px; height: 12px; border-radius: 50%;
|
||||||
|
background: var(--cyan); cursor: pointer;
|
||||||
|
}
|
||||||
|
.param-slider::-moz-range-thumb {
|
||||||
|
width: 12px; height: 12px; border-radius: 50%;
|
||||||
|
background: var(--cyan); cursor: pointer; border: none;
|
||||||
|
}
|
||||||
|
.param-slider.changed::-webkit-slider-thumb { background: var(--amber); }
|
||||||
|
.param-slider.changed::-moz-range-thumb { background: var(--amber); }
|
||||||
|
|
||||||
|
/* Number input */
|
||||||
|
.param-input {
|
||||||
|
background: var(--bg0); border: 1px solid var(--bd2);
|
||||||
|
border-radius: 3px; color: var(--hi); padding: 2px 5px;
|
||||||
|
font-family: monospace; font-size: 10px; text-align: right;
|
||||||
|
width: 72px;
|
||||||
|
}
|
||||||
|
.param-input:focus { outline: none; border-color: var(--cyan); }
|
||||||
|
.param-input.changed { border-color: var(--amber); color: var(--amber); }
|
||||||
|
|
||||||
|
/* Toggle switch */
|
||||||
|
.toggle-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid var(--bd);
|
||||||
|
}
|
||||||
|
.toggle-row:last-child { border-bottom: none; }
|
||||||
|
.toggle-desc { font-size: 9px; color: var(--dim); }
|
||||||
|
|
||||||
|
.toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
width: 36px; height: 18px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.toggle-switch input { opacity: 0; width: 0; height: 0; }
|
||||||
|
.toggle-track {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background: var(--dim); border-radius: 9px; cursor: pointer;
|
||||||
|
transition: background .2s;
|
||||||
|
}
|
||||||
|
.toggle-track::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 2px; top: 2px;
|
||||||
|
width: 14px; height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
transition: transform .2s;
|
||||||
|
}
|
||||||
|
.toggle-switch input:checked + .toggle-track { background: var(--cyan); }
|
||||||
|
.toggle-switch input:checked + .toggle-track::after { transform: translateX(18px); }
|
||||||
|
|
||||||
|
/* Status / feedback line */
|
||||||
|
.sec-status {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 9px;
|
||||||
|
min-height: 14px;
|
||||||
|
transition: color .3s;
|
||||||
|
}
|
||||||
|
.sec-status.ok { color: var(--green); }
|
||||||
|
.sec-status.err { color: var(--red); }
|
||||||
|
.sec-status.loading { color: var(--amber); }
|
||||||
|
|
||||||
|
/* ── System tab ── */
|
||||||
|
.diag-placeholder {
|
||||||
|
color: var(--dim); font-size: 10px; padding: 12px 0; text-align: center;
|
||||||
|
}
|
||||||
|
#diag-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.diag-card {
|
||||||
|
background: var(--bg0);
|
||||||
|
border: 1px solid var(--bd);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.diag-card-title {
|
||||||
|
font-size: 9px; font-weight: bold; letter-spacing: .08em;
|
||||||
|
color: #0891b2; margin-bottom: 5px; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.diag-kv {
|
||||||
|
display: flex; justify-content: space-between;
|
||||||
|
padding: 2px 0; font-size: 9px;
|
||||||
|
border-bottom: 1px solid var(--bd);
|
||||||
|
}
|
||||||
|
.diag-kv:last-child { border-bottom: none; }
|
||||||
|
.diag-k { color: var(--mid); }
|
||||||
|
.diag-v { color: var(--hi); font-family: monospace; }
|
||||||
|
.diag-v.ok { color: var(--green); }
|
||||||
|
.diag-v.warn { color: var(--amber); }
|
||||||
|
.diag-v.err { color: var(--red); }
|
||||||
|
|
||||||
|
#net-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Signal bars */
|
||||||
|
.sig-bars {
|
||||||
|
display: inline-flex; align-items: flex-end; gap: 2px; height: 14px;
|
||||||
|
}
|
||||||
|
.sig-bar {
|
||||||
|
width: 4px; border-radius: 1px; background: var(--dim);
|
||||||
|
}
|
||||||
|
.sig-bar.lit { background: var(--cyan); }
|
||||||
|
|
||||||
|
/* Node list */
|
||||||
|
#node-list-wrap {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 4px;
|
||||||
|
max-height: 160px; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.node-chip {
|
||||||
|
font-size: 9px; padding: 1px 6px; border-radius: 2px;
|
||||||
|
border: 1px solid var(--bd); background: var(--bg0);
|
||||||
|
color: var(--mid);
|
||||||
|
}
|
||||||
|
.node-chip.active-node { border-color: var(--bd2); color: var(--base); }
|
||||||
|
|
||||||
|
/* ── Preset bar ── */
|
||||||
|
#preset-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
background: var(--bg1);
|
||||||
|
border-top: 1px solid var(--bd);
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.plbl { font-size: 10px; color: var(--mid); letter-spacing: .08em; }
|
||||||
|
|
||||||
|
#preset-select {
|
||||||
|
background: var(--bg2); border: 1px solid var(--bd2); border-radius: 4px;
|
||||||
|
color: #67e8f9; padding: 2px 6px; font-family: monospace; font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#preset-select:focus { outline: none; }
|
||||||
|
|
||||||
|
#preset-name {
|
||||||
|
background: var(--bg2); border: 1px solid var(--bd2); border-radius: 4px;
|
||||||
|
color: var(--hi); padding: 2px 7px; font-family: monospace; font-size: 11px; width: 140px;
|
||||||
|
}
|
||||||
|
#preset-name:focus { outline: none; border-color: var(--cyan); }
|
||||||
|
|
||||||
|
/* ── Footer ── */
|
||||||
|
#footer {
|
||||||
|
background: var(--bg1); border-top: 1px solid var(--bd);
|
||||||
|
padding: 3px 12px;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
flex-shrink: 0; font-size: 10px; color: var(--dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.param-row {
|
||||||
|
grid-template-columns: 140px 1fr 70px 44px;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
#diag-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.param-row {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
}
|
||||||
|
.param-label { grid-column: 1 / 3; }
|
||||||
|
.param-unit { text-align: left; }
|
||||||
|
}
|
||||||
313
ui/settings_panel.html
Normal file
313
ui/settings_panel.html
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
|
||||||
|
<title>Saltybot — Settings</title>
|
||||||
|
<link rel="stylesheet" href="settings_panel.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/roslib@1.3.0/build/roslib.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ── Header ── -->
|
||||||
|
<div id="header">
|
||||||
|
<div class="logo">⚡ SALTYBOT — SETTINGS</div>
|
||||||
|
<div id="conn-dot"></div>
|
||||||
|
<input id="ws-input" type="text" value="ws://localhost:9090" placeholder="ws://robot-ip:9090" />
|
||||||
|
<button id="btn-connect" class="hbtn">CONNECT</button>
|
||||||
|
<span id="conn-label" style="color:#4b5563;font-size:10px">Not connected</span>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<div id="save-indicator" class="save-ind hidden">✓ SAVED</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Tab bar ── -->
|
||||||
|
<div id="tab-bar">
|
||||||
|
<button class="tab-btn active" data-tab="pid">PID</button>
|
||||||
|
<button class="tab-btn" data-tab="speed">SPEED</button>
|
||||||
|
<button class="tab-btn" data-tab="safety">SAFETY</button>
|
||||||
|
<button class="tab-btn" data-tab="sensors">SENSORS</button>
|
||||||
|
<button class="tab-btn" data-tab="system">SYSTEM</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Main ── -->
|
||||||
|
<div id="main">
|
||||||
|
|
||||||
|
<!-- ═══ PID TAB ═══ -->
|
||||||
|
<div class="tab-panel active" id="tab-pid">
|
||||||
|
<div class="panel-col">
|
||||||
|
|
||||||
|
<!-- Balance PID -->
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="sec-header">
|
||||||
|
<div>
|
||||||
|
<div class="sec-title">Balance Controller PID</div>
|
||||||
|
<div class="sec-node">/balance_controller</div>
|
||||||
|
</div>
|
||||||
|
<div class="sec-actions">
|
||||||
|
<button class="hbtn" onclick="loadSection('balance_pid')">↓ LOAD</button>
|
||||||
|
<button class="hbtn apply-btn" onclick="applySection('balance_pid')">↑ APPLY</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="balance_pid-fields"></div>
|
||||||
|
<div class="sec-status" id="balance_pid-status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Adaptive PID -->
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="sec-header">
|
||||||
|
<div>
|
||||||
|
<div class="sec-title">Adaptive PID — Empty Load</div>
|
||||||
|
<div class="sec-node">/adaptive_pid</div>
|
||||||
|
</div>
|
||||||
|
<div class="sec-actions">
|
||||||
|
<button class="hbtn" onclick="loadSection('adaptive_pid_empty')">↓ LOAD</button>
|
||||||
|
<button class="hbtn apply-btn" onclick="applySection('adaptive_pid_empty')">↑ APPLY</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="adaptive_pid_empty-fields"></div>
|
||||||
|
<div class="sec-status" id="adaptive_pid_empty-status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Adaptive PID bounds -->
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="sec-header">
|
||||||
|
<div>
|
||||||
|
<div class="sec-title">Adaptive PID — Bounds</div>
|
||||||
|
<div class="sec-node">/adaptive_pid</div>
|
||||||
|
</div>
|
||||||
|
<div class="sec-actions">
|
||||||
|
<button class="hbtn" onclick="loadSection('adaptive_pid_bounds')">↓ LOAD</button>
|
||||||
|
<button class="hbtn apply-btn" onclick="applySection('adaptive_pid_bounds')">↑ APPLY</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="adaptive_pid_bounds-fields"></div>
|
||||||
|
<div class="sec-status" id="adaptive_pid_bounds-status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ SPEED TAB ═══ -->
|
||||||
|
<div class="tab-panel" id="tab-speed">
|
||||||
|
<div class="panel-col">
|
||||||
|
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="sec-header">
|
||||||
|
<div>
|
||||||
|
<div class="sec-title">Tank Driver Limits</div>
|
||||||
|
<div class="sec-node">/tank_driver</div>
|
||||||
|
</div>
|
||||||
|
<div class="sec-actions">
|
||||||
|
<button class="hbtn" onclick="loadSection('tank_limits')">↓ LOAD</button>
|
||||||
|
<button class="hbtn apply-btn" onclick="applySection('tank_limits')">↑ APPLY</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="tank_limits-fields"></div>
|
||||||
|
<div class="sec-status" id="tank_limits-status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="sec-header">
|
||||||
|
<div>
|
||||||
|
<div class="sec-title">Smooth Velocity Controller</div>
|
||||||
|
<div class="sec-node">/smooth_velocity_controller</div>
|
||||||
|
</div>
|
||||||
|
<div class="sec-actions">
|
||||||
|
<button class="hbtn" onclick="loadSection('smooth_vel')">↓ LOAD</button>
|
||||||
|
<button class="hbtn apply-btn" onclick="applySection('smooth_vel')">↑ APPLY</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="smooth_vel-fields"></div>
|
||||||
|
<div class="sec-status" id="smooth_vel-status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="sec-header">
|
||||||
|
<div>
|
||||||
|
<div class="sec-title">Battery Speed Limiter</div>
|
||||||
|
<div class="sec-node">/battery_speed_limiter</div>
|
||||||
|
</div>
|
||||||
|
<div class="sec-actions">
|
||||||
|
<button class="hbtn" onclick="loadSection('batt_speed')">↓ LOAD</button>
|
||||||
|
<button class="hbtn apply-btn" onclick="applySection('batt_speed')">↑ APPLY</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="batt_speed-fields"></div>
|
||||||
|
<div class="sec-status" id="batt_speed-status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ SAFETY TAB ═══ -->
|
||||||
|
<div class="tab-panel" id="tab-safety">
|
||||||
|
<div class="panel-col">
|
||||||
|
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="sec-header">
|
||||||
|
<div>
|
||||||
|
<div class="sec-title">Safety Zone</div>
|
||||||
|
<div class="sec-node">/safety_zone</div>
|
||||||
|
</div>
|
||||||
|
<div class="sec-actions">
|
||||||
|
<button class="hbtn" onclick="loadSection('safety_zone')">↓ LOAD</button>
|
||||||
|
<button class="hbtn apply-btn" onclick="applySection('safety_zone')">↑ APPLY</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="safety_zone-fields"></div>
|
||||||
|
<div class="sec-status" id="safety_zone-status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="sec-header">
|
||||||
|
<div>
|
||||||
|
<div class="sec-title">Power Supervisor</div>
|
||||||
|
<div class="sec-node">/power_supervisor_node</div>
|
||||||
|
</div>
|
||||||
|
<div class="sec-actions">
|
||||||
|
<button class="hbtn" onclick="loadSection('power_sup')">↓ LOAD</button>
|
||||||
|
<button class="hbtn apply-btn" onclick="applySection('power_sup')">↑ APPLY</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="power_sup-fields"></div>
|
||||||
|
<div class="sec-status" id="power_sup-status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="sec-header">
|
||||||
|
<div>
|
||||||
|
<div class="sec-title">LIDAR Avoidance</div>
|
||||||
|
<div class="sec-node">/lidar_avoidance</div>
|
||||||
|
</div>
|
||||||
|
<div class="sec-actions">
|
||||||
|
<button class="hbtn" onclick="loadSection('lidar_avoid')">↓ LOAD</button>
|
||||||
|
<button class="hbtn apply-btn" onclick="applySection('lidar_avoid')">↑ APPLY</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="lidar_avoid-fields"></div>
|
||||||
|
<div class="sec-status" id="lidar_avoid-status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ SENSORS TAB ═══ -->
|
||||||
|
<div class="tab-panel" id="tab-sensors">
|
||||||
|
<div class="panel-col">
|
||||||
|
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="sec-header">
|
||||||
|
<div>
|
||||||
|
<div class="sec-title">Sensor Enable / Disable</div>
|
||||||
|
<div class="sec-node">various nodes</div>
|
||||||
|
</div>
|
||||||
|
<div class="sec-actions">
|
||||||
|
<button class="hbtn" onclick="loadSection('sensor_toggles')">↓ LOAD ALL</button>
|
||||||
|
<button class="hbtn apply-btn" onclick="applySection('sensor_toggles')">↑ APPLY ALL</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="sensor_toggles-fields"></div>
|
||||||
|
<div class="sec-status" id="sensor_toggles-status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="sec-header">
|
||||||
|
<div>
|
||||||
|
<div class="sec-title">IMU / Odometry Fusion</div>
|
||||||
|
<div class="sec-node">/uwb_imu_fusion</div>
|
||||||
|
</div>
|
||||||
|
<div class="sec-actions">
|
||||||
|
<button class="hbtn" onclick="loadSection('imu_fusion')">↓ LOAD</button>
|
||||||
|
<button class="hbtn apply-btn" onclick="applySection('imu_fusion')">↑ APPLY</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="imu_fusion-fields"></div>
|
||||||
|
<div class="sec-status" id="imu_fusion-status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ SYSTEM TAB ═══ -->
|
||||||
|
<div class="tab-panel" id="tab-system">
|
||||||
|
<div class="panel-col">
|
||||||
|
|
||||||
|
<!-- Live diagnostics -->
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="sec-header">
|
||||||
|
<div>
|
||||||
|
<div class="sec-title">System Diagnostics</div>
|
||||||
|
<div class="sec-node">/diagnostics · auto-refresh 2 s</div>
|
||||||
|
</div>
|
||||||
|
<div class="sec-actions">
|
||||||
|
<button class="hbtn" id="btn-refresh-diag" onclick="refreshDiag()">⟳ REFRESH</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="diag-grid">
|
||||||
|
<div class="diag-placeholder">Connect to rosbridge to view diagnostics.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WiFi / network -->
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="sec-header">
|
||||||
|
<div>
|
||||||
|
<div class="sec-title">Network</div>
|
||||||
|
<div class="sec-node">/diagnostics (wifi keys)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="net-grid">
|
||||||
|
<div class="diag-placeholder">Waiting for network data…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Node list -->
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="sec-header">
|
||||||
|
<div>
|
||||||
|
<div class="sec-title">Active ROS2 Nodes</div>
|
||||||
|
<div class="sec-node">rosbridge /rosapi/nodes</div>
|
||||||
|
</div>
|
||||||
|
<div class="sec-actions">
|
||||||
|
<button class="hbtn" onclick="loadNodeList()">⟳ REFRESH</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="node-list-wrap">
|
||||||
|
<div class="diag-placeholder">Click refresh to list active nodes.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /#main -->
|
||||||
|
|
||||||
|
<!-- ── Preset bar ── -->
|
||||||
|
<div id="preset-bar">
|
||||||
|
<span class="plbl">PRESETS:</span>
|
||||||
|
<select id="preset-select">
|
||||||
|
<option value="">— select —</option>
|
||||||
|
</select>
|
||||||
|
<button class="hbtn" onclick="loadPreset()">↓ LOAD</button>
|
||||||
|
<input id="preset-name" type="text" placeholder="Preset name…" />
|
||||||
|
<button class="hbtn" onclick="savePreset()">↑ SAVE</button>
|
||||||
|
<button class="hbtn del-btn" onclick="deletePreset()">✕ DELETE</button>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<button class="hbtn" onclick="resetAllToDefaults()">⟲ DEFAULTS</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Footer ── -->
|
||||||
|
<div id="footer">
|
||||||
|
<span>ROS2 param services · rcl_interfaces/srv/GetParameters · SetParameters</span>
|
||||||
|
<span>rosbridge: <code id="footer-ws">ws://localhost:9090</code></span>
|
||||||
|
<span>settings panel — issue #614</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="settings_panel.js"></script>
|
||||||
|
<script>
|
||||||
|
document.getElementById('ws-input').addEventListener('input', (e) => {
|
||||||
|
document.getElementById('footer-ws').textContent = e.target.value;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
716
ui/settings_panel.js
Normal file
716
ui/settings_panel.js
Normal file
@ -0,0 +1,716 @@
|
|||||||
|
/* settings_panel.js — Saltybot Settings Panel (Issue #614) */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ── ROS2 parameter type constants ──────────────────────────────────────────
|
||||||
|
const P_BOOL = 1;
|
||||||
|
const P_INT = 2;
|
||||||
|
const P_DOUBLE = 3;
|
||||||
|
|
||||||
|
// ── Section / parameter definitions ───────────────────────────────────────
|
||||||
|
// Each section: { id, node, params: [{name, label, type, min, max, step, unit, def}] }
|
||||||
|
const SECTIONS = {
|
||||||
|
|
||||||
|
balance_pid: {
|
||||||
|
node: 'balance_controller',
|
||||||
|
params: [
|
||||||
|
{ name: 'pid_p', label: 'Proportional (Kp)', type: P_DOUBLE, min: 0, max: 5, step: 0.01, unit: '', def: 0.5 },
|
||||||
|
{ name: 'pid_i', label: 'Integral (Ki)', type: P_DOUBLE, min: 0, max: 2, step: 0.005, unit: '', def: 0.1 },
|
||||||
|
{ name: 'pid_d', label: 'Derivative (Kd)', type: P_DOUBLE, min: 0, max: 1, step: 0.005, unit: '', def: 0.05 },
|
||||||
|
{ name: 'i_clamp',label:'I clamp', type: P_DOUBLE, min: 0, max: 50, step: 0.5, unit: '', def: 10.0 },
|
||||||
|
{ name: 'frequency',label:'Control rate', type: P_INT, min: 10, max: 200, step: 10, unit: 'Hz', def: 50 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
adaptive_pid_empty: {
|
||||||
|
node: 'adaptive_pid',
|
||||||
|
params: [
|
||||||
|
{ name: 'kp_empty', label: 'Kp (empty load)', type: P_DOUBLE, min: 0, max: 50, step: 0.5, unit: '', def: 15.0 },
|
||||||
|
{ name: 'ki_empty', label: 'Ki (empty load)', type: P_DOUBLE, min: 0, max: 5, step: 0.05, unit: '', def: 0.5 },
|
||||||
|
{ name: 'kd_empty', label: 'Kd (empty load)', type: P_DOUBLE, min: 0, max: 10, step: 0.1, unit: '', def: 1.5 },
|
||||||
|
{ name: 'kp_light', label: 'Kp (light load)', type: P_DOUBLE, min: 0, max: 50, step: 0.5, unit: '', def: 18.0 },
|
||||||
|
{ name: 'ki_light', label: 'Ki (light load)', type: P_DOUBLE, min: 0, max: 5, step: 0.05, unit: '', def: 0.6 },
|
||||||
|
{ name: 'kd_light', label: 'Kd (light load)', type: P_DOUBLE, min: 0, max: 10, step: 0.1, unit: '', def: 2.0 },
|
||||||
|
{ name: 'kp_heavy', label: 'Kp (heavy load)', type: P_DOUBLE, min: 0, max: 50, step: 0.5, unit: '', def: 22.0 },
|
||||||
|
{ name: 'ki_heavy', label: 'Ki (heavy load)', type: P_DOUBLE, min: 0, max: 5, step: 0.05, unit: '', def: 0.8 },
|
||||||
|
{ name: 'kd_heavy', label: 'Kd (heavy load)', type: P_DOUBLE, min: 0, max: 10, step: 0.1, unit: '', def: 2.5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
adaptive_pid_bounds: {
|
||||||
|
node: 'adaptive_pid',
|
||||||
|
params: [
|
||||||
|
{ name: 'kp_min', label: 'Kp min', type: P_DOUBLE, min: 0, max: 20, step: 0.5, unit: '', def: 5.0 },
|
||||||
|
{ name: 'kp_max', label: 'Kp max', type: P_DOUBLE, min: 0, max: 80, step: 1, unit: '', def: 40.0 },
|
||||||
|
{ name: 'ki_min', label: 'Ki min', type: P_DOUBLE, min: 0, max: 5, step: 0.05, unit: '', def: 0.0 },
|
||||||
|
{ name: 'ki_max', label: 'Ki max', type: P_DOUBLE, min: 0, max: 10, step: 0.1, unit: '', def: 5.0 },
|
||||||
|
{ name: 'kd_min', label: 'Kd min', type: P_DOUBLE, min: 0, max: 5, step: 0.05, unit: '', def: 0.0 },
|
||||||
|
{ name: 'kd_max', label: 'Kd max', type: P_DOUBLE, min: 0, max: 20, step: 0.2, unit: '', def: 10.0 },
|
||||||
|
{ name: 'output_min', label: 'Output min', type: P_DOUBLE, min: -100, max: 0, step: 1, unit: '', def: -50.0},
|
||||||
|
{ name: 'output_max', label: 'Output max', type: P_DOUBLE, min: 0, max: 100, step: 1, unit: '', def: 50.0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
tank_limits: {
|
||||||
|
node: 'tank_driver',
|
||||||
|
params: [
|
||||||
|
{ name: 'max_linear_vel', label: 'Max linear vel', type: P_DOUBLE, min: 0.1, max: 3.0, step: 0.05, unit: 'm/s', def: 1.0 },
|
||||||
|
{ name: 'max_angular_vel', label: 'Max angular vel', type: P_DOUBLE, min: 0.1, max: 5.0, step: 0.1, unit: 'rad/s', def: 2.5 },
|
||||||
|
{ name: 'max_speed_ms', label: 'Max drive speed', type: P_DOUBLE, min: 0.1, max: 5.0, step: 0.1, unit: 'm/s', def: 1.5 },
|
||||||
|
{ name: 'slip_factor', label: 'Track slip factor',type: P_DOUBLE,min: 0, max: 0.5, step: 0.01, unit: '', def: 0.3 },
|
||||||
|
{ name: 'watchdog_timeout_s',label:'Watchdog timeout',type:P_DOUBLE, min: 0.1, max: 2.0, step: 0.05, unit: 's', def: 0.3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
smooth_vel: {
|
||||||
|
node: 'smooth_velocity_controller',
|
||||||
|
params: [
|
||||||
|
{ name: 'max_linear_accel', label: 'Max linear accel', type: P_DOUBLE, min: 0.1, max: 5.0, step: 0.05, unit: 'm/s²', def: 0.5 },
|
||||||
|
{ name: 'max_linear_decel', label: 'Max linear decel', type: P_DOUBLE, min: 0.1, max: 5.0, step: 0.05, unit: 'm/s²', def: 0.8 },
|
||||||
|
{ name: 'max_angular_accel', label: 'Max angular accel', type: P_DOUBLE, min: 0.1, max: 5.0, step: 0.1, unit: 'rad/s²', def: 1.0 },
|
||||||
|
{ name: 'max_angular_decel', label: 'Max angular decel', type: P_DOUBLE, min: 0.1, max: 5.0, step: 0.1, unit: 'rad/s²', def: 1.0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
batt_speed: {
|
||||||
|
node: 'battery_speed_limiter',
|
||||||
|
params: [
|
||||||
|
{ name: 'speed_factor_full', label: 'Speed factor (full)', type: P_DOUBLE, min: 0, max: 1, step: 0.05, unit: '', def: 1.0 },
|
||||||
|
{ name: 'speed_factor_reduced', label: 'Speed factor (reduced)', type: P_DOUBLE, min: 0, max: 1, step: 0.05, unit: '', def: 0.7 },
|
||||||
|
{ name: 'speed_factor_critical', label: 'Speed factor (critical)', type: P_DOUBLE, min: 0, max: 1, step: 0.05, unit: '', def: 0.4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
safety_zone: {
|
||||||
|
node: 'safety_zone',
|
||||||
|
params: [
|
||||||
|
{ name: 'danger_range_m', label: 'Danger range', type: P_DOUBLE, min: 0.05, max: 1.0, step: 0.01, unit: 'm', def: 0.30 },
|
||||||
|
{ name: 'warn_range_m', label: 'Warn range', type: P_DOUBLE, min: 0.2, max: 5.0, step: 0.05, unit: 'm', def: 1.00 },
|
||||||
|
{ name: 'forward_arc_deg', label: 'Forward arc (±)', type: P_DOUBLE, min: 10, max: 180, step: 5, unit: '°', def: 60.0 },
|
||||||
|
{ name: 'estop_debounce_frames',label: 'E-stop debounce', type: P_INT, min: 1, max: 20, step: 1, unit: 'frames', def: 2 },
|
||||||
|
{ name: 'min_valid_range_m', label: 'Min valid range', type: P_DOUBLE, min: 0.01, max: 0.5, step: 0.01, unit: 'm', def: 0.05 },
|
||||||
|
{ name: 'publish_rate', label: 'Publish rate', type: P_DOUBLE, min: 1, max: 50, step: 1, unit: 'Hz', def: 10.0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
power_sup: {
|
||||||
|
node: 'power_supervisor_node',
|
||||||
|
params: [
|
||||||
|
{ name: 'warning_pct', label: 'Warning threshold', type: P_DOUBLE, min: 5, max: 60, step: 1, unit: '%', def: 30.0 },
|
||||||
|
{ name: 'dock_search_pct', label: 'Dock-search threshold',type: P_DOUBLE, min: 5, max: 50, step: 1, unit: '%', def: 20.0 },
|
||||||
|
{ name: 'critical_pct', label: 'Critical threshold', type: P_DOUBLE, min: 2, max: 30, step: 1, unit: '%', def: 10.0 },
|
||||||
|
{ name: 'emergency_pct', label: 'Emergency threshold', type: P_DOUBLE, min: 1, max: 15, step: 1, unit: '%', def: 5.0 },
|
||||||
|
{ name: 'warn_speed_factor', label: 'Speed factor (warn)',type: P_DOUBLE, min: 0, max: 1, step: 0.05, unit: '', def: 0.6 },
|
||||||
|
{ name: 'critical_speed_factor',label:'Speed factor (crit)',type: P_DOUBLE, min: 0, max: 1, step: 0.05, unit: '', def: 0.3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
lidar_avoid: {
|
||||||
|
node: 'lidar_avoidance',
|
||||||
|
params: [
|
||||||
|
{ name: 'emergency_stop_distance', label: 'E-stop distance', type: P_DOUBLE, min: 0.1, max: 3.0, step: 0.05, unit: 'm', def: 0.5 },
|
||||||
|
{ name: 'min_safety_zone', label: 'Min safety zone', type: P_DOUBLE, min: 0.1, max: 2.0, step: 0.05, unit: 'm', def: 0.6 },
|
||||||
|
{ name: 'safety_zone_at_max_speed',label: 'Zone at max speed', type: P_DOUBLE, min: 0.5, max: 10, step: 0.1, unit: 'm', def: 3.0 },
|
||||||
|
{ name: 'max_speed_reference', label: 'Max speed reference',type: P_DOUBLE, min: 0.5, max: 20, step: 0.1, unit: 'm/s', def: 5.56 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
sensor_toggles: {
|
||||||
|
node: 'safety_zone', // placeholder — booleans often live on their own node
|
||||||
|
params: [
|
||||||
|
{ name: 'estop_all_arcs', label: 'E-stop all arcs', type: P_BOOL, unit: '', def: false, desc: 'Any sector triggers e-stop' },
|
||||||
|
{ name: 'lidar_enabled', label: 'LIDAR enabled', type: P_BOOL, unit: '', def: true, desc: '/scan input active' },
|
||||||
|
{ name: 'uwb_enabled', label: 'UWB positioning', type: P_BOOL, unit: '', def: true, desc: 'UWB anchors active' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
imu_fusion: {
|
||||||
|
node: 'uwb_imu_fusion',
|
||||||
|
params: [
|
||||||
|
{ name: 'uwb_weight', label: 'UWB weight', type: P_DOUBLE, min: 0, max: 1, step: 0.05, unit: '', def: 0.7 },
|
||||||
|
{ name: 'imu_weight', label: 'IMU weight', type: P_DOUBLE, min: 0, max: 1, step: 0.05, unit: '', def: 0.3 },
|
||||||
|
{ name: 'publish_rate', label: 'Publish rate', type: P_DOUBLE, min: 1, max: 200, step: 1, unit: 'Hz', def: 50.0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Runtime state: current values per section ──────────────────────────────
|
||||||
|
const values = {}; // values[sectionId][paramName] = currentValue
|
||||||
|
const dirty = {}; // dirty[sectionId][paramName] = true if changed vs loaded
|
||||||
|
|
||||||
|
// Initialise values from defaults
|
||||||
|
Object.keys(SECTIONS).forEach(sid => {
|
||||||
|
values[sid] = {};
|
||||||
|
dirty[sid] = {};
|
||||||
|
SECTIONS[sid].params.forEach(p => {
|
||||||
|
values[sid][p.name] = p.def;
|
||||||
|
dirty[sid][p.name] = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ROS ────────────────────────────────────────────────────────────────────
|
||||||
|
let ros = null;
|
||||||
|
|
||||||
|
function getService(nodeName, type) {
|
||||||
|
return new ROSLIB.Service({ ros, name: `/${nodeName}/${type}`, serviceType: `rcl_interfaces/srv/${type === 'get_parameters' ? 'GetParameters' : 'SetParameters'}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractValue(rosVal) {
|
||||||
|
switch (rosVal.type) {
|
||||||
|
case P_BOOL: return rosVal.bool_value;
|
||||||
|
case P_INT: return rosVal.integer_value;
|
||||||
|
case P_DOUBLE: return rosVal.double_value;
|
||||||
|
default: return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRosValue(type, value) {
|
||||||
|
const v = { type };
|
||||||
|
if (type === P_BOOL) v.bool_value = !!value;
|
||||||
|
if (type === P_INT) v.integer_value = Math.round(value);
|
||||||
|
if (type === P_DOUBLE) v.double_value = parseFloat(value);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section load / apply ───────────────────────────────────────────────────
|
||||||
|
window.loadSection = function(sid) {
|
||||||
|
if (!ros) { setStatus(sid, 'err', 'Not connected'); return; }
|
||||||
|
const sec = SECTIONS[sid];
|
||||||
|
const svc = getService(sec.node, 'get_parameters');
|
||||||
|
const names = sec.params.map(p => p.name);
|
||||||
|
|
||||||
|
setStatus(sid, 'loading', `Loading from /${sec.node}…`);
|
||||||
|
|
||||||
|
svc.callService({ names }, (resp) => {
|
||||||
|
if (!resp || !resp.values) {
|
||||||
|
setStatus(sid, 'err', 'No response'); return;
|
||||||
|
}
|
||||||
|
sec.params.forEach((p, i) => {
|
||||||
|
const v = extractValue(resp.values[i]);
|
||||||
|
if (v !== undefined) {
|
||||||
|
values[sid][p.name] = v;
|
||||||
|
dirty[sid][p.name] = false;
|
||||||
|
updateFieldUI(sid, p.name, v, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setStatus(sid, 'ok', `Loaded ${resp.values.length} params`);
|
||||||
|
}, (err) => {
|
||||||
|
setStatus(sid, 'err', `Error: ${err}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.applySection = function(sid) {
|
||||||
|
if (!ros) { setStatus(sid, 'err', 'Not connected'); return; }
|
||||||
|
const sec = SECTIONS[sid];
|
||||||
|
const svc = getService(sec.node, 'set_parameters');
|
||||||
|
|
||||||
|
const parameters = sec.params.map(p => ({
|
||||||
|
name: p.name,
|
||||||
|
value: makeRosValue(p.type, values[sid][p.name]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
setStatus(sid, 'loading', `Applying to /${sec.node}…`);
|
||||||
|
|
||||||
|
svc.callService({ parameters }, (resp) => {
|
||||||
|
if (!resp || !resp.results) {
|
||||||
|
setStatus(sid, 'err', 'No response'); return;
|
||||||
|
}
|
||||||
|
const failures = resp.results.filter(r => !r.successful);
|
||||||
|
if (failures.length === 0) {
|
||||||
|
sec.params.forEach(p => { dirty[sid][p.name] = false; });
|
||||||
|
refreshFieldDirty(sid);
|
||||||
|
setStatus(sid, 'ok', `Applied ${parameters.length} params ✓`);
|
||||||
|
} else {
|
||||||
|
const reasons = failures.map(r => r.reason).join('; ');
|
||||||
|
setStatus(sid, 'err', `${failures.length} failed: ${reasons}`);
|
||||||
|
}
|
||||||
|
}, (err) => {
|
||||||
|
setStatus(sid, 'err', `Error: ${err}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── UI builder ─────────────────────────────────────────────────────────────
|
||||||
|
function buildFields() {
|
||||||
|
Object.keys(SECTIONS).forEach(sid => {
|
||||||
|
const sec = SECTIONS[sid];
|
||||||
|
const container = document.getElementById(`${sid}-fields`);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
sec.params.forEach(p => {
|
||||||
|
if (p.type === P_BOOL) {
|
||||||
|
// Toggle row
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'toggle-row';
|
||||||
|
row.innerHTML = `
|
||||||
|
<span class="param-label">${p.label}</span>
|
||||||
|
<span class="toggle-desc">${p.desc || ''}</span>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="tgl-${sid}-${p.name}"
|
||||||
|
${p.def ? 'checked' : ''}>
|
||||||
|
<div class="toggle-track"></div>
|
||||||
|
</label>`;
|
||||||
|
container.appendChild(row);
|
||||||
|
|
||||||
|
const cb = row.querySelector(`#tgl-${sid}-${p.name}`);
|
||||||
|
cb.addEventListener('change', () => {
|
||||||
|
values[sid][p.name] = cb.checked;
|
||||||
|
dirty[sid][p.name] = true;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Slider + number input row
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'param-row';
|
||||||
|
const sliderMin = p.min !== undefined ? p.min : 0;
|
||||||
|
const sliderMax = p.max !== undefined ? p.max : 100;
|
||||||
|
const sliderStep = p.step || 0.01;
|
||||||
|
const defVal = p.def !== undefined ? p.def : 0;
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<span class="param-label">${p.label}</span>
|
||||||
|
<input type="range" class="param-slider"
|
||||||
|
id="sld-${sid}-${p.name}"
|
||||||
|
min="${sliderMin}" max="${sliderMax}" step="${sliderStep}"
|
||||||
|
value="${defVal}">
|
||||||
|
<input type="number" class="param-input"
|
||||||
|
id="inp-${sid}-${p.name}"
|
||||||
|
min="${sliderMin}" max="${sliderMax}" step="${sliderStep}"
|
||||||
|
value="${defVal}">
|
||||||
|
<span class="param-unit">${p.unit || ''}</span>`;
|
||||||
|
container.appendChild(row);
|
||||||
|
|
||||||
|
const slider = row.querySelector(`#sld-${sid}-${p.name}`);
|
||||||
|
const input = row.querySelector(`#inp-${sid}-${p.name}`);
|
||||||
|
|
||||||
|
slider.addEventListener('input', () => {
|
||||||
|
const v = parseFloat(slider.value);
|
||||||
|
input.value = v;
|
||||||
|
values[sid][p.name] = v;
|
||||||
|
dirty[sid][p.name] = true;
|
||||||
|
input.classList.add('changed');
|
||||||
|
slider.classList.add('changed');
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('change', () => {
|
||||||
|
let v = parseFloat(input.value);
|
||||||
|
v = Math.max(sliderMin, Math.min(sliderMax, v));
|
||||||
|
input.value = v;
|
||||||
|
slider.value = v;
|
||||||
|
values[sid][p.name] = v;
|
||||||
|
dirty[sid][p.name] = true;
|
||||||
|
input.classList.add('changed');
|
||||||
|
slider.classList.add('changed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFieldUI(sid, paramName, value, markDirty) {
|
||||||
|
const sec = SECTIONS[sid];
|
||||||
|
const p = sec.params.find(x => x.name === paramName);
|
||||||
|
if (!p) return;
|
||||||
|
|
||||||
|
if (p.type === P_BOOL) {
|
||||||
|
const cb = document.getElementById(`tgl-${sid}-${paramName}`);
|
||||||
|
if (cb) cb.checked = !!value;
|
||||||
|
} else {
|
||||||
|
const sld = document.getElementById(`sld-${sid}-${paramName}`);
|
||||||
|
const inp = document.getElementById(`inp-${sid}-${paramName}`);
|
||||||
|
if (sld) sld.value = value;
|
||||||
|
if (inp) inp.value = value;
|
||||||
|
if (!markDirty) {
|
||||||
|
if (sld) sld.classList.remove('changed');
|
||||||
|
if (inp) inp.classList.remove('changed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshFieldDirty(sid) {
|
||||||
|
const sec = SECTIONS[sid];
|
||||||
|
sec.params.forEach(p => {
|
||||||
|
if (p.type !== P_BOOL) {
|
||||||
|
const sld = document.getElementById(`sld-${sid}-${p.name}`);
|
||||||
|
const inp = document.getElementById(`inp-${sid}-${p.name}`);
|
||||||
|
if (!dirty[sid][p.name]) {
|
||||||
|
if (sld) sld.classList.remove('changed');
|
||||||
|
if (inp) inp.classList.remove('changed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(sid, cls, msg) {
|
||||||
|
const el = document.getElementById(`${sid}-status`);
|
||||||
|
if (!el) return;
|
||||||
|
el.className = `sec-status ${cls}`;
|
||||||
|
el.textContent = msg;
|
||||||
|
if (cls === 'ok') setTimeout(() => { el.textContent = ''; el.className = 'sec-status'; }, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tabs ───────────────────────────────────────────────────────────────────
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
document.getElementById(`tab-${btn.dataset.tab}`).classList.add('active');
|
||||||
|
if (btn.dataset.tab === 'system') startDiagRefresh();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Presets ────────────────────────────────────────────────────────────────
|
||||||
|
const PRESET_KEY = 'saltybot_settings_presets';
|
||||||
|
|
||||||
|
function getPresets() {
|
||||||
|
try { return JSON.parse(localStorage.getItem(PRESET_KEY) || '{}'); } catch(_) { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePresetsToStorage(presets) {
|
||||||
|
localStorage.setItem(PRESET_KEY, JSON.stringify(presets));
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshPresetSelect() {
|
||||||
|
const sel = document.getElementById('preset-select');
|
||||||
|
const cur = sel.value;
|
||||||
|
sel.innerHTML = '<option value="">— select —</option>';
|
||||||
|
const presets = getPresets();
|
||||||
|
Object.keys(presets).sort().forEach(name => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = name;
|
||||||
|
opt.textContent = name;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (cur) sel.value = cur;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotAllValues() {
|
||||||
|
const snap = {};
|
||||||
|
Object.keys(values).forEach(sid => {
|
||||||
|
snap[sid] = Object.assign({}, values[sid]);
|
||||||
|
});
|
||||||
|
return snap;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.savePreset = function() {
|
||||||
|
const nameInput = document.getElementById('preset-name');
|
||||||
|
const name = nameInput.value.trim();
|
||||||
|
if (!name) { alert('Enter a preset name'); return; }
|
||||||
|
const presets = getPresets();
|
||||||
|
presets[name] = snapshotAllValues();
|
||||||
|
savePresetsToStorage(presets);
|
||||||
|
nameInput.value = '';
|
||||||
|
refreshPresetSelect();
|
||||||
|
document.getElementById('preset-select').value = name;
|
||||||
|
flashSaved();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.loadPreset = function() {
|
||||||
|
const name = document.getElementById('preset-select').value;
|
||||||
|
if (!name) return;
|
||||||
|
const presets = getPresets();
|
||||||
|
const snap = presets[name];
|
||||||
|
if (!snap) return;
|
||||||
|
Object.keys(snap).forEach(sid => {
|
||||||
|
if (!values[sid]) return;
|
||||||
|
Object.keys(snap[sid]).forEach(paramName => {
|
||||||
|
values[sid][paramName] = snap[sid][paramName];
|
||||||
|
dirty[sid][paramName] = true;
|
||||||
|
updateFieldUI(sid, paramName, snap[sid][paramName], true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
flashSaved();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.deletePreset = function() {
|
||||||
|
const name = document.getElementById('preset-select').value;
|
||||||
|
if (!name) return;
|
||||||
|
if (!confirm(`Delete preset "${name}"?`)) return;
|
||||||
|
const presets = getPresets();
|
||||||
|
delete presets[name];
|
||||||
|
savePresetsToStorage(presets);
|
||||||
|
refreshPresetSelect();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.resetAllToDefaults = function() {
|
||||||
|
if (!confirm('Reset all fields to built-in defaults?')) return;
|
||||||
|
Object.keys(SECTIONS).forEach(sid => {
|
||||||
|
SECTIONS[sid].params.forEach(p => {
|
||||||
|
values[sid][p.name] = p.def;
|
||||||
|
dirty[sid][p.name] = false;
|
||||||
|
updateFieldUI(sid, p.name, p.def, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function flashSaved() {
|
||||||
|
const el = document.getElementById('save-indicator');
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
el.style.animation = 'none';
|
||||||
|
void el.offsetHeight;
|
||||||
|
el.style.animation = 'fadeout 2s forwards';
|
||||||
|
setTimeout(() => el.classList.add('hidden'), 2100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── System tab: diagnostics ────────────────────────────────────────────────
|
||||||
|
let diagTopic = null;
|
||||||
|
let diagRefreshTimer = null;
|
||||||
|
let diagState = {
|
||||||
|
cpuTemp: null, gpuTemp: null, boardTemp: null,
|
||||||
|
motorTempL: null, motorTempR: null,
|
||||||
|
ramUsed: null, ramTotal: null,
|
||||||
|
gpuUsed: null, gpuTotal: null,
|
||||||
|
diskUsed: null, diskTotal: null,
|
||||||
|
rssi: null, latency: null, mqttConnected: null,
|
||||||
|
nodes: [],
|
||||||
|
lastUpdate: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function startDiagRefresh() {
|
||||||
|
if (!ros || diagTopic) return;
|
||||||
|
diagTopic = new ROSLIB.Topic({
|
||||||
|
ros, name: '/diagnostics', messageType: 'diagnostic_msgs/DiagnosticArray',
|
||||||
|
throttle_rate: 2000,
|
||||||
|
});
|
||||||
|
diagTopic.subscribe(onDiagnostics);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopDiagRefresh() {
|
||||||
|
if (diagTopic) { diagTopic.unsubscribe(); diagTopic = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDiagnostics(msg) {
|
||||||
|
const kv = {};
|
||||||
|
(msg.status || []).forEach(status => {
|
||||||
|
(status.values || []).forEach(pair => {
|
||||||
|
kv[pair.key] = pair.value;
|
||||||
|
});
|
||||||
|
diagState.nodes.push({ name: status.name, level: status.level, msg: status.message });
|
||||||
|
if (diagState.nodes.length > 40) diagState.nodes.splice(0, diagState.nodes.length - 40);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract known keys
|
||||||
|
if (kv.cpu_temp_c) diagState.cpuTemp = parseFloat(kv.cpu_temp_c);
|
||||||
|
if (kv.gpu_temp_c) diagState.gpuTemp = parseFloat(kv.gpu_temp_c);
|
||||||
|
if (kv.board_temp_c) diagState.boardTemp = parseFloat(kv.board_temp_c);
|
||||||
|
if (kv.motor_temp_l_c) diagState.motorTempL = parseFloat(kv.motor_temp_l_c);
|
||||||
|
if (kv.motor_temp_r_c) diagState.motorTempR = parseFloat(kv.motor_temp_r_c);
|
||||||
|
if (kv.ram_used_mb) diagState.ramUsed = parseFloat(kv.ram_used_mb);
|
||||||
|
if (kv.ram_total_mb) diagState.ramTotal = parseFloat(kv.ram_total_mb);
|
||||||
|
if (kv.gpu_used_mb) diagState.gpuUsed = parseFloat(kv.gpu_used_mb);
|
||||||
|
if (kv.gpu_total_mb) diagState.gpuTotal = parseFloat(kv.gpu_total_mb);
|
||||||
|
if (kv.disk_used_gb) diagState.diskUsed = parseFloat(kv.disk_used_gb);
|
||||||
|
if (kv.disk_total_gb) diagState.diskTotal = parseFloat(kv.disk_total_gb);
|
||||||
|
if (kv.wifi_rssi_dbm) diagState.rssi = parseFloat(kv.wifi_rssi_dbm);
|
||||||
|
if (kv.wifi_latency_ms) diagState.latency = parseFloat(kv.wifi_latency_ms);
|
||||||
|
if (kv.mqtt_connected !== undefined) diagState.mqttConnected = kv.mqtt_connected === 'true';
|
||||||
|
|
||||||
|
diagState.lastUpdate = new Date();
|
||||||
|
renderDiag();
|
||||||
|
renderNet();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.refreshDiag = function() {
|
||||||
|
startDiagRefresh();
|
||||||
|
renderDiag();
|
||||||
|
};
|
||||||
|
|
||||||
|
function tempColor(t) {
|
||||||
|
if (t === null) return '';
|
||||||
|
if (t > 80) return 'err';
|
||||||
|
if (t > 65) return 'warn';
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pct(used, total) {
|
||||||
|
if (!total) return '—';
|
||||||
|
return ((used / total) * 100).toFixed(0) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDiag() {
|
||||||
|
const g = document.getElementById('diag-grid');
|
||||||
|
if (!g) return;
|
||||||
|
const d = diagState;
|
||||||
|
if (d.lastUpdate === null) {
|
||||||
|
g.innerHTML = '<div class="diag-placeholder">Waiting for /diagnostics…</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{
|
||||||
|
title: 'Temperature',
|
||||||
|
rows: [
|
||||||
|
{ k: 'CPU', v: d.cpuTemp !== null ? d.cpuTemp.toFixed(1) + ' °C' : '—', cls: tempColor(d.cpuTemp) },
|
||||||
|
{ k: 'GPU', v: d.gpuTemp !== null ? d.gpuTemp.toFixed(1) + ' °C' : '—', cls: tempColor(d.gpuTemp) },
|
||||||
|
{ k: 'Board',v:d.boardTemp !== null ? d.boardTemp.toFixed(1)+' °C':'—', cls:tempColor(d.boardTemp)},
|
||||||
|
{ k: 'Motor L', v: d.motorTempL !== null ? d.motorTempL.toFixed(1)+' °C':'—', cls:tempColor(d.motorTempL)},
|
||||||
|
{ k: 'Motor R', v: d.motorTempR !== null ? d.motorTempR.toFixed(1)+' °C':'—', cls:tempColor(d.motorTempR)},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Memory',
|
||||||
|
rows: [
|
||||||
|
{ k: 'RAM used', v: d.ramUsed !== null ? d.ramUsed.toFixed(0)+' MB': '—', cls:'' },
|
||||||
|
{ k: 'RAM total', v: d.ramTotal !== null ? d.ramTotal.toFixed(0)+' MB': '—', cls:'' },
|
||||||
|
{ k: 'RAM %', v: pct(d.ramUsed, d.ramTotal), cls: d.ramUsed/d.ramTotal > 0.85 ? 'warn' : 'ok' },
|
||||||
|
{ k: 'GPU mem', v: d.gpuUsed !== null ? d.gpuUsed.toFixed(0)+' MB': '—', cls:'' },
|
||||||
|
{ k: 'GPU %', v: pct(d.gpuUsed, d.gpuTotal), cls:'' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Storage',
|
||||||
|
rows: [
|
||||||
|
{ k: 'Disk used', v: d.diskUsed !== null ? d.diskUsed.toFixed(1)+' GB': '—', cls:'' },
|
||||||
|
{ k: 'Disk total', v: d.diskTotal !== null ? d.diskTotal.toFixed(1)+' GB': '—', cls:'' },
|
||||||
|
{ k: 'Disk %', v: pct(d.diskUsed, d.diskTotal), cls: d.diskUsed/d.diskTotal > 0.9 ? 'err' : d.diskUsed/d.diskTotal > 0.75 ? 'warn' : 'ok' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Updated',
|
||||||
|
rows: [
|
||||||
|
{ k: 'Last msg', v: d.lastUpdate ? d.lastUpdate.toLocaleTimeString() : '—', cls:'' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
g.innerHTML = cards.map(c => `
|
||||||
|
<div class="diag-card">
|
||||||
|
<div class="diag-card-title">${c.title}</div>
|
||||||
|
${c.rows.map(r => `<div class="diag-kv"><span class="diag-k">${r.k}</span><span class="diag-v ${r.cls||''}">${r.v}</span></div>`).join('')}
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function rssiColor(rssi) {
|
||||||
|
if (rssi === null) return '';
|
||||||
|
if (rssi > -50) return 'ok';
|
||||||
|
if (rssi > -70) return 'warn';
|
||||||
|
return 'err';
|
||||||
|
}
|
||||||
|
|
||||||
|
function rssiBarCount(rssi) {
|
||||||
|
if (rssi === null) return 0;
|
||||||
|
if (rssi > -50) return 5;
|
||||||
|
if (rssi > -60) return 4;
|
||||||
|
if (rssi > -70) return 3;
|
||||||
|
if (rssi > -80) return 2;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function latencyColor(ms) {
|
||||||
|
if (ms === null) return '';
|
||||||
|
if (ms < 50) return 'ok';
|
||||||
|
if (ms < 150) return 'warn';
|
||||||
|
return 'err';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNet() {
|
||||||
|
const g = document.getElementById('net-grid');
|
||||||
|
if (!g) return;
|
||||||
|
const d = diagState;
|
||||||
|
const bars = rssiBarCount(d.rssi);
|
||||||
|
const heights = [4, 6, 9, 12, 15];
|
||||||
|
|
||||||
|
const barsHtml = heights.map((h, i) =>
|
||||||
|
`<div class="sig-bar ${i < bars ? 'lit' : ''}" style="height:${h}px"></div>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
g.innerHTML = `
|
||||||
|
<div class="diag-card">
|
||||||
|
<div class="diag-card-title">WiFi</div>
|
||||||
|
<div class="diag-kv">
|
||||||
|
<span class="diag-k">RSSI</span>
|
||||||
|
<span class="diag-v ${rssiColor(d.rssi)}">${d.rssi !== null ? d.rssi + ' dBm' : '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="diag-kv">
|
||||||
|
<span class="diag-k">Signal</span>
|
||||||
|
<span class="diag-v"><div class="sig-bars">${barsHtml}</div></span>
|
||||||
|
</div>
|
||||||
|
<div class="diag-kv">
|
||||||
|
<span class="diag-k">Latency</span>
|
||||||
|
<span class="diag-v ${latencyColor(d.latency)}">${d.latency !== null ? d.latency.toFixed(0) + ' ms' : '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="diag-card">
|
||||||
|
<div class="diag-card-title">Services</div>
|
||||||
|
<div class="diag-kv">
|
||||||
|
<span class="diag-k">MQTT</span>
|
||||||
|
<span class="diag-v ${d.mqttConnected === null ? '' : d.mqttConnected ? 'ok' : 'err'}">
|
||||||
|
${d.mqttConnected === null ? '—' : d.mqttConnected ? 'Connected' : 'Disconnected'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="diag-kv">
|
||||||
|
<span class="diag-k">rosbridge</span>
|
||||||
|
<span class="diag-v ${ros ? 'ok' : 'err'}">${ros ? 'Connected' : 'Disconnected'}</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Node list ──────────────────────────────────────────────────────────────
|
||||||
|
window.loadNodeList = function() {
|
||||||
|
if (!ros) return;
|
||||||
|
const svc = new ROSLIB.Service({
|
||||||
|
ros, name: '/rosapi/nodes', serviceType: 'rosapi/Nodes',
|
||||||
|
});
|
||||||
|
svc.callService({}, (resp) => {
|
||||||
|
const wrap = document.getElementById('node-list-wrap');
|
||||||
|
if (!resp || !resp.nodes) { wrap.innerHTML = '<div class="diag-placeholder">No response</div>'; return; }
|
||||||
|
const sorted = [...resp.nodes].sort();
|
||||||
|
wrap.innerHTML = sorted.map(n =>
|
||||||
|
`<span class="node-chip active-node">${n}</span>`
|
||||||
|
).join('');
|
||||||
|
}, () => {
|
||||||
|
document.getElementById('node-list-wrap').innerHTML =
|
||||||
|
'<div class="diag-placeholder">Service unavailable — ensure rosapi is running</div>';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Connection ─────────────────────────────────────────────────────────────
|
||||||
|
const $connDot = document.getElementById('conn-dot');
|
||||||
|
const $connLabel = document.getElementById('conn-label');
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
const url = document.getElementById('ws-input').value.trim() || 'ws://localhost:9090';
|
||||||
|
if (ros) { stopDiagRefresh(); try { ros.close(); } catch(_){} }
|
||||||
|
|
||||||
|
$connLabel.textContent = 'Connecting…';
|
||||||
|
$connLabel.style.color = '#d97706';
|
||||||
|
$connDot.className = '';
|
||||||
|
|
||||||
|
ros = new ROSLIB.Ros({ url });
|
||||||
|
|
||||||
|
ros.on('connection', () => {
|
||||||
|
$connDot.className = 'connected';
|
||||||
|
$connLabel.style.color = '#22c55e';
|
||||||
|
$connLabel.textContent = 'Connected';
|
||||||
|
localStorage.setItem('settings_ws_url', url);
|
||||||
|
// Auto-start diagnostics if system tab visible
|
||||||
|
const sysPanel = document.getElementById('tab-system');
|
||||||
|
if (sysPanel && sysPanel.classList.contains('active')) startDiagRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
ros.on('error', () => {
|
||||||
|
$connDot.className = 'error';
|
||||||
|
$connLabel.style.color = '#ef4444';
|
||||||
|
$connLabel.textContent = 'Error';
|
||||||
|
});
|
||||||
|
|
||||||
|
ros.on('close', () => {
|
||||||
|
$connDot.className = '';
|
||||||
|
$connLabel.style.color = '#6b7280';
|
||||||
|
$connLabel.textContent = 'Disconnected';
|
||||||
|
stopDiagRefresh();
|
||||||
|
ros = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-connect').addEventListener('click', connect);
|
||||||
|
|
||||||
|
// ── Init ───────────────────────────────────────────────────────────────────
|
||||||
|
buildFields();
|
||||||
|
refreshPresetSelect();
|
||||||
|
|
||||||
|
const savedUrl = localStorage.getItem('settings_ws_url');
|
||||||
|
if (savedUrl) {
|
||||||
|
document.getElementById('ws-input').value = savedUrl;
|
||||||
|
document.getElementById('footer-ws').textContent = savedUrl;
|
||||||
|
connect();
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user