feat: implement probe renaming and settings management via API

This commit is contained in:
Keith Solomon
2026-07-21 16:37:37 -05:00
parent 1bd1ddc20e
commit 2a72972b4f
4 changed files with 298 additions and 19 deletions
+45
View File
@@ -84,6 +84,51 @@ def api_cyd_latest():
return jsonify(payload)
def _fetch_cyd_settings():
try:
resp = requests.get(f"{config.CYD_BASE_URL}/api/settings", timeout=10)
resp.raise_for_status()
return resp.json()
except Exception as e:
app.logger.warning(f"CYD settings fetch failed: {e}")
return None
@app.route("/api/probes", methods=["GET", "PUT"])
def probes():
if request.method == "GET":
settings = _fetch_cyd_settings()
if settings is None:
# Fall back to the latest reading so the UI still has something to show.
latest = fetch_cyd()
names = []
if latest:
names = [p.get("name", f"Probe {i + 1}") for i, p in enumerate(latest.get("probes", []))]
if not names:
names = [f"Probe {i + 1}" for i in range(4)]
return jsonify({"ok": True, "names": names, "source": "fallback"}), 200
names = settings.get("probeNames", [f"Probe {i + 1}" for i in range(4)])
return jsonify({"ok": True, "names": names, "source": "cyd"})
# PUT — forward to CYD's /api/settings with just the probeNames field.
data = request.get_json() or {}
names = data.get("names", [])
if not isinstance(names, list) or len(names) != 4 or not all(isinstance(n, str) for n in names):
return jsonify({"ok": False, "error": "names must be an array of 4 strings"}), 422
try:
resp = requests.post(
f"{config.CYD_BASE_URL}/api/settings",
json={"probeNames": names},
timeout=10,
)
resp.raise_for_status()
except Exception as e:
app.logger.warning(f"CYD settings update failed: {e}")
return jsonify({"ok": False, "error": f"Unable to update CYD: {e}"}), 502
return jsonify({"ok": True, "names": names})
@app.route("/api/sessions", methods=["GET", "POST"])
def sessions():
if request.method == "POST":
+157 -16
View File
@@ -8,10 +8,13 @@ const PRESETS = [
];
let activeSession = null;
let viewedSession = null;
let allSessions = [];
let latestReading = null;
let setpoints = [];
let chartInstance = null;
let cardsInitialized = false;
let probeNames = [...DEFAULT_PROBE_NAMES];
async function api(path, options = {}) {
const resp = await fetch(path, options);
@@ -40,7 +43,7 @@ function createProbeCards() {
card.innerHTML = `
<div class="header">
<span class="name">${DEFAULT_PROBE_NAMES[i]}</span>
<span class="name" data-probe="${i + 1}" title="Click to rename">${DEFAULT_PROBE_NAMES[i]}</span>
<span class="number">#${i + 1}</span>
</div>
<div class="target">No target set</div>
@@ -84,9 +87,71 @@ function createProbeCards() {
});
});
// Click the probe name to rename it (writes through to the CYD).
document.querySelectorAll('.probe-card .name').forEach(el => {
el.addEventListener('click', () => beginRename(el));
});
cardsInitialized = true;
}
async function beginRename(el) {
if (el.querySelector('input')) return; // already editing
const probe = parseInt(el.dataset.probe, 10);
const current = el.textContent;
el.innerHTML = '';
const input = document.createElement('input');
input.type = 'text';
input.className = 'rename-input';
input.value = current;
input.maxLength = 24;
el.appendChild(input);
input.focus();
input.select();
let done = false;
const finish = async (commit) => {
if (done) return;
done = true;
const newName = (commit ? input.value.trim() : current) || current;
el.textContent = newName;
if (commit && newName !== current) {
await saveProbeName(probe, newName);
}
};
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
if (e.key === 'Escape') { e.preventDefault(); input.value = current; input.blur(); }
});
input.addEventListener('blur', () => finish(true));
}
async function saveProbeName(probeNumber, name) {
const next = [...probeNames];
next[probeNumber - 1] = name;
try {
const result = await api('/api/probes', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ names: next }),
});
if (result.names) probeNames = result.names;
await refreshDashboard(); // pick up the new name in the latest reading
} catch (e) {
console.error('Rename failed:', e);
alert(`Failed to rename probe: ${e.message}`);
}
}
async function loadProbeNames() {
try {
const result = await api('/api/probes');
if (result.names) probeNames = result.names;
} catch (e) {
console.warn('Probe names fetch failed, using defaults:', e);
}
}
function updateProbeCards(reading, setpoints) {
const probes = reading?.probes || [];
const unit = reading?.unit || 'F';
@@ -95,15 +160,20 @@ function updateProbeCards(reading, setpoints) {
const card = document.querySelector(`.probe-card[data-probe="${i + 1}"]`);
if (!card) continue;
const probe = probes[i] || { id: i + 1, name: DEFAULT_PROBE_NAMES[i], temperature: null, connected: false };
const probe = probes[i] || { id: i + 1, name: probeNames[i] || DEFAULT_PROBE_NAMES[i], temperature: null, connected: false };
const setpoint = setpoints.find(s => s.probe_number === (i + 1));
card.classList.toggle('connected', probe.connected);
card.querySelector('.name').textContent = probe.name || DEFAULT_PROBE_NAMES[i];
// Don't overwrite the name while the user is editing it.
const nameEl = card.querySelector('.name');
if (!nameEl.querySelector('input')) {
nameEl.textContent = probe.name || probeNames[i] || DEFAULT_PROBE_NAMES[i];
}
const targetEl = card.querySelector('.target');
if (setpoint) {
targetEl.textContent = `Target: ${setpoint.label ? setpoint.label + ' ' : ''}${setpoint.target_temp.toFixed(0)}°${unit}`;
const labelText = setpoint.label ? setpoint.label : `Probe ${i + 1}`;
targetEl.textContent = `${labelText}: ${setpoint.target_temp.toFixed(0)}°${unit}`;
} else {
targetEl.textContent = 'No target set';
}
@@ -143,8 +213,8 @@ function updateProbeCards(reading, setpoints) {
}
async function saveSetpoint(probeNumber, label, targetTemp) {
if (!activeSession) return;
await api(`/api/setpoints/${activeSession.id}`, {
if (!viewedSession) return;
await api(`/api/setpoints/${viewedSession.id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ probe_number: probeNumber, label, target_temp: targetTemp }),
@@ -176,8 +246,12 @@ function renderStatusBar(reading) {
}
async function loadSessions() {
const sessions = await api('/api/sessions');
activeSession = sessions.find(s => !s.ended_at) || null;
allSessions = await api('/api/sessions');
activeSession = allSessions.find(s => !s.ended_at) || null;
// Default the viewed session to the active one, or the most recent past one.
if (!viewedSession || !allSessions.find(s => s.id === viewedSession.id)) {
viewedSession = activeSession || allSessions[0] || null;
}
const activeEl = document.getElementById('active-session');
const endBtn = document.getElementById('end-session-btn');
@@ -189,17 +263,73 @@ async function loadSessions() {
`;
endBtn.disabled = false;
} else {
activeEl.textContent = 'No active session.';
activeEl.textContent = 'No active session. Start one above to begin recording.';
endBtn.disabled = true;
}
renderSessionList();
}
function renderSessionList() {
const listEl = document.getElementById('past-sessions');
if (!listEl) return;
const past = allSessions.filter(s => s.ended_at);
if (past.length === 0) {
listEl.innerHTML = '<p class="muted">No past sessions yet.</p>';
return;
}
listEl.innerHTML = past.map(s => {
const isViewed = viewedSession && viewedSession.id === s.id;
const started = formatTime(s.started_at);
const ended = formatTime(s.ended_at);
return `
<button type="button" class="session-row ${isViewed ? 'viewed' : ''}" data-id="${s.id}">
<span class="session-name">${escapeHtml(s.name)}</span>
<span class="session-times">${started} ${ended}</span>
</button>
`;
}).join('');
listEl.querySelectorAll('.session-row').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id, 10);
viewedSession = allSessions.find(s => s.id === id) || null;
renderSessionList();
loadSetpoints();
refreshChart();
updateChartHeader();
});
});
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
}
function updateChartHeader() {
const headerEl = document.getElementById('chart-session-label');
if (!headerEl) return;
if (!viewedSession) {
headerEl.textContent = 'No session';
return;
}
if (activeSession && viewedSession.id === activeSession.id) {
headerEl.textContent = `Live — ${viewedSession.name}`;
} else {
headerEl.textContent = `Viewing past — ${viewedSession.name}`;
}
}
async function loadSetpoints() {
if (!activeSession) {
if (!viewedSession) {
setpoints = [];
updateChartHeader();
return;
}
setpoints = await api(`/api/setpoints/${activeSession.id}`);
setpoints = await api(`/api/setpoints/${viewedSession.id}`);
updateChartHeader();
}
async function refreshDashboard() {
@@ -221,13 +351,19 @@ async function refreshDashboard() {
}
async function refreshChart() {
if (!activeSession || !chartInstance) return;
if (!chartInstance) return;
if (!viewedSession) {
chartInstance.data.labels = [];
chartInstance.data.datasets = [];
chartInstance.update();
return;
}
const range = parseInt(document.getElementById('chart-range').value, 10);
const data = await api(`/api/readings/${activeSession.id}?minutes=${range}`);
const data = await api(`/api/readings/${viewedSession.id}?minutes=${range}`);
const labels = data.map(r => formatTime(r.received_at));
const datasets = [1, 2, 3, 4].map((num, idx) => ({
label: (latestReading?.probes?.[idx]?.name) || `Probe ${num}`,
label: probeNames[idx] || (latestReading?.probes?.[idx]?.name) || `Probe ${num}`,
data: data.map(r => r[`probe${num}_temp`]),
borderColor: ['#ff6b35', '#4caf50', '#2196f3', '#9c27b0'][idx],
backgroundColor: ['rgba(255,107,53,0.1)', 'rgba(76,175,80,0.1)', 'rgba(33,150,243,0.1)', 'rgba(156,39,176,0.1)'][idx],
@@ -271,7 +407,7 @@ document.getElementById('new-session-form').addEventListener('submit', async (e)
e.preventDefault();
const name = document.getElementById('session-name').value;
const notes = document.getElementById('session-notes').value;
await api('/api/sessions', {
const result = await api('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, notes }),
@@ -279,8 +415,11 @@ document.getElementById('new-session-form').addEventListener('submit', async (e)
document.getElementById('session-name').value = '';
document.getElementById('session-notes').value = '';
await loadSessions();
// Switch the chart to the newly created active session.
viewedSession = allSessions.find(s => s.id === result.session_id) || activeSession;
await loadSetpoints();
await refreshChart();
renderSessionList();
});
document.getElementById('end-session-btn').addEventListener('click', async () => {
@@ -291,6 +430,7 @@ document.getElementById('end-session-btn').addEventListener('click', async () =>
body: JSON.stringify({ end: true }),
});
await loadSessions();
// If we were viewing the just-ended session, keep viewing it (now a past session).
await loadSetpoints();
await refreshChart();
});
@@ -300,6 +440,7 @@ document.getElementById('chart-range').addEventListener('change', refreshChart);
async function init() {
initChart();
await loadProbeNames();
await loadSessions();
await loadSetpoints();
// First call creates the cards; subsequent calls only update values.
@@ -307,7 +448,7 @@ async function init() {
await refreshChart();
setInterval(refreshDashboard, 5000);
setInterval(() => { if (activeSession) refreshChart(); }, 30000);
setInterval(() => { if (viewedSession) refreshChart(); }, 30000);
}
init();
+86
View File
@@ -139,6 +139,25 @@ main {
.probe-card .name {
font-weight: bold;
font-size: 1.1rem;
cursor: text;
padding: 0 0.15rem;
border-radius: 4px;
}
.probe-card .name:hover {
background: #2a2a2a;
}
.probe-card .name .rename-input {
font: inherit;
font-weight: bold;
color: var(--text);
background: #2a2a2a;
border: 1px solid var(--accent);
border-radius: 4px;
padding: 0.1rem 0.3rem;
width: 10ch;
max-width: 100%;
}
.probe-card .target {
@@ -212,6 +231,73 @@ main {
cursor: pointer;
}
.chart-container {
position: relative;
width: 100%;
height: 320px;
}
.chart-container canvas {
width: 100% !important;
height: 100% !important;
display: block;
}
.muted { color: var(--muted); }
#chart-session-label {
font-size: 0.85rem;
font-weight: normal;
margin-left: 0.5rem;
}
#past-sessions {
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 280px;
overflow-y: auto;
}
#past-sessions p {
margin: 0.5rem 0;
}
.session-row {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.15rem;
background: #2a2a2a;
border: 1px solid #333;
border-left: 4px solid var(--muted);
color: var(--text);
text-align: left;
padding: 0.6rem 0.8rem;
border-radius: 6px;
cursor: pointer;
font: inherit;
}
.session-row:hover {
border-left-color: var(--accent);
background: #333;
}
.session-row.viewed {
border-left-color: var(--accent);
background: #2f2520;
}
.session-row .session-name {
font-weight: 600;
}
.session-row .session-times {
font-size: 0.8rem;
color: var(--muted);
}
footer {
text-align: center;
padding: 1rem;
+10 -3
View File
@@ -28,7 +28,12 @@
<textarea id="session-notes" placeholder="Notes (freeform)"></textarea>
<button type="submit">Start New Session</button>
</form>
<button id="end-session-btn" disabled>End Session</button>
<button id="end-session-btn" disabled>End Active Session</button>
</div>
<div class="panel">
<h2>Past Sessions</h2>
<div id="past-sessions"><p class="muted">Loading…</p></div>
</div>
</section>
@@ -38,7 +43,7 @@
<section id="charts">
<div class="panel">
<h2>Temperature History</h2>
<h2>Temperature History <span id="chart-session-label" class="muted"></span></h2>
<div class="chart-controls">
<label>Range:
<select id="chart-range">
@@ -50,7 +55,9 @@
</label>
<button id="refresh-chart-btn">Refresh Chart</button>
</div>
<canvas id="temp-chart"></canvas>
<div class="chart-container">
<canvas id="temp-chart"></canvas>
</div>
</div>
</section>
</main>