sophia_controller/script.js

909 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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: 0255
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);
}
};