Restore safety cutoff to 800F, faster PID loop timing, enhanced simple UI with stats/charts/flight controls, compact TTY display
This commit is contained in:
+2
-2
@@ -44,9 +44,9 @@
|
||||
- If current temp starts above setpoint, autotune may begin in cooling phase.
|
||||
|
||||
## Safety behaviors
|
||||
- Hard max temp cutoff.
|
||||
- Hard max temp cutoff (800F).
|
||||
- Thermocouple disconnect handling.
|
||||
- Idle shutoff timer.
|
||||
- Idle shutoff timer (default 30 min).
|
||||
- Watchdog status exposed in heartbeat.
|
||||
|
||||
## Operations quick commands
|
||||
|
||||
+9
-9
@@ -7,8 +7,8 @@
|
||||
},
|
||||
"control": {
|
||||
"setpoint": 530,
|
||||
"loop_size_ms": 3000,
|
||||
"sleep_time": 0.4,
|
||||
"loop_size_ms": 1800,
|
||||
"sleep_time": 0.2,
|
||||
"enabled": false
|
||||
},
|
||||
"flight": {
|
||||
@@ -26,7 +26,7 @@
|
||||
]
|
||||
},
|
||||
"safety": {
|
||||
"max_temp_f": 750,
|
||||
"max_temp_f": 800,
|
||||
"spike_threshold_f": 50.0,
|
||||
"idle_shutoff_minutes": 30,
|
||||
"watchdog_timeout_s": 10,
|
||||
@@ -70,8 +70,8 @@
|
||||
},
|
||||
"control": {
|
||||
"setpoint": 530,
|
||||
"loop_size_ms": 3000,
|
||||
"sleep_time": 0.4,
|
||||
"loop_size_ms": 1800,
|
||||
"sleep_time": 0.2,
|
||||
"enabled": false
|
||||
},
|
||||
"flight": {
|
||||
@@ -89,7 +89,7 @@
|
||||
]
|
||||
},
|
||||
"safety": {
|
||||
"max_temp_f": 750,
|
||||
"max_temp_f": 800,
|
||||
"spike_threshold_f": 50.0,
|
||||
"idle_shutoff_minutes": 30,
|
||||
"watchdog_timeout_s": 10,
|
||||
@@ -123,8 +123,8 @@
|
||||
},
|
||||
"control": {
|
||||
"setpoint": 530,
|
||||
"loop_size_ms": 3000,
|
||||
"sleep_time": 0.4,
|
||||
"loop_size_ms": 1800,
|
||||
"sleep_time": 0.2,
|
||||
"enabled": false
|
||||
},
|
||||
"flight": {
|
||||
@@ -142,7 +142,7 @@
|
||||
]
|
||||
},
|
||||
"safety": {
|
||||
"max_temp_f": 750,
|
||||
"max_temp_f": 800,
|
||||
"spike_threshold_f": 50.0,
|
||||
"idle_shutoff_minutes": 30,
|
||||
"watchdog_timeout_s": 10,
|
||||
|
||||
+9
-9
@@ -21,8 +21,8 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
"control": {
|
||||
"setpoint": 530,
|
||||
"loop_size_ms": 3000,
|
||||
"sleep_time": 0.4,
|
||||
"loop_size_ms": 1800,
|
||||
"sleep_time": 0.2,
|
||||
"enabled": False
|
||||
},
|
||||
"flight": {
|
||||
@@ -40,7 +40,7 @@ DEFAULT_CONFIG = {
|
||||
]
|
||||
},
|
||||
"safety": {
|
||||
"max_temp_f": 750,
|
||||
"max_temp_f": 800,
|
||||
"spike_threshold_f": 50.0,
|
||||
"idle_shutoff_minutes": 30,
|
||||
"watchdog_timeout_s": 10,
|
||||
@@ -84,8 +84,8 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
"control": {
|
||||
"setpoint": 530,
|
||||
"loop_size_ms": 3000,
|
||||
"sleep_time": 0.4,
|
||||
"loop_size_ms": 1800,
|
||||
"sleep_time": 0.2,
|
||||
"enabled": False
|
||||
},
|
||||
"flight": {
|
||||
@@ -103,7 +103,7 @@ DEFAULT_CONFIG = {
|
||||
]
|
||||
},
|
||||
"safety": {
|
||||
"max_temp_f": 750,
|
||||
"max_temp_f": 800,
|
||||
"spike_threshold_f": 50.0,
|
||||
"idle_shutoff_minutes": 30,
|
||||
"watchdog_timeout_s": 10,
|
||||
@@ -137,8 +137,8 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
"control": {
|
||||
"setpoint": 530,
|
||||
"loop_size_ms": 3000,
|
||||
"sleep_time": 0.4,
|
||||
"loop_size_ms": 1800,
|
||||
"sleep_time": 0.2,
|
||||
"enabled": False
|
||||
},
|
||||
"flight": {
|
||||
@@ -156,7 +156,7 @@ DEFAULT_CONFIG = {
|
||||
]
|
||||
},
|
||||
"safety": {
|
||||
"max_temp_f": 750,
|
||||
"max_temp_f": 800,
|
||||
"spike_threshold_f": 50.0,
|
||||
"idle_shutoff_minutes": 30,
|
||||
"watchdog_timeout_s": 10,
|
||||
|
||||
+141
-8
@@ -1,8 +1,10 @@
|
||||
let pollInterval = 500;
|
||||
let chartMaxPoints = 300;
|
||||
let lastTimestamp = 0;
|
||||
let lastTimestampByNail = { nail1: 0, nail2: 0 };
|
||||
let chart = null;
|
||||
let firstTimestamp = null;
|
||||
let simpleChart = null;
|
||||
let simpleFirstTimestamp = null;
|
||||
let lastApiError = '';
|
||||
let actionBannerTimer = null;
|
||||
let heartbeatMisses = 0;
|
||||
@@ -12,6 +14,7 @@ let deferredInstallPrompt = null;
|
||||
let uiMode = 'nail1';
|
||||
let activeNailId = 'nail1';
|
||||
let historyBuffer = [];
|
||||
let simpleHistoryByNail = { nail1: [], nail2: [] };
|
||||
let schedulerTimesByNail = { nail1: [], nail2: [] };
|
||||
let states = { nail1: null, nail2: null };
|
||||
|
||||
@@ -166,6 +169,26 @@ function schedulerEnabledChanged() {
|
||||
saveSchedulerSettings();
|
||||
}
|
||||
|
||||
function formatSeconds(sec) {
|
||||
if (typeof sec !== 'number' || !isFinite(sec)) return '--';
|
||||
const s = Math.max(0, Math.round(sec));
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const r = s % 60;
|
||||
if (h > 0) return h + 'h ' + m + 'm ' + r + 's';
|
||||
return m + 'm ' + r + 's';
|
||||
}
|
||||
|
||||
function formatUptime(sec) {
|
||||
if (typeof sec !== 'number' || !isFinite(sec)) return '--';
|
||||
const s = Math.max(0, Math.round(sec));
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const r = s % 60;
|
||||
if (h > 0) return h + 'h ' + m + 'm';
|
||||
return m + 'm ' + r + 's';
|
||||
}
|
||||
|
||||
function updateSimpleCards() {
|
||||
['nail1', 'nail2'].forEach(function(id) {
|
||||
const s = states[id];
|
||||
@@ -176,10 +199,35 @@ function updateSimpleCards() {
|
||||
const spEl = document.getElementById('simple-target-' + suffix);
|
||||
const pEl = document.getElementById('simple-power-' + suffix);
|
||||
const inEl = document.getElementById('simple-setpoint-' + suffix);
|
||||
const relayEl = document.getElementById('simple-relay-' + suffix);
|
||||
const outEl = document.getElementById('simple-output-' + suffix);
|
||||
const tcEl = document.getElementById('simple-tc-' + suffix);
|
||||
const upEl = document.getElementById('simple-uptime-' + suffix);
|
||||
const etaEl = document.getElementById('simple-eta-' + suffix);
|
||||
const cutoffEl = document.getElementById('simple-cutoff-' + suffix);
|
||||
const alertEl = document.getElementById('simple-alert-' + suffix);
|
||||
if (tEl) tEl.textContent = s.temp.toFixed(1);
|
||||
if (mEl) mEl.textContent = s.mode || 'grounded';
|
||||
if (spEl) spEl.textContent = s.effective_setpoint.toFixed(0);
|
||||
if (inEl && document.activeElement !== inEl) inEl.value = s.setpoint;
|
||||
if (relayEl) relayEl.textContent = s.relay_on ? 'ON' : 'OFF';
|
||||
if (outEl) outEl.textContent = (typeof s.output === 'number' ? s.output.toFixed(0) : '--');
|
||||
if (tcEl) tcEl.textContent = s.thermocouple_connected ? 'OK' : 'DISC';
|
||||
if (upEl) upEl.textContent = formatUptime(s.uptime_seconds);
|
||||
if (etaEl) etaEl.textContent = formatSeconds(s.mode_eta_seconds);
|
||||
if (cutoffEl) cutoffEl.textContent = s.scheduler && s.scheduler.enabled ? formatSeconds(s.next_cutoff_seconds) : 'Scheduler off';
|
||||
if (alertEl) {
|
||||
if (s.safety_tripped) {
|
||||
alertEl.className = 'simple-alert bad';
|
||||
alertEl.textContent = 'Safety trip: ' + (s.safety_reason || 'Unknown');
|
||||
} else if (!s.thermocouple_connected) {
|
||||
alertEl.className = 'simple-alert warn';
|
||||
alertEl.textContent = 'Thermocouple disconnected.';
|
||||
} else {
|
||||
alertEl.className = 'simple-alert';
|
||||
alertEl.textContent = 'No active safety alerts.';
|
||||
}
|
||||
}
|
||||
if (pEl) {
|
||||
pEl.textContent = s.enabled ? 'ON' : 'OFF';
|
||||
pEl.className = 'power-mini ' + (s.enabled ? 'on' : 'off');
|
||||
@@ -282,9 +330,50 @@ function initChart() {
|
||||
});
|
||||
}
|
||||
|
||||
function initSimpleChart() {
|
||||
const canvas = document.getElementById('simple-temp-chart');
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
simpleChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: { datasets: [
|
||||
{ label: 'Nail 1 Temp', borderColor: '#ff6b35', borderWidth: 2, pointRadius: 0, fill: false, data: [] },
|
||||
{ label: 'Nail 2 Temp', borderColor: '#4ecdc4', borderWidth: 2, pointRadius: 0, fill: false, data: [] },
|
||||
{ label: 'Nail 1 Target', borderColor: '#ff9f6b', borderWidth: 1, borderDash: [5, 4], pointRadius: 0, fill: false, data: [] },
|
||||
{ label: 'Nail 2 Target', borderColor: '#7df7ef', borderWidth: 1, borderDash: [5, 4], pointRadius: 0, fill: false, data: [] },
|
||||
]},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
scales: {
|
||||
x: {
|
||||
type: 'linear',
|
||||
ticks: {
|
||||
color: '#888',
|
||||
callback: function(value) { return Math.round(value) + 's'; },
|
||||
maxTicksLimit: 10,
|
||||
},
|
||||
grid: { color: 'rgba(255,255,255,0.05)' },
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
ticks: { color: '#ccc' },
|
||||
grid: { color: 'rgba(255,255,255,0.05)' },
|
||||
suggestedMin: 0,
|
||||
suggestedMax: 700,
|
||||
title: { display: true, text: 'Temperature (F)', color: '#aaa' },
|
||||
},
|
||||
},
|
||||
plugins: { legend: { labels: { color: '#ccc', boxWidth: 12 } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function resetChartForNail() {
|
||||
firstTimestamp = null;
|
||||
lastTimestamp = 0;
|
||||
lastTimestampByNail[currentNailId()] = 0;
|
||||
historyBuffer = [];
|
||||
if (chart) {
|
||||
chart.data.datasets.forEach(function(ds) { ds.data = []; });
|
||||
@@ -293,6 +382,28 @@ function resetChartForNail() {
|
||||
updateErrorStats();
|
||||
}
|
||||
|
||||
function addSimpleChartData(nailId, points) {
|
||||
if (!simpleChart || !points || !points.length) return;
|
||||
if (simpleFirstTimestamp === null) simpleFirstTimestamp = points[0].timestamp;
|
||||
const tempDatasetIndex = (nailId === 'nail2') ? 1 : 0;
|
||||
const targetDatasetIndex = (nailId === 'nail2') ? 3 : 2;
|
||||
|
||||
points.forEach(function(p) {
|
||||
const x = p.timestamp - simpleFirstTimestamp;
|
||||
simpleChart.data.datasets[tempDatasetIndex].data.push({ x: x, y: p.temp });
|
||||
simpleChart.data.datasets[targetDatasetIndex].data.push({ x: x, y: p.setpoint });
|
||||
simpleHistoryByNail[nailId].push(p);
|
||||
});
|
||||
|
||||
const newest = simpleHistoryByNail[nailId].length ? simpleHistoryByNail[nailId][simpleHistoryByNail[nailId].length - 1].timestamp : 0;
|
||||
simpleHistoryByNail[nailId] = simpleHistoryByNail[nailId].filter(function(p) { return p.timestamp >= (newest - 600); });
|
||||
|
||||
simpleChart.data.datasets.forEach(function(ds) {
|
||||
if (ds.data.length > chartMaxPoints) ds.data = ds.data.slice(ds.data.length - chartMaxPoints);
|
||||
});
|
||||
simpleChart.update('none');
|
||||
}
|
||||
|
||||
function addChartData(points) {
|
||||
if (!chart || !points.length) return;
|
||||
if (firstTimestamp === null) firstTimestamp = points[0].timestamp;
|
||||
@@ -422,7 +533,7 @@ function renderAdvanced(status) {
|
||||
if (document.activeElement.id !== 'flight-descent-seconds') document.getElementById('flight-descent-seconds').value = flight.descent_seconds || 90;
|
||||
if (document.activeElement.id !== 'flight-descent-target') document.getElementById('flight-descent-target').value = flight.descent_target_f || 120;
|
||||
const fm = document.getElementById('flight-mode-status');
|
||||
if (fm) fm.textContent = 'Current mode: ' + (status.mode || 'grounded') + ' (Power button handles Grounded/Cruise)';
|
||||
if (fm) fm.textContent = '';
|
||||
|
||||
if (document.activeElement.id !== 'sched-enabled') document.getElementById('sched-enabled').value = status.scheduler.enabled ? 'true' : 'false';
|
||||
const incoming = (status.scheduler.cutoff_times || []).slice().sort();
|
||||
@@ -445,12 +556,23 @@ async function pollStatus() {
|
||||
renderAdvanced(states[currentNailId()]);
|
||||
}
|
||||
|
||||
async function pollHistory() {
|
||||
const nailNum = nailNumFromId(currentNailId());
|
||||
const data = await fetchJSON('/api/history?since=' + lastTimestamp + '&nail=' + nailNum);
|
||||
async function pollHistoryForNail(nailId) {
|
||||
const since = lastTimestampByNail[nailId] || 0;
|
||||
const nailNum = nailNumFromId(nailId);
|
||||
const data = await fetchJSON('/api/history?since=' + since + '&nail=' + nailNum);
|
||||
if (!data || !data.length) return;
|
||||
lastTimestamp = data[data.length - 1].timestamp;
|
||||
addChartData(data);
|
||||
lastTimestampByNail[nailId] = data[data.length - 1].timestamp;
|
||||
addSimpleChartData(nailId, data);
|
||||
if (nailId === currentNailId()) {
|
||||
addChartData(data);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollHistory() {
|
||||
await Promise.all([
|
||||
pollHistoryForNail('nail1'),
|
||||
pollHistoryForNail('nail2'),
|
||||
]);
|
||||
}
|
||||
|
||||
async function pollHeartbeat() {
|
||||
@@ -492,6 +614,16 @@ async function simpleTogglePower(num) {
|
||||
setLastAck('power ' + (result.enabled ? 'ON' : 'OFF') + ' (' + id + ')', true);
|
||||
}
|
||||
|
||||
async function simpleSetFlightMode(num, mode) {
|
||||
const id = nailIdFromNum(num);
|
||||
const result = await apiPost('/api/flight', { mode: mode }, id);
|
||||
if (!result) {
|
||||
setLastAck('mode ' + mode + ' failed', false);
|
||||
return;
|
||||
}
|
||||
setLastAck('mode ' + mode + ' (' + id + ')', true);
|
||||
}
|
||||
|
||||
async function applySetpoint() {
|
||||
const value = parseFloat(document.getElementById('setpoint-input').value);
|
||||
if (isNaN(value)) return;
|
||||
@@ -696,6 +828,7 @@ window.addEventListener('appinstalled', function() {
|
||||
});
|
||||
|
||||
initChart();
|
||||
initSimpleChart();
|
||||
setInterval(pollStatus, pollInterval);
|
||||
setInterval(pollHistory, pollInterval);
|
||||
setInterval(pollHeartbeat, 2000);
|
||||
|
||||
@@ -212,6 +212,76 @@ body.ui-simple .app-footer {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.simple-flight-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.simple-stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.simple-stat {
|
||||
font-size: 0.74rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 5px 7px;
|
||||
color: var(--text-dim);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.simple-stat strong {
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.simple-alert {
|
||||
margin-top: 6px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 0.73rem;
|
||||
color: var(--text-dim);
|
||||
border-left: 2px solid var(--border);
|
||||
padding-left: 8px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.simple-alert.warn {
|
||||
color: #f5b041;
|
||||
border-left-color: #f5b041;
|
||||
}
|
||||
|
||||
.simple-alert.bad {
|
||||
color: var(--accent-red);
|
||||
border-left-color: var(--accent-red);
|
||||
}
|
||||
|
||||
.simple-chart-wrap {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
height: 280px;
|
||||
box-shadow: inset 0 0 0 1px rgba(211, 84, 0, 0.12);
|
||||
}
|
||||
|
||||
.simple-chart-wrap h3 {
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.simple-chart-wrap canvas {
|
||||
width: 100% !important;
|
||||
height: calc(100% - 26px) !important;
|
||||
}
|
||||
|
||||
.power-mini {
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
@@ -681,6 +751,13 @@ button:disabled {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
#control-loop-size,
|
||||
#flight-takeoff-seconds,
|
||||
#flight-descent-seconds,
|
||||
#flight-descent-target {
|
||||
width: 112px;
|
||||
}
|
||||
|
||||
/* Status Bar */
|
||||
.status-bar {
|
||||
display: flex;
|
||||
@@ -783,6 +860,10 @@ button:disabled {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.simple-chart-wrap {
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
@@ -41,25 +41,56 @@
|
||||
<div class="simple-temp"><span id="simple-temp-nail1">---</span>°F</div>
|
||||
<div class="simple-line">Mode: <span id="simple-mode-nail1">grounded</span></div>
|
||||
<div class="simple-line">Target: <span id="simple-target-nail1">---</span>°F</div>
|
||||
<div class="simple-stats">
|
||||
<span class="simple-stat">Relay <strong id="simple-relay-nail1">OFF</strong></span>
|
||||
<span class="simple-stat">Output <strong id="simple-output-nail1">0</strong></span>
|
||||
<span class="simple-stat">TC <strong id="simple-tc-nail1">--</strong></span>
|
||||
<span class="simple-stat">Uptime <strong id="simple-uptime-nail1">--</strong></span>
|
||||
</div>
|
||||
<div class="simple-line">ETA: <span id="simple-eta-nail1">--</span></div>
|
||||
<div class="simple-line">Next descent: <span id="simple-cutoff-nail1">--</span></div>
|
||||
<div class="simple-alert" id="simple-alert-nail1">No active safety alerts.</div>
|
||||
<div class="simple-controls">
|
||||
<input type="number" id="simple-setpoint-nail1" value="530" min="0" max="800" step="5">
|
||||
<button class="apply-btn" onclick="simpleApplySetpoint(1)">Set</button>
|
||||
<button id="simple-power-nail1" class="power-mini off" onclick="simpleTogglePower(1)">OFF</button>
|
||||
</div>
|
||||
<div class="simple-flight-controls">
|
||||
<button class="adj-btn" onclick="simpleSetFlightMode(1, 'takeoff')">Takeoff</button>
|
||||
<button class="adj-btn" onclick="simpleSetFlightMode(1, 'descent')">Descent</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="simple-card" id="simple-card-nail2">
|
||||
<h3>Nail 2</h3>
|
||||
<div class="simple-temp"><span id="simple-temp-nail2">---</span>°F</div>
|
||||
<div class="simple-line">Mode: <span id="simple-mode-nail2">grounded</span></div>
|
||||
<div class="simple-line">Target: <span id="simple-target-nail2">---</span>°F</div>
|
||||
<div class="simple-stats">
|
||||
<span class="simple-stat">Relay <strong id="simple-relay-nail2">OFF</strong></span>
|
||||
<span class="simple-stat">Output <strong id="simple-output-nail2">0</strong></span>
|
||||
<span class="simple-stat">TC <strong id="simple-tc-nail2">--</strong></span>
|
||||
<span class="simple-stat">Uptime <strong id="simple-uptime-nail2">--</strong></span>
|
||||
</div>
|
||||
<div class="simple-line">ETA: <span id="simple-eta-nail2">--</span></div>
|
||||
<div class="simple-line">Next descent: <span id="simple-cutoff-nail2">--</span></div>
|
||||
<div class="simple-alert" id="simple-alert-nail2">No active safety alerts.</div>
|
||||
<div class="simple-controls">
|
||||
<input type="number" id="simple-setpoint-nail2" value="530" min="0" max="800" step="5">
|
||||
<button class="apply-btn" onclick="simpleApplySetpoint(2)">Set</button>
|
||||
<button id="simple-power-nail2" class="power-mini off" onclick="simpleTogglePower(2)">OFF</button>
|
||||
</div>
|
||||
<div class="simple-flight-controls">
|
||||
<button class="adj-btn" onclick="simpleSetFlightMode(2, 'takeoff')">Takeoff</button>
|
||||
<button class="adj-btn" onclick="simpleSetFlightMode(2, 'descent')">Descent</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="simple-only simple-chart-wrap">
|
||||
<h3>Combined Temperature View</h3>
|
||||
<canvas id="simple-temp-chart"></canvas>
|
||||
</section>
|
||||
|
||||
<section class="hero">
|
||||
<div class="temp-display">
|
||||
<div id="advanced-nail-label" class="wall-clock">Nail 1</div>
|
||||
@@ -123,11 +154,11 @@
|
||||
<div class="pid-controls">
|
||||
<label>
|
||||
Loop Size (ms)
|
||||
<input type="number" id="control-loop-size" step="100" min="1500" max="5000" value="3000">
|
||||
<input type="number" id="control-loop-size" step="100" min="1500" max="5000" value="1800">
|
||||
</label>
|
||||
<label>
|
||||
Sleep (s)
|
||||
<input type="number" id="control-sleep-time" step="0.01" min="0.15" max="0.6" value="0.4">
|
||||
<input type="number" id="control-sleep-time" step="0.01" min="0.15" max="0.6" value="0.2">
|
||||
</label>
|
||||
<button class="apply-btn" onclick="applyControlTiming()">Apply Timing</button>
|
||||
</div>
|
||||
@@ -179,7 +210,7 @@
|
||||
<div class="autotune-controls">
|
||||
<button class="adj-btn" onclick="setFlightMode('takeoff')">Takeoff</button>
|
||||
<button class="adj-btn" onclick="setFlightMode('descent')">Descent</button>
|
||||
<span id="flight-mode-status" class="autotune-status idle">Use power button for Grounded/Cruise.</span>
|
||||
<span id="flight-mode-status" class="autotune-status idle"></span>
|
||||
</div>
|
||||
<div class="pid-controls">
|
||||
<label>
|
||||
@@ -209,14 +240,11 @@
|
||||
</label>
|
||||
<label>
|
||||
Add time (HH:MM)
|
||||
<input type="text" id="sched-time-input" value="23:00" placeholder="HH:MM" inputmode="numeric" maxlength="5">
|
||||
<input type="time" id="sched-time-input" value="23:00" step="60">
|
||||
</label>
|
||||
<button class="adj-btn" onclick="addSchedulerTime()">Add Time</button>
|
||||
</div>
|
||||
<div id="sched-time-list" class="sched-time-list"></div>
|
||||
<div class="autotune-controls">
|
||||
<span class="autotune-status idle">Scheduler only triggers descent -> grounded. No auto takeoff.</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -155,64 +155,35 @@ class StatusDisplay:
|
||||
return ansi_escape.sub('', text)
|
||||
|
||||
def draw_nail_panel_compact(self, nail_id, status):
|
||||
"""Draw compact status panel for a single nail (for side-by-side layout)."""
|
||||
"""Draw compact status lines for a single nail."""
|
||||
if not status:
|
||||
return []
|
||||
|
||||
lines = []
|
||||
|
||||
# Header
|
||||
return ["No data", "No data", "No data"]
|
||||
|
||||
enabled = status.get("enabled", False)
|
||||
has_error = status.get("error", False)
|
||||
icon = self.format_status_icon(enabled, has_error)
|
||||
|
||||
status_text = "ONLINE" if enabled else "OFFLINE"
|
||||
lines.append("{} {}{}".format(icon, status_text, Colors.RESET))
|
||||
|
||||
# Temperature display
|
||||
state = "ERR" if has_error else ("ON" if enabled else "OFF")
|
||||
|
||||
current = float(status.get("current_temp", 0))
|
||||
setpoint = float(status.get("setpoint", 0))
|
||||
temp_str = "{:5.0f}F".format(current) if current else "ERROR"
|
||||
setpt_str = "{:5.0f}F".format(setpoint)
|
||||
lines.append("T: {} S: {}".format(temp_str, setpt_str))
|
||||
|
||||
# Error display
|
||||
current_str = "{:5.0f}F".format(current) if current else "ERROR"
|
||||
setpoint_str = "{:5.0f}F".format(setpoint)
|
||||
|
||||
error = float(status.get("error", 0))
|
||||
error_color = Colors.RED if abs(error) > 20 else Colors.YELLOW if abs(error) > 5 else Colors.GREEN
|
||||
lines.append("{}Err: {:+5.1f}F{}".format(error_color, error, Colors.RESET))
|
||||
|
||||
# PID Output bar
|
||||
output = float(status.get("output", 0))
|
||||
output_pct = min(100.0, max(0.0, output * 100))
|
||||
bar_filled = int(output_pct / 5)
|
||||
bar = "{}[{}{}]{}".format(
|
||||
Colors.BRIGHT_GREEN,
|
||||
"=" * bar_filled,
|
||||
" " * (5 - bar_filled),
|
||||
Colors.RESET
|
||||
)
|
||||
lines.append("Out: {} {:3.0f}%".format(bar, output_pct))
|
||||
|
||||
# Flight mode
|
||||
mode = status.get("flight_mode", "grounded")
|
||||
mode_short = mode[:7]
|
||||
lines.append("Mode: {}".format(mode_short))
|
||||
|
||||
# Phase
|
||||
phase = status.get("phase", "idle")
|
||||
phase_short = phase[:7]
|
||||
lines.append("Phase: {}".format(phase_short))
|
||||
|
||||
# Safety status
|
||||
bar_filled = int(output_pct / 10)
|
||||
bar = "[{}{}]".format("=" * bar_filled, " " * (10 - bar_filled))
|
||||
|
||||
mode = status.get("flight_mode", "grounded")[:8]
|
||||
phase = status.get("phase", "idle")[:8]
|
||||
safety = status.get("safety", {})
|
||||
temp_ok = safety.get("temp_ok", True)
|
||||
tc_ok = safety.get("tc_ok", True)
|
||||
watchdog_ok = safety.get("watchdog_ok", True)
|
||||
|
||||
safety_status = "{}OK{}".format(Colors.GREEN, Colors.RESET) if (temp_ok and tc_ok and watchdog_ok) else "{}WARN{}".format(Colors.RED, Colors.RESET)
|
||||
lines.append("Safety: {}".format(safety_status))
|
||||
|
||||
return lines
|
||||
safe = "OK" if (safety.get("temp_ok", True) and safety.get("tc_ok", True) and safety.get("watchdog_ok", True)) else "WARN"
|
||||
|
||||
return [
|
||||
"State: {} Temp: {} Set: {}".format(state, current_str, setpoint_str),
|
||||
"Err: {:+6.1f}F Out: {:5.1f}% {}".format(error, output_pct, bar),
|
||||
"Mode: {:<8} Phase: {:<8} Safe: {}".format(mode, phase, safe),
|
||||
]
|
||||
|
||||
def draw_frame(self):
|
||||
"""Draw the complete status display frame."""
|
||||
@@ -261,57 +232,39 @@ class StatusDisplay:
|
||||
nail1_data = self.data.get("nail1", {})
|
||||
nail2_data = self.data.get("nail2", {})
|
||||
|
||||
# Get compact panel lines for both nails
|
||||
# Draw vertical distinct boxes (compact, avoids side bleed)
|
||||
box_width = 58
|
||||
|
||||
def pad(text):
|
||||
if len(text) >= box_width:
|
||||
return text[:box_width]
|
||||
return text + (" " * (box_width - len(text)))
|
||||
|
||||
nail1_lines = self.draw_nail_panel_compact("nail1", nail1_data)
|
||||
lines.append("{}┌{}┐{}".format(Colors.CYAN, "─" * box_width, Colors.RESET))
|
||||
lines.append("{}│{}│{}".format(
|
||||
Colors.CYAN,
|
||||
pad(" NAIL 1"),
|
||||
Colors.RESET,
|
||||
))
|
||||
lines.append("{}├{}┤{}".format(Colors.CYAN, "─" * box_width, Colors.RESET))
|
||||
for panel_line in nail1_lines:
|
||||
lines.append("{}│{}│{}".format(Colors.CYAN, pad(panel_line), Colors.RESET))
|
||||
lines.append("{}└{}┘{}".format(Colors.CYAN, "─" * box_width, Colors.RESET))
|
||||
|
||||
lines.append("")
|
||||
|
||||
nail2_lines = self.draw_nail_panel_compact("nail2", nail2_data)
|
||||
|
||||
# Pad lines to same length
|
||||
max_lines = max(len(nail1_lines), len(nail2_lines))
|
||||
while len(nail1_lines) < max_lines:
|
||||
nail1_lines.append("")
|
||||
while len(nail2_lines) < max_lines:
|
||||
nail2_lines.append("")
|
||||
|
||||
# Draw separate boxes for each nail side-by-side
|
||||
# Top border
|
||||
lines.append("{}┌──────────────────────────┐ ┌──────────────────────────┐{}".format(
|
||||
Colors.CYAN, Colors.RESET))
|
||||
|
||||
# Header line with nail names
|
||||
n1_header = "{}NAIL 1{}".format(Colors.BRIGHT_RED + Colors.BOLD, Colors.CYAN)
|
||||
n2_header = "{}NAIL 2{}".format(Colors.BRIGHT_BLUE + Colors.BOLD, Colors.CYAN)
|
||||
# Manually pad with spaces after stripping ANSI codes
|
||||
n1_spaces = 22 - len(self.strip_ansi(n1_header))
|
||||
n2_spaces = 22 - len(self.strip_ansi(n2_header))
|
||||
lines.append("{}│ {}{:<{}}│ │ {}{:<{}}│{}".format(
|
||||
Colors.CYAN, n1_header, "", n1_spaces, n2_header, "", n2_spaces, Colors.RESET))
|
||||
|
||||
# Middle border
|
||||
lines.append("{}├──────────────────────────┤ ├──────────────────────────┤{}".format(
|
||||
Colors.CYAN, Colors.RESET))
|
||||
|
||||
# Content lines
|
||||
for n1_line, n2_line in zip(nail1_lines, nail2_lines):
|
||||
# Calculate visible length without ANSI codes
|
||||
n1_visible = self.strip_ansi(n1_line)[:22]
|
||||
n2_visible = self.strip_ansi(n2_line)[:22]
|
||||
n1_spaces = 22 - len(n1_visible)
|
||||
n2_spaces = 22 - len(n2_visible)
|
||||
|
||||
lines.append("{}│ {}{:<{}}│ │ {}{:<{}}│{}".format(
|
||||
Colors.CYAN,
|
||||
n1_line,
|
||||
"",
|
||||
n1_spaces,
|
||||
n2_line,
|
||||
"",
|
||||
n2_spaces,
|
||||
Colors.RESET
|
||||
))
|
||||
|
||||
# Bottom border
|
||||
lines.append("{}└──────────────────────────┘ └──────────────────────────┘{}".format(
|
||||
Colors.CYAN, Colors.RESET))
|
||||
lines.append("{}┌{}┐{}".format(Colors.CYAN, "─" * box_width, Colors.RESET))
|
||||
lines.append("{}│{}│{}".format(
|
||||
Colors.CYAN,
|
||||
pad(" NAIL 2"),
|
||||
Colors.RESET,
|
||||
))
|
||||
lines.append("{}├{}┤{}".format(Colors.CYAN, "─" * box_width, Colors.RESET))
|
||||
for panel_line in nail2_lines:
|
||||
lines.append("{}│{}│{}".format(Colors.CYAN, pad(panel_line), Colors.RESET))
|
||||
lines.append("{}└{}┘{}".format(Colors.CYAN, "─" * box_width, Colors.RESET))
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Raspberry Pi Implementation Draft
|
||||
|
||||
This is a concrete implementation plan for Pi-based piNail onboarding.
|
||||
|
||||
## Components
|
||||
- `Flask` app (already present in `piNail2`) for setup endpoints/pages.
|
||||
- `hostapd` for AP mode.
|
||||
- `dnsmasq` for DHCP + captive DNS in setup mode.
|
||||
- `avahi-daemon` for `pinail.local` mDNS in normal mode.
|
||||
|
||||
## Modes
|
||||
- `normal`:
|
||||
- Connect to configured WiFi.
|
||||
- Run control server at `0.0.0.0:5000`.
|
||||
- `setup`:
|
||||
- Start AP on wlan0 (`192.168.4.1/24`).
|
||||
- Start captive portal UI.
|
||||
|
||||
## Suggested State Rules
|
||||
- Enter setup mode when:
|
||||
- no wifi config exists, or
|
||||
- join fails N times over T seconds, or
|
||||
- user holds setup/reset button at boot.
|
||||
- Exit setup mode when:
|
||||
- credentials validate and connection succeeds.
|
||||
|
||||
## Boot Sequence
|
||||
1. Start `pinail-network-bootstrap.service` (Before `pinail2.service`).
|
||||
2. Bootstrap checks `/home/pi/piNail2/wifi_config.json`.
|
||||
3. If valid and connectable, ensure normal mode and continue boot.
|
||||
4. If not, bring up setup mode and start setup UI.
|
||||
|
||||
## Setup AP Parameters (draft)
|
||||
- SSID: `piNail-Setup-<last4mac>`
|
||||
- Security: WPA2 PSK (device label) or open AP (if easier UX).
|
||||
- AP IP: `192.168.4.1`
|
||||
- DHCP range: `192.168.4.20-192.168.4.120`
|
||||
|
||||
## Captive Portal Endpoints (draft)
|
||||
- `GET /setup`: setup form page
|
||||
- `POST /setup`: submit WiFi/network settings
|
||||
- `GET /setup/status`: async connection progress
|
||||
- `POST /setup/reset`: clear saved creds and return to setup mode
|
||||
|
||||
## Config Write Path
|
||||
1. Validate form values server-side.
|
||||
2. Write to temp file then atomic rename:
|
||||
- `/home/pi/piNail2/wifi_config.json.tmp`
|
||||
- `/home/pi/piNail2/wifi_config.json`
|
||||
3. Apply network config.
|
||||
4. Show success page with hostname and assigned IP.
|
||||
5. Reboot or restart networking services.
|
||||
|
||||
## Normal Mode Addressing
|
||||
- Primary URL: `http://pinail.local:5000`
|
||||
- Secondary URL: `http://<dhcp-ip>:5000`
|
||||
- Optional static mode if user enters:
|
||||
- IP
|
||||
- subnet
|
||||
- gateway
|
||||
- DNS
|
||||
|
||||
## Security Notes
|
||||
- Do not log WiFi password.
|
||||
- Restrict setup endpoints to setup mode only.
|
||||
- Optionally disable setup AP once normal mode succeeds.
|
||||
@@ -0,0 +1,36 @@
|
||||
# WiFi Onboarding Draft
|
||||
|
||||
This folder contains a draft onboarding design for getting a piNail device onto WiFi and giving the user the correct webserver address with minimal friction.
|
||||
|
||||
## Goals
|
||||
- Zero SSH required for end users.
|
||||
- Works on first boot with no preloaded WiFi credentials.
|
||||
- Reliable recovery if router/SSID/password changes later.
|
||||
- Stable web UI address after setup.
|
||||
|
||||
## User Flow (Target UX)
|
||||
1. User powers on device.
|
||||
2. Device attempts connection using saved WiFi config.
|
||||
3. If connection fails (or no config), device starts setup AP: `piNail-Setup-XXXX`.
|
||||
4. User joins setup AP from phone/laptop.
|
||||
5. Captive portal opens (or user browses to `http://192.168.4.1`).
|
||||
6. User enters:
|
||||
- WiFi SSID
|
||||
- WiFi password
|
||||
- Optional hostname (default `pinail`)
|
||||
- Network mode: DHCP (recommended) or static IP
|
||||
7. Device validates, saves config, reboots.
|
||||
8. On success, user opens:
|
||||
- `http://pinail.local:5000` (preferred)
|
||||
- fallback LAN IP shown on success page or label.
|
||||
|
||||
## Recommended Network Strategy
|
||||
- Default: DHCP + router DHCP reservation (best supportability).
|
||||
- Expose both mDNS and IP to users:
|
||||
- `pinail.local`
|
||||
- `192.168.x.y`
|
||||
|
||||
## Files in this folder
|
||||
- `PI_IMPLEMENTATION.md`: concrete Raspberry Pi setup design.
|
||||
- `RECOVERY_AND_RUNBOOK.md`: failure handling and support steps.
|
||||
- `wifi_config.example.json`: suggested stored config schema.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Recovery and Support Runbook
|
||||
|
||||
Use this when users report they cannot reach the web UI.
|
||||
|
||||
## User-Facing Recovery
|
||||
1. Power cycle device.
|
||||
2. Try `http://pinail.local:5000`.
|
||||
3. If unavailable, check router client list for `pinail` host and use IP.
|
||||
4. If still unavailable, hold setup/reset button (5-10s) to force setup AP.
|
||||
5. Rejoin `piNail-Setup-XXXX` and re-enter WiFi credentials.
|
||||
|
||||
## Technician Quick Checks (SSH)
|
||||
- Service status:
|
||||
- `sudo systemctl status pinail2`
|
||||
- Logs:
|
||||
- `sudo journalctl -u pinail2 -n 150 --no-pager`
|
||||
- WLAN status:
|
||||
- `ip addr show wlan0`
|
||||
- `iwgetid`
|
||||
- Reachability:
|
||||
- `ping -c 3 192.168.0.1`
|
||||
|
||||
## Common Failure Cases
|
||||
- Wrong SSID/password:
|
||||
- force setup mode, re-enter credentials.
|
||||
- Router changed subnet:
|
||||
- use mDNS or router client list to discover new IP.
|
||||
- Stale browser cache:
|
||||
- hard refresh (`Ctrl+Shift+R` / `Cmd+Shift+R`).
|
||||
- Service stuck after network transitions:
|
||||
- `sudo systemctl restart pinail2`.
|
||||
|
||||
## Recommended Production Defaults
|
||||
- DHCP + router reservation.
|
||||
- mDNS hostname advertised (`pinail.local`).
|
||||
- Physical setup/reset button available without opening enclosure.
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"mode": "dhcp",
|
||||
"hostname": "pinail",
|
||||
"wifi": {
|
||||
"ssid": "YourSSID",
|
||||
"password": "YourPassword"
|
||||
},
|
||||
"static": {
|
||||
"ip": "192.168.0.159",
|
||||
"prefix": 24,
|
||||
"gateway": "192.168.0.1",
|
||||
"dns": [
|
||||
"192.168.0.153",
|
||||
"8.8.8.8"
|
||||
]
|
||||
},
|
||||
"web": {
|
||||
"port": 5000
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user