window.onload = () => { let isInterpolating = false; let currentFrame = 0; const dialKeyframes = Array.from({ length: 5 }, () => ({})); let currentAnimation = null; let port, reader, writer; let selectedDial = null; let draggingKeyframe = null; // { dialIndex, originalFrame } let isDragging = false; const dialColors = ['red', 'green', 'blue', 'orange', 'purple']; const dials = []; const frameSlider = document.getElementById('frameSlider'); const frameDisplay = document.getElementById('frameDisplay'); const canvas = document.getElementById('timelineCanvas'); const ctx = canvas.getContext('2d'); const totalFrames = 500; // Animation File List const fileListElement = document.getElementById('fileList'); const loadButton = document.getElementById('loadFile'); const deleteButton = document.getElementById('deleteFile'); let selectedFile = null; function clearFileList() { fileListElement.innerHTML = ''; selectedFile = null; loadButton.disabled = true; deleteButton.disabled = true; } function addFileToList(filename) { const li = document.createElement('li'); li.textContent = filename; li.addEventListener('click', () => { // Deselect previous const previouslySelected = fileListElement.querySelector('.selected'); if (previouslySelected) previouslySelected.classList.remove('selected'); // Select new li.classList.add('selected'); selectedFile = filename; loadButton.disabled = false; deleteButton.disabled = false; }); fileListElement.appendChild(li); } loadButton.addEventListener('click', () => { if (!selectedFile || loadButton.disabled) return; const filename = "/" + selectedFile; console.log(`Loading file: ${filename}`); // ๐Ÿงท Lock buttons loadButton.disabled = true; deleteButton.disabled = true; // ๐Ÿงน Clear previous state fileAssembly[filename] = null; fileStats[filename] = { chunks: 0, bytes: 0 }; const encoder = new TextEncoder(); const payload = Array.from(encoder.encode(filename)); const CMD_LOAD_FILE = 0x03; sendCommand(port, CMD_LOAD_FILE, payload) .then(response => { const { file, status, chunks, bytesSent } = response; const stats = fileStats[file]; if (status === "complete" && stats) { const chunkMatch = stats.chunks === chunks; const byteMatch = stats.bytes === bytesSent; console.log(`Chunks match: ${chunkMatch} (${stats.chunks} vs ${chunks})`); console.log(`Bytes match: ${byteMatch} (${stats.bytes} vs ${bytesSent})`); if (chunkMatch && byteMatch) { console.log(`โœ… File ${file} loaded successfully`); console.log("Reassembled file bytes:", fileAssembly[file]); // TODO: trigger animation preview or playback currentAnimation = parseAnimFile(fileAssembly[file]); console.log(currentAnimation); } else { console.warn(`โš ๏ธ Mismatch detected for ${file}`); } } else { console.warn("Unexpected final response:", response); } }) .catch(err => { console.error("Failed to load file:", err); }) .finally(() => { // ๐Ÿ”“ Unlock buttons loadButton.disabled = false; deleteButton.disabled = false; }); }); function parseAnimFile(buffer) { console.log("decoding anim file"); const view = new DataView(buffer.buffer); console.log(view); let offset = 0; // 1. Parse header const magic = String.fromCharCode(...buffer.slice(offset, offset + 4)); offset += 4; const frameCount = view.getUint16(offset, true); offset += 2; const version = view.getUint8(offset++); const frameRate = view.getUint8(offset++); offset += 8; // skip reserved if (magic !== "ANIM" || version !== 1) { throw new Error("Invalid animation file"); } console.log("Version: " + version); console.log("Framerate: " + frameRate); console.log("Frame Count: " + frameCount); // 2. Parse frame data const frames = []; for (let frame = 0; frame < frameCount; frame++) { const positions = []; for (let ch = 0; ch < 5; ch++) { positions.push(view.getUint16(offset, true)); offset += 2; } frames.push(positions); } // Move offset to end of reserved 10 seconds of animation, to keyframe data offset = 5016; const keyFrameCount = view.getUint16(offset, true); offset += 2; let keyframes = []; for (var i = 0; i < keyFrameCount; i++) { let motorID = view.getUint8(offset); offset += 1; let frame = view.getUint16(offset, true); offset += 2; let position = view.getUint16(offset, true); offset += 2; keyframes.push({ motorID, frame, position }); } console.log(keyFrameCount); console.log(keyframes); // Populate from parsed animation frames keyframes.forEach(({ motorID, frame, position }) => { if (!dialKeyframes[motorID]) { dialKeyframes[motorID] = []; // Initialize if missing } dialKeyframes[motorID][frame] = position; }); return { header: { magic, version, frameCount, frameRate }, frames, keyFrameCount, keyframes }; } deleteButton.addEventListener('click', () => { if (selectedFile) { console.log(`Deleting file: ${selectedFile}`); // Add logic to send delete command to ESP32 // Remove from UI const selectedLi = fileListElement.querySelector('.selected'); if (selectedLi) selectedLi.remove(); selectedFile = null; loadButton.disabled = true; deleteButton.disabled = true; } }); // Communications let pendingResponse = null; let byteBuffer = []; async function readLoop(port) { const reader = port.readable.getReader(); try { while (true) { const { value, done } = await reader.read(); if (done) break; if (value) { for (let byte of value) { byteBuffer.push(byte); tryParseBuffer(); } } } } catch (err) { console.error("Read loop error:", err); } finally { reader.releaseLock(); } } function sendCommand(port, commandCode, payload = []) { return new Promise(async (resolve, reject) => { if (pendingResponse) { reject("Another command is still pending"); return; } pendingResponse = { commandCode, resolve, reject, timeout: null }; const header = [0xAA, 0x55]; const length = payload.length; const message = [...header, commandCode, length, ...payload]; const writer = port.writable.getWriter(); await writer.write(new Uint8Array(message)); writer.releaseLock(); // Set timeout pendingResponse.timeout = setTimeout(() => { pendingResponse.reject("Timeout waiting for response"); pendingResponse = null; }, 1000); }); } function dispatchResponse({ command, payload }) { // Notify any listeners waiting for "ok" if (payload && typeof payload === "object" && payload.status === "ok") { console.log(command, payload); console.log("Chunk acknowledged โœ…"); return; } // Your existing logic... if (command === 0x05) { handleChunkResponse(payload); } else if (pendingResponse && pendingResponse.commandCode === command) { clearTimeout(pendingResponse.timeout); pendingResponse.resolve(payload); pendingResponse = null; } else { console.warn("Unexpected or unsolicited response:", command, payload); } } function tryParseBuffer() { const HEADER1 = 0xAA; const HEADER2 = 0x55; while (byteBuffer.length >= 6) { // Minimum size with 2-byte length // Look for header if (byteBuffer[0] !== HEADER1 || byteBuffer[1] !== HEADER2) { byteBuffer.shift(); // discard until we find header continue; } const command = byteBuffer[2]; const lengthHigh = byteBuffer[3]; const lengthLow = byteBuffer[4]; const length = (lengthHigh << 8) | lengthLow; const totalLength = 5 + length + 1; // header + payload + checksum if (byteBuffer.length < totalLength) { // Wait for more data return; } const payloadBytes = byteBuffer.slice(5, 5 + length); const checksum = byteBuffer[5 + length]; // Verify checksum let computedChecksum = command ^ lengthHigh ^ lengthLow; for (let b of payloadBytes) { computedChecksum ^= b; } if (checksum !== computedChecksum) { console.warn("Checksum mismatch"); byteBuffer.shift(); // discard first byte and retry continue; } // Parse payload const payloadText = new TextDecoder().decode(new Uint8Array(payloadBytes)); let payload; try { payload = JSON.parse(payloadText); } catch { payload = payloadText; } const parsed = { command, payload }; dispatchResponse(parsed); // Remove parsed packet from buffer byteBuffer.splice(0, totalLength); } } const fileAssembly = {}; const fileStats = {}; function handleChunkResponse({ file, offset, totalSize, chunk }) { if (!fileAssembly[file]) { fileAssembly[file] = new Uint8Array(totalSize); fileStats[file] = { chunks: 0, bytes: 0 }; } const chunkBytes = Uint8Array.from(chunk); fileAssembly[file].set(chunkBytes, offset); fileStats[file].chunks += 1; fileStats[file].bytes += chunkBytes.length; console.log(`Chunk received: ${file} offset=${offset} size=${chunkBytes.length}`); } function buildAnimationPayload({ header, frames, keyframes }) { const frameCount = frames.length; const channelCount = frames[0].length; const frameDataSize = frameCount * channelCount * 2; const keyframeCount = keyframes.length; const keyframeBlockSize = 2 + keyframeCount * 5; const totalSize = 16 + frameDataSize + keyframeBlockSize; const buffer = new ArrayBuffer(totalSize); const view = new DataView(buffer); let offset = 0; // ๐Ÿ”น Header (16 bytes) for (let i = 0; i < 4; i++) view.setUint8(offset++, header.magic.charCodeAt(i)); view.setUint16(offset, header.frameCount, true); offset += 2; view.setUint8(offset++, header.version); view.setUint8(offset++, header.frameRate); for (let i = 0; i < 8; i++) view.setUint8(offset++, 0); // reserved // ๐Ÿ”น Frame Data (5000 bytes) for (let i = 0; i < frameCount; i++) { for (let j = 0; j < channelCount; j++) { view.setUint16(offset, frames[i][j], true); offset += 2; } } // ๐Ÿ”น Keyframes Block view.setUint16(offset, keyframeCount, true); offset += 2; keyframes.forEach(({ motorID, frame, position }) => { view.setUint8(offset++, motorID); view.setUint16(offset, frame, true); offset += 2; view.setUint16(offset, position, true); offset += 2; }); return buffer; } async function sendAnimationToESP32(port, commandCode, filename, currentAnimation, chunkSize = 256) { const { header, frames, keyframes } = currentAnimation; const payloadBuffer = buildAnimationPayload({ header, frames, keyframes }); const totalSize = payloadBuffer.byteLength; const payloadArray = new Uint8Array(payloadBuffer); for (let offset = 0; offset < totalSize; offset += chunkSize) { const end = Math.min(offset + chunkSize, totalSize); const chunkData = payloadArray.slice(offset, end); const HEADER1 = 0xAA; const HEADER2 = 0x55; const offsetHigh = (offset >> 8) & 0xFF; const offsetLow = offset & 0xFF; const totalHigh = (totalSize >> 8) & 0xFF; const totalLow = totalSize & 0xFF; const length = 4 + chunkData.length; // offset(2) + total(2) + chunk const lengthHigh = (length >> 8) & 0xFF; const lengthLow = length & 0xFF; const packet = new Uint8Array(5 + length + 1); packet[0] = HEADER1; packet[1] = HEADER2; packet[2] = commandCode; packet[3] = lengthHigh; packet[4] = lengthLow; packet[5] = offsetHigh; packet[6] = offsetLow; packet[7] = totalHigh; packet[8] = totalLow; packet.set(chunkData, 9); let checksum = commandCode ^ lengthHigh ^ lengthLow ^ offsetHigh ^ offsetLow ^ totalHigh ^ totalLow; for (let b of chunkData) checksum ^= b; packet[5 + length] = checksum; const writer = port.writable.getWriter(); await writer.write(packet); writer.releaseLock(); console.log(`Sent chunk: ${filename} offset=${offset} size=${chunkData.length}`); } } function waitForOkResponse(timeoutMs = 1000) { return new Promise((resolve) => { const timeout = setTimeout(() => { const index = okResponseQueue.indexOf(resolve); if (index !== -1) okResponseQueue.splice(index, 1); resolve(false); }, timeoutMs); okResponseQueue.push(() => { clearTimeout(timeout); resolve(true); }); }); } function flattenKeyframes(dialKeyframes) { const flat = []; dialKeyframes.forEach((frameMap, motorId) => { Object.entries(frameMap).forEach(([frameStr, position]) => { flat.push({ motorId, frame: parseInt(frameStr), position }); }); }); return flat; } // Timeline frameSlider.oninput = () => { currentFrame = parseInt(frameSlider.value); frameDisplay.textContent = currentFrame; isInterpolating = true; for (let ch = 0; ch < 5; ch++) { const keyframes = dialKeyframes[ch]; let prevFrame = null, nextFrame = null; for (let f = currentFrame; f >= 0; f--) { if (keyframes[f] !== undefined) { prevFrame = f; break; } } for (let f = currentFrame; f <= 399; f++) { if (keyframes[f] !== undefined) { nextFrame = f; break; } } let value; if (prevFrame !== null && nextFrame !== null && prevFrame !== nextFrame) { const prevVal = keyframes[prevFrame]; const nextVal = keyframes[nextFrame]; const t = (currentFrame - prevFrame) / (nextFrame - prevFrame); value = Math.round(prevVal + (nextVal - prevVal) * t); } else if (prevFrame !== null) { value = keyframes[prevFrame]; } else if (nextFrame !== null) { value = keyframes[nextFrame]; } else { value = 512; } dials[ch].value = value; document.getElementById(`value${ch}`).textContent = value; } drawTimelineMarkers(); isInterpolating = false; }; for (let i = 0; i < 5; i++) { dials[i] = new Nexus.Dial(`#dial${i}`, { size: [80, 80], min: 0, max: 1023, value: 512 }); dials[i].colorize("accent", dialColors[i]); dials[i].on('change', (v) => { if (isInterpolating) return; const val = Math.round(v); document.getElementById(`value${i}`).textContent = val; dialKeyframes[i][currentFrame] = val; drawTimelineMarkers(); }); } document.querySelectorAll('.dial').forEach(el => { el.onclick = () => { selectedDial = parseInt(el.dataset.index); document.querySelectorAll('.dial').forEach(d => d.classList.remove('selected')); el.classList.add('selected'); drawTimelineMarkers(); }; }); document.getElementById('connect').addEventListener('click', async () => { try { port = await navigator.serial.requestPort(); await port.open({ baudRate: 115200 }); readLoop(port); // Start listening const id = await sendCommand(port, 0x01); console.log("Device ID:", id); const files = await sendCommand(port, 0x02); clearFileList(); console.log("File list:", files); files.forEach(addFileToList); } catch (err) { console.error("Connection or communication failed:", err); } }); document.getElementById('send').onclick = async () => { const text = document.getElementById('input').value + '\n'; const encoder = new TextEncoder(); await writer.write(encoder.encode(text)); document.getElementById('input').value = ''; }; document.getElementById('sendFrame').onclick = async () => { // const positions = dials.map(d => Math.round(d.value)); // const message = `FRAME ${positions.join(',')}\n`; // const encoder = new TextEncoder(); // await writer.write(encoder.encode(message)); console.log(dialKeyframes); }; function drawTimelineMarkers() { ctx.clearRect(0, 0, canvas.width, canvas.height); const width = canvas.width; const height = canvas.height; // Draw tick marks every 50 frames ctx.strokeStyle = '#aaa'; ctx.lineWidth = 1; for (let f = 0; f <= totalFrames; f += 25) { const x = (f / totalFrames) * width; ctx.beginPath(); ctx.moveTo(x, height * 0.75); // small tick at bottom ctx.lineTo(x, height); ctx.stroke(); } for (let f = 0; f <= totalFrames; f += 50) { const x = (f / totalFrames) * width; ctx.beginPath(); ctx.moveTo(x, height * 0.65); // small tick at bottom ctx.lineTo(x, height); ctx.stroke(); } // Draw label on tick marks ctx.fillStyle = '#666'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center'; for (let f = 0; f <= totalFrames; f += 50) { const x = (f / totalFrames) * width; const seconds = f / 50; ctx.fillText(seconds.toString() + "s", x, height - 12); } if (selectedDial !== null) { for (let frame in dialKeyframes[selectedDial]) { const x = (frame / 400) * width; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, height); ctx.strokeStyle = dialColors[selectedDial]; ctx.lineWidth = 2; ctx.stroke(); } } else { for (let ch = 0; ch < 5; ch++) { for (let frame in dialKeyframes[ch]) { const x = (frame / 400) * width; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, height); ctx.strokeStyle = dialColors[ch]; ctx.lineWidth = 1; ctx.stroke(); } } } const currentX = (currentFrame / 400) * width; ctx.beginPath(); ctx.moveTo(currentX, 0); ctx.lineTo(currentX, height); ctx.strokeStyle = 'black'; ctx.lineWidth = 2; ctx.stroke(); } document.addEventListener('click', (e) => { const ignoredTags = ['BUTTON', 'INPUT', 'TEXTAREA', 'CANVAS']; const clickedInsideDial = e.target.closest('.dial'); const clickedControl = ignoredTags.includes(e.target.tagName); if (!clickedInsideDial && !clickedControl && selectedDial !== null) { selectedDial = null; document.querySelectorAll('.dial').forEach(el => el.classList.remove('selected')); drawTimelineMarkers(); } }); canvas.addEventListener('mousedown', (e) => { const x = e.offsetX; const frame = Math.round((x / canvas.width) * totalFrames); const dialIndices = selectedDial !== null ? [selectedDial] : [0, 1, 2, 3, 4]; for (let ch of dialIndices) { for (let f in dialKeyframes[ch]) { const fx = (f / totalFrames) * canvas.width; if (Math.abs(fx - x) < 5) { draggingKeyframe = { dialIndex: ch, originalFrame: parseInt(f) }; isDragging = true; return; } } } }); canvas.addEventListener('mousemove', (e) => { if (!isDragging || !draggingKeyframe) return; const x = e.offsetX; const newFrame = Math.max(0, Math.min(totalFrames - 1, Math.round((x / canvas.width) * totalFrames))); const { dialIndex, originalFrame } = draggingKeyframe; const value = dialKeyframes[dialIndex][originalFrame]; if (newFrame !== originalFrame) { delete dialKeyframes[dialIndex][originalFrame]; dialKeyframes[dialIndex][newFrame] = value; draggingKeyframe.originalFrame = newFrame; drawTimelineMarkers(); } }); canvas.addEventListener('mouseup', () => { isDragging = false; draggingKeyframe = null; }); canvas.addEventListener('dblclick', (e) => { const x = e.offsetX; const frame = Math.round((x / canvas.width) * totalFrames); const dialIndices = selectedDial !== null ? [selectedDial] : [0, 1, 2, 3, 4]; for (let ch of dialIndices) { if (dialKeyframes[ch][frame] !== undefined) { delete dialKeyframes[ch][frame]; drawTimelineMarkers(); } } }); document.getElementById('saveAnimation').onclick = async () => { await sendAnimationToESP32(port, 0x05, "anim1.bin", currentAnimation); }; drawTimelineMarkers(); };