sophia_controller/script.js

666 lines
22 KiB
JavaScript

import { SerialManager } from './serial.js';
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");
// 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 currentAnimation = null;
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;
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);
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);
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;
// 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, "");
}
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");
});
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();
};