Initial commit
This commit is contained in:
+139
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* WijiBoard – BLE Singleton
|
||||
* web/js/ble.js
|
||||
*
|
||||
* Provides a singleton BLE object with:
|
||||
* BLE.connect() → connects to device, gets characteristics
|
||||
* BLE.disconnect() → disconnects
|
||||
* BLE.write(cmd) → writes ASCII command (write-without-response)
|
||||
* BLE.isConnected() → boolean
|
||||
* BLE.on(event, cb) → subscribe to 'connected' | 'disconnected' | 'status'
|
||||
* BLE.off(event, cb) → unsubscribe
|
||||
*
|
||||
* Command characteristic (WRITE_NR):
|
||||
* S1+<n> → step motor 1 clockwise n steps
|
||||
* S1-<n> → step motor 1 counter-clockwise n steps
|
||||
* S2+<n> → step motor 2 clockwise n steps
|
||||
* S2-<n> → step motor 2 counter-clockwise n steps
|
||||
* SPD:<n> → set max speed (steps/sec)
|
||||
* ACC:<n> → set acceleration (steps/sec²)
|
||||
* HOME → zero both steppers
|
||||
* POS → request position report
|
||||
*
|
||||
* Status characteristic (NOTIFY):
|
||||
* P:<s1>,<s2> → current step positions for motor 1 & 2
|
||||
*/
|
||||
|
||||
// ── UUIDs – must match src/main.cpp exactly ──────────────────────
|
||||
const SERVICE_UUID = 'a0b1c2d3-e4f5-6789-abcd-ef0123456700';
|
||||
const CMD_UUID = 'a0b1c2d3-e4f5-6789-abcd-ef0123456701';
|
||||
const STATUS_UUID = 'a0b1c2d3-e4f5-6789-abcd-ef0123456702';
|
||||
const DEVICE_NAME = 'WijiBoard';
|
||||
|
||||
const BLE = (() => {
|
||||
// ── Internal state ──────────────────────────────────────────
|
||||
let device = null;
|
||||
let server = null;
|
||||
let cmdChar = null;
|
||||
let statusChar = null;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// ── Tiny event emitter ──────────────────────────────────────
|
||||
const listeners = {};
|
||||
|
||||
function emit(event, ...args) {
|
||||
(listeners[event] || []).forEach(cb => {
|
||||
try { cb(...args); } catch (e) { console.error('[BLE] listener error', e); }
|
||||
});
|
||||
}
|
||||
|
||||
function on(event, cb) {
|
||||
if (!listeners[event]) listeners[event] = [];
|
||||
listeners[event].push(cb);
|
||||
}
|
||||
|
||||
function off(event, cb) {
|
||||
if (!listeners[event]) return;
|
||||
listeners[event] = listeners[event].filter(fn => fn !== cb);
|
||||
}
|
||||
|
||||
// ── Disconnect handler ──────────────────────────────────────
|
||||
function onDisconnected() {
|
||||
console.log('[BLE] Device disconnected');
|
||||
device = null;
|
||||
server = null;
|
||||
cmdChar = null;
|
||||
statusChar = null;
|
||||
emit('disconnected');
|
||||
}
|
||||
|
||||
// ── Status notification handler ─────────────────────────────
|
||||
function onStatusNotification(event) {
|
||||
const value = new TextDecoder().decode(event.target.value);
|
||||
emit('status', value.trim());
|
||||
}
|
||||
|
||||
// ── connect() ───────────────────────────────────────────────
|
||||
async function connect() {
|
||||
if (!navigator.bluetooth) {
|
||||
throw new Error('Web Bluetooth API not available. Use Chrome/Edge over HTTPS or localhost.');
|
||||
}
|
||||
|
||||
emit('connecting');
|
||||
|
||||
device = await navigator.bluetooth.requestDevice({
|
||||
filters: [{ name: DEVICE_NAME }],
|
||||
optionalServices: [SERVICE_UUID],
|
||||
});
|
||||
|
||||
device.addEventListener('gattserverdisconnected', onDisconnected);
|
||||
|
||||
server = await device.gatt.connect();
|
||||
|
||||
const service = await server.getPrimaryService(SERVICE_UUID);
|
||||
|
||||
cmdChar = await service.getCharacteristic(CMD_UUID);
|
||||
|
||||
// Status notifications (optional – firmware may not have this char yet)
|
||||
try {
|
||||
statusChar = await service.getCharacteristic(STATUS_UUID);
|
||||
await statusChar.startNotifications();
|
||||
statusChar.addEventListener('characteristicvaluechanged', onStatusNotification);
|
||||
} catch {
|
||||
console.warn('[BLE] Status characteristic not available – notifications disabled');
|
||||
}
|
||||
|
||||
emit('connected', device.name);
|
||||
}
|
||||
|
||||
// ── disconnect() ────────────────────────────────────────────
|
||||
async function disconnect() {
|
||||
if (device?.gatt?.connected) {
|
||||
device.gatt.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// ── write() ─────────────────────────────────────────────────
|
||||
async function write(cmd) {
|
||||
if (!cmdChar) throw new Error('BLE not connected');
|
||||
await cmdChar.writeValueWithoutResponse(encoder.encode(cmd));
|
||||
emit('sent', cmd);
|
||||
}
|
||||
|
||||
// ── isConnected() ───────────────────────────────────────────
|
||||
function isConnected() {
|
||||
return !!(device?.gatt?.connected && cmdChar);
|
||||
}
|
||||
|
||||
// ── getDeviceName() ─────────────────────────────────────────
|
||||
function getDeviceName() {
|
||||
return device?.name ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
on, off, connect, disconnect, write, isConnected, getDeviceName,
|
||||
SERVICE_UUID, CMD_UUID, STATUS_UUID, DEVICE_NAME,
|
||||
};
|
||||
})();
|
||||
|
||||
export default BLE;
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* WijiBoard – Kinematics (exact port of PositionControl.cpp)
|
||||
* web/js/kinematics.js
|
||||
*
|
||||
* ── Mechanism: symmetric 5-bar parallel linkage ─────────────────
|
||||
*
|
||||
* END EFFECTOR (x, y)
|
||||
* / \
|
||||
* L2 (110) L2 (110)
|
||||
* / \
|
||||
* ELBOW1 ELBOW2
|
||||
* \ /
|
||||
* L1 (85) L1 (85)
|
||||
* \ /
|
||||
* MOTOR1 (-d2,0) MOTOR2 (+d2,0)
|
||||
* | |
|
||||
* [===BASE===] (d=25.8 mm wide)
|
||||
* d2=12.9 mm
|
||||
*
|
||||
* Both motors are mounted in the centre mechanism box.
|
||||
* Motor 1 is at (-d2, 0), Motor 2 is at (+d2, 0).
|
||||
* Each motor drives a proximal arm (l1). The distal arms (l2)
|
||||
* connect the elbows to the shared end-effector.
|
||||
*
|
||||
* IK: given target (x, y), solve θ1 and θ2 independently:
|
||||
* Motor 1 sees the target at (x - d2, y) from its pivot.
|
||||
* Motor 2 sees the target at (x + d2, y) from its pivot.
|
||||
* Each uses the standard 2-link IK (law of cosines).
|
||||
*
|
||||
* Source: lib/Position/PositionControl.cpp (nerd-sniped/WijiBoard)
|
||||
*/
|
||||
|
||||
// ── Exact constants from PositionControl.cpp ─────────────────────
|
||||
const ARM = {
|
||||
d: 25.8, // full motor separation (mm)
|
||||
d2: 12.9, // half motor separation — motor 1 at (-d2,0), motor 2 at (+d2,0)
|
||||
l1: 85.0, // proximal link length (mm)
|
||||
l2: 110.0, // distal link length (mm)
|
||||
STEPS_PER_REV: 2048,
|
||||
STEP_ANGLE_DEG: 360 / 2048, // ≈ 0.17578125°
|
||||
};
|
||||
|
||||
// ── Workspace limits ──────────────────────────────────────────────
|
||||
const LIMITS = {
|
||||
// Outer bounding box (the board surface)
|
||||
X_MIN: -150,
|
||||
X_MAX: 150,
|
||||
Y_MIN: -30, // numbers sit slightly below Y=0
|
||||
Y_MAX: 145,
|
||||
|
||||
// Centre exclusion zone (the motor/mechanism box)
|
||||
// Motors are at ±12.9 mm; box is a bit larger to account for the housing
|
||||
BOX_HALF_W: 22, // ±22 mm in X (tune to real hardware)
|
||||
BOX_HALF_H: 22, // 0..22 mm in Y (box sits above the base line)
|
||||
BOX_Y_MIN: -5,
|
||||
BOX_Y_MAX: 22,
|
||||
};
|
||||
|
||||
// ── Letter / number position lookup table ────────────────────────
|
||||
// Directly ported from PositionControl.cpp
|
||||
const LOOKUP_TABLE = {
|
||||
'Q': { x: -66.5, y: 91.6 },
|
||||
'W': { x: 70.8, y: 92.0 },
|
||||
'E': { x: -41.4, y: 125.5 },
|
||||
'R': { x: -44.0, y: 96.0 },
|
||||
'T': { x: 1.3, y: 97.8 },
|
||||
'Y': { x: 117.8, y: 91.7 },
|
||||
'U': { x: 22.5, y: 98.3 },
|
||||
'I': { x: 53.5, y: 124.5 },
|
||||
'O': { x: -112.0, y: 82.0 },
|
||||
'P': { x: -90.0, y: 89.0 },
|
||||
'A': { x: -143.0, y: 99.0 },
|
||||
'S': { x: -19.8, y: 98.0 },
|
||||
'D': { x: -68.5, y: 121.7 },
|
||||
'F': { x: -19.5, y: 127.0 },
|
||||
'G': { x: 4.0, y: 128.4 },
|
||||
'H': { x: 31.3, y: 127.3 },
|
||||
'J': { x: 72.5, y: 121.0 },
|
||||
'K': { x: 93.0, y: 116.8 },
|
||||
'L': { x: 114.3, y: 110.2 },
|
||||
'Z': { x: 130.0, y: 75.0 },
|
||||
'X': { x: 95.9, y: 87.3 },
|
||||
'C': { x: -94.0, y: 116.0 },
|
||||
'V': { x: 44.5, y: 97.0 },
|
||||
'B': { x: -119.0, y: 109.0 },
|
||||
'N': { x: -135.4, y: 75.1 },
|
||||
'M': { x: 132.0, y: 100.5 },
|
||||
|
||||
'+': { x: -110.0, y: 1.0 },
|
||||
'-': { x: 110.0, y: 1.0 },
|
||||
'*': { x: 110.0, y: -24.0 },
|
||||
',': { x: -110.0, y: -24.0 },
|
||||
|
||||
'0': { x: -130.4, y: 44.2 },
|
||||
'1': { x: -101.1, y: 53.3 },
|
||||
'2': { x: -71.5, y: 60.4 },
|
||||
'3': { x: -41.7, y: 65.2 },
|
||||
'4': { x: -13.0, y: 66.5 },
|
||||
'5': { x: 16.3, y: 68.0 },
|
||||
'6': { x: 45.8, y: 64.0 },
|
||||
'7': { x: 75.7, y: 61.1 },
|
||||
'8': { x: 103.6, y: 53.8 },
|
||||
'9': { x: 132.1, y: 45.0 },
|
||||
};
|
||||
|
||||
// ── IK solver — exact port of calculateInverseKinematics() ───────
|
||||
/**
|
||||
* Compute motor angles for a given target end-effector position.
|
||||
* Exactly mirrors the C++ implementation in PositionControl.cpp.
|
||||
*
|
||||
* @param {number} x Target X in mm (origin = midpoint between motors)
|
||||
* @param {number} y Target Y in mm
|
||||
* @returns {{ theta1: number, theta2: number, reachable: boolean }}
|
||||
* theta1 / theta2 in RADIANS (motor 1 = left, motor 2 = right)
|
||||
*/
|
||||
function solve(x, y) {
|
||||
const { d2, l1, l2 } = ARM;
|
||||
|
||||
// ── Motor 1 (left pivot at -d2, 0) ───────────────────────────
|
||||
const xmd = x - d2; // target X relative to left motor
|
||||
const s = Math.sqrt(xmd * xmd + y * y); // distance: left motor → target
|
||||
const cosW1 = (l2 * l2 - s * s - l1 * l1) / (-2 * l1 * s);
|
||||
if (cosW1 < -1 || cosW1 > 1) return { theta1: 0, theta2: 0, reachable: false };
|
||||
const q = Math.atan2(y, xmd);
|
||||
const w1 = Math.acos(cosW1);
|
||||
const theta1 = q - w1;
|
||||
|
||||
// ── Motor 2 (right pivot at +d2, 0) ──────────────────────────
|
||||
const xpd = x + d2; // target X relative to right motor
|
||||
const t = Math.sqrt(xpd * xpd + y * y); // distance: right motor → target
|
||||
const cosW2 = (l2 * l2 - t * t - l1 * l1) / (-2 * l1 * t);
|
||||
if (cosW2 < -1 || cosW2 > 1) return { theta1: 0, theta2: 0, reachable: false };
|
||||
const r = Math.atan2(y, xpd);
|
||||
const w2 = Math.acos(cosW2);
|
||||
const theta2 = r + w2;
|
||||
|
||||
return { theta1, theta2, reachable: true };
|
||||
}
|
||||
|
||||
// ── Forward kinematics ───────────────────────────────────────────
|
||||
/**
|
||||
* Given motor angles, compute elbow and end-effector positions.
|
||||
* The end-effector is found as the intersection of the two distal
|
||||
* link circles — this is the FK complement to the 5-bar IK above.
|
||||
*
|
||||
* In practice for visualisation we just re-derive the elbow
|
||||
* positions from each motor.
|
||||
*
|
||||
* @param {number} theta1 Motor 1 angle (radians)
|
||||
* @param {number} theta2 Motor 2 angle (radians)
|
||||
* @returns {{
|
||||
* elbow1: {x,y}, elbow2: {x,y},
|
||||
* endX: number, endY: number
|
||||
* }}
|
||||
*/
|
||||
function forward(theta1, theta2) {
|
||||
const { d2, l1, l2 } = ARM;
|
||||
|
||||
// Elbow 1 (tip of motor 1's proximal link)
|
||||
const e1x = -d2 + l1 * Math.cos(theta1);
|
||||
const e1y = l1 * Math.sin(theta1);
|
||||
|
||||
// Elbow 2 (tip of motor 2's proximal link)
|
||||
const e2x = d2 + l1 * Math.cos(theta2);
|
||||
const e2y = l1 * Math.sin(theta2);
|
||||
|
||||
// End-effector: intersection of circle(elbow1, l2) and circle(elbow2, l2)
|
||||
// Use the same approach as the IK: each distal link points from its elbow to the EE.
|
||||
// For visualisation accuracy, reconstruct EE by reversing the IK:
|
||||
// From IK: theta1 = q - w1 → q = atan2(y, x - d2)
|
||||
// We know theta1 and the elbow position, so EE is at l2 along some direction.
|
||||
// Simplest: use circle-circle intersection of the two elbow-radius-l2 circles.
|
||||
const dx = e2x - e1x;
|
||||
const dy = e2y - e1y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist < 1e-6 || dist > 2 * l2) {
|
||||
// Degenerate / unreachable — just average the two elbows
|
||||
return {
|
||||
elbow1: { x: e1x, y: e1y },
|
||||
elbow2: { x: e2x, y: e2y },
|
||||
endX: (e1x + e2x) / 2,
|
||||
endY: (e1y + e2y) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
const a = dist / 2;
|
||||
const h = Math.sqrt(l2 * l2 - a * a);
|
||||
const mx = (e1x + e2x) / 2;
|
||||
const my = (e1y + e2y) / 2;
|
||||
|
||||
// Two intersection candidates — pick the one with higher Y (the "up" configuration)
|
||||
const px1 = mx + h * (dy / dist);
|
||||
const py1 = my - h * (dx / dist);
|
||||
const px2 = mx - h * (dy / dist);
|
||||
const py2 = my + h * (dx / dist);
|
||||
|
||||
const { endX, endY } = py1 > py2
|
||||
? { endX: px1, endY: py1 }
|
||||
: { endX: px2, endY: py2 };
|
||||
|
||||
return {
|
||||
elbow1: { x: e1x, y: e1y },
|
||||
elbow2: { x: e2x, y: e2y },
|
||||
endX, endY,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Workspace check ───────────────────────────────────────────────
|
||||
/**
|
||||
* Check whether a point is inside the valid workspace.
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @returns {{ ok: boolean, reason: string }}
|
||||
*/
|
||||
function checkWorkspace(x, y) {
|
||||
// Outer bounding box
|
||||
if (x < LIMITS.X_MIN || x > LIMITS.X_MAX)
|
||||
return { ok: false, reason: `X=${x.toFixed(1)} outside board limits [${LIMITS.X_MIN}, ${LIMITS.X_MAX}]` };
|
||||
if (y < LIMITS.Y_MIN || y > LIMITS.Y_MAX)
|
||||
return { ok: false, reason: `Y=${y.toFixed(1)} outside board limits [${LIMITS.Y_MIN}, ${LIMITS.Y_MAX}]` };
|
||||
|
||||
// Centre exclusion zone (mechanism box)
|
||||
if (
|
||||
x > -LIMITS.BOX_HALF_W && x < LIMITS.BOX_HALF_W &&
|
||||
y > LIMITS.BOX_Y_MIN && y < LIMITS.BOX_Y_MAX
|
||||
) {
|
||||
return { ok: false, reason: `(${x.toFixed(1)}, ${y.toFixed(1)}) is inside the mechanism exclusion zone` };
|
||||
}
|
||||
|
||||
// IK reachability
|
||||
const { reachable } = solve(x, y);
|
||||
if (!reachable) return { ok: false, reason: `(${x.toFixed(1)}, ${y.toFixed(1)}) is outside arm reach` };
|
||||
|
||||
return { ok: true, reason: '' };
|
||||
}
|
||||
|
||||
// ── Lookup ────────────────────────────────────────────────────────
|
||||
function lookup(char) {
|
||||
return LOOKUP_TABLE[char.toUpperCase()] ?? null;
|
||||
}
|
||||
|
||||
// ── Unit converters ───────────────────────────────────────────────
|
||||
function stepsToRad(steps) {
|
||||
return (steps / ARM.STEPS_PER_REV) * 2 * Math.PI;
|
||||
}
|
||||
function radToSteps(rad) {
|
||||
return Math.round((rad / (2 * Math.PI)) * ARM.STEPS_PER_REV);
|
||||
}
|
||||
function stepsToDeg(steps) {
|
||||
return steps * ARM.STEP_ANGLE_DEG;
|
||||
}
|
||||
function radToDeg(rad) {
|
||||
return rad * 180 / Math.PI;
|
||||
}
|
||||
|
||||
export default {
|
||||
ARM, LIMITS, LOOKUP_TABLE,
|
||||
solve, forward, checkWorkspace, lookup,
|
||||
stepsToRad, radToSteps, stepsToDeg, radToDeg,
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* WijiBoard – Hash-based SPA Router
|
||||
* web/js/router.js
|
||||
*
|
||||
* Usage:
|
||||
* Router.register('home', homeModule);
|
||||
* Router.register('stepper-test', stepperModule);
|
||||
* Router.init('#view', '#home'); // outlet element id, default route
|
||||
*
|
||||
* Each section module must export:
|
||||
* { mount(containerEl), unmount() }
|
||||
*/
|
||||
const Router = (() => {
|
||||
const routes = new Map(); // hash → module
|
||||
let outlet = null; // DOM element to render into
|
||||
let current = null; // currently active route key
|
||||
let currentMod = null; // currently mounted module
|
||||
|
||||
// ── Register a route ─────────────────────────────────────────
|
||||
function register(hash, module) {
|
||||
routes.set(hash, module);
|
||||
}
|
||||
|
||||
// ── Navigate to a route ──────────────────────────────────────
|
||||
async function navigate(hash) {
|
||||
const key = hash.replace(/^#/, '');
|
||||
|
||||
if (!routes.has(key)) {
|
||||
console.warn(`[Router] Unknown route: "${key}"`);
|
||||
return;
|
||||
}
|
||||
if (key === current) return; // already there
|
||||
|
||||
// Unmount old section
|
||||
if (currentMod?.unmount) {
|
||||
try { await currentMod.unmount(); } catch (e) { console.error('[Router] unmount error', e); }
|
||||
}
|
||||
|
||||
// Clear outlet
|
||||
outlet.innerHTML = '';
|
||||
|
||||
// Mount new section
|
||||
const mod = routes.get(key);
|
||||
currentMod = mod;
|
||||
current = key;
|
||||
|
||||
try {
|
||||
await mod.mount(outlet);
|
||||
} catch (e) {
|
||||
console.error('[Router] mount error', e);
|
||||
outlet.innerHTML = `<p style="color:var(--accent-red);padding:24px">
|
||||
Failed to load section: ${e.message}</p>`;
|
||||
}
|
||||
|
||||
// Update sidebar active state
|
||||
document.querySelectorAll('.sidebar-nav-item').forEach(el => {
|
||||
el.classList.toggle('active', el.dataset.route === key);
|
||||
});
|
||||
|
||||
// Update URL hash (without re-triggering hashchange)
|
||||
if (location.hash.replace('#','') !== key) {
|
||||
history.replaceState(null, '', `#${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Init: wire up outlet + default route ─────────────────────
|
||||
function init(outletSelector, defaultHash = '#home') {
|
||||
outlet = document.querySelector(outletSelector);
|
||||
if (!outlet) throw new Error(`[Router] Outlet "${outletSelector}" not found`);
|
||||
|
||||
// Listen for hash changes
|
||||
window.addEventListener('hashchange', () => {
|
||||
navigate(location.hash || defaultHash);
|
||||
});
|
||||
|
||||
// Handle initial load
|
||||
const initial = location.hash && location.hash.length > 1
|
||||
? location.hash
|
||||
: defaultHash;
|
||||
|
||||
// Small defer to let the page fully render first
|
||||
requestAnimationFrame(() => navigate(initial));
|
||||
}
|
||||
|
||||
// ── Active route getter ───────────────────────────────────────
|
||||
function getActive() { return current; }
|
||||
|
||||
return { register, navigate, init, getActive };
|
||||
})();
|
||||
|
||||
export default Router;
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* WijiBoard – Home Section
|
||||
* web/js/sections/home.js
|
||||
*
|
||||
* A minimal placeholder home section.
|
||||
*/
|
||||
|
||||
import BLE from '../ble.js';
|
||||
|
||||
const HomeSection = {
|
||||
_cleanup: [],
|
||||
|
||||
mount(container) {
|
||||
container.innerHTML = `
|
||||
<div class="section-page fade-in">
|
||||
<div class="section-header">
|
||||
<h2>WijiBoard</h2>
|
||||
<p>WiFi Spirit Board — SCARA arm controller. Select a section from the sidebar to get started.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid-2" style="margin-bottom:16px">
|
||||
<!-- Status card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Connection</span>
|
||||
</div>
|
||||
<div class="status-row" style="margin-bottom:10px">
|
||||
<span class="status-label">BLE</span>
|
||||
<span class="status-value" id="home-ble-status">
|
||||
${BLE.isConnected() ? `✓ ${BLE.getDeviceName()}` : 'Not connected'}
|
||||
</span>
|
||||
</div>
|
||||
<p style="font-size:0.8rem;color:var(--text-muted)">
|
||||
Use the <strong style="color:var(--text-secondary)">Connect</strong> button
|
||||
in the top bar to pair with the WijiBoard via Bluetooth.
|
||||
The device name is <code style="color:var(--accent-blue)">WijiBoard</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- About card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">About</span>
|
||||
</div>
|
||||
<p style="font-size:0.82rem;line-height:1.8;color:var(--text-secondary)">
|
||||
This dashboard controls a two-arm SCARA robot that moves a planchette
|
||||
across a spirit board. It uses <strong style="color:var(--text-primary)">Web Bluetooth</strong>
|
||||
to communicate with an ESP32-S3 running two 28BYJ-48 stepper motors
|
||||
with AccelStepper.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sections overview -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Sections</span>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<button class="sidebar-nav-item" data-route="stepper-test"
|
||||
onclick="document.getElementById('nav-stepper-test').click()"
|
||||
style="padding:14px 16px;background:var(--surface-2)">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/>
|
||||
</svg>
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:0.875rem;color:var(--text-primary)">Stepper Test</div>
|
||||
<div style="font-size:0.75rem;color:var(--text-muted);margin-top:2px">Manually jog each motor, visualize arm position</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="sidebar-nav-item" style="padding:14px 16px;background:var(--surface-2);opacity:0.45;cursor:default">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:0.875rem;color:var(--text-primary)">Board Control</div>
|
||||
<div style="font-size:0.75rem;color:var(--text-muted);margin-top:2px">Coming soon</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Keep home BLE status label in sync
|
||||
const statusEl = container.querySelector('#home-ble-status');
|
||||
const update = () => {
|
||||
if (statusEl) statusEl.textContent = BLE.isConnected()
|
||||
? `✓ ${BLE.getDeviceName()}`
|
||||
: 'Not connected';
|
||||
};
|
||||
BLE.on('connected', update);
|
||||
BLE.on('disconnected', update);
|
||||
this._cleanup = [
|
||||
() => BLE.off('connected', update),
|
||||
() => BLE.off('disconnected', update),
|
||||
];
|
||||
},
|
||||
|
||||
unmount() {
|
||||
this._cleanup.forEach(fn => fn());
|
||||
this._cleanup = [];
|
||||
},
|
||||
};
|
||||
|
||||
export default HomeSection;
|
||||
@@ -0,0 +1,766 @@
|
||||
/**
|
||||
* WijiBoard – Stepper Test Section
|
||||
* web/js/sections/stepper-test.js
|
||||
*
|
||||
* 5-bar parallel linkage visualiser + IK-based click-to-move.
|
||||
*
|
||||
* Mechanism geometry (from PositionControl.cpp):
|
||||
* Motor 1 at (-12.9, 0) mm Motor 2 at (+12.9, 0) mm
|
||||
* Proximal links: l1 = 85 mm Distal links: l2 = 110 mm
|
||||
*
|
||||
* IK flow: click XY → IK.solve(x,y) → {θ1, θ2} → steps → BLE
|
||||
* FK flow: steps → radians → IK.forward(θ1, θ2) → draw SVG
|
||||
*/
|
||||
|
||||
import BLE from '../ble.js';
|
||||
import IK from '../kinematics.js';
|
||||
import UI from '../ui.js';
|
||||
|
||||
// ── SVG viewport ──────────────────────────────────────────────────
|
||||
// We map the real workspace onto the SVG canvas.
|
||||
// Real workspace: X ∈ [-155, 155], Y ∈ [-35, 150]
|
||||
// We add some margin and invert Y (SVG y grows downward).
|
||||
|
||||
const SV = {
|
||||
W: 480, H: 380,
|
||||
|
||||
// World→SVG transform: scale + offset so the board fits nicely
|
||||
SCALE: 1.35, // px per mm
|
||||
// Origin (world 0,0) maps to this SVG point:
|
||||
OX: 240, // ≈ centre horizontally
|
||||
OY: 295, // base line — motors sit here
|
||||
|
||||
// Convenience: world mm → SVG px
|
||||
wx(worldX) { return this.OX + worldX * this.SCALE; },
|
||||
wy(worldY) { return this.OY - worldY * this.SCALE; },
|
||||
|
||||
// SVG px → world mm
|
||||
svgToWorldX(sx) { return (sx - this.OX) / this.SCALE; },
|
||||
svgToWorldY(sy) { return -(sy - this.OY) / this.SCALE; },
|
||||
};
|
||||
|
||||
// ── Arm length in px (for readable references) ────────────────────
|
||||
const PX_L1 = IK.ARM.l1 * SV.SCALE;
|
||||
const PX_L2 = IK.ARM.l2 * SV.SCALE;
|
||||
const PX_D2 = IK.ARM.d2 * SV.SCALE;
|
||||
|
||||
// ── Section state ─────────────────────────────────────────────────
|
||||
let steps1 = 0;
|
||||
let steps2 = 0;
|
||||
let eventCleanup = [];
|
||||
|
||||
// ── SVG element refs ──────────────────────────────────────────────
|
||||
let svgEl;
|
||||
// Real arm elements
|
||||
let arm1Prox, arm1Dist, arm2Prox, arm2Dist;
|
||||
let elbow1, elbow2, endEff, coordLabel, xhH, xhV;
|
||||
// Ghost arm
|
||||
let ghost1Prox, ghost1Dist, ghost2Prox, ghost2Dist;
|
||||
let ghostElbow1, ghostElbow2, ghostEnd, ghostLabel;
|
||||
// Target marker
|
||||
let targetMarker;
|
||||
|
||||
// ── Readout refs ──────────────────────────────────────────────────
|
||||
let elCurrX, elCurrY, elTheta1, elTheta2, elSteps1, elSteps2;
|
||||
let simBadge;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// SVG rendering helpers
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function setLine(el, x1, y1, x2, y2) {
|
||||
el.setAttribute('x1', x1); el.setAttribute('y1', y1);
|
||||
el.setAttribute('x2', x2); el.setAttribute('y2', y2);
|
||||
}
|
||||
function setCircle(el, cx, cy) {
|
||||
el.setAttribute('cx', cx); el.setAttribute('cy', cy);
|
||||
}
|
||||
|
||||
/** Render the 5-bar arm from two motor angles. */
|
||||
function renderArm(theta1, theta2, isGhost = false) {
|
||||
const { elbow1: e1, elbow2: e2, endX, endY } = IK.forward(theta1, theta2);
|
||||
|
||||
const motor1sx = SV.wx(-IK.ARM.d2); const motor1sy = SV.wy(0);
|
||||
const motor2sx = SV.wx( IK.ARM.d2); const motor2sy = SV.wy(0);
|
||||
const elbow1sx = SV.wx(e1.x); const elbow1sy = SV.wy(e1.y);
|
||||
const elbow2sx = SV.wx(e2.x); const elbow2sy = SV.wy(e2.y);
|
||||
const endsx = SV.wx(endX); const endsy = SV.wy(endY);
|
||||
|
||||
// Guard: forward() can return NaN for degenerate angles – skip render
|
||||
if (!isFinite(endX) || !isFinite(endY)) return { endsx: SV.OX, endsy: SV.OY };
|
||||
|
||||
if (isGhost) {
|
||||
setLine(ghost1Prox, motor1sx, motor1sy, elbow1sx, elbow1sy);
|
||||
setLine(ghost1Dist, elbow1sx, elbow1sy, endsx, endsy);
|
||||
setLine(ghost2Prox, motor2sx, motor2sy, elbow2sx, elbow2sy);
|
||||
setLine(ghost2Dist, elbow2sx, elbow2sy, endsx, endsy);
|
||||
setCircle(ghostElbow1, elbow1sx, elbow1sy);
|
||||
setCircle(ghostElbow2, elbow2sx, elbow2sy);
|
||||
setCircle(ghostEnd, endsx, endsy);
|
||||
ghostLabel.setAttribute('x', endsx + 10);
|
||||
ghostLabel.setAttribute('y', endsy);
|
||||
} else {
|
||||
setLine(arm1Prox, motor1sx, motor1sy, elbow1sx, elbow1sy);
|
||||
setLine(arm1Dist, elbow1sx, elbow1sy, endsx, endsy);
|
||||
setLine(arm2Prox, motor2sx, motor2sy, elbow2sx, elbow2sy);
|
||||
setLine(arm2Dist, elbow2sx, elbow2sy, endsx, endsy);
|
||||
setCircle(elbow1, elbow1sx, elbow1sy);
|
||||
setCircle(elbow2, elbow2sx, elbow2sy);
|
||||
setCircle(endEff, endsx, endsy);
|
||||
|
||||
// Crosshairs
|
||||
xhH.setAttribute('x1', endsx - 13); xhH.setAttribute('y1', endsy);
|
||||
xhH.setAttribute('x2', endsx + 13); xhH.setAttribute('y2', endsy);
|
||||
xhV.setAttribute('x1', endsx); xhV.setAttribute('y1', endsy - 13);
|
||||
xhV.setAttribute('x2', endsx); xhV.setAttribute('y2', endsy + 13);
|
||||
|
||||
coordLabel.textContent = `(${endX.toFixed(1)}, ${endY.toFixed(1)}) mm`;
|
||||
updateReadouts(theta1, theta2, endX, endY);
|
||||
}
|
||||
|
||||
return { endsx, endsy };
|
||||
}
|
||||
|
||||
function renderArmFromSteps() {
|
||||
const t1 = IK.stepsToRad(steps1);
|
||||
const t2 = IK.stepsToRad(steps2);
|
||||
renderArm(t1, t2);
|
||||
}
|
||||
|
||||
function updateReadouts(t1, t2, ex, ey) {
|
||||
if (elCurrX) elCurrX.textContent = isFinite(ex) ? ex.toFixed(1) : '–';
|
||||
if (elCurrY) elCurrY.textContent = isFinite(ey) ? ey.toFixed(1) : '–';
|
||||
if (elTheta1) elTheta1.textContent = IK.radToDeg(t1).toFixed(1) + '°';
|
||||
if (elTheta2) elTheta2.textContent = IK.radToDeg(t2).toFixed(1) + '°';
|
||||
if (elSteps1) elSteps1.textContent = steps1;
|
||||
if (elSteps2) elSteps2.textContent = steps2;
|
||||
}
|
||||
|
||||
// ── Ghost arm show/hide ───────────────────────────────────────────
|
||||
function showGhost(theta1, theta2, labelText) {
|
||||
const ghosts = [ghost1Prox, ghost1Dist, ghost2Prox, ghost2Dist,
|
||||
ghostElbow1, ghostElbow2, ghostEnd, ghostLabel];
|
||||
ghosts.forEach(el => el.style.display = '');
|
||||
renderArm(theta1, theta2, true);
|
||||
ghostLabel.textContent = labelText;
|
||||
}
|
||||
function hideGhost() {
|
||||
[ghost1Prox, ghost1Dist, ghost2Prox, ghost2Dist,
|
||||
ghostElbow1, ghostElbow2, ghostEnd, ghostLabel]
|
||||
.forEach(el => el.style.display = 'none');
|
||||
}
|
||||
|
||||
// ── Target marker ─────────────────────────────────────────────────
|
||||
function showTargetMarker(sx, sy) {
|
||||
targetMarker.setAttribute('cx', sx);
|
||||
targetMarker.setAttribute('cy', sy);
|
||||
targetMarker.style.display = '';
|
||||
targetMarker.classList.remove('target-pulse');
|
||||
void targetMarker.offsetWidth; // force reflow
|
||||
targetMarker.classList.add('target-pulse');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// IK movement
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function moveToXY(targetX, targetY) {
|
||||
// Check workspace limits first
|
||||
const wsCheck = IK.checkWorkspace(targetX, targetY);
|
||||
if (!wsCheck.ok) {
|
||||
UI.log(`⛔ ${wsCheck.reason}`, 'warn');
|
||||
return false;
|
||||
}
|
||||
|
||||
const { theta1, theta2 } = IK.solve(targetX, targetY);
|
||||
|
||||
const newSteps1 = IK.radToSteps(theta1);
|
||||
const newSteps2 = IK.radToSteps(theta2);
|
||||
const delta1 = newSteps1 - steps1;
|
||||
const delta2 = newSteps2 - steps2;
|
||||
|
||||
steps1 = newSteps1;
|
||||
steps2 = newSteps2;
|
||||
renderArm(theta1, theta2);
|
||||
|
||||
const cmd1 = `S1${delta1 >= 0 ? '+' : ''}${delta1}`;
|
||||
const cmd2 = `S2${delta2 >= 0 ? '+' : ''}${delta2}`;
|
||||
|
||||
if (BLE.isConnected()) {
|
||||
try {
|
||||
await BLE.write(cmd1);
|
||||
await BLE.write(cmd2);
|
||||
} catch (e) {
|
||||
UI.log(`BLE error: ${e.message}`, 'error');
|
||||
}
|
||||
} else {
|
||||
UI.log(`[sim] IK→ θ1=${IK.radToDeg(theta1).toFixed(1)}° θ2=${IK.radToDeg(theta2).toFixed(1)}° | ${cmd1} ${cmd2}`, 'info');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Manual jog
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function jogMotor(motor, delta) {
|
||||
if (motor === 1) steps1 += delta;
|
||||
else steps2 += delta;
|
||||
renderArmFromSteps();
|
||||
const cmd = `S${motor}${delta >= 0 ? '+' : ''}${delta}`;
|
||||
BLE.isConnected()
|
||||
? await BLE.write(cmd).catch(e => UI.log(e.message, 'error'))
|
||||
: UI.log(`[sim] ${cmd}`, 'info');
|
||||
}
|
||||
|
||||
async function zeroMotor(motor) {
|
||||
if (motor === 1) steps1 = 0;
|
||||
else steps2 = 0;
|
||||
renderArmFromSteps();
|
||||
const cmd = `HOME${motor}`;
|
||||
BLE.isConnected()
|
||||
? await BLE.write(cmd).catch(e => UI.log(e.message, 'error'))
|
||||
: UI.log(`[sim] ${cmd}`, 'info');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// SVG workspace zones (pre-computed)
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildZones() {
|
||||
const L = IK.LIMITS;
|
||||
// Outer bounding box rect
|
||||
const bx = SV.wx(L.X_MIN);
|
||||
const by = SV.wy(L.Y_MAX);
|
||||
const bw = (L.X_MAX - L.X_MIN) * SV.SCALE;
|
||||
const bh = (L.Y_MAX - L.Y_MIN) * SV.SCALE;
|
||||
|
||||
// Mechanism exclusion box
|
||||
const ex = SV.wx(-L.BOX_HALF_W);
|
||||
const ey = SV.wy(L.BOX_Y_MAX);
|
||||
const ew = L.BOX_HALF_W * 2 * SV.SCALE;
|
||||
const eh = (L.BOX_Y_MAX - L.BOX_Y_MIN) * SV.SCALE;
|
||||
|
||||
// Motor positions
|
||||
const m1x = SV.wx(-IK.ARM.d2);
|
||||
const m2x = SV.wx( IK.ARM.d2);
|
||||
const my = SV.wy(0);
|
||||
|
||||
// Scale ruler
|
||||
const r0x = SV.wx(0); const r0y = SV.wy(-20);
|
||||
const r1x = SV.wx(50);
|
||||
|
||||
return `
|
||||
<!-- Board boundary (outer workspace) -->
|
||||
<rect x="${bx}" y="${by}" width="${bw}" height="${bh}"
|
||||
fill="rgba(68,138,255,0.04)"
|
||||
stroke="rgba(68,138,255,0.30)" stroke-width="1.5" stroke-dasharray="6 4"
|
||||
rx="4"/>
|
||||
|
||||
<!-- Mechanism exclusion zone -->
|
||||
<rect x="${ex}" y="${ey}" width="${ew}" height="${eh}"
|
||||
fill="rgba(255,82,82,0.12)"
|
||||
stroke="rgba(255,82,82,0.55)" stroke-width="1.5"
|
||||
rx="3"/>
|
||||
<text x="${SV.OX}" y="${ey + eh/2 + 4}" text-anchor="middle"
|
||||
font-size="8" fill="rgba(255,82,82,0.7)" font-family="monospace">
|
||||
NO-GO ZONE
|
||||
</text>
|
||||
|
||||
<!-- Axes -->
|
||||
<line x1="${SV.wx(-160)}" y1="${my}" x2="${SV.wx(160)}" y2="${my}"
|
||||
stroke="rgba(255,255,255,0.07)" stroke-width="1"/>
|
||||
<line x1="${SV.OX}" y1="${SV.wy(-40)}" x2="${SV.OX}" y2="${SV.wy(155)}"
|
||||
stroke="rgba(255,255,255,0.07)" stroke-width="1"/>
|
||||
<text x="${SV.wx(163)}" y="${my + 4}" font-size="8"
|
||||
fill="rgba(255,255,255,0.2)" font-family="monospace">+X</text>
|
||||
<text x="${SV.OX + 4}" y="${SV.wy(158)}" font-size="8"
|
||||
fill="rgba(255,255,255,0.2)" font-family="monospace">+Y</text>
|
||||
|
||||
<!-- Scale ruler: 50 mm -->
|
||||
<line x1="${r0x}" y1="${r0y}" x2="${r1x}" y2="${r0y}"
|
||||
stroke="rgba(255,255,255,0.25)" stroke-width="1"/>
|
||||
<line x1="${r0x}" y1="${r0y - 4}" x2="${r0x}" y2="${r0y + 4}"
|
||||
stroke="rgba(255,255,255,0.25)" stroke-width="1"/>
|
||||
<line x1="${r1x}" y1="${r0y - 4}" x2="${r1x}" y2="${r0y + 4}"
|
||||
stroke="rgba(255,255,255,0.25)" stroke-width="1"/>
|
||||
<text x="${(r0x + r1x)/2}" y="${r0y + 12}" text-anchor="middle"
|
||||
font-size="8" fill="rgba(255,255,255,0.3)" font-family="monospace">50 mm</text>
|
||||
|
||||
<!-- Motor markers -->
|
||||
<circle cx="${m1x}" cy="${my}" r="5"
|
||||
fill="#448aff" stroke="#0a0e18" stroke-width="1.5"/>
|
||||
<circle cx="${m2x}" cy="${my}" r="5"
|
||||
fill="#448aff" stroke="#0a0e18" stroke-width="1.5"/>
|
||||
<text x="${m1x}" y="${my + 16}" text-anchor="middle"
|
||||
font-size="7.5" fill="rgba(68,138,255,0.7)" font-family="monospace">M1</text>
|
||||
<text x="${m2x}" y="${my + 16}" text-anchor="middle"
|
||||
font-size="7.5" fill="rgba(68,138,255,0.7)" font-family="monospace">M2</text>
|
||||
`;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// HTML template
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildHTML() {
|
||||
return `
|
||||
<style>
|
||||
.test-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 260px;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 900px) { .test-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
#scara-svg { cursor: crosshair; display: block; width: 100%; }
|
||||
|
||||
@keyframes targetPulse {
|
||||
0% { r: 5px; opacity: 1; }
|
||||
100% { r: 20px; opacity: 0; }
|
||||
}
|
||||
.target-pulse { animation: targetPulse 0.45s ease-out forwards; }
|
||||
|
||||
.pos-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px;
|
||||
}
|
||||
.pos-box {
|
||||
background: var(--surface-2); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm); padding: 7px 8px; text-align: center;
|
||||
}
|
||||
.pos-val { font-size:1.05rem; font-weight:700; font-variant-numeric:tabular-nums; letter-spacing:-0.02em; }
|
||||
.pos-val.xy { color: var(--accent-green); }
|
||||
.pos-val.ang { color: var(--accent-blue); }
|
||||
.pos-val.stp { color: var(--text-secondary); font-size:0.88rem; }
|
||||
.pos-sub { font-size:0.62rem; font-weight:600; text-transform:uppercase;
|
||||
letter-spacing:0.09em; color:var(--text-muted); margin-top:2px; }
|
||||
.jog-compact { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
||||
.jog-motor { background:var(--surface-2); border:1px solid var(--border);
|
||||
border-radius:var(--radius-md); padding:12px;
|
||||
display:flex; flex-direction:column; gap:8px; }
|
||||
.jog-title { font-size:0.72rem; font-weight:700; text-transform:uppercase;
|
||||
letter-spacing:0.09em; color:var(--text-muted); }
|
||||
.quick-steps { display:flex; gap:4px; flex-wrap:wrap; }
|
||||
.quick-steps button { flex:1; min-width:30px; padding:4px 2px; font-size:0.68rem;
|
||||
border-radius:var(--radius-xs); }
|
||||
</style>
|
||||
|
||||
<div class="section-page fade-in">
|
||||
<div class="section-header">
|
||||
<h2>Stepper Test</h2>
|
||||
<p>Click anywhere on the SCARA map to move via IK. Red zone = mechanism exclusion. Dashed box = board limits.</p>
|
||||
</div>
|
||||
|
||||
<div class="test-grid">
|
||||
<!-- ── SCARA map ─────────────────────────────────────── -->
|
||||
<div class="card" style="padding:12px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
|
||||
<span class="card-title">SCARA Map — click to move</span>
|
||||
<span id="sim-badge" style="font-size:0.67rem;padding:3px 8px;border-radius:100px;
|
||||
background:rgba(255,202,40,0.12);color:var(--accent-amber);
|
||||
border:1px solid rgba(255,202,40,0.25);font-weight:600">SIMULATION</span>
|
||||
</div>
|
||||
|
||||
<svg id="scara-svg" viewBox="0 0 ${SV.W} ${SV.H}" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background grid -->
|
||||
<defs>
|
||||
<pattern id="grid-pat" width="27" height="27" patternUnits="userSpaceOnUse"
|
||||
patternTransform="translate(${SV.OX % 27},${SV.OY % 27})">
|
||||
<path d="M 27 0 L 0 0 0 27" fill="none"
|
||||
stroke="rgba(255,255,255,0.035)" stroke-width="0.5"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="${SV.W}" height="${SV.H}" fill="#070a0f"/>
|
||||
<rect width="${SV.W}" height="${SV.H}" fill="url(#grid-pat)"/>
|
||||
|
||||
<!-- Workspace zones (board boundary, exclusion zone, axes) -->
|
||||
${buildZones()}
|
||||
|
||||
<!-- ── Ghost arm (hover preview) ─────────────────── -->
|
||||
<line id="ghost-1p" style="display:none"
|
||||
stroke="rgba(68,138,255,0.20)" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<line id="ghost-1d" style="display:none"
|
||||
stroke="rgba(68,138,255,0.16)" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<line id="ghost-2p" style="display:none"
|
||||
stroke="rgba(68,138,255,0.20)" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<line id="ghost-2d" style="display:none"
|
||||
stroke="rgba(68,138,255,0.16)" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<circle id="ghost-e1" r="4" style="display:none" fill="rgba(160,196,255,0.22)"/>
|
||||
<circle id="ghost-e2" r="4" style="display:none" fill="rgba(160,196,255,0.22)"/>
|
||||
<circle id="ghost-end" r="5" style="display:none" fill="rgba(0,230,118,0.22)"/>
|
||||
<text id="ghost-label" font-size="9" fill="rgba(0,230,118,0.6)"
|
||||
font-family="monospace" style="display:none"/>
|
||||
|
||||
<!-- ── Real arm ──────────────────────────────────── -->
|
||||
<!-- Arm 1: proximal + distal -->
|
||||
<line id="arm1-prox"
|
||||
x1="${SV.wx(-IK.ARM.d2)}" y1="${SV.wy(0)}"
|
||||
x2="${SV.wx(-IK.ARM.d2)}" y2="${SV.wy(IK.ARM.l1)}"
|
||||
stroke="#448aff" stroke-width="5" stroke-linecap="round"/>
|
||||
<line id="arm1-dist"
|
||||
x1="${SV.wx(-IK.ARM.d2)}" y1="${SV.wy(IK.ARM.l1)}"
|
||||
x2="${SV.wx(0)}" y2="${SV.wy(IK.ARM.l1 + IK.ARM.l2)}"
|
||||
stroke="#7eb8f7" stroke-width="3.5" stroke-linecap="round" stroke-dasharray="6 3"/>
|
||||
|
||||
<!-- Arm 2: proximal + distal -->
|
||||
<line id="arm2-prox"
|
||||
x1="${SV.wx(IK.ARM.d2)}" y1="${SV.wy(0)}"
|
||||
x2="${SV.wx(IK.ARM.d2)}" y2="${SV.wy(IK.ARM.l1)}"
|
||||
stroke="#f7a03c" stroke-width="5" stroke-linecap="round"/>
|
||||
<line id="arm2-dist"
|
||||
x1="${SV.wx(IK.ARM.d2)}" y1="${SV.wy(IK.ARM.l1)}"
|
||||
x2="${SV.wx(0)}" y2="${SV.wy(IK.ARM.l1 + IK.ARM.l2)}"
|
||||
stroke="#ffd08a" stroke-width="3.5" stroke-linecap="round" stroke-dasharray="6 3"/>
|
||||
|
||||
<!-- Elbow joints -->
|
||||
<circle id="elbow1" r="5" fill="#a0c4ff" stroke="#0a0e18" stroke-width="1.5"
|
||||
cx="${SV.wx(-IK.ARM.d2)}" cy="${SV.wy(IK.ARM.l1)}"/>
|
||||
<circle id="elbow2" r="5" fill="#ffd08a" stroke="#0a0e18" stroke-width="1.5"
|
||||
cx="${SV.wx( IK.ARM.d2)}" cy="${SV.wy(IK.ARM.l1)}"/>
|
||||
|
||||
<!-- End effector -->
|
||||
<circle id="end-eff" r="7"
|
||||
cx="${SV.wx(0)}" cy="${SV.wy(IK.ARM.l1 + IK.ARM.l2)}"
|
||||
fill="var(--accent-green)" stroke="#0a0e18" stroke-width="1.5"/>
|
||||
|
||||
<!-- Crosshair -->
|
||||
<line id="xh-h" stroke="rgba(0,230,118,0.5)" stroke-width="1"
|
||||
x1="0" y1="0" x2="0" y2="0"/>
|
||||
<line id="xh-v" stroke="rgba(0,230,118,0.5)" stroke-width="1"
|
||||
x1="0" y1="0" x2="0" y2="0"/>
|
||||
|
||||
<!-- Target marker -->
|
||||
<circle id="target-marker" r="5" style="display:none"
|
||||
fill="none" stroke="var(--accent-green)" stroke-width="1.5"/>
|
||||
|
||||
<!-- Coordinate label -->
|
||||
<text id="coord-label"
|
||||
x="${SV.OX}" y="${SV.H - 8}"
|
||||
text-anchor="middle" font-size="10"
|
||||
fill="rgba(0,230,118,0.65)" font-family="monospace">
|
||||
(0.0, 0.0) mm
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
<div style="display:flex;gap:16px;font-size:0.70rem;color:var(--text-muted);margin-top:6px;flex-wrap:wrap">
|
||||
<span style="color:rgba(68,138,255,0.8)">━ M1 (left)</span>
|
||||
<span style="color:rgba(247,160,60,0.8)">━ M2 (right)</span>
|
||||
<span style="color:rgba(255,82,82,0.7)">▪ NO-GO zone</span>
|
||||
<span style="color:rgba(68,138,255,0.5)">╌ board limits</span>
|
||||
<span>l1=${IK.ARM.l1}mm l2=${IK.ARM.l2}mm d=${IK.ARM.d}mm</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Right: controls ──────────────────────────────── -->
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
|
||||
<!-- Current position -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Current Position</span></div>
|
||||
<div class="pos-grid">
|
||||
<div class="pos-box">
|
||||
<div class="pos-val xy" id="curr-x">0.0</div>
|
||||
<div class="pos-sub">X (mm)</div>
|
||||
</div>
|
||||
<div class="pos-box">
|
||||
<div class="pos-val xy" id="curr-y">0.0</div>
|
||||
<div class="pos-sub">Y (mm)</div>
|
||||
</div>
|
||||
<div class="pos-box">
|
||||
<div class="pos-val ang" id="curr-t1">0.0°</div>
|
||||
<div class="pos-sub">θ1 (M1)</div>
|
||||
</div>
|
||||
<div class="pos-box">
|
||||
<div class="pos-val ang" id="curr-t2">0.0°</div>
|
||||
<div class="pos-sub">θ2 (M2)</div>
|
||||
</div>
|
||||
<div class="pos-box">
|
||||
<div class="pos-val stp" id="curr-s1">0</div>
|
||||
<div class="pos-sub">M1 steps</div>
|
||||
</div>
|
||||
<div class="pos-box">
|
||||
<div class="pos-val stp" id="curr-s2">0</div>
|
||||
<div class="pos-sub">M2 steps</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Move to XY -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Move to XY</span></div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
<label class="form-label" style="width:18px">X</label>
|
||||
<input class="form-input" type="number" id="input-x"
|
||||
value="0" step="5" style="flex:1;text-align:center"/>
|
||||
<span class="form-label">mm</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
<label class="form-label" style="width:18px">Y</label>
|
||||
<input class="form-input" type="number" id="input-y"
|
||||
value="100" step="5" style="flex:1;text-align:center"/>
|
||||
<span class="form-label">mm</span>
|
||||
</div>
|
||||
<button class="btn btn-success btn-full" id="btn-goto">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
<span class="btn-text">Move to XY</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Motion settings -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">Motion</span></div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Speed (steps/s)</label>
|
||||
<div class="range-row">
|
||||
<input class="form-range" type="range" id="speed-slider"
|
||||
min="100" max="1000" step="50" value="600"/>
|
||||
<span class="range-value" id="speed-val">600</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Accel (steps/s²)</label>
|
||||
<div class="range-row">
|
||||
<input class="form-range" type="range" id="accel-slider"
|
||||
min="50" max="500" step="25" value="100"/>
|
||||
<span class="range-value" id="accel-val">100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /right panel -->
|
||||
</div><!-- /test-grid -->
|
||||
|
||||
<!-- Manual jog -->
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Manual Jog</span>
|
||||
<span style="font-size:0.72rem;color:var(--text-muted)">Incremental step control — bypasses IK</span>
|
||||
</div>
|
||||
<div class="jog-compact">
|
||||
<!-- Motor 1 -->
|
||||
<div class="jog-motor">
|
||||
<div class="jog-title" style="color:rgba(68,138,255,0.9)">Motor 1 — Left</div>
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
<button class="btn btn-ghost btn-sm" style="flex:1" id="s1-ccw">◀ CCW</button>
|
||||
<input class="form-input" type="number" id="jog-steps-1"
|
||||
value="50" min="1" max="2048"
|
||||
style="width:60px;padding:5px 3px;text-align:center;font-size:0.82rem"/>
|
||||
<button class="btn btn-ghost btn-sm" style="flex:1" id="s1-cw">CW ▶</button>
|
||||
</div>
|
||||
<div class="quick-steps">
|
||||
${[10,50,100,512].map(n=>`<button class="btn btn-ghost" data-motor="1" data-steps="${n}">+${n}</button>`).join('')}
|
||||
${[10,50,100,512].map(n=>`<button class="btn btn-ghost" data-motor="1" data-steps="-${n}">-${n}</button>`).join('')}
|
||||
</div>
|
||||
<button class="btn btn-danger btn-sm btn-full" id="s1-zero">Zero M1</button>
|
||||
</div>
|
||||
<!-- Motor 2 -->
|
||||
<div class="jog-motor">
|
||||
<div class="jog-title" style="color:rgba(247,160,60,0.9)">Motor 2 — Right</div>
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
<button class="btn btn-ghost btn-sm" style="flex:1" id="s2-ccw">◀ CCW</button>
|
||||
<input class="form-input" type="number" id="jog-steps-2"
|
||||
value="50" min="1" max="2048"
|
||||
style="width:60px;padding:5px 3px;text-align:center;font-size:0.82rem"/>
|
||||
<button class="btn btn-ghost btn-sm" style="flex:1" id="s2-cw">CW ▶</button>
|
||||
</div>
|
||||
<div class="quick-steps">
|
||||
${[10,50,100,512].map(n=>`<button class="btn btn-ghost" data-motor="2" data-steps="${n}">+${n}</button>`).join('')}
|
||||
${[10,50,100,512].map(n=>`<button class="btn btn-ghost" data-motor="2" data-steps="-${n}">-${n}</button>`).join('')}
|
||||
</div>
|
||||
<button class="btn btn-danger btn-sm btn-full" id="s2-zero">Zero M2</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event log -->
|
||||
<div class="card">
|
||||
<div class="log-header">
|
||||
<span class="log-title">Event Log</span>
|
||||
<button class="log-clear-btn" id="clear-log-btn">Clear</button>
|
||||
</div>
|
||||
<div class="log-console" id="log-console" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Section lifecycle
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const StepperTestSection = {
|
||||
mount(container) {
|
||||
container.innerHTML = buildHTML();
|
||||
|
||||
// ── Cache refs ──────────────────────────────────────────────
|
||||
svgEl = document.getElementById('scara-svg');
|
||||
arm1Prox = document.getElementById('arm1-prox');
|
||||
arm1Dist = document.getElementById('arm1-dist');
|
||||
arm2Prox = document.getElementById('arm2-prox');
|
||||
arm2Dist = document.getElementById('arm2-dist');
|
||||
elbow1 = document.getElementById('elbow1');
|
||||
elbow2 = document.getElementById('elbow2');
|
||||
endEff = document.getElementById('end-eff');
|
||||
coordLabel= document.getElementById('coord-label');
|
||||
xhH = document.getElementById('xh-h');
|
||||
xhV = document.getElementById('xh-v');
|
||||
|
||||
ghost1Prox = document.getElementById('ghost-1p');
|
||||
ghost1Dist = document.getElementById('ghost-1d');
|
||||
ghost2Prox = document.getElementById('ghost-2p');
|
||||
ghost2Dist = document.getElementById('ghost-2d');
|
||||
ghostElbow1 = document.getElementById('ghost-e1');
|
||||
ghostElbow2 = document.getElementById('ghost-e2');
|
||||
ghostEnd = document.getElementById('ghost-end');
|
||||
ghostLabel = document.getElementById('ghost-label');
|
||||
targetMarker= document.getElementById('target-marker');
|
||||
|
||||
elCurrX = document.getElementById('curr-x');
|
||||
elCurrY = document.getElementById('curr-y');
|
||||
elTheta1 = document.getElementById('curr-t1');
|
||||
elTheta2 = document.getElementById('curr-t2');
|
||||
elSteps1 = document.getElementById('curr-s1');
|
||||
elSteps2 = document.getElementById('curr-s2');
|
||||
simBadge = document.getElementById('sim-badge');
|
||||
|
||||
// ── SVG coordinate helpers ───────────────────────────────────
|
||||
function pointerToWorld(e) {
|
||||
const rect = svgEl.getBoundingClientRect();
|
||||
const sx = (e.clientX - rect.left) * (SV.W / rect.width);
|
||||
const sy = (e.clientY - rect.top) * (SV.H / rect.height);
|
||||
return { wx: SV.svgToWorldX(sx), wy: SV.svgToWorldY(sy), sx, sy };
|
||||
}
|
||||
|
||||
// ── SVG hover ───────────────────────────────────────────────
|
||||
svgEl.addEventListener('mousemove', (e) => {
|
||||
const { wx, wy } = pointerToWorld(e);
|
||||
const ws = IK.checkWorkspace(wx, wy);
|
||||
if (ws.ok) {
|
||||
const { theta1, theta2 } = IK.solve(wx, wy);
|
||||
showGhost(theta1, theta2, `(${wx.toFixed(0)},${wy.toFixed(0)})`);
|
||||
svgEl.style.cursor = 'crosshair';
|
||||
} else {
|
||||
hideGhost();
|
||||
svgEl.style.cursor = 'not-allowed';
|
||||
}
|
||||
});
|
||||
svgEl.addEventListener('mouseleave', () => {
|
||||
hideGhost();
|
||||
svgEl.style.cursor = 'crosshair';
|
||||
});
|
||||
|
||||
// ── SVG click ───────────────────────────────────────────────
|
||||
svgEl.addEventListener('click', async (e) => {
|
||||
const { wx, wy, sx, sy } = pointerToWorld(e);
|
||||
const ws = IK.checkWorkspace(wx, wy);
|
||||
if (!ws.ok) {
|
||||
UI.log(`⛔ ${ws.reason}`, 'warn');
|
||||
return;
|
||||
}
|
||||
showTargetMarker(sx, sy);
|
||||
const xi = document.getElementById('input-x');
|
||||
const yi = document.getElementById('input-y');
|
||||
if (xi) xi.value = wx.toFixed(1);
|
||||
if (yi) yi.value = wy.toFixed(1);
|
||||
await moveToXY(wx, wy);
|
||||
});
|
||||
|
||||
// ── Move to XY button ────────────────────────────────────────
|
||||
document.getElementById('btn-goto').addEventListener('click', async () => {
|
||||
const x = parseFloat(document.getElementById('input-x').value);
|
||||
const y = parseFloat(document.getElementById('input-y').value);
|
||||
if (isNaN(x) || isNaN(y)) { UI.log('Invalid XY.', 'warn'); return; }
|
||||
await moveToXY(x, y);
|
||||
});
|
||||
|
||||
// ── Jog CW / CCW ────────────────────────────────────────────
|
||||
const gs = (n) => parseInt(document.getElementById(`jog-steps-${n}`)?.value ?? '50', 10) || 50;
|
||||
document.getElementById('s1-cw') .addEventListener('click', () => jogMotor(1, +gs(1)));
|
||||
document.getElementById('s1-ccw').addEventListener('click', () => jogMotor(1, -gs(1)));
|
||||
document.getElementById('s2-cw') .addEventListener('click', () => jogMotor(2, +gs(2)));
|
||||
document.getElementById('s2-ccw').addEventListener('click', () => jogMotor(2, -gs(2)));
|
||||
document.getElementById('s1-zero').addEventListener('click', () => zeroMotor(1));
|
||||
document.getElementById('s2-zero').addEventListener('click', () => zeroMotor(2));
|
||||
|
||||
// ── Quick-step buttons ────────────────────────────────────────
|
||||
container.querySelectorAll('[data-motor][data-steps]').forEach(btn => {
|
||||
btn.addEventListener('click', () =>
|
||||
jogMotor(parseInt(btn.dataset.motor, 10), parseInt(btn.dataset.steps, 10)));
|
||||
});
|
||||
|
||||
// ── Speed / Accel sliders ─────────────────────────────────────
|
||||
let spdT, accT;
|
||||
document.getElementById('speed-slider').addEventListener('input', (e) => {
|
||||
document.getElementById('speed-val').textContent = e.target.value;
|
||||
clearTimeout(spdT);
|
||||
spdT = setTimeout(() => {
|
||||
const cmd = `SPD:${e.target.value}`;
|
||||
BLE.isConnected() ? BLE.write(cmd).catch(() => {}) : UI.log(`[sim] ${cmd}`, 'info');
|
||||
}, 400);
|
||||
});
|
||||
document.getElementById('accel-slider').addEventListener('input', (e) => {
|
||||
document.getElementById('accel-val').textContent = e.target.value;
|
||||
clearTimeout(accT);
|
||||
accT = setTimeout(() => {
|
||||
const cmd = `ACC:${e.target.value}`;
|
||||
BLE.isConnected() ? BLE.write(cmd).catch(() => {}) : UI.log(`[sim] ${cmd}`, 'info');
|
||||
}, 400);
|
||||
});
|
||||
|
||||
// ── Log clear ─────────────────────────────────────────────────
|
||||
document.getElementById('clear-log-btn').addEventListener('click', UI.clearLog);
|
||||
|
||||
// ── BLE badge ─────────────────────────────────────────────────
|
||||
function updateBadge() {
|
||||
if (!simBadge) return;
|
||||
const live = BLE.isConnected();
|
||||
simBadge.textContent = live ? 'LIVE' : 'SIMULATION';
|
||||
simBadge.style.background = live ? 'rgba(0,230,118,0.12)' : 'rgba(255,202,40,0.12)';
|
||||
simBadge.style.color = live ? 'var(--accent-green)' : 'var(--accent-amber)';
|
||||
simBadge.style.border = live ? '1px solid rgba(0,230,118,0.3)' : '1px solid rgba(255,202,40,0.25)';
|
||||
}
|
||||
BLE.on('connected', updateBadge);
|
||||
BLE.on('disconnected', updateBadge);
|
||||
updateBadge();
|
||||
|
||||
// ── BLE NOTIFY position feedback ──────────────────────────────
|
||||
function onBLEStatus(e) {
|
||||
const msg = e.detail;
|
||||
if (msg.startsWith('P:')) {
|
||||
const [s1, s2] = msg.slice(2).split(',').map(Number);
|
||||
steps1 = s1; steps2 = s2;
|
||||
renderArmFromSteps();
|
||||
}
|
||||
}
|
||||
document.addEventListener('ble:status', onBLEStatus);
|
||||
|
||||
eventCleanup = [
|
||||
() => BLE.off('connected', updateBadge),
|
||||
() => BLE.off('disconnected', updateBadge),
|
||||
() => document.removeEventListener('ble:status', onBLEStatus),
|
||||
];
|
||||
|
||||
// ── Initial render ─────────────────────────────────────────────
|
||||
renderArmFromSteps();
|
||||
UI.log('Stepper Test ready — 5-bar parallel IK active.', 'success');
|
||||
UI.log(`Arm: d=${IK.ARM.d}mm l1=${IK.ARM.l1}mm l2=${IK.ARM.l2}mm`, 'info');
|
||||
if (!BLE.isConnected()) {
|
||||
UI.log('Simulation mode — BLE commands logged but not sent.', 'warn');
|
||||
}
|
||||
},
|
||||
|
||||
unmount() {
|
||||
eventCleanup.forEach(fn => fn());
|
||||
eventCleanup = [];
|
||||
},
|
||||
};
|
||||
|
||||
export default StepperTestSection;
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* WijiBoard – Shared UI Utilities
|
||||
* web/js/ui.js
|
||||
*
|
||||
* Exports a UI object with helpers used across all sections.
|
||||
* Requires the SPA shell to contain:
|
||||
* #ble-status, #ble-label (top-bar BLE indicator)
|
||||
* #log-console (global event log)
|
||||
* #btn-ble-connect (top-bar connect button)
|
||||
*/
|
||||
|
||||
import BLE from './ble.js';
|
||||
|
||||
const UI = (() => {
|
||||
|
||||
// ── Log console ─────────────────────────────────────────────
|
||||
function log(message, type = 'info') {
|
||||
const console = document.getElementById('log-console');
|
||||
if (!console) return;
|
||||
|
||||
const ts = new Date().toLocaleTimeString('en-US', { hour12: false });
|
||||
const entry = document.createElement('span');
|
||||
entry.className = `log-entry ${type}`;
|
||||
entry.textContent = `[${ts}] ${message}`;
|
||||
|
||||
const br = document.createElement('br');
|
||||
console.appendChild(entry);
|
||||
console.appendChild(br);
|
||||
console.scrollTop = console.scrollHeight;
|
||||
}
|
||||
|
||||
function clearLog() {
|
||||
const el = document.getElementById('log-console');
|
||||
if (el) el.innerHTML = '';
|
||||
}
|
||||
|
||||
// ── BLE status indicator (top bar) ──────────────────────────
|
||||
function setConnectionStatus(connected, deviceName = null) {
|
||||
const statusEl = document.getElementById('ble-status');
|
||||
const labelEl = document.getElementById('ble-label');
|
||||
const btn = document.getElementById('btn-ble-connect');
|
||||
|
||||
if (statusEl) statusEl.classList.toggle('connected', connected);
|
||||
if (labelEl) labelEl.textContent = connected
|
||||
? (deviceName ?? 'Connected')
|
||||
: 'Disconnected';
|
||||
if (btn) {
|
||||
btn.disabled = connected;
|
||||
btn.innerHTML = connected
|
||||
? '<span class="btn-text">Disconnect</span>'
|
||||
: '<span class="btn-text">Connect</span>';
|
||||
btn.className = connected
|
||||
? 'btn btn-sm btn-danger'
|
||||
: 'btn btn-sm btn-primary';
|
||||
|
||||
// Rebind: when connected, button should disconnect
|
||||
btn.onclick = connected
|
||||
? () => BLE.disconnect()
|
||||
: () => BLE.connect().catch(e => {
|
||||
if (!e.message?.includes('cancelled')) {
|
||||
log(`Connect failed: ${e.message}`, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Button loading state ─────────────────────────────────────
|
||||
function setButtonLoading(btnId, loading) {
|
||||
const btn = document.getElementById(btnId);
|
||||
if (!btn) return;
|
||||
btn.classList.toggle('loading', loading);
|
||||
btn.disabled = loading;
|
||||
|
||||
// Toggle spinner visibility
|
||||
const spinner = btn.querySelector('.spinner');
|
||||
if (spinner) spinner.style.display = loading ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// ── Wire BLE events to UI ────────────────────────────────────
|
||||
function initBLEListeners() {
|
||||
BLE.on('connecting', () => {
|
||||
log('Scanning for WijiBoard…', 'info');
|
||||
setButtonLoading('btn-ble-connect', true);
|
||||
});
|
||||
|
||||
BLE.on('connected', (name) => {
|
||||
setButtonLoading('btn-ble-connect', false);
|
||||
setConnectionStatus(true, name);
|
||||
log(`Connected to "${name}" ✓`, 'success');
|
||||
});
|
||||
|
||||
BLE.on('disconnected', () => {
|
||||
setButtonLoading('btn-ble-connect', false);
|
||||
setConnectionStatus(false);
|
||||
log('BLE connection lost.', 'warn');
|
||||
});
|
||||
|
||||
BLE.on('sent', (cmd) => {
|
||||
log(`→ ${cmd}`, 'sent');
|
||||
});
|
||||
|
||||
BLE.on('status', (msg) => {
|
||||
// Forward raw status messages to sections that need them
|
||||
document.dispatchEvent(new CustomEvent('ble:status', { detail: msg }));
|
||||
});
|
||||
}
|
||||
|
||||
return { log, clearLog, setConnectionStatus, setButtonLoading, initBLEListeners };
|
||||
})();
|
||||
|
||||
export default UI;
|
||||
Reference in New Issue
Block a user