Initialize piNail project with modern piNail2 web controller
This commit is contained in:
@@ -0,0 +1,564 @@
|
||||
/**
|
||||
* piNail2 Frontend — Dashboard Controller
|
||||
*
|
||||
* Polls the REST API for status updates, renders a live Chart.js chart,
|
||||
* and provides controls for setpoint, PID tuning, and power toggle.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
let pollInterval = 500; // ms between status polls
|
||||
let chartMaxPoints = 300; // max data points on chart
|
||||
let lastTimestamp = 0; // for incremental history fetches
|
||||
let isEnabled = false;
|
||||
let currentSetpoint = 530;
|
||||
let chart = null;
|
||||
let lastApiError = '';
|
||||
let actionBannerTimer = null;
|
||||
let heartbeatMisses = 0;
|
||||
let heartbeatInstanceId = null;
|
||||
let controlsEnabled = true;
|
||||
|
||||
function nowHms() {
|
||||
const d = new Date();
|
||||
return d.toLocaleTimeString();
|
||||
}
|
||||
|
||||
function setLastAck(message, ok=true) {
|
||||
const el = document.getElementById('last-ack');
|
||||
if (!el) return;
|
||||
el.className = 'last-ack ' + (ok ? 'ok' : 'err');
|
||||
el.textContent = 'Last command: ' + message + ' at ' + nowHms();
|
||||
}
|
||||
|
||||
function showAction(message, type='info', timeoutMs=3000) {
|
||||
const banner = document.getElementById('action-banner');
|
||||
const msg = document.getElementById('action-message');
|
||||
if (!banner || !msg) return;
|
||||
msg.textContent = message;
|
||||
banner.className = 'action-banner ' + type;
|
||||
if (actionBannerTimer) clearTimeout(actionBannerTimer);
|
||||
if (timeoutMs > 0) {
|
||||
actionBannerTimer = setTimeout(function() {
|
||||
banner.className = 'action-banner hidden';
|
||||
}, timeoutMs);
|
||||
}
|
||||
}
|
||||
|
||||
function setControlsEnabled(enabled) {
|
||||
if (controlsEnabled === enabled) return;
|
||||
controlsEnabled = enabled;
|
||||
const btns = document.querySelectorAll('button');
|
||||
btns.forEach(function(b) {
|
||||
if (b.id === 'autotune-stop-btn' && enabled) {
|
||||
// stop button is governed by autotune state in setAutotuneUi()
|
||||
return;
|
||||
}
|
||||
b.disabled = !enabled;
|
||||
});
|
||||
}
|
||||
|
||||
function setBackendStatus(mode, text) {
|
||||
const el = document.getElementById('backend-status');
|
||||
if (!el) return;
|
||||
el.className = 'backend-status ' + mode;
|
||||
el.textContent = text;
|
||||
}
|
||||
|
||||
function setAutotuneUi(tune) {
|
||||
const statusEl = document.getElementById('autotune-status');
|
||||
const startBtn = document.getElementById('autotune-start-btn');
|
||||
const stopBtn = document.getElementById('autotune-stop-btn');
|
||||
const pill = document.getElementById('autotune-pill');
|
||||
if (!statusEl || !startBtn || !stopBtn) return;
|
||||
|
||||
if (tune && tune.active) {
|
||||
statusEl.className = 'autotune-status running';
|
||||
const phase = tune.phase ? (' ' + tune.phase) : '';
|
||||
statusEl.textContent = 'Running' + phase + ' (' + tune.high_peaks + '/' + tune.cycles + ' peaks)';
|
||||
if (pill) {
|
||||
pill.className = 'autotune-pill running';
|
||||
pill.textContent = 'Autotune: Running' + phase;
|
||||
}
|
||||
startBtn.disabled = true;
|
||||
stopBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
startBtn.disabled = false;
|
||||
stopBtn.disabled = true;
|
||||
|
||||
if (tune && tune.last_result) {
|
||||
statusEl.className = 'autotune-status done';
|
||||
statusEl.textContent = 'Complete: kP ' + tune.last_result.kP + ', kI ' + tune.last_result.kI + ', kD ' + tune.last_result.kD;
|
||||
if (pill) {
|
||||
pill.className = 'autotune-pill done';
|
||||
pill.textContent = 'Autotune: Complete';
|
||||
}
|
||||
} else if (tune && tune.message) {
|
||||
const lower = String(tune.message).toLowerCase();
|
||||
statusEl.className = 'autotune-status ' + (lower.indexOf('failed') >= 0 ? 'error' : 'idle');
|
||||
statusEl.textContent = tune.message;
|
||||
if (pill) {
|
||||
const failed = lower.indexOf('failed') >= 0 || lower.indexOf('error') >= 0;
|
||||
pill.className = 'autotune-pill ' + (failed ? 'error' : 'idle');
|
||||
pill.textContent = failed ? 'Autotune: Error' : 'Autotune: Idle';
|
||||
}
|
||||
} else {
|
||||
statusEl.className = 'autotune-status idle';
|
||||
statusEl.textContent = 'Idle';
|
||||
if (pill) {
|
||||
pill.className = 'autotune-pill idle';
|
||||
pill.textContent = 'Autotune: Idle';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chart Setup
|
||||
// ---------------------------------------------------------------------------
|
||||
function initChart() {
|
||||
const ctx = document.getElementById('temp-chart').getContext('2d');
|
||||
chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
label: 'Temperature (F)',
|
||||
borderColor: '#ff6b35',
|
||||
backgroundColor: 'rgba(255, 107, 53, 0.1)',
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
fill: true,
|
||||
data: [],
|
||||
yAxisID: 'y'
|
||||
},
|
||||
{
|
||||
label: 'Setpoint (F)',
|
||||
borderColor: '#4ecdc4',
|
||||
borderWidth: 1.5,
|
||||
borderDash: [5, 5],
|
||||
pointRadius: 0,
|
||||
fill: false,
|
||||
data: [],
|
||||
yAxisID: 'y'
|
||||
},
|
||||
{
|
||||
label: 'Output',
|
||||
borderColor: '#45b7d1',
|
||||
backgroundColor: 'rgba(69, 183, 209, 0.1)',
|
||||
borderWidth: 1,
|
||||
pointRadius: 0,
|
||||
fill: true,
|
||||
data: [],
|
||||
yAxisID: 'y1'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
title: { display: false },
|
||||
ticks: {
|
||||
color: '#888',
|
||||
callback: function(value) {
|
||||
// Show relative seconds
|
||||
return Math.round(value) + 's';
|
||||
},
|
||||
maxTicksLimit: 10
|
||||
},
|
||||
grid: { color: 'rgba(255,255,255,0.05)' }
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: { display: true, text: 'Temperature (F)', color: '#aaa' },
|
||||
ticks: { color: '#ff6b35' },
|
||||
grid: { color: 'rgba(255,255,255,0.05)' },
|
||||
suggestedMin: 0,
|
||||
suggestedMax: 700
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: { display: true, text: 'PID Output', color: '#aaa' },
|
||||
ticks: { color: '#45b7d1' },
|
||||
grid: { drawOnChartArea: false },
|
||||
suggestedMin: 0
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: { color: '#ccc', boxWidth: 12 }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data Update
|
||||
// ---------------------------------------------------------------------------
|
||||
let firstTimestamp = null;
|
||||
|
||||
function addChartData(points) {
|
||||
if (!chart || !points.length) return;
|
||||
|
||||
if (firstTimestamp === null) {
|
||||
firstTimestamp = points[0].timestamp;
|
||||
}
|
||||
|
||||
for (const p of points) {
|
||||
const x = p.timestamp - firstTimestamp; // relative seconds
|
||||
chart.data.datasets[0].data.push({ x: x, y: p.temp });
|
||||
chart.data.datasets[1].data.push({ x: x, y: p.setpoint });
|
||||
chart.data.datasets[2].data.push({ x: x, y: p.output });
|
||||
}
|
||||
|
||||
// Trim to max points
|
||||
for (const ds of chart.data.datasets) {
|
||||
if (ds.data.length > chartMaxPoints) {
|
||||
ds.data = ds.data.slice(ds.data.length - chartMaxPoints);
|
||||
}
|
||||
}
|
||||
|
||||
chart.update('none'); // skip animation for performance
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API Calls
|
||||
// ---------------------------------------------------------------------------
|
||||
async function fetchJSON(url, options) {
|
||||
try {
|
||||
const resp = await fetch(url, options);
|
||||
const payload = await resp.json();
|
||||
if (!resp.ok) {
|
||||
throw new Error(payload.error || ('HTTP ' + resp.status));
|
||||
}
|
||||
lastApiError = '';
|
||||
return payload;
|
||||
} catch (e) {
|
||||
console.error('API error:', url, e);
|
||||
lastApiError = String(e);
|
||||
if (String(e).indexOf('HTTP') >= 0) {
|
||||
// API-level error, connection is still fine.
|
||||
} else {
|
||||
setConnectionStatus(false);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
const status = await fetchJSON('/api/status');
|
||||
if (!status) return;
|
||||
|
||||
setConnectionStatus(true);
|
||||
|
||||
// Temperature display
|
||||
const tempEl = document.getElementById('current-temp');
|
||||
tempEl.textContent = status.temp.toFixed(1);
|
||||
|
||||
// Color the temp based on proximity to setpoint
|
||||
const delta = Math.abs(status.temp - status.setpoint);
|
||||
if (!status.enabled) {
|
||||
tempEl.className = 'temp-value';
|
||||
} else if (delta < 15) {
|
||||
tempEl.className = 'temp-value temp-good';
|
||||
} else if (delta < 50) {
|
||||
tempEl.className = 'temp-value temp-warming';
|
||||
} else {
|
||||
tempEl.className = 'temp-value temp-cold';
|
||||
}
|
||||
|
||||
// Setpoint display
|
||||
document.getElementById('current-setpoint').textContent = status.setpoint.toFixed(0);
|
||||
currentSetpoint = status.setpoint;
|
||||
|
||||
// Power button
|
||||
isEnabled = status.enabled;
|
||||
const powerBtn = document.getElementById('power-btn');
|
||||
if (isEnabled) {
|
||||
powerBtn.textContent = 'ON';
|
||||
powerBtn.className = 'power-btn on';
|
||||
} else {
|
||||
powerBtn.textContent = 'OFF';
|
||||
powerBtn.className = 'power-btn off';
|
||||
}
|
||||
|
||||
// Safety banner
|
||||
const banner = document.getElementById('safety-banner');
|
||||
if (status.safety_tripped) {
|
||||
banner.classList.remove('hidden');
|
||||
document.getElementById('safety-message').textContent =
|
||||
'SAFETY TRIP: ' + status.safety_reason;
|
||||
} else {
|
||||
banner.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Status bar
|
||||
document.getElementById('status-output').textContent =
|
||||
status.output.toFixed(1) + ' / ' + (status.config.loop_size_ms || '?');
|
||||
|
||||
const relayEl = document.getElementById('status-relay');
|
||||
relayEl.textContent = status.relay_on ? 'ON' : 'OFF';
|
||||
relayEl.className = 'value ' + (status.relay_on ? 'relay-on' : 'relay-off');
|
||||
|
||||
if (status.uptime_seconds !== null) {
|
||||
const mins = Math.floor(status.uptime_seconds / 60);
|
||||
const secs = Math.floor(status.uptime_seconds % 60);
|
||||
document.getElementById('status-uptime').textContent =
|
||||
mins + 'm ' + secs + 's';
|
||||
} else {
|
||||
document.getElementById('status-uptime').textContent = '--';
|
||||
}
|
||||
|
||||
document.getElementById('status-loops').textContent = status.loop_count;
|
||||
|
||||
const tcEl = document.getElementById('status-tc');
|
||||
tcEl.textContent = status.thermocouple_connected ? 'OK' : 'DISCONNECTED';
|
||||
tcEl.className = 'value ' + (status.thermocouple_connected ? 'tc-ok' : 'tc-err');
|
||||
|
||||
// PID fields (only update if user isn't focused on them)
|
||||
if (document.activeElement.id !== 'pid-kp')
|
||||
document.getElementById('pid-kp').value = status.pid.kP;
|
||||
if (document.activeElement.id !== 'pid-ki')
|
||||
document.getElementById('pid-ki').value = status.pid.kI;
|
||||
if (document.activeElement.id !== 'pid-kd')
|
||||
document.getElementById('pid-kd').value = status.pid.kD;
|
||||
if (document.activeElement.id !== 'pid-pmode') {
|
||||
const mode = status.pid.proportional_mode || (status.pid.proportional_on_measurement ? 'measurement' : 'error');
|
||||
document.getElementById('pid-pmode').value = mode;
|
||||
}
|
||||
|
||||
const tune = status.autotune || {};
|
||||
setAutotuneUi(tune);
|
||||
|
||||
// Setpoint input (only update if user isn't focused)
|
||||
if (document.activeElement.id !== 'setpoint-input')
|
||||
document.getElementById('setpoint-input').value = status.setpoint;
|
||||
|
||||
// Presets
|
||||
if (status.presets) {
|
||||
renderPresets(status.presets);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollHistory() {
|
||||
const data = await fetchJSON('/api/history?since=' + lastTimestamp);
|
||||
if (!data || !data.length) return;
|
||||
lastTimestamp = data[data.length - 1].timestamp;
|
||||
addChartData(data);
|
||||
}
|
||||
|
||||
function setConnectionStatus(connected) {
|
||||
const dot = document.getElementById('connection-status');
|
||||
if (connected) {
|
||||
dot.className = 'status-dot connected';
|
||||
dot.title = 'Connected';
|
||||
setBackendStatus('online', 'Backend: Online');
|
||||
} else {
|
||||
dot.className = 'status-dot disconnected';
|
||||
dot.title = 'Disconnected';
|
||||
setBackendStatus('offline', 'Backend: Offline');
|
||||
}
|
||||
}
|
||||
|
||||
async function pollHeartbeat() {
|
||||
const hb = await fetchJSON('/api/heartbeat?ts=' + Date.now());
|
||||
if (!hb || !hb.ok) {
|
||||
heartbeatMisses += 1;
|
||||
if (heartbeatMisses >= 2) {
|
||||
setBackendStatus('reconnecting', 'Backend: Reconnecting...');
|
||||
setControlsEnabled(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (heartbeatMisses >= 2) {
|
||||
showAction('Backend reconnected.', 'success', 2500);
|
||||
}
|
||||
heartbeatMisses = 0;
|
||||
setConnectionStatus(true);
|
||||
setControlsEnabled(true);
|
||||
|
||||
if (heartbeatInstanceId === null) {
|
||||
heartbeatInstanceId = hb.instance_id;
|
||||
} else if (heartbeatInstanceId !== hb.instance_id) {
|
||||
showAction('Backend restarted. Reloading UI...', 'info', 1500);
|
||||
setTimeout(function() { window.location.reload(); }, 1200);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
async function togglePower() {
|
||||
const result = await fetchJSON('/api/power', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: !isEnabled })
|
||||
});
|
||||
if (!result) {
|
||||
setLastAck('power failed', false);
|
||||
return;
|
||||
}
|
||||
setLastAck('power ' + (result.enabled ? 'ON' : 'OFF'), true);
|
||||
// Reset chart on power toggle
|
||||
if (!isEnabled) {
|
||||
firstTimestamp = null;
|
||||
lastTimestamp = 0;
|
||||
if (chart) {
|
||||
for (const ds of chart.data.datasets) ds.data = [];
|
||||
chart.update('none');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function applySetpoint() {
|
||||
const value = parseFloat(document.getElementById('setpoint-input').value);
|
||||
if (isNaN(value)) return;
|
||||
const result = await fetchJSON('/api/setpoint', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ setpoint: value })
|
||||
});
|
||||
if (!result) {
|
||||
setLastAck('setpoint failed', false);
|
||||
return;
|
||||
}
|
||||
setLastAck('setpoint ' + value + 'F', true);
|
||||
}
|
||||
|
||||
function adjustSetpoint(delta) {
|
||||
const input = document.getElementById('setpoint-input');
|
||||
const newVal = parseFloat(input.value) + delta;
|
||||
input.value = newVal;
|
||||
applySetpoint();
|
||||
}
|
||||
|
||||
async function applyPID() {
|
||||
const kp = parseFloat(document.getElementById('pid-kp').value);
|
||||
const ki = parseFloat(document.getElementById('pid-ki').value);
|
||||
const kd = parseFloat(document.getElementById('pid-kd').value);
|
||||
const pMode = document.getElementById('pid-pmode').value;
|
||||
if (isNaN(kp) || isNaN(ki) || isNaN(kd)) return;
|
||||
const result = await fetchJSON('/api/pid', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ kP: kp, kI: ki, kD: kd, proportional_mode: pMode })
|
||||
});
|
||||
if (!result) {
|
||||
setLastAck('PID apply failed', false);
|
||||
return;
|
||||
}
|
||||
setLastAck('PID applied (' + pMode + ')', true);
|
||||
}
|
||||
|
||||
async function resetPID() {
|
||||
const result = await fetchJSON('/api/pid/reset', { method: 'POST' });
|
||||
if (!result) {
|
||||
setLastAck('PID reset failed', false);
|
||||
return;
|
||||
}
|
||||
setLastAck('PID reset', true);
|
||||
}
|
||||
|
||||
async function startAutotune() {
|
||||
const target = parseFloat(document.getElementById('setpoint-input').value);
|
||||
showAction('Starting autotune at ' + target + 'F (auto-enables heater if needed)...', 'info', 5000);
|
||||
setAutotuneUi({ message: 'Starting autotune...', last_result: null, active: true, high_peaks: 0, cycles: 0 });
|
||||
const result = await fetchJSON('/api/autotune/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
setpoint: target
|
||||
})
|
||||
});
|
||||
if (!result) {
|
||||
setAutotuneUi({ message: lastApiError || 'Failed to start autotune', active: false, last_result: null });
|
||||
showAction(lastApiError || 'Failed to start autotune', 'error', 6000);
|
||||
setLastAck('autotune start failed', false);
|
||||
return;
|
||||
}
|
||||
showAction('Autotune started. Watch peaks progress.', 'success', 5000);
|
||||
setAutotuneUi(result.autotune || { message: 'Autotune started', active: true });
|
||||
setLastAck('autotune started', true);
|
||||
}
|
||||
|
||||
async function stopAutotune() {
|
||||
setAutotuneUi({ message: 'Stopping autotune...', last_result: null, active: false });
|
||||
showAction('Stopping autotune...', 'info', 3000);
|
||||
const result = await fetchJSON('/api/autotune/stop', { method: 'POST' });
|
||||
if (!result) {
|
||||
setAutotuneUi({ message: lastApiError || 'Failed to stop autotune', active: false, last_result: null });
|
||||
showAction(lastApiError || 'Failed to stop autotune', 'error', 6000);
|
||||
setLastAck('autotune stop failed', false);
|
||||
return;
|
||||
}
|
||||
showAction('Autotune stopped.', 'success', 4000);
|
||||
setAutotuneUi(result.autotune || { message: 'Autotune stopped', active: false, last_result: null });
|
||||
setLastAck('autotune stopped', true);
|
||||
}
|
||||
|
||||
async function applyPreset(name) {
|
||||
const result = await fetchJSON('/api/preset/' + encodeURIComponent(name), { method: 'POST' });
|
||||
if (!result) {
|
||||
setLastAck('preset failed', false);
|
||||
return;
|
||||
}
|
||||
setLastAck('preset ' + name, true);
|
||||
}
|
||||
|
||||
async function resetSafety() {
|
||||
const result = await fetchJSON('/api/safety/reset', { method: 'POST' });
|
||||
if (!result) {
|
||||
setLastAck('safety reset failed', false);
|
||||
return;
|
||||
}
|
||||
setLastAck('safety reset', true);
|
||||
}
|
||||
|
||||
function renderPresets(presets) {
|
||||
const container = document.getElementById('presets-container');
|
||||
const buttons = Object.entries(presets).map(([name, temp]) =>
|
||||
'<button class="preset-btn" onclick="applyPreset(\'' +
|
||||
name.replace(/'/g, "\\'") + '\')">' + name + ' (' + temp + '°F)</button>'
|
||||
).join('');
|
||||
container.innerHTML = buttons;
|
||||
}
|
||||
|
||||
// Handle Enter key in setpoint input
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.getElementById('setpoint-input').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') applySetpoint();
|
||||
});
|
||||
setAutotuneUi({ active: false, message: 'Idle', last_result: null });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Init
|
||||
// ---------------------------------------------------------------------------
|
||||
initChart();
|
||||
|
||||
// Start polling loops
|
||||
setInterval(pollStatus, pollInterval);
|
||||
setInterval(pollHistory, pollInterval);
|
||||
setInterval(pollHeartbeat, 2000);
|
||||
|
||||
// Initial fetch
|
||||
pollStatus();
|
||||
pollHistory();
|
||||
pollHeartbeat();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1087" height="220" viewBox="0 0 1087.330 220.000">
|
||||
<title>PINAIL</title>
|
||||
<g transform="translate(0.000,0.000) scale(1.262119) translate(-0.000000,-0.000000)"><ns0:g xmlns:ns0="http://www.w3.org/2000/svg" id="Letter_Family" data-name="Letter Family">
|
||||
<ns0:path d="M52.509,125.877v-29.491h70.012c10.707,0,19.062-3.034,25.056-9.111,5.994-6.072,8.991-14.063,8.991-23.977,0-10.069-2.997-18.061-8.991-23.977-5.994-5.911-14.349-8.871-25.056-8.871H30.21v143.859H0V0h122.521c10.069,0,19.062,1.479,26.974,4.436,7.912,2.96,14.584,7.114,20.021,12.468,5.432,5.357,9.59,11.868,12.468,19.541,2.877,7.673,4.315,16.304,4.315,25.895,0,9.434-1.438,18.024-4.315,25.775-2.878,7.755-7.036,14.427-12.468,20.021-5.437,5.597-12.108,9.95-20.021,13.067-7.912,3.116-16.904,4.675-26.974,4.675H52.509Z" fill="#D35400" />
|
||||
</ns0:g>
|
||||
</g><g transform="translate(251.130,0.000) scale(1.262119) translate(-0.000000,-0.000000)"><ns0:g xmlns:ns0="http://www.w3.org/2000/svg" id="Letter_Family" data-name="Letter Family">
|
||||
<ns0:path d="M0,174.31V0h30.45v174.31H0Z" fill="#D35400" />
|
||||
</ns0:g>
|
||||
</g><g transform="translate(305.562,0.000) scale(1.231624) translate(-0.000000,-0.000000)"><ns0:g xmlns:ns0="http://www.w3.org/2000/svg" id="Letter_Family" data-name="Letter Family">
|
||||
<ns0:path d="M29.012,48.912v127.556H0V18.223c0-5.594,1.397-10.029,4.196-13.307C6.991,1.642,10.79,0,15.585,0c2.236,0,4.395.479,6.474,1.438,2.075.96,4.233,2.56,6.474,4.796l123,122.041V.72h29.012v159.684c0,5.755-1.401,10.231-4.196,13.427-2.798,3.196-6.436,4.796-10.909,4.796-4.956,0-9.591-2.158-13.906-6.474L29.012,48.912Z" fill="#D35400" />
|
||||
</ns0:g>
|
||||
</g><g transform="translate(543.924,0.000) scale(1.244992) translate(-0.000000,-0.000000)"><ns0:g xmlns:ns0="http://www.w3.org/2000/svg" id="Letter_Family" data-name="Letter Family">
|
||||
<ns0:path d="M193.491,176.708l-26.134-43.877h-82.479l14.386-24.696h53.468l-38.842-65.217L34.766,176.708H0L100.222,9.831c1.757-3.035,3.836-5.433,6.234-7.193,2.397-1.757,5.274-2.638,8.631-2.638s6.193.881,8.512,2.638c2.315,1.761,4.353,4.158,6.114,7.193l100.462,166.877h-36.685Z" fill="#D35400" />
|
||||
</ns0:g>
|
||||
</g><g transform="translate(846.490,0.000) scale(1.262119) translate(-0.000000,-0.000000)"><ns0:g xmlns:ns0="http://www.w3.org/2000/svg" id="Letter_Family" data-name="Letter Family">
|
||||
<ns0:path d="M0,174.31V0h30.45v174.31H0Z" fill="#D35400" />
|
||||
</ns0:g>
|
||||
</g><g transform="translate(900.922,0.000) scale(1.262119) translate(-0.000000,-0.000000)"><ns0:g xmlns:ns0="http://www.w3.org/2000/svg" id="Letter_Family" data-name="Letter Family">
|
||||
<ns0:path d="M0,174.31V0h30.45v143.859h117.245v30.45H0Z" fill="#D35400" />
|
||||
</ns0:g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,571 @@
|
||||
/* piNail2 — Dark theme dashboard */
|
||||
|
||||
:root {
|
||||
--bg: #1a1a1a;
|
||||
--bg-card: #252525;
|
||||
--bg-input: #2a2a2a;
|
||||
--text: #e0e0e0;
|
||||
--text-dim: #cccccc;
|
||||
--accent-orange: #d35400;
|
||||
--accent-orange-hover: #e65c00;
|
||||
--accent-orange-deep: #a84300;
|
||||
--accent-teal: #d35400;
|
||||
--accent-blue: #d35400;
|
||||
--accent-red: #e74c3c;
|
||||
--accent-green: #2ecc71;
|
||||
--border: #333333;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace;
|
||||
background: radial-gradient(circle at 20% 0%, #222 0%, #1a1a1a 35%, #000 100%);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
background: #000;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
height: 34px;
|
||||
width: auto;
|
||||
max-width: 42vw;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.conn-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.backend-status {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.last-ack {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.last-ack.ok {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.last-ack.err {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.backend-status.online { color: var(--accent-green); }
|
||||
.backend-status.reconnecting { color: var(--accent-orange); }
|
||||
.backend-status.offline { color: var(--accent-red); }
|
||||
|
||||
header h1 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-dot.connected { background: var(--accent-green); }
|
||||
.status-dot.disconnected { background: var(--accent-red); }
|
||||
|
||||
/* Main */
|
||||
main {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Hero: temp + power */
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px 30px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.temp-display {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.temp-value {
|
||||
font-size: 4rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-dim);
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.temp-unit {
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-dim);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.temp-good { color: var(--accent-green); }
|
||||
.temp-warming { color: var(--accent-orange); }
|
||||
.temp-cold { color: var(--accent-blue); }
|
||||
|
||||
.hero-right {
|
||||
text-align: right;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.autotune-pill {
|
||||
font-size: 0.8rem;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.autotune-pill.idle {
|
||||
color: var(--text-dim);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.autotune-pill.running {
|
||||
color: #111;
|
||||
background: var(--accent-orange);
|
||||
border-color: var(--accent-orange);
|
||||
animation: autotunePulse 1.2s infinite;
|
||||
}
|
||||
|
||||
.autotune-pill.done {
|
||||
color: #111;
|
||||
background: var(--accent-green);
|
||||
border-color: var(--accent-green);
|
||||
}
|
||||
|
||||
.autotune-pill.error {
|
||||
color: #fff;
|
||||
background: var(--accent-red);
|
||||
border-color: var(--accent-red);
|
||||
}
|
||||
|
||||
.setpoint-display {
|
||||
font-size: 1.1rem;
|
||||
color: var(--accent-teal);
|
||||
}
|
||||
|
||||
.power-btn {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.power-btn.off {
|
||||
background: transparent;
|
||||
border-color: var(--text-dim);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.power-btn.on {
|
||||
background: rgba(46, 204, 113, 0.15);
|
||||
border-color: var(--accent-green);
|
||||
color: var(--accent-green);
|
||||
box-shadow: 0 0 20px rgba(46, 204, 113, 0.3);
|
||||
}
|
||||
|
||||
.power-btn:hover { opacity: 0.8; }
|
||||
.power-btn:active { transform: scale(0.95); }
|
||||
|
||||
/* Safety Banner */
|
||||
.safety-banner {
|
||||
background: rgba(231, 76, 60, 0.15);
|
||||
border: 1px solid var(--accent-red);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 20px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--accent-red);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.safety-banner.hidden { display: none; }
|
||||
|
||||
.safety-banner button {
|
||||
background: var(--accent-red);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action-banner {
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action-banner.hidden { display: none; }
|
||||
.action-banner.info { border-color: var(--accent-blue); color: var(--accent-blue); }
|
||||
.action-banner.success { border-color: var(--accent-green); color: var(--accent-green); }
|
||||
.action-banner.error { border-color: var(--accent-red); color: var(--accent-red); }
|
||||
|
||||
/* Chart */
|
||||
.chart-section {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
box-shadow: inset 0 0 0 1px rgba(211, 84, 0, 0.12);
|
||||
}
|
||||
|
||||
.chart-section canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.control-group h3 {
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 12px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Setpoint controls */
|
||||
.setpoint-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.adj-btn {
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.adj-btn:hover { background: var(--accent-orange-deep); color: #fff; }
|
||||
.adj-btn:active { transform: scale(0.95); }
|
||||
|
||||
input[type="number"] {
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
width: 80px;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
select {
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-teal);
|
||||
}
|
||||
|
||||
input[type="number"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-teal);
|
||||
}
|
||||
|
||||
.apply-btn {
|
||||
background: var(--accent-teal);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.apply-btn:hover { background: var(--accent-orange-hover); opacity: 1; }
|
||||
.apply-btn:active { transform: scale(0.95); }
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* Presets */
|
||||
.presets {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
background: var(--bg-input);
|
||||
color: var(--accent-teal);
|
||||
border: 1px solid var(--accent-teal);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.preset-btn:hover {
|
||||
background: var(--accent-teal);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* PID controls */
|
||||
.pid-controls {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pid-controls label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.checkbox-label input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.autotune-controls {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.autotune-status {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.autotune-status.idle {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.autotune-status.running {
|
||||
color: var(--accent-orange);
|
||||
animation: autotunePulse 1.2s infinite;
|
||||
}
|
||||
|
||||
.autotune-status.done {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.autotune-status.error {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
@keyframes autotunePulse {
|
||||
0% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.pid-controls input {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
/* Status Bar */
|
||||
.status-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 16px;
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-item .label {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.status-item .value {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
color: var(--text-dim);
|
||||
font-size: 0.78rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.relay-on { color: var(--accent-green); }
|
||||
.relay-off { color: var(--text-dim); }
|
||||
.tc-ok { color: var(--accent-green); }
|
||||
.tc-err { color: var(--accent-red); }
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 600px) {
|
||||
.hero {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
height: 28px;
|
||||
max-width: 46vw;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-right {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.temp-value {
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.power-btn {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.setpoint-controls {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pid-controls {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user