980 lines
31 KiB
JavaScript
980 lines
31 KiB
JavaScript
import { SerialManager } from './serial.js';
|
|
import { ServoMotor, getModelType, writeData } from './feetechDefinitions.js';
|
|
import { CurveEditor } from './curveEditor.js';
|
|
import { Robot } from './robot.js';
|
|
import { NodeEditor } from './nodeeditor/NodeEditor.js';
|
|
|
|
|
|
|
|
|
|
window.onload = () => {
|
|
|
|
|
|
|
|
const serial = new SerialManager();
|
|
const servoMotors = [[], []]; // index 0 = channel 0, index 1 = channel 1
|
|
const statusText = document.getElementById('statusText');
|
|
const disconnectBtn = document.getElementById('disconnect');
|
|
const connectBtn = document.getElementById('connect');
|
|
const syncCheckbox = document.getElementById("syncCheckbox");
|
|
const feebackCheckbox = document.getElementById("feebackCheckbox");
|
|
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 curveCanvas = document.getElementById('curveCanvas');
|
|
const curveEditor = new CurveEditor(curveCanvas, 10, frameSlider);
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
let connectedRobot = GenerateTestRobot();
|
|
|
|
console.log(connectedRobot);
|
|
let motorIDList = []
|
|
for (const [position, motor] of connectedRobot.positionMap.entries()) {
|
|
console.log(`Assigning ${position} motor with ID ${motor.ID}`);
|
|
curveEditor.addChannel(motor.ID);
|
|
motorIDList.push(motor.ID);
|
|
}
|
|
setSelectedMotor(10);
|
|
|
|
const nodeCanvas = document.getElementById("nodeeditor");
|
|
|
|
const nodeEditor = new NodeEditor(nodeCanvas, {
|
|
motorIds: motorIDList
|
|
});
|
|
|
|
nodeEditor.generateDefaultNodes(curveEditor.curveSets, motorIDList);
|
|
|
|
//nodeEditor.addServoNode(400, 150, "Servo Output", 5 );
|
|
// nodeEditor.addInputNode(100, 500, "Input Nod", { defaultValue: 3 });
|
|
// nodeEditor.addNoiseNode(400, 450); // Adds a random generator node at (200, 150)
|
|
// nodeEditor.addVariableNode(300, 250);
|
|
|
|
// nodeEditor.addNode(100, 100, "Time", { fill: "#e0f7e9", stroke: "#2e7d32" }); // mint green
|
|
// nodeEditor.addNode(300, 200, "Output"); // uses default pastel
|
|
|
|
|
|
|
|
function setSelectedMotor(motorID) {
|
|
console.log(motorID);
|
|
curveEditor.selectMotor(motorID);
|
|
selectedDial = motorID;
|
|
|
|
const dialElements = document.querySelectorAll('.dial');
|
|
|
|
dialElements.forEach((el, index) => {
|
|
el.classList.remove('selected');
|
|
|
|
if (dials[index]?.motorID === motorID) {
|
|
el.classList.add('selected');
|
|
}
|
|
});
|
|
|
|
console.log("Selected motor:", motorID);
|
|
// Any other logic you want to run
|
|
|
|
|
|
}
|
|
window.setSelectedMotor = setSelectedMotor;
|
|
|
|
|
|
// TODO: Info should all be loaded on connect from handshake packet
|
|
function GenerateTestRobot() {
|
|
const robot = new Robot('Atlas', 'FW-2.0.1');
|
|
|
|
// Create motors manually
|
|
let testMotors = [];
|
|
let positions = ["eyelids", "headtilt", "neckrotate", "rightshoulder", "rightforearm"];
|
|
for (let i = 0; i < 5; i++) {
|
|
let motor = new ServoMotor(0, 10 + i, "SCS009")
|
|
robot.assignMotor(positions[i], motor);
|
|
|
|
addDial(motor.ID);
|
|
}
|
|
// addDial(123);
|
|
// Retrieve motor by position
|
|
//console.log(robot.getMotor('eyelids'));
|
|
|
|
// List all assignments
|
|
console.log(robot.getAllAssignments());
|
|
return robot;
|
|
}
|
|
|
|
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;
|
|
//console.log(currentFrame);
|
|
|
|
syncDialsWithCurveEditor();
|
|
syncMotorsWithTimeline();
|
|
|
|
|
|
};
|
|
|
|
function addDial(motorID) {
|
|
const index = dials.length;
|
|
|
|
// Create dial wrapper
|
|
const dialWrapper = document.createElement('div');
|
|
dialWrapper.className = 'dial';
|
|
dialWrapper.dataset.index = index;
|
|
|
|
// Create label
|
|
const label = document.createElement('label');
|
|
label.textContent = "Motor " + motorID;
|
|
console.log(motorID);
|
|
|
|
// Create dial container
|
|
const dialDiv = document.createElement('div');
|
|
dialDiv.id = `dial${index}`;
|
|
|
|
// Create value display
|
|
const valueSpan = document.createElement('span');
|
|
valueSpan.id = `value${index}`;
|
|
valueSpan.textContent = '2048';
|
|
|
|
// Assemble and append
|
|
dialWrapper.appendChild(label);
|
|
dialWrapper.appendChild(dialDiv);
|
|
dialWrapper.appendChild(valueSpan);
|
|
document.getElementById('dialArea').appendChild(dialWrapper);
|
|
|
|
// Create Nexus dial
|
|
const dial = new Nexus.Dial(`#dial${index}`, {
|
|
size: [80, 80],
|
|
min: 0,
|
|
max: 4095,
|
|
value: 4095 / 2
|
|
});
|
|
|
|
dial.motorID = motorID;
|
|
dial.colorize("accent", dialColors[index]);
|
|
|
|
dial.on('change', (v) => {
|
|
if (isInterpolating) return;
|
|
const val = Math.round(v);
|
|
document.getElementById(`value${index}`).textContent = val;
|
|
//dialKeyframes[index][currentFrame] = val;
|
|
|
|
syncMotorsWithTimeline();
|
|
});
|
|
|
|
dials.push(dial);
|
|
}
|
|
|
|
function syncDialsWithCurveEditor() {
|
|
for (let ch = 0; ch < dials.length; ch++) {
|
|
dials[ch].value = curveEditor.getMotorPositionAtTime(dials[ch].motorID, currentFrame);
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
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 = () => {
|
|
const selectedDial = parseInt(el.dataset.index);
|
|
|
|
setSelectedMotor(dials[selectedDial].motorID);
|
|
};
|
|
});
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
case 0x10: // CMD_SCAN_CHANNEL
|
|
console.log(new Uint8Array(payload));
|
|
document.getElementById('log').value += `Data updated\n`;
|
|
//handleScanChannelResponse(new Uint8Array(payload))
|
|
break;
|
|
|
|
case 0x15: // POSITION STREAM
|
|
//console.log(new Uint8Array(payload));
|
|
handlePositionStreamPacket(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 handlePositionStreamPacket(data) {
|
|
for (let i = 0; i < 5; i++) {
|
|
const high = data[i * 2]; // High byte
|
|
const low = data[i * 2 + 1]; // Low byte
|
|
const value = (high << 8) | low; // Combine into uint16_t
|
|
|
|
dials[i].value = value;
|
|
}
|
|
}
|
|
|
|
function handleLoadedFile(data) {
|
|
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;
|
|
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);
|
|
|
|
// 🔹 Read curve segment count
|
|
if (offset + 2 > view.byteLength) {
|
|
console.error("File too short to contain curve count");
|
|
return;
|
|
}
|
|
|
|
const curveCount = view.getUint16(offset, true);
|
|
offset += 2;
|
|
|
|
|
|
const SEGMENT_SIZE = 17;
|
|
const curveSets = {};
|
|
let latestEndTime = 1;
|
|
console.log(raw);
|
|
|
|
for (let i = 0; i < curveCount; i++) {
|
|
const motorID = 10;
|
|
offset += 1;
|
|
const startTime = view.getUint16(offset, true); offset += 2;
|
|
const endTime = view.getUint16(offset, true); offset += 2;
|
|
const startPointY = view.getInt16(offset, true); offset += 2;
|
|
const startHandleX = view.getUint16(offset, true); offset += 2;
|
|
const startHandleY = view.getInt16(offset, true); offset += 2;
|
|
const endHandleX = view.getUint16(offset, true); offset += 2;
|
|
const endHandleY = view.getInt16(offset, true); offset += 2;
|
|
const endPointY = view.getInt16(offset, true); offset += 2;
|
|
|
|
//console.log("RECEIVED VALUES RAW:");
|
|
//console.log(startTime, endTime, startPointY, endPointY);
|
|
|
|
const toFloat = v => (v / 65535) * 2 - 1;
|
|
|
|
const curve = {
|
|
startPoint: {
|
|
x: startTime,
|
|
y: curveEditor.exportRangeToY(startPointY)
|
|
},
|
|
startPointHandle: {
|
|
x: startHandleX,
|
|
y: curveEditor.exportRangeToY(startHandleY)
|
|
},
|
|
endPointHandle: {
|
|
x: endHandleX,
|
|
y: curveEditor.exportRangeToY(endHandleY)
|
|
},
|
|
endPoint: {
|
|
x: endTime,
|
|
y: curveEditor.exportRangeToY(endPointY)
|
|
}
|
|
};
|
|
|
|
if (endTime > latestEndTime) {
|
|
latestEndTime = endTime;
|
|
}
|
|
|
|
if (!curveSets[motorID]) {
|
|
curveSets[motorID] = [];
|
|
}
|
|
curveSets[motorID].push(curve);
|
|
console.log(motorID);
|
|
}
|
|
|
|
console.log("🎯 Loaded Curves:", curveSets);
|
|
|
|
// 🔁 Inject into your curve editor
|
|
//loadCurvesIntoEditor(curves); // Replace with your actual editor hook
|
|
curveEditor.loadCurveSets(curveSets);
|
|
curveEditor.setLength(latestEndTime);
|
|
|
|
|
|
// 🔓 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 = 800; // or whatever your timeline length is
|
|
const frameRate = 50;
|
|
const version = 1;
|
|
|
|
const headerSize = 16;
|
|
|
|
const filenameBytes = new TextEncoder().encode(filename);
|
|
const filenameLength = filenameBytes.length;
|
|
|
|
const totalSize = 1024;
|
|
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
|
|
|
|
|
|
let curvePacket = curveEditor.encodeCurves()
|
|
let nodeGraphPacket = nodeEditor.encodeNodeGraph();
|
|
|
|
// 🔹 Append nodeGraphPacket
|
|
|
|
|
|
// 🔹 Append curvePacket
|
|
curvePacket.forEach(byte => view.setUint8(offset++, byte));
|
|
nodeGraphPacket.forEach(byte => view.setUint8(offset++, byte));
|
|
|
|
// 🔹 Send to ESP32
|
|
const payload = new Uint8Array(buffer.slice(0, offset));
|
|
console.log()
|
|
console.log(payload);
|
|
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 }, () => ({}));
|
|
});
|
|
|
|
|
|
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('sendNodes').onclick = async () => {
|
|
nodeGraphPacket = nodeEditor.encodeNodeGraph();
|
|
curvePacket = curveEditor.encodeCurves()
|
|
|
|
};
|
|
|
|
|
|
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'));
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.getElementById('saveAnimation').onclick = async () => {
|
|
await sendAnimationToESP32();
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// MOTOR CONTROL PANEL
|
|
|
|
function insertTableRow(motor) {
|
|
const tableId = motor.CHANNEL === 0 ? 'channel0-motor-table' :
|
|
motor.CHANNEL === 1 ? 'channel1-motor-table' : null;
|
|
|
|
if (!tableId) {
|
|
console.error('Invalid channel number. Use 0 or 1.');
|
|
return;
|
|
}
|
|
|
|
const tbody = document.querySelector(`#${tableId} tbody`);
|
|
const newRow = document.createElement('tr');
|
|
newRow.setAttribute('data-row-id', motor.ID);
|
|
|
|
const cells = [
|
|
motor.MODEL,
|
|
motor.ID,
|
|
motor.MIN_ANGLE_LIMIT,
|
|
motor.MAX_ANGLE_LIMIT,
|
|
motor.POSITION,
|
|
motor.CW_DEAD_ZONE,
|
|
motor.CCW_DEAD_ZONE,
|
|
motor.OFFSET,
|
|
motor.MODE,
|
|
motor.TORQUE_ENABLE,
|
|
motor.ACCELERATION,
|
|
motor.GOAL_POSITION,
|
|
motor.GOAL_TIME,
|
|
motor.GOAL_SPEED,
|
|
motor.LOCK,
|
|
motor.CURRENT_SPEED,
|
|
motor.CURRENT_LOAD,
|
|
motor.TEMPERATURE,
|
|
motor.MOVING,
|
|
motor.CURRENT_CURRENT,
|
|
motor.VOLTAGE
|
|
];
|
|
|
|
const modelType = motor.MODEL.startsWith('SCS') ? 'SCS' :
|
|
motor.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) {
|
|
td.classList.add('non-editable');
|
|
} else {
|
|
td.classList.add('editable-cell');
|
|
td.setAttribute('contenteditable', 'true');
|
|
td.setAttribute('data-type', 'number');
|
|
|
|
if (index === 1) {
|
|
td.setAttribute('data-min', '0');
|
|
td.setAttribute('data-max', '255');
|
|
} else if (index === 9) { // TORQUE ENABLE
|
|
td.setAttribute('data-min', '0');
|
|
td.setAttribute('data-max', '1');
|
|
} else if (index === 14) { // EEPROM LOCK
|
|
td.setAttribute('data-min', '0');
|
|
td.setAttribute('data-max', '1');
|
|
} else {
|
|
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 === 0 ? 'channel0-motor-table' :
|
|
channel === 1 ? 'channel1-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);
|
|
const value = cell.textContent.trim();
|
|
|
|
packets.push({
|
|
id: rowId,
|
|
title,
|
|
value
|
|
});
|
|
|
|
// Remove the classes
|
|
cell.classList.remove('edited', 'bg-warning');
|
|
});
|
|
|
|
return packets;
|
|
}
|
|
|
|
|
|
function getColumnTitle(cell) {
|
|
const table = cell.closest('table');
|
|
const cellIndex = cell.cellIndex;
|
|
|
|
const headerRow = table.querySelector('thead tr');
|
|
const headerCell = headerRow.children[cellIndex];
|
|
|
|
return headerCell.dataset.key || headerCell.textContent.trim();
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
}
|
|
|
|
function getServoMotorByID(channel, id) {
|
|
for (var i = 0; i < servoMotors[channel].length; i++) {
|
|
console.log(servoMotors[channel][i].ID, id);
|
|
if (servoMotors[channel][i].ID === id) {
|
|
return servoMotors[channel][i];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
document.getElementById('btn_scan_channel_0').onclick = async () => {
|
|
// Clear table
|
|
document.querySelector("#channel0-motor-table tbody").innerHTML = "";
|
|
servoMotors[0] = [];
|
|
|
|
await serial.requestScan(0);
|
|
};
|
|
|
|
document.getElementById('btn_scan_channel_1').onclick = async () => {
|
|
// Clear table
|
|
|
|
servoMotors[1] = [];
|
|
document.querySelector("#channel1-motor-table tbody").innerHTML = "";
|
|
|
|
await serial.requestScan(1);
|
|
};
|
|
|
|
document.getElementById('btnSendChangesCh0').onclick = async () => {
|
|
|
|
const packets = collectChangePackets(0); // or 2
|
|
for (var i = 0; i < packets.length; i++) {
|
|
let channel = 0
|
|
let servoMotor = getServoMotorByID(channel, parseInt(packets[i].id, 10));
|
|
let dataKey = packets[i].title;
|
|
let value = packets[i].value;
|
|
servoMotor[dataKey] = value;
|
|
let dataPacket = writeData(servoMotor, dataKey);
|
|
serial.requestWriteData(dataPacket);
|
|
}
|
|
console.log(servoMotors);
|
|
console.log('Sending packets:', packets);
|
|
};
|
|
|
|
document.getElementById('btnSendChangesCh1').onclick = async () => {
|
|
|
|
const packets = collectChangePackets(1); // or 2
|
|
for (var i = 0; i < packets.length; i++) {
|
|
let channel = 1
|
|
let servoMotor = getServoMotorByID(channel, parseInt(packets[i].id, 10));
|
|
let dataKey = packets[i].title;
|
|
let value = packets[i].value;
|
|
servoMotor[dataKey] = value;
|
|
let dataPacket = writeData(servoMotor, dataKey);
|
|
serial.requestWriteData(dataPacket);
|
|
}
|
|
console.log(servoMotors);
|
|
console.log('Sending packets:', packets);
|
|
};
|
|
|
|
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 != 33) {
|
|
console.log("ERROR: INCORRECT PACKET SIZE: " + payload.length);
|
|
return;
|
|
}
|
|
const motor = new ServoMotor(payload);
|
|
console.log(motor.MODEL, motor.POSITION, motor.CURRENT_SPEED);
|
|
servoMotors[motor.CHANNEL].push(motor);
|
|
|
|
insertTableRow(motor);
|
|
|
|
}
|
|
|
|
|
|
|
|
feebackCheckbox.addEventListener('change', async function () {
|
|
if (feebackCheckbox.checked) {
|
|
console.log("Checkbox is checked!");
|
|
serial.requestPositionStreaming(true);
|
|
} else {
|
|
console.log("Checkbox is unchecked!");
|
|
serial.requestPositionStreaming(false);
|
|
}
|
|
});
|
|
|
|
|
|
function matchPositions() {
|
|
// if (feebackCheckbox.checked) {
|
|
// console.log("Running every 100ms");
|
|
// }
|
|
}
|
|
|
|
// Run 10 times per second
|
|
const intervalId = setInterval(matchPositions, 100);
|
|
};
|