diff --git a/.eslintrc.json b/.eslintrc.json index 201789d..6f107d4 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -42,6 +42,10 @@ "node/no-extraneous-require": "off", "node/no-unsupported-features/node-builtins": "off", "no-empty": "warn", - "no-mixed-spaces-and-tabs": "warn" + "no-mixed-spaces-and-tabs": "warn", + "no-multiple-empty-lines": ["error", { "max": 1, "maxEOF": 0, "maxBOF": 0 }], + "no-trailing-spaces": "error", + "padded-blocks": ["error", "never"], + "eol-last": ["error", "always"] } } \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5c46032..b2d322c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,6 @@ logs/ dist/ release/ bin/ -/release-builds -/logs/*.log +release-builds/ +logs/*.log +dev/agents/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4083d42 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,34 @@ +Mini4wdChrono is a Mini4wd lap timer program with race management. It is aimed at Mini4wd clubs who want to host races. +It is a standalone program which is meant to be paired with a hardware lap timer. + +## Your role +- You are a backend developer, expert in plain javascript and electron. +- Your job is to help upgrading parts of the program and to develop new features. + +## Project knowledge +- **Tech Stack:** Electron with vanilla javascript. Plain html with Bulma for css. +- **Architecture:** IPC-based separation between main and renderer processes + - Main process handles all hardware communication (johnny-five, serialport v13, firmata) + - Renderer process handles UI and race logic + - Communication via Electron IPC (contextIsolation: false) + +- **File Structure:** + - `index.html` - main frontend + - `window.js` - Electron main process entry point (handles IPC, hardware, file system) + - `preload.js` - Exposes IPC APIs to renderer via `window.electronAPI` + - `js/` – Application source code (renderer process) + - `js/main.js` – Renderer initialization and IPC event handlers + - `js/client.js` - Race handling logic and program orchestrator + - `js/chrono.js` - Lap timer logic with microsecond-precision timestamps (DO NOT change this file) + - `js/ui.js` - Frontend rendering logic + - `js/led_managers/` - LED abstraction layer (talks to main process via IPC) + +## Boundaries +- Only change code in the `js/` folder, and other javascript files in the root directory. +- Do not change `js/chrono.js` which contains battle-tested lap timer logic. +- Do not change the `index.html` and css files. +- Hardware operations must go through IPC - never use johnny-five or serialport directly in renderer +- Timestamps for lap timing are captured in main process at sensor trigger for accuracy + +## Lap timer hardware +Mini4wdChrono connects via usb to a physical lap timer. The lap timer looks like a bridge over the three lanes of a mini4wd track. It is powered by an arduino with firmata firmware, and has 3 light sensor to detect the cars passing under the lap timer. It also sports an rgb led strip for visual feedback and a buzzer for alerts. \ No newline at end of file diff --git a/i18n/en.json b/i18n/en.json index 1d5ef54..6ee8f5a 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -82,13 +82,10 @@ "label-best-speed-km": "Best speed km/h", "label-custom-title": "Your team/club name", "label-starting-tab": "Starting screen", - "label-led-type": "LED type", "label-led-animation": "LED animation at race start (only for RGB strip)", "button-led-animation-full": "Animation + countdown", "button-led-animation-cd": "Countdown only", "button-led-animation-off": "None", - "button-led-type-normal": "3 single LEDs", - "button-led-type-strip": "Multicolor LED strip", "label-usb-port": "USB Port", "label-direction": "Direction", "label-reverse": "Invert left-right", diff --git a/i18n/i18n.js b/i18n/i18n.js index 04f32b2..9844eb6 100644 --- a/i18n/i18n.js +++ b/i18n/i18n.js @@ -1,6 +1,5 @@ 'use strict'; -const { app } = require('electron').remote; const fs = require('fs'); const path = require('path'); let loadedLanguage; @@ -8,13 +7,20 @@ let loadedLanguage; module.exports = i18n; function i18n() { - const tnpath = path.join(__dirname, app.getLocale().substring(0, 2) + '.json'); - // tnpath = path.join(__dirname, 'it.json'); // uncomment this line to force italian language - if (fs.existsSync(tnpath)) { - loadedLanguage = JSON.parse(fs.readFileSync(tnpath), 'utf8'); - } - else { - loadedLanguage = JSON.parse(fs.readFileSync(path.join(__dirname, 'en.json'), 'utf8')); + // Load English by default, we'll try to get the proper locale asynchronously + loadedLanguage = JSON.parse(fs.readFileSync(path.join(__dirname, 'en.json'), 'utf8')); + + // Try to load the correct locale asynchronously + if (window.electronAPI && window.electronAPI.getAppLocale) { + window.electronAPI.getAppLocale().then(locale => { + const tnpath = path.join(__dirname, locale.substring(0, 2) + '.json'); + // tnpath = path.join(__dirname, 'it.json'); // uncomment this line to force italian language + if (fs.existsSync(tnpath)) { + loadedLanguage = JSON.parse(fs.readFileSync(tnpath), 'utf8'); + } + }).catch(err => { + console.warn('Could not load locale, using English:', err); + }); } } diff --git a/i18n/it.json b/i18n/it.json index ab97b1b..fcc1536 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -86,9 +86,6 @@ "button-led-animation-full": "Animazione + conto alla rovescia", "button-led-animation-cd": "Solo conto alla rovescia", "button-led-animation-off": "Nessuna", - "label-led-type": "Tipo di LED", - "button-led-type-normal": "3 LED singoli", - "button-led-type-strip": "Striscia LED multicolore", "label-usb-port": "Porta USB", "label-direction": "Senso di marcia", "label-reverse": "Inverti destra-sinistra", diff --git a/index.html b/index.html index 3f77f43..76d2762 100644 --- a/index.html +++ b/index.html @@ -494,18 +494,6 @@

-
- -
-
-
- 3 LEDs - LED strip -
-
-
-
-
@@ -681,6 +669,13 @@ + diff --git a/js/chrono.js b/js/chrono.js index b768b67..7cf1374 100644 --- a/js/chrono.js +++ b/js/chrono.js @@ -71,11 +71,13 @@ const init = (track, playerIds, cars) => { }; // method called when a sensor receives a signal -const addLap = (lane) => { - // current time in milliseconds - const timestamp = new Date().getTime(); +const addLap = (lane, timestamp) => { + // Use provided timestamp (captured at hardware level) or fallback to current time + if (!timestamp) { + timestamp = new Date().getTime(); + } - console.log(`======= got signal for lane ${lane} at time ${timestamp}`); + console.log(`${timestamp} sensor triggered for lane ${lane}`); // console.log(JSON.stringify(rCars, null, 2)); // find all cars that may have passes under this lane sensor @@ -92,11 +94,11 @@ const addLap = (lane) => { // false sensor read if (rTempCar === null) { - console.log(`error: no valid car for signal on lane ${lane}`); + console.error(`${timestamp} no valid car found`); return; } else { - console.log(`ok: valid car ${rTempCar.startLane}`); + console.log(`${timestamp} valid car found (start lane ${rTempCar.startLane})`); } // handle the correct car diff --git a/js/client.js b/js/client.js index 3d2932a..9c5893d 100644 --- a/js/client.js +++ b/js/client.js @@ -1,6 +1,5 @@ 'use strict'; -const { dialog, getCurrentWindow } = require('electron').remote; const ui = require('./ui'); const utils = require('./utils'); const storage = require('./storage'); @@ -14,15 +13,16 @@ let mancheList, mancheCount; let currManche = 0, currRound = 0, raceStarting = false, raceRunning = false, freeRound = true; let timerIntervals = [], timerSeconds = []; -const pageTimerSeconds = [$('#timer-lane0'), $('#timer-lane1'), $('#timer-lane2')]; +let pageTimerSeconds; // Initialize in init() when $ is available let checkRaceTask; -const init = (params) => { - console.log('client.init called'); +const init = async (params) => { + // Initialize jQuery selectors now that $ is available + pageTimerSeconds = [$('#timer-lane0'), $('#timer-lane1'), $('#timer-lane2')]; ledManager = params.led_manager; - ui.init(); - ui.gotoTab(configuration.get('tab')); + await ui.init(); + ui.gotoTab(await configuration.get('tab')); // init variables mancheList = []; @@ -48,8 +48,6 @@ const init = (params) => { }; const reset = (name) => { - console.log('client.reset called'); - mancheList = []; currManche = 0; currRound = 0; @@ -65,8 +63,6 @@ const reset = (name) => { }; const chronoInit = (reset) => { - console.log('client.chronoInit called'); - if (currTournament === null || freeRound) { // free round chrono.init(currTrack); @@ -87,8 +83,6 @@ const chronoInit = (reset) => { // ==== time list handling const disqualify = (mindex, rindex, pindex) => { - console.log('client.disqualify called'); - mindex = mindex || currManche; rindex = rindex || currRound; const cars = storage.loadRound(mindex, rindex); @@ -102,8 +96,6 @@ const disqualify = (mindex, rindex, pindex) => { // Reads all input fields in the manches tab and rebuilds time list const overrideTimes = () => { - console.log('client.overrideTimes called'); - let time, newTime, oldTime, cars; _.each(mancheList, (manche, mindex) => { _.each(manche, (round, rindex) => { @@ -132,8 +124,6 @@ const overrideTimes = () => { }; const initFinal = () => { - console.log('client.initFinal called'); - const ids = _.map(storage.getSortedPlayerList(), (t) => { return t.id; }); // remove any previously generated finals @@ -169,12 +159,10 @@ const initFinal = () => { // ========================================================================== // ==== handle interface buttons -const startRace = (debugMode) => { - console.log('client.startRace called'); - +const startRace = async (debugMode) => { if (!storage.get('track')) { // track not loaded - dialog.showMessageBoxSync(getCurrentWindow(), { type: 'error', title: 'Error', message: i18n.__('dialog-track-not-loaded'), buttons: ['Ok'] }); + await window.electronAPI.showMessageBox({ type: 'error', title: 'Error', message: i18n.__('dialog-track-not-loaded'), buttons: ['Ok'] }); return; } if ($('div[data-tab=race]').is(':hidden')) { @@ -196,29 +184,26 @@ const startRace = (debugMode) => { else { // production mode if (!freeRound && storage.get('tournament') && storage.loadRound()) { - if (dialog.showMessageBoxSync(getCurrentWindow(), { type: 'warning', message: i18n.__('dialog-replay-round'), buttons: ['Ok', 'Cancel'] }) === 1) { + const result = await window.electronAPI.showMessageBox({ type: 'warning', message: i18n.__('dialog-replay-round'), buttons: ['Ok', 'Cancel'] }); + if (result.response === 1) { return; } } raceStarting = true; ui.raceStarted(freeRound); initRound(); - ledManager.roundStart(configuration.get('ledAnimation'), startRound); + ledManager.roundStart(await configuration.get('ledAnimation'), startRound); } }; // called before the starting sequence const initRound = () => { - console.log('client.initRound called'); - chronoInit(!freeRound); updateRace(); }; // called when the starting sequence has finished const startRound = () => { - console.log('client.startRound called'); - timerIntervals = []; timerSeconds = []; @@ -239,7 +224,6 @@ const startRound = () => { // called when the stop button is pressed const stopRace = () => { - console.log('client.stopRace called'); if (raceStarting) { return false; } @@ -248,12 +232,10 @@ const stopRace = () => { checkRace(); }; -const prevRound = () => { - console.log('client.prevRound called'); - +const prevRound = async () => { if (currTournament === null || currTrack === null) { // tournament not loaded - dialog.showMessageBoxSync(getCurrentWindow(), { type: 'error', title: 'Error', message: i18n.__('dialog-tournament-not-loaded'), buttons: ['Ok'] }); + await window.electronAPI.showMessageBox({ type: 'error', title: 'Error', message: i18n.__('dialog-tournament-not-loaded'), buttons: ['Ok'] }); return; } if (currManche === 0 && currRound === 0) { @@ -261,7 +243,8 @@ const prevRound = () => { return; } - if (dialog.showMessageBoxSync(getCurrentWindow(), { type: 'warning', message: i18n.__('dialog-change-round'), buttons: ['Ok', 'Cancel'] }) === 0) { + const result = await window.electronAPI.showMessageBox({ type: 'warning', message: i18n.__('dialog-change-round'), buttons: ['Ok', 'Cancel'] }); + if (result.response === 0) { currRound--; if (currRound < 0) { currManche--; @@ -276,12 +259,10 @@ const prevRound = () => { } }; -const nextRound = () => { - console.log('client.nextRound called'); - +const nextRound = async () => { if (currTournament === null || currTrack === null) { // tournament not loaded - dialog.showMessageBoxSync(getCurrentWindow(), { type: 'error', title: 'Error', message: i18n.__('dialog-tournament-not-loaded'), buttons: ['Ok'] }); + await window.electronAPI.showMessageBox({ type: 'error', title: 'Error', message: i18n.__('dialog-tournament-not-loaded'), buttons: ['Ok'] }); return; } @@ -291,7 +272,8 @@ const nextRound = () => { } const dialogText = (currManche === (mancheCount - 1) && currRound === (mancheList[currManche].length - 1) && !currTournament.finals) ? i18n.__('dialog-enter-final') : i18n.__('dialog-change-round'); - if (dialog.showMessageBoxSync(getCurrentWindow(), { type: 'warning', message: dialogText, buttons: ['Ok', 'Cancel'] }) === 0) { + const result = await window.electronAPI.showMessageBox({ type: 'warning', message: dialogText, buttons: ['Ok', 'Cancel'] }); + if (result.response === 0) { currRound++; if (currRound === mancheList[currManche].length) { currManche++; @@ -314,16 +296,15 @@ const nextRound = () => { } }; -const gotoRound = (mindex, rindex) => { - console.log('client.gotoRound called'); - +const gotoRound = async (mindex, rindex) => { if (currTournament === null || currTrack === null) { // tournament not loaded - dialog.showMessageBoxSync(getCurrentWindow(), { type: 'error', title: 'Error', message: i18n.__('dialog-tournament-not-loaded'), buttons: ['Ok'] }); + await window.electronAPI.showMessageBox({ type: 'error', title: 'Error', message: i18n.__('dialog-tournament-not-loaded'), buttons: ['Ok'] }); return; } - if (dialog.showMessageBoxSync(getCurrentWindow(), { type: 'warning', message: i18n.__('dialog-change-round'), buttons: ['Ok', 'Cancel'] }) === 0) { + const result = await window.electronAPI.showMessageBox({ type: 'warning', message: i18n.__('dialog-change-round'), buttons: ['Ok', 'Cancel'] }); + if (result.response === 0) { currManche = mindex; currRound = rindex; storage.set('currManche', currManche); @@ -339,8 +320,6 @@ const isFreeRound = () => freeRound; const isStarted = () => raceStarting || raceRunning; const toggleFreeRound = () => { - console.log('client.toggleFreeRound called'); - freeRound = !freeRound; chronoInit(); ui.toggleFreeRound(freeRound); @@ -370,8 +349,6 @@ const keydown = (keyCode) => { // ==== API calls const loadTrack = (code) => { - console.log('client.loadTrack called'); - $.getJSON(`https://mini4wd-track-editor.pimentoso.com/api/track/${code}`) .done((obj) => { trackLoadDone(obj); @@ -383,16 +360,12 @@ const loadTrack = (code) => { }; const setTrackManual = (length, order) => { - console.log('client.setTrackManual called'); - const obj = { 'code': i18n.__('tag-track-manual'), 'length': length, 'order': order, 'manual': true }; storage.set('track', obj); trackLoadDone(obj); }; const loadTournament = (code) => { - console.log('client.loadTournament called'); - $.getJSON(`https://mini4wd-tournament.pimentoso.com/api/tournament/${code}`) .done((obj) => { tournamentLoadDone(obj); @@ -404,8 +377,6 @@ const loadTournament = (code) => { }; const trackLoadDone = (obj) => { - console.log('client.trackLoadDone called'); - currTrack = obj; storage.set('track', currTrack); @@ -414,16 +385,12 @@ const trackLoadDone = (obj) => { }; const trackLoadFail = () => { - console.log('client.trackLoadFail called'); - currTrack = null; ui.trackLoadFail(); showTrackDetails(); }; const tournamentLoadDone = (obj) => { - console.log('client.tournamentLoadDone called'); - currTournament = obj; mancheList = clone(obj.manches); @@ -443,8 +410,6 @@ const tournamentLoadDone = (obj) => { }; const tournamentLoadFail = () => { - console.log('client.tournamentLoadFail called'); - currTournament = null; ui.tournamentLoadFail(); }; @@ -454,8 +419,6 @@ const tournamentLoadFail = () => { // timer task to check for cars out of track const checkRace = () => { - console.log('client.checkRace called'); - let redraw = chrono.checkOutCars(); if (chrono.isRaceFinished()) { raceFinished(); @@ -466,8 +429,6 @@ const checkRace = () => { // timer task to invalidate cars not passed in 3 seconds const checkStart = () => { - console.log('client.checkStart called'); - let redraw = chrono.checkNotStartedCars(); if (chrono.isRaceFinished()) { raceFinished(); @@ -478,8 +439,6 @@ const checkStart = () => { // called when the current round has completed. Saves times and handles UI changes const raceFinished = () => { - console.log('client.raceFinished called'); - // kill race check task clearInterval(checkRaceTask); @@ -502,8 +461,6 @@ const raceFinished = () => { // ==== write to interface const showTrackDetails = () => { - console.log('client.showTrackDetails called'); - ui.showTrackDetails(currTrack); ui.showThresholds(); chronoInit(); @@ -512,16 +469,12 @@ const showTrackDetails = () => { }; const showTournamentDetails = () => { - console.log('client.showTournamentDetails called'); - ui.showTournamentDetails(currTournament); ui.initRace(freeRound); updateRace(); }; const updateRace = () => { - console.log('client.updateRace called'); - let cars = (raceRunning || freeRound) ? chrono.getCars() : storage.loadRound(currManche, currRound); cars = cars || chrono.getCars(); // if loaded round was undefined ui.drawRace(cars, raceRunning); @@ -564,14 +517,12 @@ const saveXls = () => { // ========================================================================== // ==== listen to arduino events -const addLap = (lane) => { - console.log('client.addLap called'); - +const addLap = (lane, timestamp) => { if (!raceRunning) { return; } - chrono.addLap(lane); + chrono.addLap(lane, timestamp); if (chrono.isRaceFinished()) { raceFinished(); } diff --git a/js/configuration.js b/js/configuration.js index e297449..4aa5546 100755 --- a/js/configuration.js +++ b/js/configuration.js @@ -1,58 +1,75 @@ 'use strict'; -const { app } = require('electron').remote; -const fs = require('fs'); -const path = require('path'); +// This renderer-side module acts as a client to main-process config handlers -// %APPDATA% on Windows -// $XDG_CONFIG_HOME or ~/.config on Linux -// ~/Library/Application Support on macOS -const dir = app.getPath('userData'); -const filepath = path.join(dir, 'settings.json'); -let globalConf; - -const init = () => { - globalConf = require('nconf').file('global', { file: filepath }); - - globalConf.defaults({ - 'ledAnimation': 0, - 'ledType': 0, - 'sensorPin1': 6, - 'sensorPin2': 7, - 'sensorPin3': 8, - 'ledPin1': 3, - 'ledPin2': 4, - 'ledPin3': 5, - 'piezoPin': 2, - 'startButtonPin': 0, - 'reverse': 0, - // 'usbPort': 'COM3', - 'title': 'MINI4WD CHRONO', - 'tab': 'setup' - }); +/** + * Initializes configuration system + * Calls config-init handler in main process + * @returns {Promise} + */ +const init = async () => { + try { + await window.electronAPI.configInit(); + } catch (error) { + console.error('Error initializing configuration:', error); + throw error; + } }; -const reset = () => { - const backup_filepath = path.join(dir, 'settings.json.bak'); - fs.copyFileSync(filepath, backup_filepath); - fs.unlinkSync(filepath); - init(); - return backup_filepath; +/** + * Resets configuration to defaults with backup + * @returns {Promise} - Path to backup file + */ +const reset = async () => { + try { + return await window.electronAPI.configReset(); + } catch (error) { + console.error('Error resetting configuration:', error); + throw error; + } }; -const set = (settingKey, settingValue) => { - globalConf.set(settingKey, settingValue); - globalConf.save(); +/** + * Sets a configuration value + * @param {string} settingKey - Setting key name + * @param {any} settingValue - Value to set + * @returns {Promise} + */ +const set = async (settingKey, settingValue) => { + try { + await window.electronAPI.configSet(settingKey, settingValue); + } catch (error) { + console.error(`Error setting config ${settingKey}:`, error); + throw error; + } }; -const get = (settingKey) => { - globalConf.load(); - return globalConf.get(settingKey); +/** + * Gets a configuration value + * @param {settingKey} settingKey - Setting key name + * @returns {Promise} - Setting value + */ +const get = async (settingKey) => { + try { + return await window.electronAPI.configGet(settingKey); + } catch (error) { + console.error(`Error getting config ${settingKey}:`, error); + throw error; + } }; -const del = (settingKey) => { - globalConf.clear(settingKey); - globalConf.save(); +/** + * Deletes a configuration value + * @param {string} settingKey - Setting key name + * @returns {Promise} + */ +const del = async (settingKey) => { + try { + await window.electronAPI.configDel(settingKey); + } catch (error) { + console.error(`Error deleting config ${settingKey}:`, error); + throw error; + } }; module.exports = { diff --git a/js/export.js b/js/export.js index e3a16c6..7ae7f91 100644 --- a/js/export.js +++ b/js/export.js @@ -1,78 +1,106 @@ 'use strict'; +// Excel generation stays in renderer since it's DOM-related + const xls = require('exceljs'); const utils = require('./utils'); -const { app } = require('electron').remote; -const fs = require('fs'); -const path = require('path'); const storage = require('./storage'); const strftime = require('strftime'); -const getXlsFilePath = () => { - // {user home dir}/Mini4wdChrono - const dir = app.getPath('home'); - return path.join(dir, 'Mini4wdChrono'); +/** + * Gets the export directory path + * @returns {Promise} - Export directory path + */ +const getXlsFilePath = async () => { + try { + return await window.electronAPI.getAppPath('home'); + } catch (error) { + console.error('Error getting export path:', error); + throw error; + } }; -const createDir = () => { - const dir = getXlsFilePath(); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir); +/** + * Ensures export directory exists + * @returns {Promise} - Export directory path + */ +const createDir = async () => { + try { + const dir = await getXlsFilePath(); + const exportDir = dir + '/Mini4wdChrono'; + await window.electronAPI.ensureDir(exportDir); + return exportDir; + } catch (error) { + console.error('Error creating export directory:', error); + throw error; } - return dir; }; -const generateXls = () => { - const track = storage.get('track'); - const tournament = storage.get('tournament'); - const playerList = tournament.players; - const times = storage.getSortedPlayerList(); +/** + * Generates and saves Excel export file + * @returns {Promise} - Path to saved Excel file + */ +const generateXls = async () => { + try { + const track = await storage.get('track'); + const tournament = await storage.get('tournament'); + const playerList = tournament.players; + const times = await storage.getSortedPlayerList(); - const workbook = new xls.Workbook(); - workbook.creator = 'Mini4wd Chrono'; - workbook.created = new Date(); - workbook.modified = new Date(); + const workbook = new xls.Workbook(); + workbook.creator = 'Mini4wd Chrono'; + workbook.created = new Date(); + workbook.modified = new Date(); - const worksheet = workbook.addWorksheet('Racers data'); + const worksheet = workbook.addWorksheet('Racers data'); - const headerRow = [ - '', - '', - '', - _.times(tournament.manches.length, (i) => { return `Manche ${i + 1}`; }), - i18n.__('label-best-time'), - i18n.__('label-best-2-times'), - i18n.__('label-best-speed'), - i18n.__('label-best-speed-km') - ]; - worksheet.addRow(_.flatten(headerRow)); + const headerRow = [ + '', + '', + '', + _.times(tournament.manches.length, (i) => { return `Manche ${i + 1}`; }), + i18n.__('label-best-time'), + i18n.__('label-best-2-times'), + i18n.__('label-best-speed'), + i18n.__('label-best-speed-km') + ]; + worksheet.addRow(_.flatten(headerRow)); - _.each(times, (info, pos) => { - const bestTime = _.min(_.filter(info.times, (t) => { return t > 0 && t < 99999; })); - const bestSpeed = track.length / (bestTime / 1000); + _.each(times, (info, pos) => { + const bestTime = _.min(_.filter(info.times, (t) => { return t > 0 && t < 99999; })); + const bestSpeed = track.length / (bestTime / 1000); - const row = []; - row.push(pos + 1); - row.push(playerList[info.id].toUpperCase()); - row.push(''); - _.times(tournament.manches.length, (i) => { - row.push(utils.prettyTime(info.times[i] || 0)); + const row = []; + row.push(pos + 1); + row.push(playerList[info.id].toUpperCase()); + row.push(''); + _.times(tournament.manches.length, (i) => { + row.push(utils.prettyTime(info.times[i] || 0)); + }); + row.push(utils.prettyTime(bestTime)); + row.push(utils.prettyTime(info.best)); + row.push(bestSpeed.toFixed(2)); + row.push((bestSpeed * 3.6).toFixed(2)); + worksheet.addRow(row); }); - row.push(utils.prettyTime(bestTime)); - row.push(utils.prettyTime(info.best)); - row.push(bestSpeed.toFixed(2)); - row.push((bestSpeed * 3.6).toFixed(2)); - worksheet.addRow(row); - }); - const dir = createDir(); - const filename = path.join(dir, `mini4wd_race_${strftime('%Y-%m-%d_%H-%M-%S', new Date())}.xlsx`); - workbook.xlsx.writeFile(filename) - .then(() => { - // done - $('#button-xls').removeAttr('disabled'); - $('#status-xls').text(`saved ${filename}`); - }); + const dir = await createDir(); + const filename = dir + `/mini4wd_race_${strftime('%Y-%m-%d_%H-%M-%S', new Date())}.xlsx`; + + // Write Excel file + await workbook.xlsx.writeFile(filename); + + // Update UI + $('#button-xls').removeAttr('disabled'); + $('#status-xls').text(`saved ${filename}`); + + return filename; + } catch (error) { + console.error('Error generating Excel file:', error); + $('#button-xls').removeAttr('disabled'); + $('#status-xls').text(`Error: ${error.message}`); + throw error; + } }; module.exports = { diff --git a/js/led_managers/led_manager.js b/js/led_managers/led_manager.js index a2555df..50cad88 100644 --- a/js/led_managers/led_manager.js +++ b/js/led_managers/led_manager.js @@ -1,12 +1,9 @@ 'use strict'; -const j5 = require('johnny-five'); -const utils = require('../utils'); const storage = require('../storage'); class LedManager { - constructor(board, pinBuzzer, reverse) { - this.board = board; + constructor(pinBuzzer, reverse) { this.pinBuzzer = pinBuzzer; this.reverse = reverse; } @@ -15,40 +12,35 @@ class LedManager { return this.pinBuzzer > 0; } - connected() { + async connected() { if (this.buzzerAvailable()) { - this.board.pinMode(this.pinBuzzer, j5.Pin.OUTPUT); - this.beep(100); + await this.beep(100); } } - disconnected() { - if (this.buzzerAvailable()) { - try { - this.board.digitalWrite(this.pinBuzzer, 0); - } catch (e) { - // Safely ignore errors when disconnecting hardware - // This can happen when the board is already disconnected - } - } + async disconnected() { + // Nothing to do here in renderer } roundStart(_animationType, _startTimerCallback) { - throw 'not implemented'; + throw new Error('not implemented'); } roundFinish(_cars) { - throw 'not implemented'; + throw new Error('not implemented'); } lap(_lane) { - throw 'not implemented'; + throw new Error('not implemented'); } - beep(millis) { + async beep(millis) { if (this.buzzerAvailable()) { - this.board.digitalWrite(this.pinBuzzer, 1); - utils.delay(() => { this.board.digitalWrite(this.pinBuzzer, 0); }, millis); + try { + await window.electronAPI.hardwareBuzz(millis); + } catch (error) { + console.warn('Failed to beep:', error); + } } } diff --git a/js/led_managers/led_manager_lilypad.js b/js/led_managers/led_manager_lilypad.js deleted file mode 100644 index c0d1814..0000000 --- a/js/led_managers/led_manager_lilypad.js +++ /dev/null @@ -1,96 +0,0 @@ -'use strict'; - -const j5 = require('johnny-five'); -const LedManager = require('./led_manager'); -const utils = require('../utils'); -const storage = require('../storage'); - -// LED manager for 3 green LEDs. -class LedManagerLilypad extends LedManager { - constructor(board, pinLeds, pinBuzzer, reverse) { - super(board, pinBuzzer, reverse); - this.pinLeds = pinLeds; - this.ready = false; - } - - static getInstance(board, pinLeds, pinBuzzer, reverse) { - if (LedManagerLilypad.instance) { - return LedManagerLilypad.instance; - } - - LedManagerLilypad.instance = new LedManagerLilypad(board, pinLeds, pinBuzzer, reverse); - return LedManagerLilypad.instance; - } - - connected() { - super.connected(); - - // board is connected, init hardware - this.led1 = new j5.Led({ - board: this.board, - pin: this.pinLeds[0] - }); - this.led2 = new j5.Led({ - board: this.board, - pin: this.pinLeds[1] - }); - this.led3 = new j5.Led({ - board: this.board, - pin: this.pinLeds[2] - }); - this.leds = [this.led1, this.led2, this.led3]; - - // blink all leds for 3 sec - this.led1.blink(125); this.led2.blink(125); this.led3.blink(125); - utils.delay(() => { this.led1.stop().off(); this.led2.stop().off(); this.led3.stop().off(); this.ready = true; }, 3000); - } - - disconnected() { - super.disconnected(); - try { - this.led1.stop().off(); - this.led2.stop().off(); - this.led3.stop().off(); - } catch (e) { - // Safely ignore errors when disconnecting hardware - // This can happen when the board is already disconnected - } - } - - roundStart(animationType, startTimerCallback) { - this.led1.on(); this.led2.on(); this.led3.on(); this.beep(1500); - utils - .delay(() => { this.led1.off(); this.led2.off(); this.led3.off(); }, 1500) - .delay(() => { this.led1.on(); this.beep(500); }, 1000) - .delay(() => { this.led1.off(); this.led2.on(); this.beep(500); }, 1000) - .delay(() => { this.led2.off(); this.led3.on(); this.beep(500); }, 1000) - .delay(() => { this.led3.off(); }, 1000) - .delay(() => { this.led1.on(); this.led2.on(); this.led3.on(); this.beep(1000); startTimerCallback(); }, super.greenDelay()) - .delay(() => { this.led1.off(); this.led2.off(); this.led3.off(); }, storage.get('startDelay') * 1000); - } - - roundFinish(cars) { - // turn on winner car led - const rLaps = storage.get('roundLaps'); - const finishCars = _.filter(cars, (c) => { return !c.outOfBounds && c.lapCount === rLaps + 1; }); - utils.delay(() => { - _.each(finishCars, (c) => { - if (c.position === 1) { - this.leds[this.laneIndex(c.startLane)].on(); - } - }); - }, 1500); - } - - lap(lane) { - // flash lane led for 1 sec - if (this.ready) { - lane = this.laneIndex(lane); - const led = this.leds[lane]; - led.on(); - utils.delay(() => { led.off(); }, 1000); - } - } -} - -module.exports = LedManagerLilypad; diff --git a/js/led_managers/led_manager_mock.js b/js/led_managers/led_manager_mock.js index 82c8787..be21718 100644 --- a/js/led_managers/led_manager_mock.js +++ b/js/led_managers/led_manager_mock.js @@ -4,16 +4,16 @@ const LedManager = require('./led_manager'); // Mock led manager. Does nothing. class LedManagerMock extends LedManager { - constructor(board, pinBuzzer, reverse) { - super(board, pinBuzzer, reverse); + constructor(pinBuzzer, reverse) { + super(pinBuzzer, reverse); } - static getInstance(board, pinBuzzer) { + static getInstance(pinBuzzer) { if (LedManagerMock.instance) { return LedManagerMock.instance; } - LedManagerMock.instance = new LedManagerMock(board, pinBuzzer); + LedManagerMock.instance = new LedManagerMock(pinBuzzer); return LedManagerMock.instance; } diff --git a/js/led_managers/led_manager_rgb_strip.js b/js/led_managers/led_manager_rgb_strip.js index c16382e..a25db0b 100644 --- a/js/led_managers/led_manager_rgb_strip.js +++ b/js/led_managers/led_manager_rgb_strip.js @@ -1,6 +1,5 @@ 'use strict'; -const pixel = require('node-pixel'); const LedManager = require('./led_manager'); const utils = require('../utils'); const storage = require('../storage'); @@ -19,203 +18,226 @@ const COLOR_TAMIYA_BLUE = COLOR_BLUE; // Manager for a 9 LEDs WS2812b strip and a buzzer. class LedManagerRgbStrip extends LedManager { - constructor(board, pin, pinBuzzer, reverse) { - super(board, pinBuzzer, reverse); + constructor(pin, pinBuzzer, reverse) { + super(pinBuzzer, reverse); this.pin = pin; this.ready = false; } - static getInstance(board, pin, pinBuzzer, reverse) { + static getInstance(pin, pinBuzzer, reverse) { if (LedManagerRgbStrip.instance) { return LedManagerRgbStrip.instance; } - LedManagerRgbStrip.instance = new LedManagerRgbStrip(board, pin, pinBuzzer, reverse); + LedManagerRgbStrip.instance = new LedManagerRgbStrip(pin, pinBuzzer, reverse); return LedManagerRgbStrip.instance; } - connected() { - super.connected(); - - // board is connected, init hardware - this.strip = new pixel.Strip({ - board: this.board, - controller: 'FIRMATA', - strips: [{ pin: this.pin, length: 9 }], - gamma: 2.8 - }); - - // light animation - const manager = this; - this.strip.on('ready', function () { - manager.tamiyaSlide(); - }); + async connected() { + await super.connected(); + + // Simulate tamiya slide animation + await this.tamiyaSlide(); + this.ready = true; } - disconnected() { - super.disconnected(); + async disconnected() { + await super.disconnected(); try { - this.strip.off(); - } catch (e) { + await window.electronAPI.hardwareLedOff({}); + } catch (e) { // Safely ignore errors when disconnecting hardware - // This can happen when the board is already disconnected } } - roundStart(animationType, startTimerCallback) { + async roundStart(animationType, startTimerCallback) { if (animationType === 0) { // full animation - this.beep(1500); - this.kitt(COLOR_BLUE); - this.countdown(2500); - this.greenLight(2500 + 3200 + super.greenDelay(), startTimerCallback); + await this.beep(1500); + await this.kitt(COLOR_BLUE); + await this.countdown(2500); + await this.greenLight(2500 + 3200 + super.greenDelay(), startTimerCallback); } else if (animationType === 1) { // countdown only - this.countdown(0); - this.greenLight(3200 + super.greenDelay(), startTimerCallback); + await this.countdown(0); + await this.greenLight(3200 + super.greenDelay(), startTimerCallback); } else { // no animations - this.greenLight(0, startTimerCallback); + await this.greenLight(0, startTimerCallback); } } roundFinish(cars) { - // color lanes based on positions + // color lanes based on positions const rLaps = storage.get('roundLaps'); const finishCars = _.filter(cars, (c) => { return !c.outOfBounds && c.lapCount === rLaps + 1; }); - utils.delay(() => { - _.each(finishCars, (c) => { + utils.delay(async () => { + for (const c of finishCars) { + let color; if (c.position === 1) { - this.colorLane(c.startLane, COLOR_POS1); - } - else if (c.position === 2) { - this.colorLane(c.startLane, COLOR_POS2); + color = COLOR_POS1; + } else if (c.position === 2) { + color = COLOR_POS2; + } else if (c.position === 3) { + color = COLOR_POS3; } - else if (c.position === 3) { - this.colorLane(c.startLane, COLOR_POS3); + if (color) { + await this.colorLane(c.startLane, color); } - }); + } }, 1500); } lap(lane) { - // flash lane led for 1 sec + // flash lane led for 1 sec if (this.ready) { this.colorLane(lane, COLOR_GREEN); - utils.delay(() => { - this.clearLane(lane); + utils.delay(async () => { + await this.clearLane(lane); }, 1000); } } - colorLane(lane, color) { + async colorLane(lane, color) { lane = this.laneIndex(lane); - const start = lane * 3; - for (let i = start; i <= start + 2; i++) { - this.strip.pixel(i).color(color); + try { + await window.electronAPI.hardwareWriteLeds({ + lane: lane, + color: color + }); + } catch (error) { + console.warn('Failed to color lane:', error); } - this.strip.show(); } - clearLane(lane) { + async clearLane(lane) { lane = this.laneIndex(lane); - const start = lane * 3; - for (let i = start; i <= start + 2; ++i) { - this.strip.pixel(i).off(); + try { + await window.electronAPI.hardwareLedOff({ + lane: lane + }); + } catch (error) { + console.warn('Failed to clear lane:', error); } - this.strip.show(); } - greenLight(delay, callback) { - const stripp = this.strip; - utils - .delay(() => { stripp.color(COLOR_GREEN); stripp.show(); this.beep(1000); callback(); }, delay) - .delay(() => { stripp.off(); }, storage.get('startDelay') * 1000); + async greenLight(delay, callback) { + try { + await utils.delayAsync(async () => { + // Turn all LEDs green + for (let i = 0; i < 9; i++) { + await window.electronAPI.hardwareWriteLeds({ + pixelIndex: i, + color: COLOR_GREEN, + show: false + }); + } + await window.electronAPI.hardwareLedShow(); + await this.beep(1000); + callback(); + }, delay); + + await utils.delayAsync(async () => { + await window.electronAPI.hardwareLedOff({}); + }, storage.get('startDelay') * 1000); + } catch (error) { + console.warn('Failed in greenLight:', error); + } } - countdown(delay) { - const stripp = this.strip; - if (this.reverse) { - utils - .delay(() => { stripp.pixel(8).color(COLOR_RED); stripp.show(); this.beep(200); }, delay) - .delay(() => { stripp.pixel(7).color(COLOR_RED); stripp.show(); }, 400) - .delay(() => { stripp.pixel(6).color(COLOR_RED); stripp.show(); }, 400) - .delay(() => { stripp.pixel(5).color(COLOR_RED); stripp.show(); this.beep(200); }, 400) - .delay(() => { stripp.pixel(4).color(COLOR_RED); stripp.show(); }, 400) - .delay(() => { stripp.pixel(3).color(COLOR_RED); stripp.show(); }, 400) - .delay(() => { stripp.pixel(2).color(COLOR_RED); stripp.show(); this.beep(200); }, 400) - .delay(() => { stripp.pixel(1).color(COLOR_RED); stripp.show(); }, 400) - .delay(() => { stripp.pixel(0).color(COLOR_RED); stripp.show(); }, 400); - } - else { - utils - .delay(() => { stripp.pixel(0).color(COLOR_RED); stripp.show(); this.beep(200); }, delay) - .delay(() => { stripp.pixel(1).color(COLOR_RED); stripp.show(); }, 400) - .delay(() => { stripp.pixel(2).color(COLOR_RED); stripp.show(); }, 400) - .delay(() => { stripp.pixel(3).color(COLOR_RED); stripp.show(); this.beep(200); }, 400) - .delay(() => { stripp.pixel(4).color(COLOR_RED); stripp.show(); }, 400) - .delay(() => { stripp.pixel(5).color(COLOR_RED); stripp.show(); }, 400) - .delay(() => { stripp.pixel(6).color(COLOR_RED); stripp.show(); this.beep(200); }, 400) - .delay(() => { stripp.pixel(7).color(COLOR_RED); stripp.show(); }, 400) - .delay(() => { stripp.pixel(8).color(COLOR_RED); stripp.show(); }, 400); + async countdown(delay) { + try { + const pixels = this.reverse ? [8,7,6,5,4,3,2,1,0] : [0,1,2,3,4,5,6,7,8]; + let currentDelay = delay; + + for (let i = 0; i < pixels.length; i++) { + await utils.delayAsync(async () => { + await window.electronAPI.hardwareWriteLeds({ + pixelIndex: pixels[i], + color: COLOR_RED + }); + if (i % 3 === 0) { + await this.beep(200); + } + }, currentDelay); + currentDelay = 400; + } + } catch (error) { + console.warn('Failed in countdown:', error); } } - kitt(color) { - const stripp = this.strip; - let direction = 0, curr = 0, prev = -1; - const millis = 50; - const shift = setInterval(function () { - stripp.pixel(curr).color(color); - if (prev >= 0) { - stripp.pixel(prev).off(); + async kitt(color) { + try { + let direction = 0, curr = 0, prev = -1; + const millis = 50; + const iterations = Math.floor(1650 / millis); + + for (let i = 0; i < iterations; i++) { + await utils.delayAsync(async () => { + await window.electronAPI.hardwareWriteLeds({ + pixelIndex: curr, + color: color, + show: false + }); + if (prev >= 0) { + await window.electronAPI.hardwareLedOff({ + pixelIndex: prev, + show: false + }); + } + await window.electronAPI.hardwareLedShow(); + + if (direction === 0) { + curr++; prev++; + if (curr > 8) { + direction = 1; + curr = 7; + } + } else { + curr--; prev--; + if (curr < 0) { + direction = 0; + curr = 1; + } + } + }, i * millis); } - stripp.show(); - if (direction === 0) { - curr++; prev++; - if (curr > 8) { - direction = 1; - curr = 7; - } - } - else { - curr--; prev--; - if (curr < 0) { - direction = 0; - curr = 1; - } - } - }, millis); - utils - .delay(() => { clearInterval(shift); }, 1650) - .delay(() => { stripp.off(); }, millis); + await utils.delayAsync(async () => { + await window.electronAPI.hardwareLedOff({}); + }, iterations * millis + millis); + } catch (error) { + console.warn('Failed in kitt:', error); + } } - tamiyaSlide() { - const manager = this; - const stripp = this.strip; - const millis = 100; - stripp.pixel(0).color(COLOR_TAMIYA_BLUE); - stripp.pixel(1).color(COLOR_TAMIYA_BLUE); - stripp.pixel(2).color(COLOR_TAMIYA_BLUE); - stripp.pixel(3).color(COLOR_TAMIYA_RED); - stripp.pixel(4).color(COLOR_TAMIYA_RED); - stripp.pixel(5).color(COLOR_TAMIYA_RED); - stripp.pixel(6).color(COLOR_TAMIYA_WHITE); - stripp.pixel(7).color(COLOR_TAMIYA_WHITE); - stripp.pixel(8).color(COLOR_TAMIYA_WHITE); - stripp.show(); - - const shift = setInterval(function () { - stripp.shift(1, pixel.FORWARD, true); - stripp.show(); - }, millis); - utils - .delay(() => { clearInterval(shift); }, 3000) - .delay(() => { stripp.off(); manager.ready = true; }, millis); + async tamiyaSlide() { + try { + const millis = 100; + + // Set initial colors + await window.electronAPI.hardwareWriteLeds({ pixelIndex: 0, color: COLOR_TAMIYA_BLUE, show: false }); + await window.electronAPI.hardwareWriteLeds({ pixelIndex: 1, color: COLOR_TAMIYA_BLUE, show: false }); + await window.electronAPI.hardwareWriteLeds({ pixelIndex: 2, color: COLOR_TAMIYA_BLUE, show: false }); + await window.electronAPI.hardwareWriteLeds({ pixelIndex: 3, color: COLOR_TAMIYA_RED, show: false }); + await window.electronAPI.hardwareWriteLeds({ pixelIndex: 4, color: COLOR_TAMIYA_RED, show: false }); + await window.electronAPI.hardwareWriteLeds({ pixelIndex: 5, color: COLOR_TAMIYA_RED, show: false }); + await window.electronAPI.hardwareWriteLeds({ pixelIndex: 6, color: COLOR_TAMIYA_WHITE, show: false }); + await window.electronAPI.hardwareWriteLeds({ pixelIndex: 7, color: COLOR_TAMIYA_WHITE, show: false }); + await window.electronAPI.hardwareWriteLeds({ pixelIndex: 8, color: COLOR_TAMIYA_WHITE, show: false }); + await window.electronAPI.hardwareLedShow(); + + // Note: Full shift animation is complex to do via IPC + // For now, just show the Tamiya colors and fade out + await utils.delayAsync(async () => { + await window.electronAPI.hardwareLedOff({}); + }, 3000); + } catch (error) { + console.warn('Failed in tamiyaSlide:', error); + } } } diff --git a/js/main.js b/js/main.js index 9d4735a..ce93dba 100755 --- a/js/main.js +++ b/js/main.js @@ -4,466 +4,473 @@ const debugMode = false; //////////////////////// -window.$ = require('jquery'); -window._ = require('underscore'); - -const { dialog, shell, app, webContents, getCurrentWindow } = require('electron').remote; +// With nodeIntegration:true, we can use require directly +// jQuery and Underscore loaded via HTML script tag const log = require('electron-log'); -log.info(`Launched Mini4wdChrono v${app.getVersion()} at ${new Date()}`); +// Use IPC to get app version +window.electronAPI.getAppVersion().then(version => { + log.info(`Launched Mini4wdChrono v${version} at ${new Date()}`); +}); log.catchErrors(); -const j5 = require('johnny-five'); const configuration = require('./js/configuration'); const i18n = new (require('./i18n/i18n')); -// load conf -try { - configuration.init(); -} -catch (e) { - // JSON parse error - log.error('Error loading configuration.'); - log.error(e.message); - const backup_filepath = configuration.reset(); - dialog.showMessageBoxSync(getCurrentWindow(), { type: 'error', title: 'Error', message: i18n.__('dialog-configuration-error'), detail: `${i18n.__('dialog-configuration-error-detail')} ${backup_filepath}`, buttons: ['Ok'] }); -} - const storage = require('./js/storage'); const client = require('./js/client'); const ui = require('./js/ui'); const xls = require('./js/export'); +const utils = require('./js/utils'); + +// Handles loading configuration and storage via IPC +(async () => { + try { + // Initialize locale for utils + await utils.initLocale(); + + // Initialize configuration and storage via IPC + await storage.initAsync(); + log.info('Configuration and storage initialized successfully'); + } catch (e) { + // Configuration/storage error + log.error('Error during initialization'); + log.error(e.message); + try { + await configuration.reset(); + } catch (resetErr) { + log.error('Error resetting configuration:', resetErr.message); + } + await window.electronAPI.showMessageBox({ + type: 'error', + title: 'Error', + message: i18n.__('dialog-configuration-error'), + detail: `${i18n.__('dialog-configuration-error-detail')} Check logs for details`, + buttons: ['Ok'] + }); + return; // Exit if initialization failed + } -// load race from file -storage.loadRace(); - -// Show version in about tab -$('#js-about-version').text(`Version ${app.getVersion()}`); + // Continue with application initialization + initializeApplication(); +})(); + +/** + * Main application initialization (called after async setup) + */ +async function initializeApplication() { + // Show version in about tab (async) + window.electronAPI.getAppVersion().then(version => { + $('#js-about-version').text(`Version ${version}`); + }); -// open links externally by default -$(document).on('click', 'a[href^="http"]', function (event) { - event.preventDefault(); - shell.openExternal(this.href); -}); + // open links externally by default + $(document).on('click', 'a[href^="http"]', function (event) { + event.preventDefault(); + window.electronAPI.openExternal(this.href); + }); -// Johnny-Five initialize -const board = new j5.Board({ - port: configuration.get('usbPort'), - timeout: 1e5, - repl: false // does not work with browser console -}); -let connected = false; -let reverse; -let ledManager; -let button1; -let sensorPin1, sensorPin2, sensorPin3; -let tag1, tag2, tag3; -let val1 = 0, val2 = 0, val3 = 0; - -// led manager instance -if (debugMode) { - const LedManagerMock = require('./js/led_managers/led_manager_mock'); - ledManager = LedManagerMock.getInstance(board, configuration.get('piezoPin')); -} -else if (configuration.get('ledType') === 0) { - const LedManagerLilypad = require('./js/led_managers/led_manager_lilypad'); - ledManager = LedManagerLilypad.getInstance(board, [ - configuration.get('ledPin1'), - configuration.get('ledPin2'), - configuration.get('ledPin3') - ], - configuration.get('piezoPin'), - configuration.get('reverse') > 0 - ); -} -else if (configuration.get('ledType') === 1) { - const LedManagerRgbStrip = require('./js/led_managers/led_manager_rgb_strip'); - ledManager = LedManagerRgbStrip.getInstance( - board, - configuration.get('ledPin1'), - configuration.get('piezoPin'), - configuration.get('reverse') > 0 - ); -} - -// translate ui -ui.translate(); - -// init client -client.init({ led_manager: ledManager }); - -// show interface -$('#main').show(); - -// Start race function. Handles all hardware checks. -const startRace = () => { - log.info(`Starting race at ${new Date()}`); - if (!debugMode) { - if (!connected) { - dialog.showMessageBoxSync(getCurrentWindow(), { type: 'error', title: 'Error', message: i18n.__('dialog-disconnected'), buttons: ['Ok'] }); - return; - } - else if (tag1.text() !== '1') { - dialog.showMessageBoxSync(getCurrentWindow(), { type: 'error', title: 'Error', message: `${i18n.__('dialog-sensor-error')} 1`, buttons: ['Ok'] }); - return; - } - else if (tag2.text() !== '1') { - dialog.showMessageBoxSync(getCurrentWindow(), { type: 'error', title: 'Error', message: `${i18n.__('dialog-sensor-error')} 2`, buttons: ['Ok'] }); - return; - } - else if (tag3.text() !== '1') { - dialog.showMessageBoxSync(getCurrentWindow(), { type: 'error', title: 'Error', message: `${i18n.__('dialog-sensor-error')} 3`, buttons: ['Ok'] }); - return; - } + let connected = false; + let reverse; + let ledManager; + let sensorPin1, sensorPin2, sensorPin3; + let tag1, tag2, tag3; + let val1 = 0, val2 = 0, val3 = 0; + + // LED manager instance - temporarily keep in renderer + // TODO: Will be refactored in Step 4 + if (debugMode) { + const LedManagerMock = require('./js/led_managers/led_manager_mock'); + ledManager = LedManagerMock.getInstance(await configuration.get('piezoPin')); } - client.startRace(debugMode); -}; - -const buttonPressed = () => { - client.isStarted() ? client.stopRace() : startRace(); -}; - -// board events -board.on('ready', function () { - connected = true; - log.info(`Board READY at ${new Date()}`); - - tag1 = $('#sensor-reading-1'); - tag2 = $('#sensor-reading-2'); - tag3 = $('#sensor-reading-3'); - - // init start button if present - if (configuration.get('startButtonPin') > 0) { - button1 = new j5.Button(configuration.get('startButtonPin')); - button1.on('release', buttonPressed); + else { + const LedManagerRgbStrip = require('./js/led_managers/led_manager_rgb_strip'); + ledManager = LedManagerRgbStrip.getInstance( + null, + await configuration.get('ledPin1'), + await configuration.get('piezoPin'), + (await configuration.get('reverse')) > 0 + ); } - // raw reading from digital pins because it's faster - sensorPin1 = configuration.get('sensorPin1'); - sensorPin2 = configuration.get('sensorPin2'); - sensorPin3 = configuration.get('sensorPin3'); + // translate ui + ui.translate(); - reverse = configuration.get('reverse') > 0; + // init client + await client.init({ led_manager: ledManager }); - this.samplingInterval(1); - this.pinMode(sensorPin1, j5.Pin.INPUT); - this.pinMode(sensorPin2, j5.Pin.INPUT); - this.pinMode(sensorPin3, j5.Pin.INPUT); + // show interface + $('#main').show(); - this.digitalRead(sensorPin1, function (val) { - tag1.text(val); - if (val === 0 && val1 === 1) { - reverse ? client.addLap(2) : client.addLap(0); - reverse ? ledManager.lap(2) : ledManager.lap(0); + // Start race function. Handles all hardware checks. + const startRace = async () => { + log.info(`Starting race at ${new Date()}`); + if (!debugMode) { + if (!connected) { + await window.electronAPI.showMessageBox({ type: 'error', title: 'Error', message: i18n.__('dialog-disconnected'), buttons: ['Ok'] }); + return; + } + else if (tag1.text() !== '1') { + await window.electronAPI.showMessageBox({ type: 'error', title: 'Error', message: `${i18n.__('dialog-sensor-error')} 1`, buttons: ['Ok'] }); + return; + } + else if (tag2.text() !== '1') { + await window.electronAPI.showMessageBox({ type: 'error', title: 'Error', message: `${i18n.__('dialog-sensor-error')} 2`, buttons: ['Ok'] }); + return; + } + else if (tag3.text() !== '1') { + await window.electronAPI.showMessageBox({ type: 'error', title: 'Error', message: `${i18n.__('dialog-sensor-error')} 3`, buttons: ['Ok'] }); + return; + } } - val1 = val; - }); + client.startRace(debugMode); + }; - this.digitalRead(sensorPin2, function (val) { - tag2.text(val); - if (val === 0 && val2 === 1) { - client.addLap(1); - ledManager.lap(1); - } - val2 = val; - }); + const buttonPressed = () => { + client.isStarted() ? client.stopRace() : startRace(); + }; - this.digitalRead(sensorPin3, function (val) { - tag3.text(val); - if (val === 0 && val3 === 1) { - reverse ? client.addLap(0) : client.addLap(2); - reverse ? ledManager.lap(0) : ledManager.lap(2); - } - val3 = val; + // Set up button press listener + window.electronAPI.onButtonPress(() => { + buttonPressed(); }); - ledManager.connected(); - ui.boardConnected(); -}); - -board.on('info', function (event) { - if (event) { - log.info(`Board INFO at ${new Date()} - ${event.message}`); - } -}); - -board.on('warn', function (event) { - if (event) { - log.warn(`Board WARN at ${new Date()} - ${event.message}`); - } -}); - -board.on('fail', function (event) { - connected = false; - ledManager.disconnected(); - ui.boardDisonnected(); - - if (event) { - log.error(`Board FAIL at ${new Date()} - ${event.message}`); + try { + // Initialize board in main process + await window.electronAPI.hardwareInitialize(); + log.info('Hardware initialization started'); + } catch (error) { + log.error('Failed to initialize hardware:', error); if (!debugMode) { - dialog.showMessageBoxSync(getCurrentWindow(), { type: 'error', title: 'Error', message: i18n.__('dialog-connection-error'), detail: event.message, buttons: ['Ok'] }); + await window.electronAPI.showMessageBox({ + type: 'error', + title: 'Error', + message: i18n.__('dialog-connection-error'), + detail: error.message, + buttons: ['Ok'] + }); } } -}); -board.on('error', function (event) { - connected = false; - ledManager.disconnected(); - ui.boardDisonnected(); + // Listen for board ready event from main process + window.electronAPI.onBoardReady(async () => { + try { + connected = true; + log.info(`Board READY at ${new Date()}`); + + tag1 = $('#sensor-reading-1'); + tag2 = $('#sensor-reading-2'); + tag3 = $('#sensor-reading-3'); + + // Get sensor pin configuration + sensorPin1 = await configuration.get('sensorPin1'); + sensorPin2 = await configuration.get('sensorPin2'); + sensorPin3 = await configuration.get('sensorPin3'); + + reverse = (await configuration.get('reverse')) > 0; + + // Set up sensors in main process + await window.electronAPI.hardwareSetupSensors({ + sensorPin1: sensorPin1, + sensorPin2: sensorPin2, + sensorPin3: sensorPin3 + }); + + // Set up start button in main process + await window.electronAPI.hardwareSetupButton({ + startButtonPin: await configuration.get('startButtonPin') + }); + log.info('Start button configured'); + + // Set up LEDs in main process + await window.electronAPI.hardwareSetupLeds({ + ledPin1: await configuration.get('ledPin1'), + ledPin2: await configuration.get('ledPin2'), + ledPin3: await configuration.get('ledPin3'), + reverse: (await configuration.get('reverse')) > 0 + }); + + // Set up buzzer in main process + await window.electronAPI.hardwareSetupBuzzer({ + piezoPin: await configuration.get('piezoPin') + }); + + log.info('Hardware components configured'); + + ledManager.connected(); + ui.boardConnected(); + } catch (error) { + log.error('Error during board ready setup:', error); + } + }); - if (event) { - log.error(`Board ERROR at ${new Date()} - ${event.message}`); - if (!debugMode) { - dialog.showMessageBoxSync(getCurrentWindow(), { type: 'error', title: 'Error', message: i18n.__('dialog-connection-error'), detail: event.message, buttons: ['Ok'] }); + // Listen for sensor changes from main process + window.electronAPI.onSensorChange((event, data) => { + const { lane, value, timestamp } = data; + + if (lane === 0) { + tag1.text(value); + if (value === 0 && val1 === 1) { + reverse ? client.addLap(2, timestamp) : client.addLap(0, timestamp); + reverse ? ledManager.lap(2) : ledManager.lap(0); + } + val1 = value; + } else if (lane === 1) { + tag2.text(value); + if (value === 0 && val2 === 1) { + client.addLap(1, timestamp); + ledManager.lap(1); + } + val2 = value; + } else if (lane === 2) { + tag3.text(value); + if (value === 0 && val3 === 1) { + reverse ? client.addLap(0, timestamp) : client.addLap(2, timestamp); + reverse ? ledManager.lap(0) : ledManager.lap(2); + } + val3 = value; } - } -}); + }); -// TODO does not work -board.on('close', function (event) { - connected = false; - ui.boardDisonnected(); - ledManager.disconnected(); - if (event) { - log.error(`Board CLOSE at ${new Date()} - ${event.message}`); - } -}); + // Listen for board errors from main process + window.electronAPI.onBoardError(async (event, errorMessage) => { + connected = false; + ledManager.disconnected(); + ui.boardDisconnected(); -board.on('exit', function (event) { - connected = false; - ui.boardDisonnected(); - ledManager.disconnected(); - if (event) { - log.error(`Board EXIT at ${new Date()} - ${event.message}`); - } -}); + log.error(`Board ERROR at ${new Date()} - ${errorMessage}`); + if (!debugMode) { + await window.electronAPI.showMessageBox({ + type: 'error', + title: 'Error', + message: i18n.__('dialog-connection-error'), + detail: errorMessage, + buttons: ['Ok'] + }); + } + }); -// ========================================================================== -// ==== listen to interface events and propagate to client + // ========================================================================== + // ==== listen to interface events and propagate to client -// tabs -$('.tabs a').on('click', (e) => { - const $this = $(e.currentTarget); - const tab = $this.closest('li').data('tab'); - ui.gotoTab(tab); -}); + // tabs + $('.tabs a').on('click', (e) => { + const $this = $(e.currentTarget); + const tab = $this.closest('li').data('tab'); + ui.gotoTab(tab); + }); -// modals -const openModal = (modal) => { - $(`#${modal}`).addClass('is-active'); - $(document.documentElement).addClass('is-clipped'); -}; - -const closeAllModals = () => { - $('.modal').removeClass('is-active'); - $(document.documentElement).removeClass('is-clipped'); -}; - -$('.open-modal').on('click', (e) => { - const $this = $(e.currentTarget); - openModal($this.data('modal')); - ui.initModal($this.data('modal')); -}); + // modals + const openModal = (modal) => { + $(`#${modal}`).addClass('is-active'); + $(document.documentElement).addClass('is-clipped'); + }; + + const closeAllModals = () => { + $('.modal').removeClass('is-active'); + $(document.documentElement).removeClass('is-clipped'); + }; + + $('.open-modal').on('click', (e) => { + const $this = $(e.currentTarget); + openModal($this.data('modal')); + ui.initModal($this.data('modal')); + }); -$('.close-modal').on('click', closeAllModals); + $('.close-modal').on('click', closeAllModals); -// keydown -document.onkeydown = (e) => { - if (!debugMode) { - return; - } - client.keydown(e.keyCode); -}; - -// ui observers -$(document).on('click', '.js-load-race', (e) => { - const $this = $(e.currentTarget); - if ($this.attr('disabled')) return; - const filename = $this.data('filename'); - storage.loadRace(filename); - client.init({ led_manager: ledManager }); - closeAllModals(); -}); + // keydown + document.onkeydown = (e) => { + if (!debugMode) { + return; + } + client.keydown(e.keyCode); + }; -$(document).on('click', '.js-delete-race', (e) => { - const $this = $(e.currentTarget); - if ($this.attr('disabled')) return; - if (dialog.showMessageBoxSync(getCurrentWindow(), { type: 'warning', message: i18n.__('dialog-delete-race'), buttons: ['Ok', 'Cancel'] }) === 0) { + // ui observers + $(document).on('click', '.js-load-race', async (e) => { + const $this = $(e.currentTarget); + if ($this.attr('disabled')) return; const filename = $this.data('filename'); - storage.deleteRace(filename); + storage.loadRace(filename); + await client.init({ led_manager: ledManager }); closeAllModals(); - } -}); - -$('#js-load-track').on('click', (e) => { - const $this = $(e.currentTarget); - if ($this.attr('disabled')) return; - const code = $('#js-input-track-code').val().slice(-6); - client.loadTrack(code); -}); + }); -$('#js-track-save-manual').on('click', (e) => { - const $this = $(e.currentTarget); - if ($this.attr('disabled')) return; - if (dialog.showMessageBoxSync(getCurrentWindow(), { type: 'warning', message: i18n.__('dialog-save-track'), buttons: ['Ok', 'Cancel'] }) === 0) { - $('#js-track-length-manual').removeClass('is-danger'); - $('#js-track-order-manual').removeClass('is-danger'); - if (!$('#js-track-length-manual').val()) { - $('#js-track-length-manual').addClass('is-danger'); - return; + $(document).on('click', '.js-delete-race', async (e) => { + const $this = $(e.currentTarget); + if ($this.attr('disabled')) return; + const result = await window.electronAPI.showMessageBox({ type: 'warning', message: i18n.__('dialog-delete-race'), buttons: ['Ok', 'Cancel'] }); + if (result.response === 0) { + const filename = $this.data('filename'); + storage.deleteRace(filename); + closeAllModals(); } - if (!$('#js-track-order-manual').val()) { - $('#js-track-order-manual').addClass('is-danger'); - return; - } - const length = parseFloat($('#js-track-length-manual').val().replace(',', '.')); - const order = _.map($('#js-track-order-manual').val().split('-'), (i) => { return parseInt(i); }); - client.setTrackManual(length, order); - } -}); + }); -$('#js-load-tournament').on('click', (e) => { - const $this = $(e.currentTarget); - if ($this.attr('disabled')) return; - const code = $('#js-input-tournament-code').val().slice(-6); - client.loadTournament(code); -}); + $('#js-load-track').on('click', (e) => { + const $this = $(e.currentTarget); + if ($this.attr('disabled')) return; + const code = $('#js-input-track-code').val().slice(-6); + client.loadTrack(code); + }); -$('#button-new-race').on('click', () => { - const name = $('#modal-new-name').val().trim(); - if (name === '') return false; - client.reset(name); - closeAllModals(); -}); + $('#js-track-save-manual').on('click', async (e) => { + const $this = $(e.currentTarget); + if ($this.attr('disabled')) return; + const result = await window.electronAPI.showMessageBox({ type: 'warning', message: i18n.__('dialog-save-track'), buttons: ['Ok', 'Cancel'] }); + if (result.response === 0) { + $('#js-track-length-manual').removeClass('is-danger'); + $('#js-track-order-manual').removeClass('is-danger'); + if (!$('#js-track-length-manual').val()) { + $('#js-track-length-manual').addClass('is-danger'); + return; + } + if (!$('#js-track-order-manual').val()) { + $('#js-track-order-manual').addClass('is-danger'); + return; + } + const length = parseFloat($('#js-track-length-manual').val().replace(',', '.')); + const order = _.map($('#js-track-order-manual').val().split('-'), (i) => { return parseInt(i); }); + client.setTrackManual(length, order); + } + }); -$('#button-start').on('click', startRace); + $('#js-load-tournament').on('click', (e) => { + const $this = $(e.currentTarget); + if ($this.attr('disabled')) return; + const code = $('#js-input-tournament-code').val().slice(-6); + client.loadTournament(code); + }); -$('#button-stop').on('click', () => { - client.stopRace(); -}); + $('#button-new-race').on('click', () => { + const name = $('#modal-new-name').val().trim(); + if (name === '') return false; + client.reset(name); + closeAllModals(); + }); -$('#button-prev').on('click', () => { - client.prevRound(); -}); + $('#button-start').on('click', startRace); -$('#button-next').on('click', () => { - client.nextRound(); -}); + $('#button-stop').on('click', () => { + client.stopRace(); + }); -$('#button-toggle-free-round').on('click', () => { - client.toggleFreeRound(); -}); + $('#button-prev').on('click', () => { + client.prevRound(); + }); -$('#button-print').on('click', () => { - webContents.getFocusedWebContents().print(); -}); + $('#button-next').on('click', () => { + client.nextRound(); + }); -$('#button-xls').on('click', () => { - client.saveXls(); - $('#button-xls').attr('disabled', true); -}); + $('#button-toggle-free-round').on('click', () => { + client.toggleFreeRound(); + }); -$('#button-xls-folder').on('click', () => { - const dir = xls.createDir(); - shell.openPath(dir); -}); + $('#button-print').on('click', () => { + // TODO webContents.getFocusedWebContents().print(); + }); -$('#button-log-file').on('click', () => { - shell.openPath(log.transports.file.findLogPath()); -}); + $('#button-xls').on('click', () => { + client.saveXls(); + $('#button-xls').attr('disabled', true); + }); -const updateThresholds = () => { - const timeThreshold = parseFloat($('#js-settings-time-threshold').val().replace(',', '.')); - const speedThreshold = parseFloat($('#js-settings-speed-threshold').val().replace(',', '.')); - const roundLaps = parseInt($('#js-settings-round-laps').val()); - if (isNaN(timeThreshold) || isNaN(speedThreshold)) return; - ui.showThresholds(timeThreshold, speedThreshold, roundLaps); -}; - -$('#js-settings-speed-threshold').on('keyup', updateThresholds); - -$('#js-settings-time-threshold').on('keyup', updateThresholds); - -$('#js-settings-round-laps').on('change', updateThresholds); - -$('#button-save-settings').on('click', (e) => { - const timeThreshold = parseFloat($('#js-settings-time-threshold').val().replace(',', '.')); - const speedThreshold = parseFloat($('#js-settings-speed-threshold').val().replace(',', '.')); - const startDelay = parseFloat($('#js-settings-start-delay').val().replace(',', '.')); - const roundLaps = parseInt($('#js-settings-round-laps').val()); - storage.set('timeThreshold', timeThreshold); - storage.set('speedThreshold', speedThreshold); - storage.set('startDelay', startDelay); - storage.set('roundLaps', roundLaps); - ui.showThresholds(); - e.preventDefault(); -}); + $('#button-xls-folder').on('click', () => { + const dir = xls.createDir(); + window.electronAPI.openPath(dir); + }); -$('#button-save-config').on('click', (e) => { - configuration.set('reverse', $('#js-config-reverse').is(':checked') ? 1 : 0); - configuration.set('sensorPin1', parseInt($('#js-config-sensor-pin-1').val())); - configuration.set('sensorPin2', parseInt($('#js-config-sensor-pin-2').val())); - configuration.set('sensorPin3', parseInt($('#js-config-sensor-pin-3').val())); - configuration.set('ledPin1', parseInt($('#js-config-led-pin-1').val())); - configuration.set('ledPin2', parseInt($('#js-config-led-pin-2').val())); - configuration.set('ledPin3', parseInt($('#js-config-led-pin-3').val())); - configuration.set('piezoPin', parseInt($('#js-config-piezo-pin').val())); - configuration.set('startButtonPin', parseInt($('#js-config-start-button-pin').val())); - configuration.set('title', $('#js-config-title').val()); - configuration.set('tab', $('#js-config-starting-tab').val()); - configuration.set('usbPort', $('#js-config-usb-port').val()); - dialog.showMessageBoxSync(getCurrentWindow(), { type: 'warning', message: i18n.__('dialog-restart'), buttons: ['Ok'] }); - getCurrentWindow().reload(); - e.preventDefault(); -}); + $('#button-log-file').on('click', () => { + window.electronAPI.openPath(log.transports.file.findLogPath()); + }); -$('#button-manches-save').on('click', (e) => { - const $this = $(e.currentTarget); - if ($this.attr('disabled')) return; - client.overrideTimes(); - dialog.showMessageBoxSync(getCurrentWindow(), { type: 'warning', message: i18n.__('dialog-saved'), buttons: ['Ok'] }); -}); + const updateThresholds = () => { + const timeThreshold = parseFloat($('#js-settings-time-threshold').val().replace(',', '.')); + const speedThreshold = parseFloat($('#js-settings-speed-threshold').val().replace(',', '.')); + const roundLaps = parseInt($('#js-settings-round-laps').val()); + if (isNaN(timeThreshold) || isNaN(speedThreshold)) return; + ui.showThresholds(timeThreshold, speedThreshold, roundLaps); + }; + + $('#js-settings-speed-threshold').on('keyup', updateThresholds); + + $('#js-settings-time-threshold').on('keyup', updateThresholds); + + $('#js-settings-round-laps').on('change', updateThresholds); + + $('#button-save-settings').on('click', (e) => { + const timeThreshold = parseFloat($('#js-settings-time-threshold').val().replace(',', '.')); + const speedThreshold = parseFloat($('#js-settings-speed-threshold').val().replace(',', '.')); + const startDelay = parseFloat($('#js-settings-start-delay').val().replace(',', '.')); + const roundLaps = parseInt($('#js-settings-round-laps').val()); + storage.set('timeThreshold', timeThreshold); + storage.set('speedThreshold', speedThreshold); + storage.set('startDelay', startDelay); + storage.set('roundLaps', roundLaps); + ui.showThresholds(); + e.preventDefault(); + }); -$(document).on('click', '.js-goto-round', (e) => { - const $this = $(e.currentTarget); - if ($this.attr('disabled')) return; - const mindex = $this.data('manche'); - const rindex = $this.data('round'); - client.gotoRound(mindex, rindex); -}); + $('#button-save-config').on('click', async (e) => { + configuration.set('reverse', $('#js-config-reverse').is(':checked') ? 1 : 0); + configuration.set('sensorPin1', parseInt($('#js-config-sensor-pin-1').val())); + configuration.set('sensorPin2', parseInt($('#js-config-sensor-pin-2').val())); + configuration.set('sensorPin3', parseInt($('#js-config-sensor-pin-3').val())); + configuration.set('ledPin1', parseInt($('#js-config-led-pin-1').val())); + configuration.set('ledPin2', parseInt($('#js-config-led-pin-2').val())); + configuration.set('ledPin3', parseInt($('#js-config-led-pin-3').val())); + configuration.set('piezoPin', parseInt($('#js-config-piezo-pin').val())); + configuration.set('startButtonPin', parseInt($('#js-config-start-button-pin').val())); + configuration.set('title', $('#js-config-title').val()); + configuration.set('tab', $('#js-config-starting-tab').val()); + configuration.set('usbPort', $('#js-config-usb-port').val()); + await window.electronAPI.showMessageBox({ type: 'warning', message: i18n.__('dialog-restart'), buttons: ['Ok'] }); + location.reload(); + e.preventDefault(); + }); -$('.js-led-animation').on('click', (e) => { - const $this = $(e.currentTarget); - if ($this.attr('disabled')) return; - $('.js-led-animation').removeClass('is-primary'); - $this.addClass('is-primary'); - const type = $this.data('led-animation'); - configuration.set('ledAnimation', type); -}); + $('#button-manches-save').on('click', async (e) => { + const $this = $(e.currentTarget); + if ($this.attr('disabled')) return; + client.overrideTimes(); + await window.electronAPI.showMessageBox({ type: 'warning', message: i18n.__('dialog-saved'), buttons: ['Ok'] }); + }); -$('.js-led-type').on('click', (e) => { - const $this = $(e.currentTarget); - if ($this.attr('disabled')) return; - $('.js-led-type').removeClass('is-primary'); - $this.addClass('is-primary'); - const type = $this.data('led-type'); - configuration.set('ledType', type); -}); + $(document).on('click', '.js-goto-round', (e) => { + const $this = $(e.currentTarget); + if ($this.attr('disabled')) return; + const mindex = $this.data('manche'); + const rindex = $this.data('round'); + client.gotoRound(mindex, rindex); + }); -$('.js-race-mode').on('click', (e) => { - const $this = $(e.currentTarget); - if ($this.attr('disabled')) return; - $('.js-race-mode').removeClass('is-primary'); - $this.addClass('is-primary'); - const mode = $this.data('race-mode'); - storage.set('raceMode', mode); - ui.showRaceModeDetails(); -}); + $('.js-led-animation').on('click', (e) => { + const $this = $(e.currentTarget); + if ($this.attr('disabled')) return; + $('.js-led-animation').removeClass('is-primary'); + $this.addClass('is-primary'); + const type = $this.data('led-animation'); + configuration.set('ledAnimation', type); + }); -$('.js-invalidate').on('click', (e) => { - const $this = $(e.currentTarget); - if ($this.attr('disabled')) return; - if (dialog.showMessageBoxSync(getCurrentWindow(), { type: 'warning', message: i18n.__('dialog-disqualify'), buttons: ['Ok', 'Cancel'] }) === 0) { - client.disqualify(null, null, parseInt($this.data('lane'))); - } -}); + $('.js-race-mode').on('click', (e) => { + const $this = $(e.currentTarget); + if ($this.attr('disabled')) return; + $('.js-race-mode').removeClass('is-primary'); + $this.addClass('is-primary'); + const mode = $this.data('race-mode'); + storage.set('raceMode', mode); + ui.showRaceModeDetails(); + }); + + $('.js-invalidate').on('click', async (e) => { + const $this = $(e.currentTarget); + if ($this.attr('disabled')) return; + const result = await window.electronAPI.showMessageBox({ type: 'warning', message: i18n.__('dialog-disqualify'), buttons: ['Ok', 'Cancel'] }); + if (result.response === 0) { + client.disqualify(null, null, parseInt($this.data('lane'))); + } + }); +} // End of initializeApplication function diff --git a/js/storage.js b/js/storage.js index 7e8a365..dbf264c 100644 --- a/js/storage.js +++ b/js/storage.js @@ -1,135 +1,316 @@ 'use strict'; -const { app } = require('electron').remote; -const fs = require('fs'); -const path = require('path'); -const jsonfile = require('jsonfile'); -const storage = require('electron-settings'); -const configuration = require('./configuration'); -configuration.init(); - -const setDefaults = () => { - const timestamp = parseInt(new Date().getTime() / 1000); - set('created', timestamp); - set('currManche', 0); - set('currRound', 0); - set('raceMode', 0); - set('timeThreshold', 40); - set('speedThreshold', 5); - set('startDelay', 3); - set('roundLaps', 3); +// This module provides both async and sync-like (cached) access patterns +// for backward compatibility with existing code during transition + +// In-memory cache of current race data +const cachedRaceData = {}; +let cacheReady = false; + +/** + * Internal: Sets values in the local cache + */ +const setCached = (key, value) => { + const keys = key.split('.'); + let current = cachedRaceData; + + for (let i = 0; i < keys.length - 1; i++) { + if (!current[keys[i]]) { + current[keys[i]] = {}; + } + current = current[keys[i]]; + } + + current[keys[keys.length - 1]] = value; }; -const newRace = (raceName) => { - const userdir = app.getPath('userData'); - const storagedir = path.join(userdir, 'races'); - if (!fs.existsSync(storagedir)) { - fs.mkdirSync(storagedir); +/** + * Internal: Gets values from the local cache + */ +const getCached = (key) => { + if (!cacheReady) { + console.warn(`[Storage] Cache not ready for key: ${key}`); + return null; + } + + const keys = key.split('.'); + let current = cachedRaceData; + + for (let i = 0; i < keys.length; i++) { + current = current[keys[i]]; + if (current === undefined || current === null) { + return null; + } } - const timestamp = parseInt(new Date().getTime() / 1000); - const filename = `${timestamp}.json`; - const filepath = path.join(userdir, 'races', filename); - configuration.set('raceFile', filename); - fs.closeSync(fs.openSync(filepath, 'w')); // create empty file - storage.setPath(filepath); - set('name', raceName); - setDefaults(); + return current; }; -const loadRace = (filename) => { - filename = filename || configuration.get('raceFile'); - if (filename) { - filename = filename.substr(filename.length - 15); // retrocompatibility - const userdir = app.getPath('userData'); - const filepath = path.join(userdir, 'races', filename); - configuration.set('raceFile', filename); - storage.setPath(filepath); - if (get('created') === null) { - setDefaults(); +/** + * Initializes the storage system (must be called once at startup) + * Loads configuration and race data into cache + * @returns {Promise} + */ +const initAsync = async () => { + try { + // Check if electronAPI is available + if (!window.electronAPI) { + throw new Error('window.electronAPI is not available. Preload script may not have run.'); } + + // Initialize main process config + await window.electronAPI.configInit(); + + // Load current race file + await loadRaceAsync(); + + cacheReady = true; + } catch (error) { + console.error('[Storage] Initialization error:', error); + throw error; } - else { - newRace(); +}; + +/** + * Creates a new race (async) + */ +const newRaceAsync = async (raceName) => { + try { + const filename = await window.electronAPI.storageNewRace(raceName); + await loadRaceAsync(filename); + return filename; + } catch (error) { + console.error('Error creating new race:', error); + throw error; + } +}; + +/** + * Creates a new race (sync wrapper - fire and forget) + */ +const newRace = (raceName) => { + newRaceAsync(raceName).catch(err => console.error('Async newRace failed:', err)); +}; + +/** + * Loads an existing race (async version) + */ +const loadRaceAsync = async (filename) => { + try { + if (!filename) { + filename = await window.electronAPI.configGet('raceFile'); + } + + if (filename) { + // Retrocompatibility: trim filename if needed + filename = filename.substr(filename.length - 15); + await window.electronAPI.storageLoadRace(filename); + cacheReady = true; + } else { + // No race file, create a new one + await newRaceAsync('Unnamed Race'); + } + } catch (error) { + console.error('Error loading race:', error); + throw error; + } +}; + +/** + * Loads an existing race (sync wrapper) + */ +const loadRace = (filename) => { + loadRaceAsync(filename).catch(err => console.error('Async loadRace failed:', err)); +}; + +/** + * Deletes a race file (async) + */ +const deleteRaceAsync = async (filename) => { + try { + await window.electronAPI.storageDeleteRace(filename); + } catch (error) { + console.error('Error deleting race:', error); + throw error; } }; +/** + * Deletes a race file (sync wrapper) + */ const deleteRace = (filename) => { - const userdir = app.getPath('userData'); - const filepath = path.join(userdir, 'races', filename); - fs.unlinkSync(filepath); + deleteRaceAsync(filename).catch(err => console.error('Async deleteRace failed:', err)); }; -const extension = (element) => { - const extName = path.extname(element); - return extName === '.json'; +/** + * Gets recent race files (async) + */ +const getRecentFilesAsync = async (num) => { + try { + num = num || 10; + const recent = await window.electronAPI.storageListRaces(num); + return _.sortBy(recent, 'created').reverse().slice(0, num); + } catch (error) { + console.error('Error getting recent files:', error); + throw error; + } }; +/** + * Gets recent race files (sync wrapper) + */ const getRecentFiles = (num) => { - num = num || 10; - const userdir = app.getPath('userData'); - const storagedir = path.join(userdir, 'races'); - let files = fs.readdirSync(storagedir); - files = files.filter(extension); - const recent = []; - files.forEach((filename) => { - const data = jsonfile.readFileSync(path.join(storagedir, filename)); - if (data) { - recent.push({ - filename: filename, - name: data.name, - created: data.created - }); - } - }); - return _.sortBy(recent, 'created').reverse().slice(0, num); + getRecentFilesAsync(num).catch(err => console.error('Async getRecentFiles failed:', err)); + return []; }; +/** + * Sets a storage value (async) + */ +const setAsync = async (key, value) => { + try { + await window.electronAPI.storageSet(key, value); + setCached(key, value); + } catch (error) { + console.error(`Error setting storage key ${key}:`, error); + throw error; + } +}; + +/** + * Sets a storage value (sync wrapper using cache) + */ const set = (key, value) => { - storage.set(key, value); + setCached(key, value); + setAsync(key, value).catch(err => console.error(`Async set(${key}) failed:`, err)); +}; + +/** + * Gets a storage value (async) + */ +const getAsync = async (key) => { // eslint-disable-line no-unused-vars + try { + return await window.electronAPI.storageGet(key); + } catch (error) { + console.error(`Error getting storage key ${key}:`, error); + throw error; + } }; +/** + * Gets a storage value (sync wrapper using cache) + */ const get = (key) => { - return storage.get(key); + return getCached(key); +}; + +/** + * Removes a storage value (async) + */ +const removeAsync = async (key) => { + try { + await window.electronAPI.storageRemove(key); + // Remove from cache + const keys = key.split('.'); + let current = cachedRaceData; + for (let i = 0; i < keys.length - 1; i++) { + current = current[keys[i]]; + if (!current) return; + } + delete current[keys[keys.length - 1]]; + } catch (error) { + console.error(`Error removing storage key ${key}:`, error); + throw error; + } }; +/** + * Removes a storage value (sync wrapper) + */ const remove = (key) => { - return storage.delete(key); + const keys = key.split('.'); + let current = cachedRaceData; + for (let i = 0; i < keys.length - 1; i++) { + current = current[keys[i]]; + if (!current) return; + } + delete current[keys[keys.length - 1]]; + removeAsync(key).catch(err => console.error(`Async remove(${key}) failed:`, err)); }; +/** + * Saves round results + */ const saveRound = (manche, round, cars) => { - set(`race.m${manche}.r${round}`, cars); + try { + set(`race.m${manche}.r${round}`, cars); + } catch (error) { + console.error(`Error saving round m${manche}.r${round}:`, error); + throw error; + } }; +/** + * Loads round results + */ const loadRound = (manche, round) => { - if (manche === null) - manche = get('currManche'); - if (round === null) - round = get('currRound'); - - return get(`race.m${manche}.r${round}`); + try { + if (manche === null) { + manche = get('currManche'); + } + if (round === null) { + round = get('currRound'); + } + return get(`race.m${manche}.r${round}`); + } catch (error) { + console.error(`Error loading round m${manche}.r${round}:`, error); + throw error; + } }; +/** + * Deletes round results + */ const deleteRound = (manche, round) => { - remove(`race.m${manche}.r${round}`); + try { + remove(`race.m${manche}.r${round}`); + } catch (error) { + console.error(`Error deleting round m${manche}.r${round}:`, error); + throw error; + } }; +/** + * Gets manche list from tournament data + */ const getManches = () => { - const tournament = get('tournament'); - if (!tournament) return null; + try { + const tournament = get('tournament'); + if (!tournament) return null; - const mancheList = tournament.manches; - if (tournament.finals) { - mancheList.push(...tournament.finals); + const mancheList = tournament.manches || []; + if (tournament.finals) { + mancheList.push(...tournament.finals); + } + return mancheList; + } catch (error) { + console.error('Error getting manches:', error); + throw error; } - return mancheList; }; +/** + * Gets player list from tournament data + */ const getPlayers = () => { - const tournament = get('tournament'); - if (!tournament) return null; - - return tournament.players; + try { + const tournament = get('tournament'); + if (!tournament) return null; + return tournament.players; + } catch (error) { + console.error('Error getting players:', error); + throw error; + } }; /* @@ -144,60 +325,71 @@ const getPlayers = () => { @return [Array] */ const getPlayerData = () => { - let cars; - const playerTimes = []; - const mancheList = getManches(); - _.each(mancheList, (manche, mindex) => { - _.each(manche, (round, rindex) => { - cars = loadRound(mindex, rindex); - _.each(round, (playerId, pindex) => { - playerTimes[playerId] = playerTimes[playerId] || []; - if (cars) { - playerTimes[playerId][mindex] = { - time: cars[pindex].currTime, - position: cars[pindex].position, - outOfBounds: cars[pindex].outOfBounds - }; - } - else { - playerTimes[playerId][mindex] = { - time: 0, - position: 0, - outOfBounds: false - }; - } + try { + let cars; + const playerTimes = []; + const mancheList = getManches(); + + _.each(mancheList, (manche, mindex) => { + _.each(manche, (round, rindex) => { + cars = loadRound(mindex, rindex); + _.each(round, (playerId, pindex) => { + playerTimes[playerId] = playerTimes[playerId] || []; + if (cars) { + playerTimes[playerId][mindex] = { + time: cars[pindex].currTime, + position: cars[pindex].position, + outOfBounds: cars[pindex].outOfBounds + }; + } else { + playerTimes[playerId][mindex] = { + time: 0, + position: 0, + outOfBounds: false + }; + } + }); }); }); - }); - return playerTimes; + return playerTimes; + } catch (error) { + console.error('Error getting player data:', error); + throw error; + } }; const getSortedPlayerList = () => { - const playerList = getPlayers(); - const playerData = getPlayerData(); - - // calculate best time sums - const sums = []; - let pData, bestTimes, bestSum; - _.each(playerList, (_player, pindex) => { - pData = playerData[pindex] || []; - bestTimes = _.sortBy(_.filter(pData, (i) => { return i && i.time > 0; }), 'time').slice(0, 2); - bestSum = (bestTimes[0] ? bestTimes[0].time : 99999) + (bestTimes[1] ? bestTimes[1].time : 99999); - sums[pindex] = bestSum; - }); - - // sort list by sum desc - const playerTimes = _.map(playerData, (data, index) => { - return { - id: index, - times: _.map(data, (i) => { return i ? i.time : null; }), - best: sums[index] - }; - }); - return _.sortBy(playerTimes, 'best'); + try { + const playerList = getPlayers(); + const playerData = getPlayerData(); + + // calculate best time sums + const sums = []; + let pData, bestTimes, bestSum; + _.each(playerList, (_player, pindex) => { + pData = playerData[pindex] || []; + bestTimes = _.sortBy(_.filter(pData, (i) => { return i && i.time > 0; }), 'time').slice(0, 2); + bestSum = (bestTimes[0] ? bestTimes[0].time : 99999) + (bestTimes[1] ? bestTimes[1].time : 99999); + sums[pindex] = bestSum; + }); + + // sort list by sum desc + const playerTimes = _.map(playerData, (data, index) => { + return { + id: index, + times: _.map(data, (i) => { return i ? i.time : null; }), + best: sums[index] + }; + }); + return _.sortBy(playerTimes, 'best'); + } catch (error) { + console.error('Error getting sorted player list:', error); + throw error; + } }; module.exports = { + initAsync: initAsync, newRace: newRace, loadRace: loadRace, deleteRace: deleteRace, diff --git a/js/ui.js b/js/ui.js index d714e77..251278b 100644 --- a/js/ui.js +++ b/js/ui.js @@ -1,11 +1,10 @@ 'use strict'; -const serialport = require('serialport'); const strftime = require('strftime'); const utils = require('./utils'); const i18n = new (require('../i18n/i18n'))(); const configuration = require('./configuration'); -configuration.init(); +// configuration.init() is now called in main.js during async initialization const storage = require('./storage'); const boardConnected = () => { @@ -34,8 +33,8 @@ const gotoTab = (tab) => { $(`div[data-tab=${tab}]`).show(); }; -const init = () => { - const title_text = _.compact([configuration.get('title'), storage.get('name')]).join(' - '); +const init = async () => { + const title_text = _.compact([await configuration.get('title'), storage.get('name')]).join(' - '); $('#js-title').text(title_text); $('#js-race-name').text(storage.get('name') || i18n.__('label-untitled')); @@ -47,20 +46,18 @@ const init = () => { showRaceModeDetails(); $('.js-led-animation').removeClass('is-primary'); - $(`#js-led-animation-${configuration.get('ledAnimation')}`).addClass('is-primary'); - $('.js-led-type').removeClass('is-primary'); - $(`#js-led-type-${configuration.get('ledType')}`).addClass('is-primary'); - $('#js-config-reverse').prop('checked', configuration.get('reverse') > 0); - $('#js-config-sensor-pin-1').val(configuration.get('sensorPin1')); - $('#js-config-sensor-pin-2').val(configuration.get('sensorPin2')); - $('#js-config-sensor-pin-3').val(configuration.get('sensorPin3')); - $('#js-config-led-pin-1').val(configuration.get('ledPin1')); - $('#js-config-led-pin-2').val(configuration.get('ledPin2')); - $('#js-config-led-pin-3').val(configuration.get('ledPin3')); - $('#js-config-piezo-pin').val(configuration.get('piezoPin')); - $('#js-config-start-button-pin').val(configuration.get('startButtonPin')); - $('#js-config-title').val(configuration.get('title')); - $('#js-config-starting-tab').val(configuration.get('tab')); + $(`#js-led-animation-${await configuration.get('ledAnimation')}`).addClass('is-primary'); + $('#js-config-reverse').prop('checked', (await configuration.get('reverse')) > 0); + $('#js-config-sensor-pin-1').val(await configuration.get('sensorPin1')); + $('#js-config-sensor-pin-2').val(await configuration.get('sensorPin2')); + $('#js-config-sensor-pin-3').val(await configuration.get('sensorPin3')); + $('#js-config-led-pin-1').val(await configuration.get('ledPin1')); + $('#js-config-led-pin-2').val(await configuration.get('ledPin2')); + $('#js-config-led-pin-3').val(await configuration.get('ledPin3')); + $('#js-config-piezo-pin').val(await configuration.get('piezoPin')); + $('#js-config-start-button-pin').val(await configuration.get('startButtonPin')); + $('#js-config-title').val(await configuration.get('title')); + $('#js-config-starting-tab').val(await configuration.get('tab')); $('#button-toggle-free-round').hide(); $('#js-input-track-code').removeClass('is-danger'); @@ -81,19 +78,18 @@ const init = () => { disableRaceInput(true); } - serialport.list().then(ports => { + window.electronAPI.hardwareListPorts().then(async ports => { ports.forEach(function (port) { $('#js-config-usb-port').append($('