feat: implement probe renaming and settings management via API
This commit is contained in:
@@ -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
@@ -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 => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user