diff --git a/leaderboard-timer/README.md b/leaderboard-timer/README.md index d69bdcca..3a748934 100644 --- a/leaderboard-timer/README.md +++ b/leaderboard-timer/README.md @@ -2,17 +2,42 @@ The DeepRacer timer is an automated timing solution which is used together with the leaderboard & timekeeper system used during DeepRacer events. -### Important +### Important Updates (v2.0) -Please note that the most recent release of Raspberry Pi OS "Bookworm" is currently not supported due to changes in the way the GPIO is handled, please use "Bullseye" (Legacy) for the RPi operating system. +**Broad Raspberry Pi Support** + +- Compatible with all Raspberry Pi models: RPi 1/2/3/4/5, Zero W, Zero 2W, Compute Modules +- Uses `node-libgpiod` (libgpiod character device interface) on all modern installs — unaffected by the kernel 6.x sysfs GPIO renumbering +- Automatic fallback to `rpi-gpio` for pre-kernel-6.x systems (not recommended) +- Recommended Raspberry Pi OS Bookworm or newer. Ubuntu will work, but installation script might need some manual tweaking. Older Raspberry Pi OS may also work. + +**Enhanced Timing Accuracy:** + +- Server-side timestamps capture exact trigger time (eliminates network latency) +- Per-sensor debounce tracking (configurable, default 2 seconds) +- Improved reliability and logging ## Hardware Requirements -**NOTE:** At this time the Raspberry Pi 5 is not supported for automated timing due to changes in the way the GPIO is connected. Please review this [issue](https://github.com/aws-solutions-library-samples/guidance-for-aws-deepracer-event-management/issues/14) for updates. +### Supported Devices + +- Raspberry Pi 5 / Compute Module 5 +- Raspberry Pi 4 / Compute Module 4 +- Raspberry Pi Zero 2W +- Raspberry Pi Zero W +- Raspberry Pi 3 / Compute Module 3 +- Raspberry Pi 1 / Zero / Compute Module 1 (ARMv6) + +**Performance Notes:** + +- **Pi 5 & Pi 4**: Excellent performance, handles multiple simultaneous connections easily +- **Pi Zero 2W**: Good performance, quad-core processor, recommended upgrade from Zero W +- **Pi Zero W**: Works well for single timing station, limited by single-core processor + +All models provide accurate timing - the performance differences mainly affect WebSocket connection handling and system responsiveness. ### Required -- Raspberry Pi 4 / Zero W (known to work) - 2x Sound Sensors [Variant 1 - Youmile (preferred)](https://www.amazon.co.uk/Youmile-Sensitivity-Microphone-Detection-Arduino/dp/B07Q1BYDS7/ref=sr_1_1_sspa?crid=YZ2AA2SUOG67&keywords=sound+sensor&qid=1655970264&sprefix=sound+sensor%2Caps%2C84&sr=8-1-spons&psc=1&smid=A3BN2T8LLIRB5S&spLa=ZW5jcnlwdGVkUXVhbGlmaWVyPUExMU5PTFY5WTlKTk8wJmVuY3J5cHRlZElkPUEwODEwNzkzM1ZCVU42MDdJQTdVUSZlbmNyeXB0ZWRBZElkPUEwNzMzMTg2MzNISEdLSjhINDRHNCZ3aWRnZXROYW1lPXNwX2F0ZiZhY3Rpb249Y2xpY2tSZWRpcmVjdCZkb05vdExvZ0NsaWNrPXRydWU=) / [Variant 2 - WaveShare](https://www.waveshare.com/sound-sensor.htm) - 2x [Pressure sensor](https://www.amazon.co.uk/gp/product/B07PM5PTPQ) - 2X 1.5m, two core flat wire between sound sensor and pressure sensors @@ -58,12 +83,18 @@ To install the Raspberry Pi (RPi) OS on an SD card the recommended approach is u Once installed choose the one of the following images based on the RPi being used. -- RPi 4 : Raspberry Pi OS (Other) -> Raspberry Pi OS Lite (64-bit) -- RPi Zero W : Raspberry Pi OS (Other) -> Raspberry Pi OS Lite (32-bit) +**Recommended OS: Raspberry Pi OS Bookworm (or newer), 32-bit or 64-bit** + +| Device | Recommended OS | +| ----------------------- | -------------------------------------------- | +| RPi 5 / CM5 | Raspberry Pi OS (64-bit) - Bookworm or later | +| RPi 4 / CM4 | Raspberry Pi OS (64-bit) - Bookworm or later | +| RPi Zero 2W | Raspberry Pi OS (64-bit) - Bookworm or later | +| RPi Zero W / Zero / CM1 | Raspberry Pi OS (32-bit) - Bookworm or later | ![Raspberry Pi Imager - Choose OS & Storage](./docs/images/pi_imager_os.png) -**Note:** Screen shot is for an RPi Zero W +**Note:** Bookworm ships with Linux kernel 6.x, which is required for the `node-libgpiod` GPIO library used by the timer. Bullseye and earlier are not recommended. Once you've selected your OS click on settings to configure the advanced settings. @@ -73,7 +104,7 @@ Here you can set the device hostname, password, enabale SSH and (optionally) con For the username we recommend you use: `deepracer` as this is the expected value used in the service definition. -**Important:** The [service-definition/deepracer-timer.service] is configured to expect the RPi username to be `deepracer`, if you use a different username you will need to change the following lines for the `WorkingDirectory` and `ExecStart` paths and also the `User` before running `service-setup.js` +**Important:** The [service-definition/deepracer-timer.service] is configured to expect the RPi username to be `deepracer`. The activation script updates these automatically, but if you are setting up manually with a different username you will need to edit the following lines: ``` WorkingDirectory=/home/deepracer/deepracer-timer @@ -84,6 +115,44 @@ User=deepracer Once the SD card has been written, eject it from your computer, insert into the RPi and boot it up. +### Node.js Installation + +The timer requires **Node.js 18 LTS** or later. The DREM activation script installs Node.js v18.20.8 automatically. To install it manually: + +```bash +# Install Node.js 18 LTS +curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - +sudo apt-get install -y nodejs + +# Verify installation +node --version # Should show v18.x.x +npm --version +``` + +### Time Synchronization (Important for Accuracy) + +For accurate timing, ensure your Raspberry Pi's clock is synchronized with NTP: + +```bash +# Check NTP status +timedatectl status + +# If NTP is not active, enable it +sudo timedatectl set-ntp true + +# Verify synchronization +sudo systemctl status systemd-timesyncd +``` + +The timer now captures timestamps on the device itself (not in the browser), which eliminates network latency from timing measurements. However, accurate time synchronization ensures consistency across multiple timing sessions and with other systems. + +**Network Latency Considerations:** + +- The timer captures the exact moment the GPIO triggers using high-precision timestamps +- These timestamps are sent to the browser along with the lap event +- Network latency only affects display updates, not the actual lap time measurements +- Typical latency: 10-50ms on local network, negligible impact on timing accuracy + ### DeepRacer Timer Service Go to `Device Management -> Timer activation` in DREM, select a fleet and enter a hostname for the RPi and click `Generate` @@ -92,8 +161,10 @@ Clicking on the `Copy` button copies the activation script to your clipboard, SS This process has been tested on: -- Pi Zero W -- Pi 4 +- Pi Zero W (32-bit OS) +- Pi Zero 2W (64-bit OS recommended) +- Pi 4 (64-bit OS) +- Pi 5 (64-bit OS, Bookworm) If you get an error with the service you can check the status of it using @@ -149,6 +220,89 @@ There is an `.stl` file to print out the sensor box choose the right `.stl` file ![TimerBox](./docs/images/timerbox.png) +## Configuration + +Configuration is done by editing the `config` object near the top of [timer.js](timer.js). + +After editing, restart the service: + +```bash +sudo systemctl restart deepracer-timer.service +``` + +### Configuration Options + +- **dremUrl**: The DREM server URL (set automatically during activation) +- **port**: HTTP/WebSocket server port (default: 8080) +- **gpio.sensor1**: BCM GPIO number for first sensor (default: 17, physical pin 11) +- **gpio.sensor2**: BCM GPIO number for second sensor (default: 27, physical pin 13) +- **gpio.debounceMs**: Debounce time in milliseconds per sensor (default: 2000) + +### Adjusting Debounce + +If you experience: + +- **Missed laps**: Decrease `debounceMs` (e.g., 1500ms) +- **False triggers**: Increase `debounceMs` (e.g., 3000ms) + +The debounce is applied per sensor independently, preventing a sensor from triggering multiple times within the configured window. + +### GPIO Pin Mapping + +The default configuration uses: + +- **Sensor 1**: GPIO17 (Physical pin 11) +- **Sensor 2**: GPIO27 (Physical pin 13) + +These match the hardware setup described in this guide. Only change these if you've wired the sensors to different GPIO pins. + +## Troubleshooting + +### Check which GPIO library is being used + +View the service logs to confirm `node-libgpiod` loaded successfully: + +```bash +sudo journalctl -u deepracer-timer.service -f +``` + +Look for lines like: + +- `Using node-libgpiod (modern GPIO library - requires kernel > 6.x)` — expected on all Bookworm installs +- `Using rpi-gpio (legacy GPIO library - requires kernel < 6.x)` — only on very old OS images, not recommended + +### Timing accuracy verification + +The timer logs each lap with: + +- Timestamp in milliseconds +- GPIO pin that triggered +- Per-sensor lap count + +Example log output: + +``` +15: Lap triggered - GPIO17 at 1709845234567 (2024-03-07T16:13:54.567Z) +``` + +### Common issues + +**No laps detected:** + +1. Check sensor calibration (see Calibration section) +2. Verify GPIO connections +3. Check service status: `sudo systemctl status deepracer-timer.service` + +**Multiple laps from single trigger:** + +1. Increase debounce time in config +2. Check pressure sensor placement + +**WebSocket connection issues:** + +1. Verify Pi is accessible on port 8080 +2. Check firewall settings: `sudo ufw status` + ## Developers When updating the code please create a new `leaderboard-timer.zip` file to make this easier to setup - Thank you. @@ -156,5 +310,5 @@ When updating the code please create a new `leaderboard-timer.zip` file to make From the parent directory: ``` -zip -r website/public/leaderboard-timer.zip leaderboard-timer -x "*.git*" -x "*node_modules*" -x "*stl*" -x "*.DS_Store" +zip -r website/public/leaderboard-timer.zip leaderboard-timer -x "*.git*" -x "*node_modules*" -x "*stl*" -x "*.DS_Store" -x "*package-lock.json" ``` diff --git a/leaderboard-timer/package.json b/leaderboard-timer/package.json index 6e0ecde9..153d318f 100644 --- a/leaderboard-timer/package.json +++ b/leaderboard-timer/package.json @@ -1,14 +1,20 @@ { "name": "summit-timer", - "version": "1.0.0", - "description": "", + "version": "2.0.0", + "description": "DeepRacer automated timing solution with Raspberry Pi 5 support", "main": "timer.js", "author": "", "license": "ISC", "dependencies": { - "http": "0.0.1-security", "http-proxy": "^1.18.1", - "rpi-gpio": "^2.1.7", - "ws": "^8.5.0" - } -} + "ws": "^8.19.0" + }, + "optionalDependencies": { + "node-libgpiod": "^0.6.0", + "rpi-gpio": "^2.1.7" + }, + "engines": { + "node": ">=18.0.0" + }, + "recommendedNode": "20.x LTS" +} \ No newline at end of file diff --git a/leaderboard-timer/timer.js b/leaderboard-timer/timer.js index eec4be76..538a00aa 100644 --- a/leaderboard-timer/timer.js +++ b/leaderboard-timer/timer.js @@ -1,50 +1,291 @@ const http = require('http'); const proxy = require('http-proxy'); -const gpio = require('rpi-gpio'); const ws = require('ws'); -drem = 'dremURL'; +const config = { + dremUrl: 'dremURL', + port: 8080, + gpio: { + sensor1: 17, // GPIO17 (pin 11) + sensor2: 27, // GPIO27 (pin 13) + debounceMs: 2000, // Configurable debounce time per sensor + }, +}; console.log('Starting timer...'); +console.log('Configuration:', JSON.stringify(config, null, 2)); + const sockets = new Set(); +let debugLapCounter = 0; + +// Per-sensor debounce tracking +const sensorDebounce = { + [config.gpio.sensor1]: { lastTrigger: 0, count: 0 }, + [config.gpio.sensor2]: { lastTrigger: 0, count: 0 }, +}; + +// Try to detect which GPIO library to use +let gpioLib = null; +let gpioChipNumber = 0; +let useModernGpio = false; +let gpioLines = []; // stored for graceful cleanup +let gpioChip = null; // kept at module scope to prevent GC of the underlying C pointer +let gpioPollingInterval = null; // stored so it can be cleared before line release + +// Try node-libgpiod first (libgpiod kernel interface — works on all RPi models with kernel 6.x+) +// kernel 6.x moved the legacy sysfs GPIO base to 512, which breaks rpi-gpio on all models. +// node-libgpiod uses /dev/gpiochipN (character device) and is unaffected by sysfs renumbering. +// Install: sudo apt install libgpiod-dev && npm install node-libgpiod +try { + const libgpiod = require('node-libgpiod'); + gpioLib = libgpiod; + useModernGpio = true; -let debounce; -let debugLapCounter = 1; -gpio.on('change', function (channel, value) { - if (debounce) return; - debounce = true; - setTimeout(() => { - debounce = false; - }, 3000); + // Detect chip number only when node-libgpiod is available: + // RPi 5 exposes the 40-pin header on gpiochip4; all earlier RPis use gpiochip0. + try { + const fs = require('fs'); + const model = fs.readFileSync('/proc/device-tree/model', 'utf8').replace(/\0/g, ''); + if (model.includes('Raspberry Pi 5') || model.includes('Compute Module 5')) { + gpioChipNumber = 4; + } + console.log(`Detected model: ${model.trim()}, using gpiochip${gpioChipNumber}`); + } catch (e) { + console.log('Could not read device model, defaulting to gpiochip0'); + } + + console.log('Using node-libgpiod (modern GPIO library - requires kernel > 6.x)'); +} catch (e) { + // Fall back to rpi-gpio for older Raspberry Pi models + try { + const rpiGpio = require('rpi-gpio'); + gpioLib = rpiGpio; + useModernGpio = false; + console.log('Using rpi-gpio (legacy GPIO library - requires kernel < 6.x)'); + } catch (e2) { + console.error('ERROR: No GPIO library available. Install either node-libgpiod or rpi-gpio.'); + process.exit(1); + } +} + +// Get timestamp as milliseconds since Unix epoch (Date.now()). +// This allows the browser to compute network latency by comparing +// the received timestamp against its own Date.now() on arrival. +function getTimestamp() { + return Date.now(); +} + +// Handle lap trigger +function handleLapTrigger(gpioPin, value, timestamp) { + const now = timestamp || getTimestamp(); + const sensor = sensorDebounce[gpioPin]; + + if (!sensor) { + console.warn(`Unknown GPIO pin: ${gpioPin}`); + return; + } + + // Check debounce - ignore triggers within debounce window + if (now - sensor.lastTrigger < config.gpio.debounceMs) { + console.log(`Debounced: GPIO${gpioPin} (${now - sensor.lastTrigger}ms since last trigger)`); + return; + } + + sensor.lastTrigger = now; + sensor.count++; + debugLapCounter++; + + const lapData = JSON.stringify({ + event: 'lap', + timestamp: now, + gpio: gpioPin, + lapNumber: debugLapCounter, + sensorLapCount: sensor.count, + }); + + console.log(`${debugLapCounter}: Lap triggered - GPIO${gpioPin} at ${now} (${new Date(now).toISOString()})`); + + // Broadcast to all connected WebSocket clients + let successCount = 0; + let failCount = 0; for (const sock of sockets) { try { - console.log(++debugLapCounter + ': Lap triggered'); - sock.send('lap'); - } catch (e) {} + if (sock.readyState === ws.OPEN) { + sock.send(lapData); + successCount++; + } + } catch (e) { + failCount++; + console.error('Error sending to WebSocket:', e.message); + } } -}); -gpio.setup(11, gpio.DIR_IN, gpio.EDGE_BOTH); -gpio.setup(13, gpio.DIR_IN, gpio.EDGE_BOTH); + if (failCount > 0) { + console.warn(`Broadcast complete: ${successCount} successful, ${failCount} failed`); + } +} + +// Initialize GPIO based on available library +function initializeGpio() { + if (useModernGpio) { + // node-libgpiod does not expose eventWait/eventRead — polling getValue() is + // the only supported approach. requestInputMode sets the line as an input; + // the setInterval below detects rising edges by comparing successive reads. + try { + gpioChip = new gpioLib.Chip(gpioChipNumber); + + if (gpioChip === null) { + console.error('Failed to initialize node-libgpiod: gpioChip is null'); + process.exit(1); + } + + const line1 = gpioChip.getLine(config.gpio.sensor1); + const line2 = gpioChip.getLine(config.gpio.sensor2); + + line1.requestInputMode('drem-timer'); + line2.requestInputMode('drem-timer'); + gpioLines = [line1, line2]; // stored for release() on graceful shutdown + + console.log( + `GPIO initialized via gpiochip${gpioChipNumber}: sensor1=GPIO${config.gpio.sensor1}, sensor2=GPIO${config.gpio.sensor2}` + ); + + let lastState1 = line1.getValue(); + let lastState2 = line2.getValue(); + + gpioPollingInterval = setInterval(() => { + try { + const state1 = line1.getValue(); + const state2 = line2.getValue(); + + if (state1 === 1 && lastState1 === 0) { + handleLapTrigger(config.gpio.sensor1, 1, getTimestamp()); + } + if (state2 === 1 && lastState2 === 0) { + handleLapTrigger(config.gpio.sensor2, 1, getTimestamp()); + } + + lastState1 = state1; + lastState2 = state2; + } catch (e) { + console.error('Error polling GPIO:', e.message); + } + }, 10); // Poll every 10ms + } catch (e) { + console.error('Failed to initialize node-libgpiod:', e); + process.exit(1); + } + } else { + // Legacy rpi-gpio for older RPi models — use BCM (GPIO) numbering to match config + gpioLib.setMode(gpioLib.MODE_BCM); + + gpioLib.on('change', function (channel, value) { + const timestamp = getTimestamp(); + // channel is the BCM GPIO number, matching config.gpio.sensor1/2 directly + if ((channel === config.gpio.sensor1 || channel === config.gpio.sensor2) && value) { + handleLapTrigger(channel, value, timestamp); + } + }); + + gpioLib.setup(config.gpio.sensor1, gpioLib.DIR_IN, gpioLib.EDGE_BOTH, (err) => { + if (err) console.error(`Failed to setup GPIO${config.gpio.sensor1}:`, err.message); + else console.log(`GPIO${config.gpio.sensor1} (sensor1) ready`); + }); + gpioLib.setup(config.gpio.sensor2, gpioLib.DIR_IN, gpioLib.EDGE_BOTH, (err) => { + if (err) console.error(`Failed to setup GPIO${config.gpio.sensor2}:`, err.message); + else console.log(`GPIO${config.gpio.sensor2} (sensor2) ready`); + }); + } +} + +// Initialize GPIO +initializeGpio(); + +// Set up HTTP proxy const p = proxy.createProxyServer(); const server = http.createServer((req, res) => { - p.web(req, res, { target: 'https://' + drem, headers: { Host: drem } }); + p.web(req, res, { + target: 'https://' + config.dremUrl, + headers: { Host: config.dremUrl }, + changeOrigin: true, + }); }); -server.listen(8080); +server.listen(config.port); +console.log(`HTTP proxy server listening on port ${config.port}`); +// Set up WebSocket server const wsServer = new ws.WebSocketServer({ server }); -console.log('Timer started'); -wsServer.on('connection', (sock) => { +console.log('Timer started and ready'); +wsServer.on('connection', (sock, req) => { sockets.add(sock); + const clientIp = req.socket.remoteAddress; + console.log(`WebSocket client connected from ${clientIp} (total clients: ${sockets.size})`); + + // Send server info to client + try { + sock.send( + JSON.stringify({ + event: 'connected', + timestamp: getTimestamp(), + serverInfo: { + gpioLibrary: useModernGpio ? 'node-libgpiod' : 'rpi-gpio', + config: config, + }, + }) + ); + } catch (e) { + console.error('Error sending connection info:', e.message); + } sock.on('close', () => { sockets.delete(sock); - console.log('Timer stopped'); + console.log(`WebSocket client disconnected (remaining clients: ${sockets.size})`); + }); + + sock.on('error', (error) => { + console.error('WebSocket error:', error.message); + sockets.delete(sock); + }); +}); + +// Graceful shutdown +process.on('SIGINT', () => { + console.log('\nShutting down gracefully...'); + + if (useModernGpio && gpioLines.length > 0) { + console.log('Releasing GPIO lines...'); + if (gpioPollingInterval) { + clearInterval(gpioPollingInterval); + gpioPollingInterval = null; + } + for (const line of gpioLines) { + try { + line.release(); + } catch (e) { + /* ignore cleanup errors */ + } + } + gpioChip = null; + } + + server.close(() => { + console.log('Server closed'); + process.exit(0); }); }); + +// Log statistics every 60 seconds +setInterval(() => { + console.log('Statistics:', { + totalLaps: debugLapCounter, + connectedClients: sockets.size, + sensor1Count: sensorDebounce[config.gpio.sensor1].count, + sensor2Count: sensorDebounce[config.gpio.sensor2].count, + }); +}, 60000); diff --git a/website/public/leaderboard-timer.zip b/website/public/leaderboard-timer.zip index 049837ac..71deca22 100644 Binary files a/website/public/leaderboard-timer.zip and b/website/public/leaderboard-timer.zip differ diff --git a/website/public/timer_activation.sh b/website/public/timer_activation.sh index ea72f3e3..f4416137 100644 --- a/website/public/timer_activation.sh +++ b/website/public/timer_activation.sh @@ -61,27 +61,55 @@ fi # Install Node echo -e -n "\n- Install Node\n" rpiVersion=$(tr -d '\0' https://unofficial-builds.nodejs.org/download/release/ - rpiArch=armv6l - ARCH=arm - curl -o node-${nodeVersion}-linux-${rpiArch}.tar.xz https://unofficial-builds.nodejs.org/download/release/${nodeVersion}/node-${nodeVersion}-linux-${rpiArch}.tar.xz -elif [[ $rpiVersion == *"Model B"* ]]; then +echo -e -n "\n Detected device: ${rpiVersion}\n" + +# Verify this is a Raspberry Pi before continuing +if [[ $rpiVersion != *"Raspberry Pi"* ]]; then + echo "This device does not appear to be a Raspberry Pi (model: '${rpiVersion}'). Exiting." + exit 1 +fi + +# Use the actual running kernel architecture to select the correct Node.js build. +# This correctly handles all RPi variants across 32-bit and 64-bit OS images: +# aarch64 : RPi 3/4/5, Zero 2W, CM3/4/5 running a 64-bit OS +# armv7l : RPi 2/3, Zero 2W, CM3 running a 32-bit (ARMv7) OS +# armv6l : RPi 1, Zero, Zero W, CM1 (ARMv6 — only unofficial Node builds exist) +cpuArch=$(uname -m) +echo -e -n "\n CPU architecture: ${cpuArch}\n" + +if [[ $cpuArch == "aarch64" ]]; then rpiArch=arm64 ARCH=arm64 - # Needed for SSM-agent + # libc6:armhf is required by the SSM agent on arm64 systems sudo dpkg --add-architecture armhf sudo apt-get update sudo apt-get install -y libc6:armhf - # Node + # Official Node.js build for arm64 + curl -o node-${nodeVersion}-linux-${rpiArch}.tar.xz https://nodejs.org/dist/${nodeVersion}/node-${nodeVersion}-linux-${rpiArch}.tar.xz + +elif [[ $cpuArch == "armv7l" ]]; then + # Covers RPi 2/3, Zero 2W and CM3 running a 32-bit OS + rpiArch=armv7l + ARCH=arm + + # Official Node.js build for armv7l curl -o node-${nodeVersion}-linux-${rpiArch}.tar.xz https://nodejs.org/dist/${nodeVersion}/node-${nodeVersion}-linux-${rpiArch}.tar.xz + +elif [[ $cpuArch == "armv6l" ]]; then + # Covers RPi Zero, Zero W and original RPi 1 / CM1 (ARMv6) + rpiArch=armv6l + ARCH=arm + + # Official builds do not exist for ARMv6; use unofficial builds instead. + # Releases -> https://unofficial-builds.nodejs.org/download/release/ + curl -o node-${nodeVersion}-linux-${rpiArch}.tar.xz https://unofficial-builds.nodejs.org/download/release/${nodeVersion}/node-${nodeVersion}-linux-${rpiArch}.tar.xz + else - echo "Not sure what kind of Pi this is.... sorry it didn't work out." + echo "Unsupported CPU architecture '${cpuArch}' on device '${rpiVersion}'. Exiting." exit 1 fi @@ -89,7 +117,8 @@ fi tar -xf node-${nodeVersion}-linux-${rpiArch}.tar.xz cd node-${nodeVersion}-linux-${rpiArch} rm -rf docs -rm README.md +rm README.md CHANGELOG.md LICENSE + sudo cp -rf * /usr/local cd .. rm -rf node-${nodeVersion}-linux-${rpiArch}.tar.xz node-${nodeVersion}-linux-${rpiArch} @@ -122,6 +151,13 @@ cd ${timerPath} echo -e -n "\n- Installing timer dependencies\n" npm install +# node-libgpiod uses the kernel character device interface (/dev/gpiochipN) and +# works on all RPi models with kernel 6.x (where legacy sysfs GPIO numbering +# changed to base 512, breaking rpi-gpio). Install it on all devices. +echo -e -n "\n- Installing node-libgpiod (required for kernel 6.x GPIO support)\n" +sudo apt-get install -y libgpiod-dev +npm install node-libgpiod + # Update deepracer-timer.service with the correct $homeDir # Using s!search!replace! for sed as there are '/' in the variables echo -e -n "\n- Update the path in the service-definition file\n" @@ -147,5 +183,14 @@ sudo systemctl status deepracer-timer.service #other possible commands #sudo systemctl [status,start,stop,restart,enable,disable] deepracer-timer.service +# Open port 8080 in ufw if it is installed and active +if command -v ufw &>/dev/null && ufw status | grep -q "^Status: active"; then + echo -e -n "\n- Opening port 8080 in ufw\n" + ufw allow 8080/tcp + ufw reload +else + echo -e -n "\n- ufw not active, skipping firewall rule for port 8080\n" +fi + echo -e -n "\nDone!" echo -e -n "\nTimer ${varHost} should be visible in DREM in ~5 minutes" diff --git a/website/src/pages/timekeeper/pages/racePage.tsx b/website/src/pages/timekeeper/pages/racePage.tsx index b541d050..f33e8077 100644 --- a/website/src/pages/timekeeper/pages/racePage.tsx +++ b/website/src/pages/timekeeper/pages/racePage.tsx @@ -89,6 +89,7 @@ export const RacePage = ({ const lapTimerRef = useRef(); const raceTimerRef = useRef(); const startTimeRef = useRef(); + const lastAutLapTimestampMsRef = useRef(null); const [PublishOverlay] = usePublishOverlay(); // populate the laps on page refresh, without this laps array in the overlay is empty @@ -100,6 +101,7 @@ export const RacePage = ({ actions: { readyToStart: (context, event) => { resetTimers(); + lastAutLapTimestampMsRef.current = null; SetCurrentLap(defaultLap); }, endRace: () => { @@ -135,13 +137,14 @@ export const RacePage = ({ const isLapValid = event.isValid && carResetCounter <= raceConfig.numberOfResetsPerLap; const lapId = raceInfo.laps.length; + const timerLapTimeMs = Number.isFinite(event?.timerLapTimeMs) ? event.timerLapTimeMs : null; const currentLapStats = { ...currentLap, resets: carResetCounter, lapId: lapId, modelId: raceInfo.currentModelId, carName: currentCar.ComputerName, - time: lapTimerRef.current.getCurrentTimeInMs(), + time: timerLapTimeMs ?? lapTimerRef.current.getCurrentTimeInMs(), isValid: isLapValid, autTimerConnected: autTimerIsConnected, }; @@ -212,7 +215,48 @@ export const RacePage = ({ const onMessageFromAutTimer = (message) => { console.info('Automated timer sent message: ' + message); - send('CAPTURE_AUT_LAP', { isValid: true }); + + try { + const payload = JSON.parse(message); + if (payload?.event !== 'lap') { + console.debug('Ignoring non-lap timer event', payload?.event); + return; + } + + const currentTimestampMs = Number(payload.timestamp); + if (!Number.isFinite(currentTimestampMs)) { + console.debug('Timer lap payload missing numeric timestamp', payload); + send('CAPTURE_AUT_LAP', { isValid: true }); + return; + } + + const receivedAtMs = Date.now(); + const latencyMs = receivedAtMs - currentTimestampMs; + console.debug('Auto-timer latency', { + sentMs: currentTimestampMs, + receivedAtMs, + latencyMs, + }); + + let timerLapTimeMs = null; + if (lastAutLapTimestampMsRef.current !== null) { + const deltaMs = currentTimestampMs - lastAutLapTimestampMsRef.current; + if (deltaMs > 0) { + timerLapTimeMs = deltaMs; + } + } + + lastAutLapTimestampMsRef.current = currentTimestampMs; + console.debug('Auto lap timing from timer', { + timestampMs: currentTimestampMs, + timerLapTimeMs, + }); + send('CAPTURE_AUT_LAP', { isValid: true, timerLapTimeMs }); + } catch (err) { + // Backward compatibility for legacy timer payloads that are plain strings. + console.debug('Failed to parse timer payload as JSON, using legacy lap trigger'); + send('CAPTURE_AUT_LAP', { isValid: true }); + } }; const wsUrl = window.location.href.split('/', 3)[2] ?? 'localhost:8080'; @@ -432,10 +476,20 @@ export const RacePage = ({ > {t('timekeeper.undo-false-finish')} - - @@ -455,9 +509,9 @@ export const RacePage = ({ {t('timekeeper.race-page.automated-timer-header')} - {autTimerIsConnected - ? t('timekeeper.race-page.automated-timer-connected') - : t('timekeeper.race-page.automated-timer-not-connected')}{' '} + {autTimerIsConnected + ? t('timekeeper.race-page.automated-timer-connected') + : t('timekeeper.race-page.automated-timer-not-connected')}{' '} diff --git a/website/src/pages/timekeeper/pages/racePageLite.tsx b/website/src/pages/timekeeper/pages/racePageLite.tsx index 1e25dd12..688758bf 100644 --- a/website/src/pages/timekeeper/pages/racePageLite.tsx +++ b/website/src/pages/timekeeper/pages/racePageLite.tsx @@ -91,6 +91,7 @@ export const RacePage = ({ const lapTimerRef = useRef(); const raceTimerRef = useRef(); const startTimeRef = useRef(); + const lastAutLapTimestampMsRef = useRef(null); const [PublishOverlay] = usePublishOverlay(); //populate the laps on page refresh, without this laps array in the overlay is empty @@ -102,6 +103,7 @@ export const RacePage = ({ actions: { readyToStart: (context, event) => { resetTimers(); + lastAutLapTimestampMsRef.current = null; SetCurrentLap(defaultLap); }, endRace: () => { @@ -138,13 +140,14 @@ export const RacePage = ({ const isLapValid = event.isValid && carResetCounter <= raceConfig.numberOfResetsPerLap; const lapId = raceInfo.laps.length; + const timerLapTimeMs = Number.isFinite(event?.timerLapTimeMs) ? event.timerLapTimeMs : null; const currentLapStats = { ...currentLap, resets: carResetCounter, lapId: lapId, modelId: raceInfo.currentModelId, carName: currentCar.ComputerName, - time: lapTimerRef.current.getCurrentTimeInMs(), + time: timerLapTimeMs ?? lapTimerRef.current.getCurrentTimeInMs(), isValid: isLapValid, autTimerConnected: autTimerIsConnected, }; @@ -215,7 +218,48 @@ export const RacePage = ({ const onMessageFromAutTimer = (message) => { console.info('Automated timer sent message: ' + message); - send('CAPTURE_AUT_LAP', { isValid: true }); + + try { + const payload = JSON.parse(message); + if (payload?.event !== 'lap') { + console.debug('Ignoring non-lap timer event', payload?.event); + return; + } + + const currentTimestampMs = Number(payload.timestamp); + if (!Number.isFinite(currentTimestampMs)) { + console.debug('Timer lap payload missing numeric timestamp', payload); + send('CAPTURE_AUT_LAP', { isValid: true }); + return; + } + + const receivedAtMs = Date.now(); + const latencyMs = receivedAtMs - currentTimestampMs; + console.debug('Auto-timer latency', { + sentMs: currentTimestampMs, + receivedAtMs, + latencyMs, + }); + + let timerLapTimeMs = null; + if (lastAutLapTimestampMsRef.current !== null) { + const deltaMs = currentTimestampMs - lastAutLapTimestampMsRef.current; + if (deltaMs > 0) { + timerLapTimeMs = deltaMs; + } + } + + lastAutLapTimestampMsRef.current = currentTimestampMs; + console.debug('Auto lap timing from timer', { + timestampMs: currentTimestampMs, + timerLapTimeMs, + }); + send('CAPTURE_AUT_LAP', { isValid: true, timerLapTimeMs }); + } catch (err) { + // Backward compatibility for legacy timer payloads that are plain strings. + console.debug('Failed to parse timer payload as JSON, using legacy lap trigger'); + send('CAPTURE_AUT_LAP', { isValid: true }); + } }; const wsUrl = window.location.href.split('/', 3)[2] ?? 'localhost:8080'; @@ -422,10 +466,20 @@ export const RacePage = ({ > {t('timekeeper.undo-false-finish')} - - @@ -445,9 +499,9 @@ export const RacePage = ({ {t('timekeeper.race-page.automated-timer-header')} - {autTimerIsConnected - ? t('timekeeper.race-page.automated-timer-connected') - : t('timekeeper.race-page.automated-timer-not-connected')}{' '} + {autTimerIsConnected + ? t('timekeeper.race-page.automated-timer-connected') + : t('timekeeper.race-page.automated-timer-not-connected')}{' '}