import { SerialManager } from './serial.js'; const feetechModelsIDs = { 777: "STS3215", 521: "STS3012", 1029: "SCS0009" }; window.onload = () => { const serial = new SerialManager(); const statusText = document.getElementById('statusText'); const disconnectBtn = document.getElementById('disconnect'); const connectBtn = document.getElementById('connect'); const syncCheckbox = document.getElementById("syncCheckbox"); const clearBtn = document.getElementById("clearAnimation"); // Limits rate of move commands sent while sliding timeslider let lastSyncTime = 0; // global or outer-scope variable const syncIntervalMs = 20; // e.g. 100ms = max 10 times per second let isInterpolating = false; let currentFrame = 0; let dialKeyframes = Array.from({ length: 5 }, () => ({})); 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 playButton = document.getElementById('playFile'); 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); } playButton.addEventListener('click', () => { if (!selectedFile) return; const filename = selectedFile; console.log("Sanitized filename for delete:", filename); const filenameBytes = new TextEncoder().encode(filename); const filenameLength = filenameBytes.length; // Total size: 2 bytes for length + filename bytes const buffer = new ArrayBuffer(2 + filenameLength); const view = new DataView(buffer); let offset = 0; // ๐Ÿ”น Filename block view.setUint16(offset, filenameLength, true); offset += 2; filenameBytes.forEach(byte => view.setUint8(offset++, byte)); const payload = new Uint8Array(buffer); //serial.deleteFile(payload); // CMD_DELETE_FILE serial.requestPlayFile(payload); }); loadButton.addEventListener('click', () => { if (!selectedFile || loadButton.disabled) return; const filename = "/" + selectedFile; console.log(`Loading file: ${filename}`); // ๐Ÿงท Lock buttons loadButton.disabled = true; deleteButton.disabled = true; serial.requestFile(filename); }); deleteButton.addEventListener('click', () => { if (selectedFile) { console.log(`Deleting file: ${selectedFile}`); sendDeleteToESP32(); const selectedLi = fileListElement.querySelector('.selected'); if (selectedLi) selectedLi.remove(); selectedFile = null; loadButton.disabled = true; deleteButton.disabled = true; } }); // 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; syncMotorsWithTimeline(); }; 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(); syncMotorsWithTimeline(); }); } function syncMotorsWithTimeline() { const now = Date.now(); if (syncCheckbox.checked && now - lastSyncTime >= syncIntervalMs) { lastSyncTime = now; const motorPayloads = []; for (let ch = 0; ch < dials.length; ch++) { const value = dials[ch].value; motorPayloads.push({ motorId: ch, position: value }); } const buffer = new ArrayBuffer(motorPayloads.length * 3); const view = new DataView(buffer); motorPayloads.forEach(({ motorId, position }, i) => { const offset = i * 3; view.setUint8(offset, motorId); view.setUint16(offset + 1, position, true); // little-endian }); const payload = new Uint8Array(buffer); serial.sendSetPositions(payload); } } 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(); }; }); // Connect button document.getElementById('connect').addEventListener('click', async () => { try { await serial.connect(); statusText.textContent = 'Connected โœ…'; disconnectBtn.hidden = false; connectBtn.hidden = true; console.log("Serial connected"); let text = ""; serial.startReading((command, payload) => { switch (command) { case 0x01: // ID response text = new TextDecoder().decode(new Uint8Array(payload)); document.getElementById('log').value += `ID Response: ${text}\n`; break; case 0x02: // File response text = new TextDecoder().decode(new Uint8Array(payload)); const files = text.trim().split('\n'); document.getElementById('log').value += `File list Response: ${files}\n`; clearFileList(); files.forEach(filename => { if (filename) addFileToList(filename); }); break; case 0x03: // CMD_LOAD_FILE const fileData = new Uint8Array(payload); console.log(`Received file (${fileData.length} bytes)`); document.getElementById('log').value += `Loaded file\n`; // ๐Ÿ”น Do something with the file handleLoadedFile(fileData); break; case 0x04: // CMD_DELETE_FILE console.log(`File deleted`); console.log(new Uint8Array(payload)); document.getElementById('log').value += `File deleted\n`; // ๐Ÿ”น Do something with the file //handleLoadedFile(fileData); serial.requestFileList(); break; case 0x05: // CMD_SAVE_FILE console.log(`Saved file Response Recieved`); console.log(new Uint8Array(payload)); document.getElementById('log').value += `Saved file Response Recieved\n`; // ๐Ÿ”น Do something with the file //handleLoadedFile(fileData); serial.requestFileList(); break; case 0x06: // CMD_MESSAGE console.log(`Message Recieved`); const decoder = new TextDecoder(); console.log(payload); const stringPayload = decoder.decode(new Uint8Array(payload)); console.log(stringPayload); document.getElementById('log').value += "MSG: " + stringPayload + `\n`; // ๐Ÿ”น Do something with the file //handleLoadedFile(fileData); break; case 0x07: // CMD_SET_POSITION console.log(`Positions set`); console.log(new Uint8Array(payload)); document.getElementById('log').value += `Positions set\n`; // ๐Ÿ”น Do something with the file //handleLoadedFile(fileData); break; case 0x08: // CMD_PLAY_FILE console.log(`Anim file played`); console.log(new Uint8Array(payload)); document.getElementById('log').value += `Anim file played\n`; break; case 0x09: // CMD_SCAN_CHANNEL console.log(new Uint8Array(payload)); handleScanChannelResponse(new Uint8Array(payload)) break; // Add more cases as needed default: document.getElementById('log').value += `Unknown command ${command}\n`; break; } }); // ๐Ÿ”น Send ID request (CMD_ID_REQUEST = 0x01) await serial.requestIDPacket(); await serial.requestFileList(); // or use a constant if defined } catch (err) { statusText.textContent = 'Connection failed โŒ'; console.error("Connection error:", err); } }); function handleLoadedFile(data) { // Ensure data is a Uint8Array console.log(data.buffer); const raw = new Uint8Array(data); const view = new DataView(raw.buffer); let offset = 0; // ๐Ÿ”น Parse header const magic = String.fromCharCode( view.getUint8(offset), view.getUint8(offset + 1), view.getUint8(offset + 2), view.getUint8(offset + 3) ); offset += 4; const frameCount = view.getUint16(offset, true); offset += 2; // big-endian const version = view.getUint8(offset++); const frameRate = view.getUint8(offset++); offset += 8; // reserved if (magic !== "ANIM") { console.error("Invalid file format"); return; } console.log("๐Ÿงฉ Magic:", magic); console.log("๐ŸŽž๏ธ Frame Count:", frameCount); console.log("๐Ÿ“ฆ Version:", version); console.log("โฑ๏ธ Frame Rate:", frameRate); const NUM_CHANNELS = 5; const HEADER_SIZE = 16; const FRAME_SIZE = NUM_CHANNELS * 2; //const frameCount = view.getUint16(4, true); // already parsed earlier const frames = []; //let offset = HEADER_SIZE; for (let i = 0; i < frameCount; i++) { const frame = []; for (let c = 0; c < NUM_CHANNELS; c++) { frame.push(view.getUint16(offset, true)); offset += 2; } frames.push(frame); } console.log(frames); console.log(offset); offset = 5016; let 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); dialKeyframes = Array.from({ length: 5 }, () => ({})); keyframes.forEach(({ motorID, frame, position }) => { if (!dialKeyframes[motorID]) { dialKeyframes[motorID] = []; // Initialize if missing } dialKeyframes[motorID][frame] = position; }); // ๐Ÿ”“ Unlock buttons loadButton.disabled = false; deleteButton.disabled = false; document.getElementById("filenameInput").value = selectedFile.replace(/\.anim$/i, ""); drawTimelineMarkers(); } function sanitizeFilename(input) { // Remove non-alphanumeric characters const stripped = input.replace(/[^a-zA-Z0-9]/g, ""); // If nothing remains, fallback to default const safeName = stripped || "default"; // Add leading slash and .anim extension return "/" + safeName + ".anim"; } async function sendAnimationToESP32() { const rawInput = document.getElementById("filenameInput").value; const filename = sanitizeFilename(rawInput); console.log("Sanitized filename:", filename); const frameCount = 500; const numChannels = 5; const frameRate = 50; const version = 1; const headerSize = 16; const frameDataSize = frameCount * numChannels * 2; const keyframeCount = dialKeyframes.reduce((sum, channel) => { return sum + Object.keys(channel).length; }, 0); const keyframeDataSize = keyframeCount * 5; // ๐Ÿ”น Filename encoding const filenameBytes = new TextEncoder().encode(filename); const filenameLength = filenameBytes.length; // Total packet size const totalSize = 2 + filenameLength + headerSize + 2 + keyframeDataSize; const buffer = new ArrayBuffer(totalSize); const view = new DataView(buffer); let offset = 0; // ๐Ÿ”น Filename block view.setUint16(offset, filenameLength, true); offset += 2; filenameBytes.forEach(byte => view.setUint8(offset++, byte)); // ๐Ÿ”น Header view.setUint8(offset++, "A".charCodeAt(0)); view.setUint8(offset++, "N".charCodeAt(0)); view.setUint8(offset++, "I".charCodeAt(0)); view.setUint8(offset++, "M".charCodeAt(0)); view.setUint16(offset, frameCount, true); offset += 2; view.setUint8(offset++, version); view.setUint8(offset++, frameRate); offset += 8; // reserved // ๐Ÿ”น Frame data (all zeroes) //offset += frameDataSize; // ๐Ÿ”น Keyframe count view.setUint16(offset, keyframeCount, true); offset += 2; const keyframeList = []; Object.entries(dialKeyframes).forEach(([motorIdStr, frameMap]) => { const motorId = parseInt(motorIdStr, 10); Object.entries(frameMap).forEach(([frameStr, position]) => { const frame = parseInt(frameStr, 10); if (position !== undefined) { keyframeList.push({ motorId, frame, position }); } }); }); // ๐Ÿ”น Keyframes keyframeList.forEach(({ motorId, frame, position }) => { view.setUint8(offset++, motorId); view.setUint16(offset, frame, true); offset += 2; view.setUint16(offset, position, true); offset += 2; }); console.log("Keyframe count: " + keyframeCount); console.log(keyframeList); // ๐Ÿ”น Send to ESP32 const payload = new Uint8Array(buffer); serial.saveFile(payload); // CMD_SAVE_ANIMATION } async function sendDeleteToESP32() { if (!selectedFile) return; const filename = selectedFile; console.log("Sanitized filename for delete:", filename); const filenameBytes = new TextEncoder().encode(filename); const filenameLength = filenameBytes.length; // Total size: 2 bytes for length + filename bytes const buffer = new ArrayBuffer(2 + filenameLength); const view = new DataView(buffer); let offset = 0; // ๐Ÿ”น Filename block view.setUint16(offset, filenameLength, true); offset += 2; filenameBytes.forEach(byte => view.setUint8(offset++, byte)); const payload = new Uint8Array(buffer); serial.deleteFile(payload); // CMD_DELETE_FILE } // Disconnect button disconnectBtn.addEventListener('click', () => { serial.disconnect(); statusText.textContent = 'Disconnected'; disconnectBtn.hidden = true; connectBtn.hidden = false; console.log("Serial disconnected"); }); clearBtn.addEventListener('click', () => { currentFrame = 0; dialKeyframes = Array.from({ length: 5 }, () => ({})); drawTimelineMarkers(); }); 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 = ''; }; 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(); }; drawTimelineMarkers(); // MOTOR CONTROL PANEL function insertTableRow(channel, model, id, minAngle, maxAngle, position) { const tableId = channel === 1 ? 'channel1-motor-table' : channel === 2 ? 'channel2-motor-table' : null; if (!tableId) { console.error('Invalid channel number. Use 1 or 2.'); return; } const tbody = document.querySelector(`#${tableId} tbody`); const newRow = document.createElement('tr'); newRow.setAttribute('data-row-id', id); // or any unique identifier const cells = [model, id, minAngle, maxAngle, position]; const modelType = model.startsWith('SCS') ? 'SCS' : model.startsWith('STS') ? 'STS' : null; const rangeMin = 0; const rangeMax = modelType === 'SCS' ? 1023 : modelType === 'STS' ? 4095 : 180; cells.forEach((value, index) => { const td = document.createElement('td'); td.textContent = value; if (index === 0) { // Model cell: not editable td.classList.add('non-editable'); } else { td.classList.add('editable-cell'); td.setAttribute('contenteditable', 'true'); td.setAttribute('data-type', 'number'); // ID cell: 0โ€“255 if (index === 1) { td.setAttribute('data-min', '0'); td.setAttribute('data-max', '255'); } else { // Angle/Position cells: based on model td.setAttribute('data-min', rangeMin.toString()); td.setAttribute('data-max', rangeMax.toString()); } td.addEventListener('input', function () { const type = td.getAttribute('data-type'); const min = parseFloat(td.getAttribute('data-min')); const max = parseFloat(td.getAttribute('data-max')); let value = td.textContent.trim(); if (type === 'number') { let num = parseFloat(value); if (value === '' || isNaN(num)) { num = min; preserveCursor(td, num.toString(), true); } else { if (num < min) num = min; if (num > max) num = max; preserveCursor(td, num.toString()); } td.classList.add('edited'); td.classList.add('bg-warning'); td.title = `Auto-corrected to ${num}`; } console.log("EDITED"); }); } newRow.appendChild(td); }); tbody.appendChild(newRow); } function collectChangePackets(channel) { const tableId = channel === 1 ? 'channel1-motor-table' : channel === 2 ? 'channel2-motor-table' : null; const editedCells = document.querySelectorAll(`#${tableId} td.edited`); const packets = []; editedCells.forEach(cell => { const row = cell.closest('tr'); const rowId = row.getAttribute('data-row-id'); const title = getColumnTitle(cell); // see below const value = cell.textContent.trim(); packets.push({ id: rowId, title, value }); }); return packets; } function getColumnTitle(cell) { const headers = ['model', 'id', 'minAngle', 'maxAngle', 'position']; const index = [...cell.parentNode.children].indexOf(cell); return headers[index] || `col${index}`; } document.getElementById('btnSendChanges').onclick = async () => { const packets = collectChangePackets(1); // or 2 console.log('Sending packets:', packets); // Replace with actual send logic // fetch('/api/send', { method: 'POST', body: JSON.stringify(packets) }) }; function preserveCursor(td, newText, forceEnd = false) { const selection = window.getSelection(); let cursorOffset = 0; if (!forceEnd && selection.rangeCount > 0 && selection.anchorNode === td.firstChild) { const range = selection.getRangeAt(0); cursorOffset = range.startOffset; } td.textContent = newText; const newRange = document.createRange(); const offset = forceEnd ? newText.length : Math.min(cursorOffset, newText.length); newRange.setStart(td.firstChild, offset); newRange.collapse(true); selection.removeAllRanges(); selection.addRange(newRange); } document.getElementById('btn_scan_channel_1').onclick = async () => { // Clear table document.querySelector("#channel1-motor-table tbody").innerHTML = ""; await serial.requestScan(0); }; document.getElementById('btn_scan_channel_2').onclick = async () => { // Clear table document.querySelector("#channel2-motor-table tbody").innerHTML = ""; await serial.requestScan(1); }; function handleScanChannelResponse(payload) { console.log("received motor info packet: " + payload); if (payload.length == 2) { if (payload[1] == 255) { console.log("SCAN COMPLETE"); document.getElementById('log').value += `Scan Complete\n`; return; } } else if (payload.length != 10) { console.log("ERROR: INCORRECT PACKET SIZE"); return; } const channel = payload[0]; // byte 0 const id = payload[1]; // byte 1 const model = (payload[2] << 8) | payload[3]; const minAngleLimit = (payload[4] << 8) | payload[5]; const maxAngleLimit = (payload[6] << 8) | payload[7]; const position = (payload[8] << 8) | payload[9]; let modelString = feetechModelsIDs[model] if (!modelString) { modelString = "UNKNOWN"; } console.log(channel, id, modelString); insertTableRow(channel + 1, modelString, id, minAngleLimit, maxAngleLimit, position); } };