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.
|
||||
|
||||
## Safety behaviors
|
||||
- Hard max temp cutoff.
|
||||
- Hard max temp cutoff (800F).
|
||||
- Thermocouple disconnect handling.
|
||||
- Idle shutoff timer.
|
||||
- Idle shutoff timer (default 30 min).
|
||||
- Watchdog status exposed in heartbeat.
|
||||
|
||||
## Operations quick commands
|
||||
|
||||
+9
-9
@@ -7,8 +7,8 @@
|
||||
},
|
||||
"control": {
|
||||
"setpoint": 530,
|
||||
"loop_size_ms": 3000,
|
||||
"sleep_time": 0.4,
|
||||
"loop_size_ms": 1800,
|
||||
"sleep_time": 0.2,
|
||||
"enabled": false
|
||||
},
|
||||
"flight": {
|
||||
@@ -26,7 +26,7 @@
|
||||
]
|
||||
},
|
||||
"safety": {
|
||||
"max_temp_f": 750,
|
||||
"max_temp_f": 800,
|
||||
"spike_threshold_f": 50.0,
|
||||
"idle_shutoff_minutes": 30,
|
||||
"watchdog_timeout_s": 10,
|
||||
@@ -70,8 +70,8 @@
|
||||
},
|
||||
"control": {
|
||||
"setpoint": 530,
|
||||
"loop_size_ms": 3000,
|
||||
"sleep_time": 0.4,
|
||||
"loop_size_ms": 1800,
|
||||
"sleep_time": 0.2,
|
||||
"enabled": false
|
||||
},
|
||||
"flight": {
|
||||
@@ -89,7 +89,7 @@
|
||||
]
|
||||
},
|
||||
"safety": {
|
||||
"max_temp_f": 750,
|
||||
"max_temp_f": 800,
|
||||
"spike_threshold_f": 50.0,
|
||||
"idle_shutoff_minutes": 30,
|
||||
"watchdog_timeout_s": 10,
|
||||
@@ -123,8 +123,8 @@
|
||||
},
|
||||
"control": {
|
||||
"setpoint": 530,
|
||||
"loop_size_ms": 3000,
|
||||
"sleep_time": 0.4,
|
||||
"loop_size_ms": 1800,
|
||||
"sleep_time": 0.2,
|
||||
"enabled": false
|
||||
},
|
||||
"flight": {
|
||||
@@ -142,7 +142,7 @@
|
||||
]
|
||||
},
|
||||
"safety": {
|
||||
"max_temp_f": 750,
|
||||
"max_temp_f": 800,
|
||||
"spike_threshold_f": 50.0,
|
||||
"idle_shutoff_minutes": 30,
|
||||
"watchdog_timeout_s": 10,
|
||||
|
||||
+9
-9
@@ -21,8 +21,8 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
"control": {
|
||||
"setpoint": 530,
|
||||
"loop_size_ms": 3000,
|
||||
"sleep_time": 0.4,
|
||||
"loop_size_ms": 1800,
|
||||
"sleep_time": 0.2,
|
||||
"enabled": False
|
||||
},
|
||||
"flight": {
|
||||
@@ -40,7 +40,7 @@ DEFAULT_CONFIG = {
|
||||
]
|
||||
},
|
||||
"safety": {
|
||||
"max_temp_f": 750,
|
||||
"max_temp_f": 800,
|
||||
"spike_threshold_f": 50.0,
|
||||
"idle_shutoff_minutes": 30,
|
||||
"watchdog_timeout_s": 10,
|
||||
@@ -84,8 +84,8 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
"control": {
|
||||
"setpoint": 530,
|
||||
"loop_size_ms": 3000,
|
||||
"sleep_time": 0.4,
|
||||
"loop_size_ms": 1800,
|
||||
"sleep_time": 0.2,
|
||||
"enabled": False
|
||||
},
|
||||
"flight": {
|
||||
@@ -103,7 +103,7 @@ DEFAULT_CONFIG = {
|
||||
]
|
||||
},
|
||||
"safety": {
|
||||
"max_temp_f": 750,
|
||||
"max_temp_f": 800,
|
||||
"spike_threshold_f": 50.0,
|
||||
"idle_shutoff_minutes": 30,
|
||||
"watchdog_timeout_s": 10,
|
||||
@@ -137,8 +137,8 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
"control": {
|
||||
"setpoint": 530,
|
||||
"loop_size_ms": 3000,
|
||||
"sleep_time": 0.4,
|
||||
"loop_size_ms": 1800,
|
||||
"sleep_time": 0.2,
|
||||
"enabled": False
|
||||
},
|
||||
"flight": {
|
||||
@@ -156,7 +156,7 @@ DEFAULT_CONFIG = {
|
||||
]
|
||||
},
|
||||
"safety": {
|
||||
"max_temp_f": 750,
|
||||
"max_temp_f": 800,
|
||||
"spike_threshold_f": 50.0,
|
||||
"idle_shutoff_minutes": 30,
|
||||
"watchdog_timeout_s": 10,
|
||||
|
||||
@@ -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
|
||||
+141
-8
@@ -1,8 +1,10 @@
|
||||
let pollInterval = 500;
|
||||
let chartMaxPoints = 300;
|
||||
let lastTimestamp = 0;
|
||||
let lastTimestampByNail = { nail1: 0, nail2: 0 };
|
||||
let chart = null;
|
||||
let firstTimestamp = null;
|
||||
let simpleChart = null;
|
||||
let simpleFirstTimestamp = null;
|
||||
let lastApiError = '';
|
||||
let actionBannerTimer = null;
|
||||
let heartbeatMisses = 0;
|
||||
@@ -12,6 +14,7 @@ let deferredInstallPrompt = null;
|
||||
let uiMode = 'nail1';
|
||||
let activeNailId = 'nail1';
|
||||
let historyBuffer = [];
|
||||
let simpleHistoryByNail = { nail1: [], nail2: [] };
|
||||
let schedulerTimesByNail = { nail1: [], nail2: [] };
|
||||
let states = { nail1: null, nail2: null };
|
||||
|
||||
@@ -166,6 +169,26 @@ function schedulerEnabledChanged() {
|
||||
saveSchedulerSettings();
|
||||
}
|
||||
|
||||
function formatSeconds(sec) {
|
||||
if (typeof sec !== 'number' || !isFinite(sec)) return '--';
|
||||
const s = Math.max(0, Math.round(sec));
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const r = s % 60;
|
||||
if (h > 0) return h + 'h ' + m + 'm ' + r + 's';
|
||||
return m + 'm ' + r + 's';
|
||||
}
|
||||
|
||||
function formatUptime(sec) {
|
||||
if (typeof sec !== 'number' || !isFinite(sec)) return '--';
|
||||
const s = Math.max(0, Math.round(sec));
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const r = s % 60;
|
||||
if (h > 0) return h + 'h ' + m + 'm';
|
||||
return m + 'm ' + r + 's';
|
||||
}
|
||||
|
||||
function updateSimpleCards() {
|
||||
['nail1', 'nail2'].forEach(function(id) {
|
||||
const s = states[id];
|
||||
@@ -176,10 +199,35 @@ function updateSimpleCards() {
|
||||
const spEl = document.getElementById('simple-target-' + suffix);
|
||||
const pEl = document.getElementById('simple-power-' + suffix);
|
||||
const inEl = document.getElementById('simple-setpoint-' + suffix);
|
||||
const relayEl = document.getElementById('simple-relay-' + suffix);
|
||||
const outEl = document.getElementById('simple-output-' + suffix);
|
||||
const tcEl = document.getElementById('simple-tc-' + suffix);
|
||||
const upEl = document.getElementById('simple-uptime-' + suffix);
|
||||
const etaEl = document.getElementById('simple-eta-' + suffix);
|
||||
const cutoffEl = document.getElementById('simple-cutoff-' + suffix);
|
||||
const alertEl = document.getElementById('simple-alert-' + suffix);
|
||||
if (tEl) tEl.textContent = s.temp.toFixed(1);
|
||||
if (mEl) mEl.textContent = s.mode || 'grounded';
|
||||
if (spEl) spEl.textContent = s.effective_setpoint.toFixed(0);
|
||||
if (inEl && document.activeElement !== inEl) inEl.value = s.setpoint;
|
||||
if (relayEl) relayEl.textContent = s.relay_on ? 'ON' : 'OFF';
|
||||
if (outEl) outEl.textContent = (typeof s.output === 'number' ? s.output.toFixed(0) : '--');
|
||||
if (tcEl) tcEl.textContent = s.thermocouple_connected ? 'OK' : 'DISC';
|
||||
if (upEl) upEl.textContent = formatUptime(s.uptime_seconds);
|
||||
if (etaEl) etaEl.textContent = formatSeconds(s.mode_eta_seconds);
|
||||
if (cutoffEl) cutoffEl.textContent = s.scheduler && s.scheduler.enabled ? formatSeconds(s.next_cutoff_seconds) : 'Scheduler off';
|
||||
if (alertEl) {
|
||||
if (s.safety_tripped) {
|
||||
alertEl.className = 'simple-alert bad';
|
||||
alertEl.textContent = 'Safety trip: ' + (s.safety_reason || 'Unknown');
|
||||
} else if (!s.thermocouple_connected) {
|
||||
alertEl.className = 'simple-alert warn';
|
||||
alertEl.textContent = 'Thermocouple disconnected.';
|
||||
} else {
|
||||
alertEl.className = 'simple-alert';
|
||||
alertEl.textContent = 'No active safety alerts.';
|
||||
}
|
||||
}
|
||||
if (pEl) {
|
||||
pEl.textContent = s.enabled ? 'ON' : 'OFF';
|
||||
pEl.className = 'power-mini ' + (s.enabled ? 'on' : 'off');
|
||||
@@ -282,9 +330,50 @@ function initChart() {
|
||||
});
|
||||
}
|
||||
|
||||
function initSimpleChart() {
|
||||
const canvas = document.getElementById('simple-temp-chart');
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
simpleChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: { datasets: [
|
||||
{ label: 'Nail 1 Temp', borderColor: '#ff6b35', borderWidth: 2, pointRadius: 0, fill: false, data: [] },
|
||||
{ label: 'Nail 2 Temp', borderColor: '#4ecdc4', borderWidth: 2, pointRadius: 0, fill: false, data: [] },
|
||||
{ label: 'Nail 1 Target', borderColor: '#ff9f6b', borderWidth: 1, borderDash: [5, 4], pointRadius: 0, fill: false, data: [] },
|
||||
{ label: 'Nail 2 Target', borderColor: '#7df7ef', borderWidth: 1, borderDash: [5, 4], pointRadius: 0, fill: false, data: [] },
|
||||
]},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
scales: {
|
||||
x: {
|
||||
type: 'linear',
|
||||
ticks: {
|
||||
color: '#888',
|
||||
callback: function(value) { return Math.round(value) + 's'; },
|
||||
maxTicksLimit: 10,
|
||||
},
|
||||
grid: { color: 'rgba(255,255,255,0.05)' },
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
ticks: { color: '#ccc' },
|
||||
grid: { color: 'rgba(255,255,255,0.05)' },
|
||||
suggestedMin: 0,
|
||||
suggestedMax: 700,
|
||||
title: { display: true, text: 'Temperature (F)', color: '#aaa' },
|
||||
},
|
||||
},
|
||||
plugins: { legend: { labels: { color: '#ccc', boxWidth: 12 } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function resetChartForNail() {
|
||||
firstTimestamp = null;
|
||||
lastTimestamp = 0;
|
||||
lastTimestampByNail[currentNailId()] = 0;
|
||||
historyBuffer = [];
|
||||
if (chart) {
|
||||
chart.data.datasets.forEach(function(ds) { ds.data = []; });
|
||||
@@ -293,6 +382,28 @@ function resetChartForNail() {
|
||||
updateErrorStats();
|
||||
}
|
||||
|
||||
function addSimpleChartData(nailId, points) {
|
||||
if (!simpleChart || !points || !points.length) return;
|
||||
if (simpleFirstTimestamp === null) simpleFirstTimestamp = points[0].timestamp;
|
||||
const tempDatasetIndex = (nailId === 'nail2') ? 1 : 0;
|
||||
const targetDatasetIndex = (nailId === 'nail2') ? 3 : 2;
|
||||
|
||||
points.forEach(function(p) {
|
||||
const x = p.timestamp - simpleFirstTimestamp;
|
||||
simpleChart.data.datasets[tempDatasetIndex].data.push({ x: x, y: p.temp });
|
||||
simpleChart.data.datasets[targetDatasetIndex].data.push({ x: x, y: p.setpoint });
|
||||
simpleHistoryByNail[nailId].push(p);
|
||||
});
|
||||
|
||||
const newest = simpleHistoryByNail[nailId].length ? simpleHistoryByNail[nailId][simpleHistoryByNail[nailId].length - 1].timestamp : 0;
|
||||
simpleHistoryByNail[nailId] = simpleHistoryByNail[nailId].filter(function(p) { return p.timestamp >= (newest - 600); });
|
||||
|
||||
simpleChart.data.datasets.forEach(function(ds) {
|
||||
if (ds.data.length > chartMaxPoints) ds.data = ds.data.slice(ds.data.length - chartMaxPoints);
|
||||
});
|
||||
simpleChart.update('none');
|
||||
}
|
||||
|
||||
function addChartData(points) {
|
||||
if (!chart || !points.length) return;
|
||||
if (firstTimestamp === null) firstTimestamp = points[0].timestamp;
|
||||
@@ -422,7 +533,7 @@ function renderAdvanced(status) {
|
||||
if (document.activeElement.id !== 'flight-descent-seconds') document.getElementById('flight-descent-seconds').value = flight.descent_seconds || 90;
|
||||
if (document.activeElement.id !== 'flight-descent-target') document.getElementById('flight-descent-target').value = flight.descent_target_f || 120;
|
||||
const fm = document.getElementById('flight-mode-status');
|
||||
if (fm) fm.textContent = 'Current mode: ' + (status.mode || 'grounded') + ' (Power button handles Grounded/Cruise)';
|
||||
if (fm) fm.textContent = '';
|
||||
|
||||
if (document.activeElement.id !== 'sched-enabled') document.getElementById('sched-enabled').value = status.scheduler.enabled ? 'true' : 'false';
|
||||
const incoming = (status.scheduler.cutoff_times || []).slice().sort();
|
||||
@@ -445,12 +556,23 @@ async function pollStatus() {
|
||||
renderAdvanced(states[currentNailId()]);
|
||||
}
|
||||
|
||||
async function pollHistory() {
|
||||
const nailNum = nailNumFromId(currentNailId());
|
||||
const data = await fetchJSON('/api/history?since=' + lastTimestamp + '&nail=' + nailNum);
|
||||
async function pollHistoryForNail(nailId) {
|
||||
const since = lastTimestampByNail[nailId] || 0;
|
||||
const nailNum = nailNumFromId(nailId);
|
||||
const data = await fetchJSON('/api/history?since=' + since + '&nail=' + nailNum);
|
||||
if (!data || !data.length) return;
|
||||
lastTimestamp = data[data.length - 1].timestamp;
|
||||
addChartData(data);
|
||||
lastTimestampByNail[nailId] = data[data.length - 1].timestamp;
|
||||
addSimpleChartData(nailId, data);
|
||||
if (nailId === currentNailId()) {
|
||||
addChartData(data);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollHistory() {
|
||||
await Promise.all([
|
||||
pollHistoryForNail('nail1'),
|
||||
pollHistoryForNail('nail2'),
|
||||
]);
|
||||
}
|
||||
|
||||
async function pollHeartbeat() {
|
||||
@@ -492,6 +614,16 @@ async function simpleTogglePower(num) {
|
||||
setLastAck('power ' + (result.enabled ? 'ON' : 'OFF') + ' (' + id + ')', true);
|
||||
}
|
||||
|
||||
async function simpleSetFlightMode(num, mode) {
|
||||
const id = nailIdFromNum(num);
|
||||
const result = await apiPost('/api/flight', { mode: mode }, id);
|
||||
if (!result) {
|
||||
setLastAck('mode ' + mode + ' failed', false);
|
||||
return;
|
||||
}
|
||||
setLastAck('mode ' + mode + ' (' + id + ')', true);
|
||||
}
|
||||
|
||||
async function applySetpoint() {
|
||||
const value = parseFloat(document.getElementById('setpoint-input').value);
|
||||
if (isNaN(value)) return;
|
||||
@@ -696,6 +828,7 @@ window.addEventListener('appinstalled', function() {
|
||||
});
|
||||
|
||||
initChart();
|
||||
initSimpleChart();
|
||||
setInterval(pollStatus, pollInterval);
|
||||
setInterval(pollHistory, pollInterval);
|
||||
setInterval(pollHeartbeat, 2000);
|
||||
|
||||
@@ -212,6 +212,76 @@ body.ui-simple .app-footer {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.simple-flight-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.simple-stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.simple-stat {
|
||||
font-size: 0.74rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 5px 7px;
|
||||
color: var(--text-dim);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.simple-stat strong {
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.simple-alert {
|
||||
margin-top: 6px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 0.73rem;
|
||||
color: var(--text-dim);
|
||||
border-left: 2px solid var(--border);
|
||||
padding-left: 8px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.simple-alert.warn {
|
||||
color: #f5b041;
|
||||
border-left-color: #f5b041;
|
||||
}
|
||||
|
||||
.simple-alert.bad {
|
||||
color: var(--accent-red);
|
||||
border-left-color: var(--accent-red);
|
||||
}
|
||||
|
||||
.simple-chart-wrap {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
height: 280px;
|
||||
box-shadow: inset 0 0 0 1px rgba(211, 84, 0, 0.12);
|
||||
}
|
||||
|
||||
.simple-chart-wrap h3 {
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.simple-chart-wrap canvas {
|
||||
width: 100% !important;
|
||||
height: calc(100% - 26px) !important;
|
||||
}
|
||||
|
||||
.power-mini {
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
@@ -681,6 +751,13 @@ button:disabled {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
#control-loop-size,
|
||||
#flight-takeoff-seconds,
|
||||
#flight-descent-seconds,
|
||||
#flight-descent-target {
|
||||
width: 112px;
|
||||
}
|
||||
|
||||
/* Status Bar */
|
||||
.status-bar {
|
||||
display: flex;
|
||||
@@ -783,6 +860,10 @@ button:disabled {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.simple-chart-wrap {
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
@@ -41,25 +41,56 @@
|
||||
<div class="simple-temp"><span id="simple-temp-nail1">---</span>°F</div>
|
||||
<div class="simple-line">Mode: <span id="simple-mode-nail1">grounded</span></div>
|
||||
<div class="simple-line">Target: <span id="simple-target-nail1">---</span>°F</div>
|
||||
<div class="simple-stats">
|
||||
<span class="simple-stat">Relay <strong id="simple-relay-nail1">OFF</strong></span>
|
||||
<span class="simple-stat">Output <strong id="simple-output-nail1">0</strong></span>
|
||||
<span class="simple-stat">TC <strong id="simple-tc-nail1">--</strong></span>
|
||||
<span class="simple-stat">Uptime <strong id="simple-uptime-nail1">--</strong></span>
|
||||
</div>
|
||||
<div class="simple-line">ETA: <span id="simple-eta-nail1">--</span></div>
|
||||
<div class="simple-line">Next descent: <span id="simple-cutoff-nail1">--</span></div>
|
||||
<div class="simple-alert" id="simple-alert-nail1">No active safety alerts.</div>
|
||||
<div class="simple-controls">
|
||||
<input type="number" id="simple-setpoint-nail1" value="530" min="0" max="800" step="5">
|
||||
<button class="apply-btn" onclick="simpleApplySetpoint(1)">Set</button>
|
||||
<button id="simple-power-nail1" class="power-mini off" onclick="simpleTogglePower(1)">OFF</button>
|
||||
</div>
|
||||
<div class="simple-flight-controls">
|
||||
<button class="adj-btn" onclick="simpleSetFlightMode(1, 'takeoff')">Takeoff</button>
|
||||
<button class="adj-btn" onclick="simpleSetFlightMode(1, 'descent')">Descent</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="simple-card" id="simple-card-nail2">
|
||||
<h3>Nail 2</h3>
|
||||
<div class="simple-temp"><span id="simple-temp-nail2">---</span>°F</div>
|
||||
<div class="simple-line">Mode: <span id="simple-mode-nail2">grounded</span></div>
|
||||
<div class="simple-line">Target: <span id="simple-target-nail2">---</span>°F</div>
|
||||
<div class="simple-stats">
|
||||
<span class="simple-stat">Relay <strong id="simple-relay-nail2">OFF</strong></span>
|
||||
<span class="simple-stat">Output <strong id="simple-output-nail2">0</strong></span>
|
||||
<span class="simple-stat">TC <strong id="simple-tc-nail2">--</strong></span>
|
||||
<span class="simple-stat">Uptime <strong id="simple-uptime-nail2">--</strong></span>
|
||||
</div>
|
||||
<div class="simple-line">ETA: <span id="simple-eta-nail2">--</span></div>
|
||||
<div class="simple-line">Next descent: <span id="simple-cutoff-nail2">--</span></div>
|
||||
<div class="simple-alert" id="simple-alert-nail2">No active safety alerts.</div>
|
||||
<div class="simple-controls">
|
||||
<input type="number" id="simple-setpoint-nail2" value="530" min="0" max="800" step="5">
|
||||
<button class="apply-btn" onclick="simpleApplySetpoint(2)">Set</button>
|
||||
<button id="simple-power-nail2" class="power-mini off" onclick="simpleTogglePower(2)">OFF</button>
|
||||
</div>
|
||||
<div class="simple-flight-controls">
|
||||
<button class="adj-btn" onclick="simpleSetFlightMode(2, 'takeoff')">Takeoff</button>
|
||||
<button class="adj-btn" onclick="simpleSetFlightMode(2, 'descent')">Descent</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="simple-only simple-chart-wrap">
|
||||
<h3>Combined Temperature View</h3>
|
||||
<canvas id="simple-temp-chart"></canvas>
|
||||
</section>
|
||||
|
||||
<section class="hero">
|
||||
<div class="temp-display">
|
||||
<div id="advanced-nail-label" class="wall-clock">Nail 1</div>
|
||||
@@ -123,11 +154,11 @@
|
||||
<div class="pid-controls">
|
||||
<label>
|
||||
Loop Size (ms)
|
||||
<input type="number" id="control-loop-size" step="100" min="1500" max="5000" value="3000">
|
||||
<input type="number" id="control-loop-size" step="100" min="1500" max="5000" value="1800">
|
||||
</label>
|
||||
<label>
|
||||
Sleep (s)
|
||||
<input type="number" id="control-sleep-time" step="0.01" min="0.15" max="0.6" value="0.4">
|
||||
<input type="number" id="control-sleep-time" step="0.01" min="0.15" max="0.6" value="0.2">
|
||||
</label>
|
||||
<button class="apply-btn" onclick="applyControlTiming()">Apply Timing</button>
|
||||
</div>
|
||||
@@ -179,7 +210,7 @@
|
||||
<div class="autotune-controls">
|
||||
<button class="adj-btn" onclick="setFlightMode('takeoff')">Takeoff</button>
|
||||
<button class="adj-btn" onclick="setFlightMode('descent')">Descent</button>
|
||||
<span id="flight-mode-status" class="autotune-status idle">Use power button for Grounded/Cruise.</span>
|
||||
<span id="flight-mode-status" class="autotune-status idle"></span>
|
||||
</div>
|
||||
<div class="pid-controls">
|
||||
<label>
|
||||
@@ -209,14 +240,11 @@
|
||||
</label>
|
||||
<label>
|
||||
Add time (HH:MM)
|
||||
<input type="text" id="sched-time-input" value="23:00" placeholder="HH:MM" inputmode="numeric" maxlength="5">
|
||||
<input type="time" id="sched-time-input" value="23:00" step="60">
|
||||
</label>
|
||||
<button class="adj-btn" onclick="addSchedulerTime()">Add Time</button>
|
||||
</div>
|
||||
<div id="sched-time-list" class="sched-time-list"></div>
|
||||
<div class="autotune-controls">
|
||||
<span class="autotune-status idle">Scheduler only triggers descent -> grounded. No auto takeoff.</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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