Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 43 additions & 26 deletions tulip/amyboardweb/static/editor/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -999,43 +999,55 @@
}
return;
}
_show_syncing_modal();
// Post-zB reload: continuation of a previous green-button click
// on Windows Chrome. The board is already rebooting into
// bootloader mode — go straight to busy state, skip the gate
// and skip zB.
var _post_zb_reload = sessionStorage.getItem('amyboard_post_zb_reload') === '1';
if (_post_zb_reload) {
// Second load of this session, after the Windows reload trick.
// The board should already be in bootloader mode from the zB
// we sent pre-reload; skip zB and just confirm readiness.
sessionStorage.removeItem('amyboard_post_zb_reload');
_show_syncing_modal_busy();
console.log('pageload: post-zB reload — skipping zB, waiting for board...');
var ready = await wait_for_board_ready(10000);
if (!ready) {
_show_syncing_modal_error();
return;
}
} else if (_IS_WINDOWS_CHROME) {
// Windows Chrome path: send zB, wait a few seconds for the
// board to finish rebooting into bootloader mode, then force
// a full page reload so Chrome hands us a fresh MIDIAccess.
reboot_to_bootloader();
console.log('pageload: Windows Chrome — zB sent, reloading in 4s for fresh MIDIAccess');
sessionStorage.setItem('amyboard_post_zb_reload', '1');
// 4s chosen to comfortably cover the ~3s board reboot budget
// without leaving the user staring at an empty spinner for
// noticeably longer than necessary.
await new Promise(function(r) { setTimeout(r, 4000); });
console.log('pageload: reloading now');
window.location.reload();
return; // unreachable after reload
} else {
// macOS and others: standard path — zB then poll for reply.
reboot_to_bootloader();
console.log('pageload: zB sent, waiting for board...');
var ready = await wait_for_board_ready();
if (!ready) {
// Board didn't respond — show the error/change-ports UI.
_show_syncing_modal_error();
// Fresh start: gate behind the green Pull button. The user
// confirms MIDI port selections (or changes them) and clicks
// Pull from AMYboard before any sysex traffic.
try {
await _show_syncing_modal();
} catch (e) {
console.log('pageload: pull cancelled', e && e.message);
return;
}
if (_IS_WINDOWS_CHROME) {
// Windows Chrome path: send zB, wait a few seconds for the
// board to finish rebooting into bootloader mode, then force
// a full page reload so Chrome hands us a fresh MIDIAccess.
reboot_to_bootloader();
console.log('pageload: Windows Chrome — zB sent, reloading in 4s for fresh MIDIAccess');
sessionStorage.setItem('amyboard_post_zb_reload', '1');
// 4s chosen to comfortably cover the ~3s board reboot budget
// without leaving the user staring at an empty spinner for
// noticeably longer than necessary.
await new Promise(function(r) { setTimeout(r, 4000); });
console.log('pageload: reloading now');
window.location.reload();
return; // unreachable after reload
} else {
// macOS and others: standard path — zB then poll for reply.
reboot_to_bootloader();
console.log('pageload: zB sent, waiting for board...');
var ready = await wait_for_board_ready();
if (!ready) {
// Board didn't respond — show the error/change-ports UI.
_show_syncing_modal_error();
return;
}
}
}
// Pull sketch.py from hardware (zD only, no zA).
_sync_stage = 'pending';
Expand Down Expand Up @@ -1069,7 +1081,10 @@
amy_add_log_message('zPimport amyboard; amyboard.restart_sketch()Z');
console.log('pageload: sketch started via zP');
};
// Auto-sync from hardware once MIDI devices are ready.
// pageload_control_sync handles continuations (post-reset,
// post-zB-upload, post-zB reload) inline and gates fresh page
// loads behind the green Pull button via _show_syncing_modal,
// so the post-MIDI-init callback can call it directly.
_on_midi_ready = window.pageload_control_sync;
await start_midi();
}
Expand Down Expand Up @@ -1255,6 +1270,8 @@ <h4 class="modal-title w-100 text-center" id="simulateWelcomeModalLabel">Welcome
<select id="sync-modal-midi-out" class="form-select form-select-sm mb-2" disabled></select>
<button type="button" class="btn btn-outline-primary btn-sm w-100" id="sync-modal-change-btn" onclick="sync_modal_change_ports();">Change MIDI Ports</button>
</div>
<!-- Pull from AMYboard (shown on fresh page load; user clicks to start the pull) -->
<button type="button" class="btn btn-success btn-lg w-100 d-none mb-3" id="sync-modal-pull-btn" onclick="start_pull_from_amyboard();">Pull from AMYboard</button>
<!-- Try Again (shown on error) -->
<button type="button" class="btn btn-primary btn-sm d-none mb-2" id="sync-modal-retry-btn" onclick="sync_modal_retry();">Try again</button>
<div class="d-flex gap-2 justify-content-center">
Expand Down
195 changes: 167 additions & 28 deletions tulip/amyboardweb/static/spss.js
Original file line number Diff line number Diff line change
Expand Up @@ -3948,7 +3948,14 @@ async function save_amy_state() {
}
}
// Step 4+5: reboot into bootloader so sketch isn't running.
_show_syncing_modal();
// Modal gates behind the green Pull button — user confirms
// MIDI ports before any sysex traffic for the upload.
try {
await _show_syncing_modal();
} catch (e) {
console.log('save: zB-reboot gate cancelled', e && e.message);
return;
}
var _saveOpts = {
sketchText: mergedSketch,
restart: true, // Step 7: restart sketch on hw
Expand Down Expand Up @@ -4238,45 +4245,172 @@ function _hide_resetting_modal() {
document.body.style.removeProperty('padding-right');
}

// Pending green-button gate. _show_syncing_modal returns a promise tied
// to these handles; start_pull_from_amyboard resolves them when the user
// clicks the green button, and modal-dismiss paths reject them.
var _pull_button_resolve = null;
var _pull_button_reject = null;

// One-shot flag: set by sync_modal_retry before re-invoking the work
// path so the next _show_syncing_modal call goes straight to busy state
// instead of re-prompting with the green button. The user already
// chose ports and hit Try again — bouncing them back to a green button
// would force two clicks per retry.
var _skip_pull_gate = false;

function _clear_pull_button_gate(reason) {
if (_pull_button_reject) {
var rj = _pull_button_reject;
_pull_button_resolve = null;
_pull_button_reject = null;
rj(new Error(reason || 'cancelled'));
} else {
_pull_button_resolve = null;
_pull_button_reject = null;
}
}

// Show the syncing modal in its "ready to act" state — green Pull button
// visible, MIDI dropdowns enabled, no spinner. Returns a promise that
// resolves when the user clicks the green button (after MIDI port
// selections have been applied to the main dropdowns and the modal has
// transitioned to the busy/spinner state). Rejects if the modal is
// dismissed via _hide_syncing_modal or an exit button.
//
// Every caller awaits this before issuing sysex traffic — the user
// always has the chance to confirm/change MIDI ports first. This is the
// ONE syncing modal the editor exposes; no separate auto-spinner path.
function _show_syncing_modal() {
if (amyboard_mode !== 'control') return;
if (amyboard_mode !== 'control') return Promise.resolve();
var el = document.getElementById('syncingModal');
if (!el || !window.bootstrap) return;
// Reset to spinner state.
if (!el || !window.bootstrap) return Promise.resolve();
// sync_modal_retry has already applied MIDI ports and validated; go
// straight to busy state and resolve so the caller's work continues.
if (_skip_pull_gate) {
_skip_pull_gate = false;
_show_syncing_modal_busy();
return Promise.resolve();
}
// Reset to ready-to-act state with green Pull button visible.
var spinner = document.getElementById('sync-modal-spinner');
var error = document.getElementById('sync-modal-error');
var retryBtn = document.getElementById('sync-modal-retry-btn');
var changeBtn = document.getElementById('sync-modal-change-btn');
var pullBtn = document.getElementById('sync-modal-pull-btn');
var modalIn = document.getElementById('sync-modal-midi-in');
var modalOut = document.getElementById('sync-modal-midi-out');
if (spinner) spinner.classList.add('d-none');
if (error) error.classList.add('d-none');
if (retryBtn) retryBtn.classList.add('d-none');
if (changeBtn) changeBtn.classList.add('d-none');
if (modalIn) modalIn.disabled = false;
if (modalOut) modalOut.disabled = false;
if (pullBtn) pullBtn.classList.remove('d-none');
_sync_modal_populate_midi();
bootstrap.Modal.getOrCreateInstance(el, { backdrop: 'static', keyboard: false }).show();
// Replace any prior pending gate (only one click handler in flight).
_clear_pull_button_gate('superseded');
return new Promise(function(resolve, reject) {
_pull_button_resolve = resolve;
_pull_button_reject = reject;
});
}

// Internal helper: transition the modal to its busy/spinner state.
// Used after the green button click and from continuation paths
// (Windows-Chrome post-zB reload) where the click already happened on
// the previous page and we need to resume in busy state.
function _show_syncing_modal_busy() {
var el = document.getElementById('syncingModal');
if (el && window.bootstrap) {
bootstrap.Modal.getOrCreateInstance(el, { backdrop: 'static', keyboard: false }).show();
}
var spinner = document.getElementById('sync-modal-spinner');
var error = document.getElementById('sync-modal-error');
var retryBtn = document.getElementById('sync-modal-retry-btn');
var changeBtn = document.getElementById('sync-modal-change-btn');
var pullBtn = document.getElementById('sync-modal-pull-btn');
var modalIn = document.getElementById('sync-modal-midi-in');
var modalOut = document.getElementById('sync-modal-midi-out');
if (spinner) spinner.classList.remove('d-none');
if (error) error.classList.add('d-none');
if (retryBtn) retryBtn.classList.add('d-none');
if (pullBtn) pullBtn.classList.add('d-none');
if (modalIn) modalIn.disabled = true;
if (modalOut) modalOut.disabled = true;
if (changeBtn) {
changeBtn.textContent = 'Change MIDI Ports';
changeBtn.classList.remove('d-none');
changeBtn.onclick = function() { sync_modal_change_ports(); };
}
_sync_modal_populate_midi();
bootstrap.Modal.getOrCreateInstance(el, { backdrop: 'static', keyboard: false }).show();
}

// Click handler for the green "Pull from AMYboard" button. Applies the
// modal's MIDI port selections back to the main dropdowns, runs
// setup_midi_devices, transitions to busy state, then resolves the
// pending _show_syncing_modal promise so the awaiting caller proceeds.
async function start_pull_from_amyboard() {
var mainIn = document.amyboard_settings && document.amyboard_settings.midi_input;
var mainOut = document.amyboard_settings && document.amyboard_settings.midi_output;
var modalIn = document.getElementById('sync-modal-midi-in');
var modalOut = document.getElementById('sync-modal-midi-out');

// Refresh main against current WebMidi state, then copy the modal
// selection over (clamped to a valid index).
if (typeof _refresh_main_midi_dropdowns === 'function') {
_refresh_main_midi_dropdowns();
}
if (mainIn && modalIn) {
var wantIn = modalIn.selectedIndex;
if (wantIn >= 0 && wantIn < mainIn.options.length) {
mainIn.selectedIndex = wantIn;
} else if (mainIn.options.length > 0) {
mainIn.selectedIndex = 0;
}
}
if (mainOut && modalOut) {
var wantOut = modalOut.selectedIndex;
if (wantOut >= 0 && wantOut < mainOut.options.length) {
mainOut.selectedIndex = wantOut;
} else if (mainOut.options.length > 0) {
mainOut.selectedIndex = 0;
}
}
try {
await setup_midi_devices();
} catch (e) {
console.warn('start_pull_from_amyboard: setup_midi_devices threw', e);
}
if (!midiOutputDevice || !midiInputDevice) {
_show_syncing_modal_error();
return;
}
_show_syncing_modal_busy();
if (_pull_button_resolve) {
var r = _pull_button_resolve;
_pull_button_resolve = null;
_pull_button_reject = null;
r();
}
}

function _show_syncing_modal_error() {
var spinner = document.getElementById('sync-modal-spinner');
var error = document.getElementById('sync-modal-error');
var retryBtn = document.getElementById('sync-modal-retry-btn');
var changeBtn = document.getElementById('sync-modal-change-btn');
var pullBtn = document.getElementById('sync-modal-pull-btn');
var modalIn = document.getElementById('sync-modal-midi-in');
var modalOut = document.getElementById('sync-modal-midi-out');
if (spinner) spinner.classList.add('d-none');
if (error) error.classList.remove('d-none');
// Use the change-ports button in Try again mode (same behavior as retry).
if (retryBtn) retryBtn.classList.add('d-none');
if (pullBtn) pullBtn.classList.add('d-none');
if (modalIn) modalIn.disabled = false;
if (modalOut) modalOut.disabled = false;
if (changeBtn) {
changeBtn.classList.remove('d-none');
changeBtn.textContent = 'Try again';
changeBtn.onclick = function() { sync_modal_retry(); };
}
Expand All @@ -4285,6 +4419,10 @@ function _show_syncing_modal_error() {
function _hide_syncing_modal() {
var el = document.getElementById('syncingModal');
if (!el) return;
// Reject any pending green-button gate so awaiting callers stop
// waiting (instead of silently leaving an orphaned promise that
// could resolve later if the modal is reshown).
_clear_pull_button_gate('hidden');
// Bootstrap modal.hide() is unreliable when called from async sysex callbacks.
// Force-remove the modal and backdrop directly.
try {
Expand Down Expand Up @@ -4375,24 +4513,11 @@ async function sync_modal_retry() {
return;
}

// Reset modal UI to "trying" state and re-run the pageload sync.
var spinner = document.getElementById('sync-modal-spinner');
var error = document.getElementById('sync-modal-error');
var retryBtn = document.getElementById('sync-modal-retry-btn');
var changeBtn = document.getElementById('sync-modal-change-btn');
if (spinner) spinner.classList.remove('d-none');
if (error) error.classList.add('d-none');
if (retryBtn) retryBtn.classList.add('d-none');
if (modalIn) modalIn.disabled = true;
if (modalOut) modalOut.disabled = true;
if (changeBtn) {
changeBtn.textContent = 'Change MIDI Ports';
changeBtn.classList.remove('d-none');
// Re-hook the button back to sync_modal_change_ports for the next cycle.
changeBtn.onclick = function() { sync_modal_change_ports(); };
}
// Re-run the pageload sync flow if in control mode, otherwise fall back
// to the simple zA+zD path.
// Skip the green-button gate on the next _show_syncing_modal call —
// the user already chose ports and clicked Try again. Reuse the work
// path (pageload_control_sync / sync_amy_state) so retries converge
// with the initial flow.
_skip_pull_gate = true;
if (amyboard_mode === 'control' && typeof pageload_control_sync === 'function') {
pageload_control_sync();
} else {
Expand All @@ -4417,14 +4542,19 @@ function sync_modal_change_ports() {
var error = document.getElementById('sync-modal-error');
var retryBtn = document.getElementById('sync-modal-retry-btn');
var changeBtn = document.getElementById('sync-modal-change-btn');
var pullBtn = document.getElementById('sync-modal-pull-btn');
var modalIn = document.getElementById('sync-modal-midi-in');
var modalOut = document.getElementById('sync-modal-midi-out');
if (spinner) spinner.classList.add('d-none');
if (error) error.classList.remove('d-none');
if (retryBtn) retryBtn.classList.add('d-none');
if (pullBtn) pullBtn.classList.add('d-none');
if (modalIn) modalIn.disabled = false;
if (modalOut) modalOut.disabled = false;
if (changeBtn) changeBtn.textContent = 'Try again';
if (changeBtn) {
changeBtn.classList.remove('d-none');
changeBtn.textContent = 'Try again';
}
// Hook the changed button to sync_modal_retry instead. Wrap so the
// async function's rejections surface in the sync-error modal rather
// than bubbling up as unhandled promise errors.
Expand Down Expand Up @@ -4640,12 +4770,21 @@ function sync_amy_state_async() {
// handler, so give it plenty of headroom.
var _SYNC_TIMEOUT_MS = 20000;

function sync_amy_state() {
async function sync_amy_state() {
// Send zA to update sketch.py on disk with current AMY state,
// then zD to get the updated file back.
// then zD to get the updated file back. Gates behind the green
// Pull button before sending any sysex.
console.log('sync_amy_state: start');
try {
await _show_syncing_modal();
} catch (e) {
console.log('sync_amy_state: gate cancelled', e && e.message);
// Surface the cancellation to sync_amy_state_async's promise so
// its caller (e.g. save()) doesn't hang forever.
if (_sync_reject) { var sr = _sync_reject; _sync_resolve = null; _sync_reject = null; sr(e); }
return;
}
_sync_stage = 'pending';
_show_syncing_modal();
if (_sync_timeout) clearTimeout(_sync_timeout);
// Drop any stale reassembly state from a previous sync attempt that may
// have timed out mid-message. Starting clean guarantees the reassembler
Expand Down
Loading