From 24c57beda831cd8c075f7cb027c61efdafd9e984 Mon Sep 17 00:00:00 2001 From: Seth Date: Fri, 13 Mar 2026 00:36:10 +0000 Subject: [PATCH] Restore safety cutoff to 800F, faster PID loop timing, enhanced simple UI with stats/charts/flight controls, compact TTY display --- CONTEXT.md | 4 +- piNail2/config.json | 18 +-- piNail2/config.py | 18 +-- piNail2/static/app.js | 149 +++++++++++++++++- piNail2/static/style.css | 81 ++++++++++ piNail2/templates/index.html | 42 ++++- piNail2/tty_status_display.py | 149 ++++++------------ wifi-onboarding-draft/PI_IMPLEMENTATION.md | 66 ++++++++ wifi-onboarding-draft/README.md | 36 +++++ wifi-onboarding-draft/RECOVERY_AND_RUNBOOK.md | 36 +++++ .../wifi_config.example.json | 20 +++ 11 files changed, 486 insertions(+), 133 deletions(-) create mode 100644 wifi-onboarding-draft/PI_IMPLEMENTATION.md create mode 100644 wifi-onboarding-draft/README.md create mode 100644 wifi-onboarding-draft/RECOVERY_AND_RUNBOOK.md create mode 100644 wifi-onboarding-draft/wifi_config.example.json diff --git a/CONTEXT.md b/CONTEXT.md index 44734ae..b797ceb 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 diff --git a/piNail2/config.json b/piNail2/config.json index fd10259..285c4fe 100644 --- a/piNail2/config.json +++ b/piNail2/config.json @@ -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, diff --git a/piNail2/config.py b/piNail2/config.py index 55b3a85..7a7b2ad 100644 --- a/piNail2/config.py +++ b/piNail2/config.py @@ -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, diff --git a/piNail2/static/app.js b/piNail2/static/app.js index b9987a1..b81ec8a 100644 --- a/piNail2/static/app.js +++ b/piNail2/static/app.js @@ -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); diff --git a/piNail2/static/style.css b/piNail2/static/style.css index 8de1610..669bb23 100644 --- a/piNail2/static/style.css +++ b/piNail2/static/style.css @@ -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; } diff --git a/piNail2/templates/index.html b/piNail2/templates/index.html index 9a21eb6..044a0c6 100644 --- a/piNail2/templates/index.html +++ b/piNail2/templates/index.html @@ -41,25 +41,56 @@
---°F
Mode: grounded
Target: ---°F
+
+ Relay OFF + Output 0 + TC -- + Uptime -- +
+
ETA: --
+
Next descent: --
+
No active safety alerts.
+
+ + +

Nail 2

---°F
Mode: grounded
Target: ---°F
+
+ Relay OFF + Output 0 + TC -- + Uptime -- +
+
ETA: --
+
Next descent: --
+
No active safety alerts.
+
+ + +
+
+

Combined Temperature View

+ +
+
Nail 1
@@ -123,11 +154,11 @@
@@ -179,7 +210,7 @@
- Use power button for Grounded/Cruise. +
-
- Scheduler only triggers descent -> grounded. No auto takeoff. -
diff --git a/piNail2/tty_status_display.py b/piNail2/tty_status_display.py index 77a58fd..960d59b 100644 --- a/piNail2/tty_status_display.py +++ b/piNail2/tty_status_display.py @@ -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) diff --git a/wifi-onboarding-draft/PI_IMPLEMENTATION.md b/wifi-onboarding-draft/PI_IMPLEMENTATION.md new file mode 100644 index 0000000..8ac0fe1 --- /dev/null +++ b/wifi-onboarding-draft/PI_IMPLEMENTATION.md @@ -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-` +- 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://: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. diff --git a/wifi-onboarding-draft/README.md b/wifi-onboarding-draft/README.md new file mode 100644 index 0000000..10e2535 --- /dev/null +++ b/wifi-onboarding-draft/README.md @@ -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. diff --git a/wifi-onboarding-draft/RECOVERY_AND_RUNBOOK.md b/wifi-onboarding-draft/RECOVERY_AND_RUNBOOK.md new file mode 100644 index 0000000..fa4c413 --- /dev/null +++ b/wifi-onboarding-draft/RECOVERY_AND_RUNBOOK.md @@ -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. diff --git a/wifi-onboarding-draft/wifi_config.example.json b/wifi-onboarding-draft/wifi_config.example.json new file mode 100644 index 0000000..e54a072 --- /dev/null +++ b/wifi-onboarding-draft/wifi_config.example.json @@ -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 + } +}