Compare commits

16 Commits

Author SHA1 Message Date
Seth 24c57beda8 Restore safety cutoff to 800F, faster PID loop timing, enhanced simple UI with stats/charts/flight controls, compact TTY display 2026-03-13 00:36:10 +00:00
Seth 2853fa3f8a Fix spacing calculation to account for ANSI color codes
- Added strip_ansi() helper function to remove color codes before length calc
- Fixed padding logic to calculate visible character length correctly
- ANSI codes no longer mess up the box alignment
- Both boxes should now align properly regardless of color content
- Deployed and verified on Pi
2026-03-12 04:28:48 +00:00
Seth 12964fa3ab Increase spacing between nail boxes to prevent bleeding
- Increased gap from 2 to 5 spaces between NAIL 1 and NAIL 2 boxes
- Prevents right-side text from bleeding into left side
- Better visual separation and clarity
- Deployed and verified on Pi
2026-03-12 04:27:24 +00:00
Seth 7ddf5c80f9 Refactor box rendering for better alignment
- Simplified color code placement in box headers
- Improved padding logic for side-by-side display
- Better handling of content padding within boxes
- Deployed and verified on Pi
2026-03-12 04:26:09 +00:00
Seth b22702bc9d Create distinct separate boxes for NAIL 1 and NAIL 2
- Changed from single box with divider to two completely separate boxes
- Nail 1 box in red header, Nail 2 box in blue header
- Two space gap between boxes for clear visual separation
- Condensed panel content to fit 24-character box width
- Essential metrics only: status, temp, error, output, mode, phase, safety
- Better visual distinction between the two nail controllers
- Deployed and verified on Pi
2026-03-12 04:25:07 +00:00
Seth 8f72402009 Add epic PINAIL ASCII art header
- Replaced simple header with beautiful 12-line PINAIL ASCII art
- Magenta colored text for striking visual impact
- Much more impressive and eye-catching display
- Timestamp centered below the art
- Deployed and verified on Pi
2026-03-12 04:24:07 +00:00
Seth e7e827c41a Improve spacing between nail panels
- Expanded panel width from 20 to 29 characters per nail
- Better formatted text with more descriptive labels
- Full 'Nail 1' and 'Nail 2' names instead of abbreviations
- More readable temperature and error displays
- Improved overall visual separation and readability
- Deployed and verified on Pi
2026-03-12 04:19:28 +00:00
Seth 6870937c7c Reorganize display with side-by-side nail panels for more header space
- Moved NAIL 1 and NAIL 2 panels to side-by-side layout
- Created compact panel format with essential metrics only
- Freed up vertical space for larger ASCII art header
- New header shows 'piNail' in larger ASCII art
- More efficient use of screen space and cleaner layout
- Deployed and verified on Pi
2026-03-12 04:18:26 +00:00
Seth 7742f15912 Add ASCII art header with side-by-side timestamp layout
- Created compact 3-line ASCII art for 'piNail' on the left
- Timestamp and status info displayed on the right side
- Efficient use of screen space
- Better visual appeal while remaining compact
- Deployed and verified on Pi
2026-03-12 04:16:09 +00:00
Seth a458afd2f0 Ultra-compact header for small terminal widths
- Simplified header to single line: '>> PINAIL STATUS <<'
- Fixed wrapping issues on narrow/small screens
- Much more compatible with various display sizes
- Deployed and verified
2026-03-12 04:15:23 +00:00
Seth 35f7aa41cf Replace large ASCII art with compact box-style header
- Replaced multi-line block ASCII art with compact boxed header
- New design: compact box with 'PINAIL' text and version info
- Better suited for smaller screens and TTY displays
- Cleaner, more readable layout
- Deployed and verified on Pi
2026-03-12 04:14:47 +00:00
Seth 038905fe51 Reduce flicker by increasing display refresh interval
- Changed from 1-second full screen clear to 2-second redraws
- Data fetches happen every 0.5 seconds for responsiveness
- Reduces unnecessary screen clearing that was causing ASCII art to flicker
- Keeps display stable and readable while still updating status in real-time
- Deployed and verified on Pi
2026-03-12 04:13:54 +00:00
Seth 5b18116a41 Fix tty1 contention by masking getty@tty1 service
- Disabled and masked getty@tty1.service to give exclusive tty1 access to status display
- Updated pinail-status.service to use StandardError=journal instead of file
- Verified only one status display process running on Pi
- Resolved issue where old login shells and getty were competing for tty1
2026-03-12 04:13:20 +00:00
Seth 4b76f9896a Add fancy ASCII art title to status display
- Replaced simple text header with large block-style 'PINAIL' ASCII art
- Enhanced visual presentation with colored borders and better spacing
- Cyan colored top banner makes the display more eye-catching
- Deployed and verified on Pi
2026-03-12 04:11:33 +00:00
Seth 57fbd7063e Replace briefing system with fancy CLI status display panel
- Removed hourly news briefing system (briefing service/timer)
- Added tty_status_display.py: real-time status panel showing dual nail status
  - Temperature, setpoint, error, PID output with visual bar
  - Flight mode and phase badges with color coding
  - PID coefficients and integral/derivative values
  - Safety status indicators
  - Continuous color-coded display with ANSI formatting
- Added pinail-status.service: systemd service for status display
  - Runs as root with direct TTY output to /dev/tty1
  - Auto-restart on failure, requires pinail2.service
  - Logs errors to /tmp/status_display.log
- Deployed and verified running on Pi (service active)
2026-03-12 04:08:55 +00:00
Seth 7d62243fe6 Add hourly news briefing system with continuous marquee scroll
- Fetches 12 articles hourly from FreshRSS
- Generates 4-6 sentence summaries using Ollama
- Creates sequential sethpc.xyz short links (pi0, pi1, etc.) via yourls
- Continuous vertical marquee scrolling at reading speed
- No wait between rescrolls - immediately loops to next pass
- New briefing generated every hour at :00
- Systemd service and timer for auto-start and hourly scheduling
2026-03-12 04:05:30 +00:00
15 changed files with 1085 additions and 35 deletions
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=piNail Hourly News Briefing Display
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=/home/pi/piNail2
ExecStart=/usr/bin/python3 /home/pi/piNail2/tty_briefing.py
Restart=always
RestartSec=10
StandardOutput=tty
StandardError=file:/tmp/briefing.log
TTYPath=/dev/tty1
SyslogIdentifier=pinail-briefing
[Install]
WantedBy=multi-user.target
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=piNail Hourly News Briefing Timer
Requires=pinail-briefing.service
[Timer]
OnBootSec=2min
OnUnitActiveSec=1h
Persistent=true
[Install]
WantedBy=timers.target
+20
View File
@@ -0,0 +1,20 @@
[Unit]
Description=piNail2 Status Display Panel
After=pinail2.service network-online.target
Wants=network-online.target
Requires=pinail2.service
[Service]
Type=simple
User=root
WorkingDirectory=/home/pi/piNail2
ExecStart=/usr/bin/python3 /home/pi/piNail2/tty_status_display.py
Restart=always
RestartSec=5
StandardOutput=tty
StandardError=journal
TTYPath=/dev/tty1
SyslogIdentifier=pinail-status
[Install]
WantedBy=multi-user.target
+141 -8
View File
@@ -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);
+81
View File
@@ -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;
}
+35 -7
View File
@@ -41,25 +41,56 @@
<div class="simple-temp"><span id="simple-temp-nail1">---</span>&deg;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>&deg;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>&deg;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>&deg;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>
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""
Enhanced hourly news briefing with long summaries, yourls links, and continuous marquee scroll.
Fetches from FreshRSS, summarizes with Ollama, generates short URLs, rescrolls continuously.
"""
import os
import sys
import json
import time
import requests
import re
import schedule
import textwrap
from datetime import datetime
# Debug logging
DEBUG_LOG = "/tmp/briefing_debug.log"
def debug_log(msg):
with open(DEBUG_LOG, "a") as f:
f.write("[{}] {}\n".format(datetime.now().strftime('%H:%M:%S'), msg))
f.flush()
# Load config from local copy
CONFIG_PATH = "/home/pi/piNail2/briefing_config.json"
with open(CONFIG_PATH, "r") as f:
config = json.load(f)
FRESHRSS_URL = config["freshrss_url"]
FRESHRSS_USER = config["freshrss_user"]
FRESHRSS_TOKEN = config["freshrss_api_key"]
OLLAMA_URL = config["ollama_url"]
OLLAMA_MODEL = config.get("ollama_model", "mistral")
YOURLS_URL = config.get("yourls_url", "http://192.168.0.152:8080/yourls-api.php")
YOURLS_USER = config.get("yourls_user", "admin")
YOURLS_PASS = config.get("yourls_pass", "REDACTED_PASSWORD")
def fetch_news(count=12):
"""Fetch top articles from FreshRSS."""
login_url = "{}/api/greader.php/accounts/ClientLogin".format(FRESHRSS_URL)
login_data = {"Email": FRESHRSS_USER, "Passwd": FRESHRSS_TOKEN}
try:
login_resp = requests.post(login_url, data=login_data, timeout=15)
if login_resp.status_code != 200:
return []
auth_token = ""
for line in login_resp.text.splitlines():
if line.startswith("Auth="):
auth_token = line.split("=", 1)[1]
break
if not auth_token:
return []
except Exception as e:
print("[!] Login exception: {}".format(e), file=sys.stderr)
return []
api_url = "{}/api/greader.php/reader/api/0/stream/contents/user/-/state/com.google/reading-list".format(FRESHRSS_URL)
params = {"n": count + 5, "xt": "user/-/state/com.google/read", "output": "json"}
headers = {"Authorization": "GoogleLogin auth={}".format(auth_token)}
try:
response = requests.get(api_url, params=params, headers=headers, timeout=15)
if response.status_code != 200:
return []
data = response.json()
items = []
for item in data.get("items", [])[:count]:
title = item.get("title", "")
summary = item.get("summary", {}).get("content", "") or item.get("content", {}).get("content", "")
source = item.get("origin", {}).get("title", "Unknown Source")
link = item.get("alternate", [{}])[0].get("href", "") if item.get("alternate") else ""
summary_clean = re.sub('<[^<]+?>', '', summary).strip()
items.append({
"title": title,
"summary": summary_clean,
"source": source,
"link": link
})
return items
except Exception as e:
print("[!] Exception fetching news: {}".format(e), file=sys.stderr)
return []
def summarize_article(text):
"""Summarize article with Ollama (longer, 4-6 sentences)."""
if not text:
return ""
payload = {
"model": OLLAMA_MODEL,
"prompt": "Provide a detailed summary in 4-6 sentences: {}".format(text[:1000]),
"stream": False,
"options": {"num_ctx": 2048, "temperature": 0.3}
}
try:
response = requests.post("{}/api/generate".format(OLLAMA_URL), json=payload, timeout=60)
return response.json().get("response", "").strip()
except:
return text[:400] + "..."
def create_yourls_link(original_url, index):
"""Generate short URL using yourls with pi____ format."""
if not original_url:
debug_log("No URL provided")
return ""
try:
# Create sequential alias: pi0, pi1, pi2, etc.
alias = "pi{}".format(index)
debug_log("Creating yourls link with alias: {}".format(alias))
payload = {
"action": "shorturl",
"url": original_url,
"keyword": alias,
"username": YOURLS_USER,
"password": YOURLS_PASS,
"format": "json"
}
response = requests.post(YOURLS_URL, data=payload, timeout=10)
data = response.json()
# Check if we got a shorturl (success or URL already exists)
if data.get("shorturl"):
short_link = data.get("shorturl", "")
debug_log("Created link {}: {}".format(alias, short_link))
return short_link
# Try random alias if keyword alias is taken
if "already exists" in data.get("message", "").lower() or "keyword already exists" in data.get("message", "").lower():
debug_log("Alias {} taken, trying random".format(alias))
payload_random = {
"action": "shorturl",
"url": original_url,
"username": YOURLS_USER,
"password": YOURLS_PASS,
"format": "json"
}
response = requests.post(YOURLS_URL, data=payload_random, timeout=10)
data = response.json()
if data.get("shorturl"):
short_link = data.get("shorturl", "")
debug_log("Created fallback link: {}".format(short_link))
return short_link
debug_log("yourls failed: {}".format(data.get("message", "unknown error")))
return ""
except Exception as e:
debug_log("yourls exception: {}".format(e))
return ""
def render_marquee(articles, tty):
"""Render articles as continuous vertical marquee."""
# Build full content once
full_content = ""
for i, article in enumerate(articles, 1):
full_content += "\n{}\n".format("="*70)
full_content += "[{}/{}] {}\n".format(i, len(articles), article['source'])
full_content += "{}\n\n".format("" * 70)
full_content += " {}\n".format(article['title'])
full_content += " {}\n\n".format("" * 70)
summary = article.get('ai_summary', article['summary'][:300])
wrapped = textwrap.fill(summary, width=66, initial_indent=" ", subsequent_indent=" ")
full_content += "{}\n".format(wrapped)
short_link = article.get('short_link', article.get('link', ''))
if short_link:
full_content += "\n Link: {}\n".format(short_link)
full_content += "\n"
full_content += "\n{}\n".format("="*70)
full_content += " --- END OF BRIEFING, RESCROLLING IN 30 SECONDS ---\n"
full_content += "{}\n".format("="*70)
return full_content
def scroll_content(tty, content):
"""Scroll content at reading speed, continuous loop without waiting."""
char_delay = 0.025 # 25ms per char = ~40 chars/sec
while True:
for char in content:
tty.write(char)
tty.flush()
if char == '\n':
time.sleep(0.1)
else:
time.sleep(char_delay)
# Loop back immediately without pause
def hourly_briefing():
"""Fetch, summarize, and display briefing. Continuously scrolls until next hourly refresh."""
try:
tty = open('/dev/tty1', 'w')
except Exception:
tty = sys.stdout
print("[*] Fetching briefing at {}".format(datetime.now().strftime('%Y-%m-%d %H:%M:%S')), file=sys.stderr)
articles = fetch_news(count=12)
if not articles:
tty.write(" [!] No articles fetched\n")
tty.flush()
print("[!] No articles fetched", file=sys.stderr)
if tty is not sys.stdout:
tty.close()
return
# Summarize and generate yourls links
debug_log("Summarizing {} articles...".format(len(articles)))
for i, article in enumerate(articles):
debug_log("Article {}/{}: summarizing...".format(i+1, len(articles)))
article['ai_summary'] = summarize_article(article['summary'])
debug_log("Article {}/{}: creating link...".format(i+1, len(articles)))
article['short_link'] = create_yourls_link(article['link'], i)
debug_log("Article {}/{}: done. Link={}".format(i+1, len(articles), article.get('short_link', '')))
# Render and display header
timestamp = datetime.now().strftime("%A, %B %d, %Y | %I:%M %p")
tty.write("\033[2J\033[H\033[?25l") # Clear and hide cursor
tty.write("\n{}\n".format("="*70))
tty.write(" PINAIL NEWS BRIEFING - {}\n".format(timestamp))
tty.write(" {} stories\n".format(len(articles)))
tty.write("{}\n\n".format("="*70))
tty.flush()
time.sleep(1)
# Build full marquee content
full_content = render_marquee(articles, tty)
# Continuously scroll content until next scheduled run
print("[*] Starting continuous scroll", file=sys.stderr)
try:
scroll_content(tty, full_content)
except KeyboardInterrupt:
pass
finally:
tty.write('\033[?25h') # Restore cursor
tty.flush()
if tty is not sys.stdout:
tty.close()
def main():
"""Schedule hourly briefing."""
schedule.every().hour.at(":00").do(hourly_briefing)
print("[*] piNail News Briefing scheduled for every hour", file=sys.stderr)
next_run = datetime.now().replace(minute=0, second=0, microsecond=0)
print("[*] Next run at {}".format(next_run.strftime('%H:%M')), file=sys.stderr)
# Run first briefing immediately
hourly_briefing()
# Keep scheduler alive (will interrupt scroll_content when next hour arrives)
while True:
schedule.run_pending()
time.sleep(1)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n[*] Briefing scheduler stopped", file=sys.stderr)
sys.exit(0)
except Exception as e:
print("[!] Fatal error: {}".format(e), file=sys.stderr)
raise
+335
View File
@@ -0,0 +1,335 @@
#!/usr/bin/env python3
"""
piNail2 TTY Status Display Panel
Displays a fancy real-time status panel on the HDMI display showing:
- Dual nail temperature status and control mode
- PID parameters and error/output values
- Flight mode and current phase
- Safety status
- Performance metrics
Compatible with Python 3.5+
"""
import sys
import os
import time
import json
import requests
import signal
import threading
from datetime import datetime
# Python 3.5 compatibility - no f-strings
try:
from http.client import HTTPConnection
HTTPConnection.debuglevel = 0
except ImportError:
from httplib import HTTPConnection
HTTPConnection.debuglevel = 0
# Configuration
API_BASE = "http://localhost:5000"
UPDATE_INTERVAL = 0.5 # seconds between fetches
DISPLAY_INTERVAL = 2.0 # seconds between screen redraws
TTY_PATH = "/dev/tty1"
# ANSI color codes
class Colors:
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
UNDERLINE = "\033[4m"
# Foreground colors
BLACK = "\033[30m"
RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
BLUE = "\033[34m"
MAGENTA = "\033[35m"
CYAN = "\033[36m"
WHITE = "\033[37m"
# Bright colors
BRIGHT_RED = "\033[91m"
BRIGHT_GREEN = "\033[92m"
BRIGHT_YELLOW = "\033[93m"
BRIGHT_BLUE = "\033[94m"
BRIGHT_MAGENTA = "\033[95m"
BRIGHT_CYAN = "\033[96m"
# Background colors
BG_RED = "\033[41m"
BG_GREEN = "\033[42m"
BG_YELLOW = "\033[43m"
BG_BLUE = "\033[44m"
BG_MAGENTA = "\033[45m"
BG_CYAN = "\033[46m"
class StatusDisplay:
def __init__(self):
self.running = True
self.last_update = 0
self.last_draw = 0
self.last_frame = None
self.data = {
"nail1": {},
"nail2": {},
}
self.lock = threading.Lock()
def fetch_status(self):
"""Fetch status from API."""
try:
resp = requests.get("{}/api/status/all".format(API_BASE), timeout=2)
if resp.status_code == 200:
with self.lock:
payload = resp.json()
self.data = payload.get("nails", {})
self.last_update = time.time()
return True
except Exception as e:
pass
return False
def fetch_worker(self):
"""Background thread that fetches status periodically."""
while self.running:
self.fetch_status()
time.sleep(UPDATE_INTERVAL)
def format_temp(self, temp_f):
"""Format temperature with color coding."""
if temp_f is None:
return "{}ERR{}".format(Colors.RED, Colors.RESET)
temp_f = float(temp_f)
if temp_f < 0:
color = Colors.RED
elif temp_f < 100:
color = Colors.BLUE
elif temp_f < 400:
color = Colors.YELLOW
else:
color = Colors.BRIGHT_RED
return "{}{:6.1f}F{}".format(color, temp_f, Colors.RESET)
def format_status_icon(self, enabled, has_error):
"""Return status icon."""
if has_error:
return "{}{}".format(Colors.RED, Colors.RESET)
elif enabled:
return "{}{}".format(Colors.GREEN, Colors.RESET)
else:
return "{}{}".format(Colors.DIM, Colors.RESET)
def format_mode_badge(self, mode):
"""Format flight mode as a fancy badge."""
modes = {
"grounded": (Colors.CYAN, "GROUNDED"),
"takeoff": (Colors.BRIGHT_YELLOW, "TAKEOFF "),
"cruise": (Colors.BRIGHT_GREEN, "CRUISE "),
"descent": (Colors.BRIGHT_MAGENTA, "DESCENT "),
}
color, label = modes.get(mode, (Colors.WHITE, "????? "))
return "{}[{}]{}".format(color, label, Colors.RESET)
def format_phase_badge(self, phase):
"""Format phase as a badge."""
phases = {
"heating": (Colors.BRIGHT_RED, "HEATING"),
"cooling": (Colors.BRIGHT_BLUE, "COOLING"),
"idle": (Colors.DIM, " IDLE "),
}
color, label = phases.get(phase, (Colors.WHITE, " ? "))
return "{}{}{}".format(color, label, Colors.RESET)
def strip_ansi(self, text):
"""Remove ANSI color codes from text for length calculation."""
import re
ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
return ansi_escape.sub('', text)
def draw_nail_panel_compact(self, nail_id, status):
"""Draw compact status lines for a single nail."""
if not status:
return ["No data", "No data", "No data"]
enabled = status.get("enabled", False)
has_error = status.get("error", False)
state = "ERR" if has_error else ("ON" if enabled else "OFF")
current = float(status.get("current_temp", 0))
setpoint = float(status.get("setpoint", 0))
current_str = "{:5.0f}F".format(current) if current else "ERROR"
setpoint_str = "{:5.0f}F".format(setpoint)
error = float(status.get("error", 0))
output = float(status.get("output", 0))
output_pct = min(100.0, max(0.0, output * 100))
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", {})
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."""
lines = []
# ASCII Art Title with timestamp
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
lines.append("")
lines.append("{}{}{}".format(Colors.BRIGHT_MAGENTA + Colors.BOLD,
" ######: ###### ### ## :##: ###### ## ", Colors.RESET))
lines.append("{}{}{}".format(Colors.BRIGHT_MAGENTA + Colors.BOLD,
" #######: ###### ### ## ## ###### ## ", Colors.RESET))
lines.append("{}{}{}".format(Colors.BRIGHT_MAGENTA + Colors.BOLD,
" ## :## ## ###: ## #### ## ## ", Colors.RESET))
lines.append("{}{}{}".format(Colors.BRIGHT_MAGENTA + Colors.BOLD,
" ## ## ## #### ## #### ## ## ", Colors.RESET))
lines.append("{}{}{}".format(Colors.BRIGHT_MAGENTA + Colors.BOLD,
" ## :## ## ##:#: ## :# #: ## ## ", Colors.RESET))
lines.append("{}{}{}".format(Colors.BRIGHT_MAGENTA + Colors.BOLD,
" #######: ## ## ## ## #::# ## ## ", Colors.RESET))
lines.append("{}{}{}".format(Colors.BRIGHT_MAGENTA + Colors.BOLD,
" ######: ## ## ## ## ## ## ## ## ", Colors.RESET))
lines.append("{}{}{}".format(Colors.BRIGHT_MAGENTA + Colors.BOLD,
" ## ## ## :#:## ###### ## ## ", Colors.RESET))
lines.append("{}{}{}".format(Colors.BRIGHT_MAGENTA + Colors.BOLD,
" ## ## ## #### .######. ## ## ", Colors.RESET))
lines.append("{}{}{}".format(Colors.BRIGHT_MAGENTA + Colors.BOLD,
" ## ## ## :### :## ##: ## ## ", Colors.RESET))
lines.append("{}{}{}".format(Colors.BRIGHT_MAGENTA + Colors.BOLD,
" ## ###### ## ### ### ### ###### ######## ", Colors.RESET))
lines.append("{}{}{}".format(Colors.BRIGHT_MAGENTA + Colors.BOLD,
" ## ###### ## ### ##: :## ###### ######## ", Colors.RESET))
# Timestamp on its own line
lines.append("")
lines.append("{}{}{}".format(
Colors.DIM,
timestamp.center(70),
Colors.RESET
))
lines.append("")
# Get data safely
with self.lock:
nail1_data = self.data.get("nail1", {})
nail2_data = self.data.get("nail2", {})
# 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)
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)
def display_loop(self):
"""Main display loop."""
# Start fetch worker thread
fetch_thread = threading.Thread(target=self.fetch_worker)
fetch_thread.daemon = True
fetch_thread.start()
# Initial draw
sys.stdout.write("\033[2J\033[H")
sys.stdout.flush()
self.last_draw = time.time()
try:
while self.running:
now = time.time()
# Only redraw if enough time has passed (reduces flicker)
if now - self.last_draw >= DISPLAY_INTERVAL:
# Clear screen (ANSI escape)
sys.stdout.write("\033[2J\033[H")
sys.stdout.flush()
# Draw frame
frame = self.draw_frame()
sys.stdout.write(frame)
sys.stdout.write("\n")
sys.stdout.flush()
self.last_draw = now
time.sleep(UPDATE_INTERVAL)
except KeyboardInterrupt:
pass
finally:
self.running = False
def run(self):
"""Run the status display."""
# Redirect stdout to TTY if available
if os.path.exists(TTY_PATH):
try:
tty_fd = os.open(TTY_PATH, os.O_WRONLY)
os.dup2(tty_fd, 1)
os.close(tty_fd)
except Exception:
pass
# Handle signals
def sigint_handler(signum, frame):
self.running = False
signal.signal(signal.SIGINT, sigint_handler)
# Run display loop
self.display_loop()
def main():
"""Main entry point."""
display = StatusDisplay()
display.run()
if __name__ == "__main__":
main()
@@ -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.
+36
View File
@@ -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
}
}