Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 24c57beda8 | |||
| 2853fa3f8a | |||
| 12964fa3ab | |||
| 7ddf5c80f9 | |||
| b22702bc9d | |||
| 8f72402009 | |||
| e7e827c41a | |||
| 6870937c7c | |||
| 7742f15912 | |||
| a458afd2f0 | |||
| 35f7aa41cf | |||
| 038905fe51 | |||
| 5b18116a41 | |||
| 4b76f9896a | |||
| 57fbd7063e | |||
| 7d62243fe6 |
+2
-2
@@ -44,9 +44,9 @@
|
|||||||
- If current temp starts above setpoint, autotune may begin in cooling phase.
|
- If current temp starts above setpoint, autotune may begin in cooling phase.
|
||||||
|
|
||||||
## Safety behaviors
|
## Safety behaviors
|
||||||
- Hard max temp cutoff.
|
- Hard max temp cutoff (800F).
|
||||||
- Thermocouple disconnect handling.
|
- Thermocouple disconnect handling.
|
||||||
- Idle shutoff timer.
|
- Idle shutoff timer (default 30 min).
|
||||||
- Watchdog status exposed in heartbeat.
|
- Watchdog status exposed in heartbeat.
|
||||||
|
|
||||||
## Operations quick commands
|
## Operations quick commands
|
||||||
|
|||||||
+9
-9
@@ -7,8 +7,8 @@
|
|||||||
},
|
},
|
||||||
"control": {
|
"control": {
|
||||||
"setpoint": 530,
|
"setpoint": 530,
|
||||||
"loop_size_ms": 3000,
|
"loop_size_ms": 1800,
|
||||||
"sleep_time": 0.4,
|
"sleep_time": 0.2,
|
||||||
"enabled": false
|
"enabled": false
|
||||||
},
|
},
|
||||||
"flight": {
|
"flight": {
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"safety": {
|
"safety": {
|
||||||
"max_temp_f": 750,
|
"max_temp_f": 800,
|
||||||
"spike_threshold_f": 50.0,
|
"spike_threshold_f": 50.0,
|
||||||
"idle_shutoff_minutes": 30,
|
"idle_shutoff_minutes": 30,
|
||||||
"watchdog_timeout_s": 10,
|
"watchdog_timeout_s": 10,
|
||||||
@@ -70,8 +70,8 @@
|
|||||||
},
|
},
|
||||||
"control": {
|
"control": {
|
||||||
"setpoint": 530,
|
"setpoint": 530,
|
||||||
"loop_size_ms": 3000,
|
"loop_size_ms": 1800,
|
||||||
"sleep_time": 0.4,
|
"sleep_time": 0.2,
|
||||||
"enabled": false
|
"enabled": false
|
||||||
},
|
},
|
||||||
"flight": {
|
"flight": {
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"safety": {
|
"safety": {
|
||||||
"max_temp_f": 750,
|
"max_temp_f": 800,
|
||||||
"spike_threshold_f": 50.0,
|
"spike_threshold_f": 50.0,
|
||||||
"idle_shutoff_minutes": 30,
|
"idle_shutoff_minutes": 30,
|
||||||
"watchdog_timeout_s": 10,
|
"watchdog_timeout_s": 10,
|
||||||
@@ -123,8 +123,8 @@
|
|||||||
},
|
},
|
||||||
"control": {
|
"control": {
|
||||||
"setpoint": 530,
|
"setpoint": 530,
|
||||||
"loop_size_ms": 3000,
|
"loop_size_ms": 1800,
|
||||||
"sleep_time": 0.4,
|
"sleep_time": 0.2,
|
||||||
"enabled": false
|
"enabled": false
|
||||||
},
|
},
|
||||||
"flight": {
|
"flight": {
|
||||||
@@ -142,7 +142,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"safety": {
|
"safety": {
|
||||||
"max_temp_f": 750,
|
"max_temp_f": 800,
|
||||||
"spike_threshold_f": 50.0,
|
"spike_threshold_f": 50.0,
|
||||||
"idle_shutoff_minutes": 30,
|
"idle_shutoff_minutes": 30,
|
||||||
"watchdog_timeout_s": 10,
|
"watchdog_timeout_s": 10,
|
||||||
|
|||||||
+9
-9
@@ -21,8 +21,8 @@ DEFAULT_CONFIG = {
|
|||||||
},
|
},
|
||||||
"control": {
|
"control": {
|
||||||
"setpoint": 530,
|
"setpoint": 530,
|
||||||
"loop_size_ms": 3000,
|
"loop_size_ms": 1800,
|
||||||
"sleep_time": 0.4,
|
"sleep_time": 0.2,
|
||||||
"enabled": False
|
"enabled": False
|
||||||
},
|
},
|
||||||
"flight": {
|
"flight": {
|
||||||
@@ -40,7 +40,7 @@ DEFAULT_CONFIG = {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"safety": {
|
"safety": {
|
||||||
"max_temp_f": 750,
|
"max_temp_f": 800,
|
||||||
"spike_threshold_f": 50.0,
|
"spike_threshold_f": 50.0,
|
||||||
"idle_shutoff_minutes": 30,
|
"idle_shutoff_minutes": 30,
|
||||||
"watchdog_timeout_s": 10,
|
"watchdog_timeout_s": 10,
|
||||||
@@ -84,8 +84,8 @@ DEFAULT_CONFIG = {
|
|||||||
},
|
},
|
||||||
"control": {
|
"control": {
|
||||||
"setpoint": 530,
|
"setpoint": 530,
|
||||||
"loop_size_ms": 3000,
|
"loop_size_ms": 1800,
|
||||||
"sleep_time": 0.4,
|
"sleep_time": 0.2,
|
||||||
"enabled": False
|
"enabled": False
|
||||||
},
|
},
|
||||||
"flight": {
|
"flight": {
|
||||||
@@ -103,7 +103,7 @@ DEFAULT_CONFIG = {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"safety": {
|
"safety": {
|
||||||
"max_temp_f": 750,
|
"max_temp_f": 800,
|
||||||
"spike_threshold_f": 50.0,
|
"spike_threshold_f": 50.0,
|
||||||
"idle_shutoff_minutes": 30,
|
"idle_shutoff_minutes": 30,
|
||||||
"watchdog_timeout_s": 10,
|
"watchdog_timeout_s": 10,
|
||||||
@@ -137,8 +137,8 @@ DEFAULT_CONFIG = {
|
|||||||
},
|
},
|
||||||
"control": {
|
"control": {
|
||||||
"setpoint": 530,
|
"setpoint": 530,
|
||||||
"loop_size_ms": 3000,
|
"loop_size_ms": 1800,
|
||||||
"sleep_time": 0.4,
|
"sleep_time": 0.2,
|
||||||
"enabled": False
|
"enabled": False
|
||||||
},
|
},
|
||||||
"flight": {
|
"flight": {
|
||||||
@@ -156,7 +156,7 @@ DEFAULT_CONFIG = {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"safety": {
|
"safety": {
|
||||||
"max_temp_f": 750,
|
"max_temp_f": 800,
|
||||||
"spike_threshold_f": 50.0,
|
"spike_threshold_f": 50.0,
|
||||||
"idle_shutoff_minutes": 30,
|
"idle_shutoff_minutes": 30,
|
||||||
"watchdog_timeout_s": 10,
|
"watchdog_timeout_s": 10,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+140
-7
@@ -1,8 +1,10 @@
|
|||||||
let pollInterval = 500;
|
let pollInterval = 500;
|
||||||
let chartMaxPoints = 300;
|
let chartMaxPoints = 300;
|
||||||
let lastTimestamp = 0;
|
let lastTimestampByNail = { nail1: 0, nail2: 0 };
|
||||||
let chart = null;
|
let chart = null;
|
||||||
let firstTimestamp = null;
|
let firstTimestamp = null;
|
||||||
|
let simpleChart = null;
|
||||||
|
let simpleFirstTimestamp = null;
|
||||||
let lastApiError = '';
|
let lastApiError = '';
|
||||||
let actionBannerTimer = null;
|
let actionBannerTimer = null;
|
||||||
let heartbeatMisses = 0;
|
let heartbeatMisses = 0;
|
||||||
@@ -12,6 +14,7 @@ let deferredInstallPrompt = null;
|
|||||||
let uiMode = 'nail1';
|
let uiMode = 'nail1';
|
||||||
let activeNailId = 'nail1';
|
let activeNailId = 'nail1';
|
||||||
let historyBuffer = [];
|
let historyBuffer = [];
|
||||||
|
let simpleHistoryByNail = { nail1: [], nail2: [] };
|
||||||
let schedulerTimesByNail = { nail1: [], nail2: [] };
|
let schedulerTimesByNail = { nail1: [], nail2: [] };
|
||||||
let states = { nail1: null, nail2: null };
|
let states = { nail1: null, nail2: null };
|
||||||
|
|
||||||
@@ -166,6 +169,26 @@ function schedulerEnabledChanged() {
|
|||||||
saveSchedulerSettings();
|
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() {
|
function updateSimpleCards() {
|
||||||
['nail1', 'nail2'].forEach(function(id) {
|
['nail1', 'nail2'].forEach(function(id) {
|
||||||
const s = states[id];
|
const s = states[id];
|
||||||
@@ -176,10 +199,35 @@ function updateSimpleCards() {
|
|||||||
const spEl = document.getElementById('simple-target-' + suffix);
|
const spEl = document.getElementById('simple-target-' + suffix);
|
||||||
const pEl = document.getElementById('simple-power-' + suffix);
|
const pEl = document.getElementById('simple-power-' + suffix);
|
||||||
const inEl = document.getElementById('simple-setpoint-' + 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 (tEl) tEl.textContent = s.temp.toFixed(1);
|
||||||
if (mEl) mEl.textContent = s.mode || 'grounded';
|
if (mEl) mEl.textContent = s.mode || 'grounded';
|
||||||
if (spEl) spEl.textContent = s.effective_setpoint.toFixed(0);
|
if (spEl) spEl.textContent = s.effective_setpoint.toFixed(0);
|
||||||
if (inEl && document.activeElement !== inEl) inEl.value = s.setpoint;
|
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) {
|
if (pEl) {
|
||||||
pEl.textContent = s.enabled ? 'ON' : 'OFF';
|
pEl.textContent = s.enabled ? 'ON' : 'OFF';
|
||||||
pEl.className = 'power-mini ' + (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() {
|
function resetChartForNail() {
|
||||||
firstTimestamp = null;
|
firstTimestamp = null;
|
||||||
lastTimestamp = 0;
|
lastTimestampByNail[currentNailId()] = 0;
|
||||||
historyBuffer = [];
|
historyBuffer = [];
|
||||||
if (chart) {
|
if (chart) {
|
||||||
chart.data.datasets.forEach(function(ds) { ds.data = []; });
|
chart.data.datasets.forEach(function(ds) { ds.data = []; });
|
||||||
@@ -293,6 +382,28 @@ function resetChartForNail() {
|
|||||||
updateErrorStats();
|
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) {
|
function addChartData(points) {
|
||||||
if (!chart || !points.length) return;
|
if (!chart || !points.length) return;
|
||||||
if (firstTimestamp === null) firstTimestamp = points[0].timestamp;
|
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-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;
|
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');
|
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';
|
if (document.activeElement.id !== 'sched-enabled') document.getElementById('sched-enabled').value = status.scheduler.enabled ? 'true' : 'false';
|
||||||
const incoming = (status.scheduler.cutoff_times || []).slice().sort();
|
const incoming = (status.scheduler.cutoff_times || []).slice().sort();
|
||||||
@@ -445,12 +556,23 @@ async function pollStatus() {
|
|||||||
renderAdvanced(states[currentNailId()]);
|
renderAdvanced(states[currentNailId()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollHistory() {
|
async function pollHistoryForNail(nailId) {
|
||||||
const nailNum = nailNumFromId(currentNailId());
|
const since = lastTimestampByNail[nailId] || 0;
|
||||||
const data = await fetchJSON('/api/history?since=' + lastTimestamp + '&nail=' + nailNum);
|
const nailNum = nailNumFromId(nailId);
|
||||||
|
const data = await fetchJSON('/api/history?since=' + since + '&nail=' + nailNum);
|
||||||
if (!data || !data.length) return;
|
if (!data || !data.length) return;
|
||||||
lastTimestamp = data[data.length - 1].timestamp;
|
lastTimestampByNail[nailId] = data[data.length - 1].timestamp;
|
||||||
|
addSimpleChartData(nailId, data);
|
||||||
|
if (nailId === currentNailId()) {
|
||||||
addChartData(data);
|
addChartData(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollHistory() {
|
||||||
|
await Promise.all([
|
||||||
|
pollHistoryForNail('nail1'),
|
||||||
|
pollHistoryForNail('nail2'),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollHeartbeat() {
|
async function pollHeartbeat() {
|
||||||
@@ -492,6 +614,16 @@ async function simpleTogglePower(num) {
|
|||||||
setLastAck('power ' + (result.enabled ? 'ON' : 'OFF') + ' (' + id + ')', true);
|
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() {
|
async function applySetpoint() {
|
||||||
const value = parseFloat(document.getElementById('setpoint-input').value);
|
const value = parseFloat(document.getElementById('setpoint-input').value);
|
||||||
if (isNaN(value)) return;
|
if (isNaN(value)) return;
|
||||||
@@ -696,6 +828,7 @@ window.addEventListener('appinstalled', function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
initChart();
|
initChart();
|
||||||
|
initSimpleChart();
|
||||||
setInterval(pollStatus, pollInterval);
|
setInterval(pollStatus, pollInterval);
|
||||||
setInterval(pollHistory, pollInterval);
|
setInterval(pollHistory, pollInterval);
|
||||||
setInterval(pollHeartbeat, 2000);
|
setInterval(pollHeartbeat, 2000);
|
||||||
|
|||||||
@@ -212,6 +212,76 @@ body.ui-simple .app-footer {
|
|||||||
margin-top: 8px;
|
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 {
|
.power-mini {
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -681,6 +751,13 @@ button:disabled {
|
|||||||
width: 70px;
|
width: 70px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#control-loop-size,
|
||||||
|
#flight-takeoff-seconds,
|
||||||
|
#flight-descent-seconds,
|
||||||
|
#flight-descent-target {
|
||||||
|
width: 112px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Status Bar */
|
/* Status Bar */
|
||||||
.status-bar {
|
.status-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -783,6 +860,10 @@ button:disabled {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.simple-chart-wrap {
|
||||||
|
height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
.chart-section {
|
.chart-section {
|
||||||
height: 220px;
|
height: 220px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,23 +41,54 @@
|
|||||||
<div class="simple-temp"><span id="simple-temp-nail1">---</span>°F</div>
|
<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">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-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">
|
<div class="simple-controls">
|
||||||
<input type="number" id="simple-setpoint-nail1" value="530" min="0" max="800" step="5">
|
<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 class="apply-btn" onclick="simpleApplySetpoint(1)">Set</button>
|
||||||
<button id="simple-power-nail1" class="power-mini off" onclick="simpleTogglePower(1)">OFF</button>
|
<button id="simple-power-nail1" class="power-mini off" onclick="simpleTogglePower(1)">OFF</button>
|
||||||
</div>
|
</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>
|
||||||
<div class="simple-card" id="simple-card-nail2">
|
<div class="simple-card" id="simple-card-nail2">
|
||||||
<h3>Nail 2</h3>
|
<h3>Nail 2</h3>
|
||||||
<div class="simple-temp"><span id="simple-temp-nail2">---</span>°F</div>
|
<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">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-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">
|
<div class="simple-controls">
|
||||||
<input type="number" id="simple-setpoint-nail2" value="530" min="0" max="800" step="5">
|
<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 class="apply-btn" onclick="simpleApplySetpoint(2)">Set</button>
|
||||||
<button id="simple-power-nail2" class="power-mini off" onclick="simpleTogglePower(2)">OFF</button>
|
<button id="simple-power-nail2" class="power-mini off" onclick="simpleTogglePower(2)">OFF</button>
|
||||||
</div>
|
</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>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="simple-only simple-chart-wrap">
|
||||||
|
<h3>Combined Temperature View</h3>
|
||||||
|
<canvas id="simple-temp-chart"></canvas>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
@@ -123,11 +154,11 @@
|
|||||||
<div class="pid-controls">
|
<div class="pid-controls">
|
||||||
<label>
|
<label>
|
||||||
Loop Size (ms)
|
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>
|
||||||
<label>
|
<label>
|
||||||
Sleep (s)
|
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>
|
</label>
|
||||||
<button class="apply-btn" onclick="applyControlTiming()">Apply Timing</button>
|
<button class="apply-btn" onclick="applyControlTiming()">Apply Timing</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -179,7 +210,7 @@
|
|||||||
<div class="autotune-controls">
|
<div class="autotune-controls">
|
||||||
<button class="adj-btn" onclick="setFlightMode('takeoff')">Takeoff</button>
|
<button class="adj-btn" onclick="setFlightMode('takeoff')">Takeoff</button>
|
||||||
<button class="adj-btn" onclick="setFlightMode('descent')">Descent</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>
|
||||||
<div class="pid-controls">
|
<div class="pid-controls">
|
||||||
<label>
|
<label>
|
||||||
@@ -209,14 +240,11 @@
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Add time (HH:MM)
|
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>
|
</label>
|
||||||
<button class="adj-btn" onclick="addSchedulerTime()">Add Time</button>
|
<button class="adj-btn" onclick="addSchedulerTime()">Add Time</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="sched-time-list" class="sched-time-list"></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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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