From fabfa5ef234243397b9e5e0dea384df60a8b71c7 Mon Sep 17 00:00:00 2001 From: Stephen Dade Date: Sat, 2 Aug 2025 15:48:18 +1000 Subject: [PATCH] Add PPP Connection config --- package-lock.json | 80 +++++++ package.json | 1 + server/index.js | 96 ++++++++- server/pppConnection.js | 340 ++++++++++++++++++++++++++++++ server/pppConnection.test.js | 187 ++++++++++++++++ src/App.test.jsx | 8 + src/AppRouter.jsx | 3 + src/components/IPAddressInput.jsx | 88 ++++++++ src/ppp.jsx | 168 +++++++++++++++ 9 files changed, 966 insertions(+), 5 deletions(-) create mode 100644 server/pppConnection.js create mode 100644 server/pppConnection.test.js create mode 100644 src/components/IPAddressInput.jsx create mode 100644 src/ppp.jsx diff --git a/package-lock.json b/package-lock.json index 61bdaef5..0655ec4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "nyc": "^17.1.0", "pino-colada": "^2.2.2", "pino-http": "^10.3.0", + "sinon": "^21.0.0", "vite-plugin-eslint": "^1.8.1", "vitest": "^3.1.1" } @@ -1500,6 +1501,47 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.3.tgz", + "integrity": "sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "type-detect": "^4.1.0" + } + }, + "node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", @@ -11436,6 +11478,34 @@ "node": ">=10" } }, + "node_modules/sinon": { + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.0.0.tgz", + "integrity": "sha512-TOgRcwFPbfGtpqvZw+hyqJDvqfapr1qUlOizROIk4bBLjlsjlB00Pg6wMFXNtJRpu+eCZuVOaLatG7M8105kAw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.5", + "@sinonjs/samsam": "^8.0.1", + "diff": "^7.0.0", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/socket.io": { "version": "4.8.1", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", @@ -12289,6 +12359,16 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", diff --git a/package.json b/package.json index dd70a66a..7b21d876 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "nyc": "^17.1.0", "pino-colada": "^2.2.2", "pino-http": "^10.3.0", + "sinon": "^21.0.0", "vite-plugin-eslint": "^1.8.1", "vitest": "^3.1.1" }, diff --git a/server/index.js b/server/index.js index d27e250d..274184c3 100644 --- a/server/index.js +++ b/server/index.js @@ -33,6 +33,7 @@ const crypto = require('crypto'); // set up rate limiter: maximum of fifty requests per minute const RateLimit = require('express-rate-limit') +const pppConnection = require('./pppConnection.js') const limiter = RateLimit({ windowMs: 1 * 60 * 1000, // 1 minute max: 50 @@ -66,17 +67,46 @@ const cloud = new cloudManager(settings) const logConversion = new logConversionManager(settings) const adhocManager = new Adhoc(settings) const userMgmt = new userLogin() +const pppConnectionManager = new pppConnection(settings) -// cleanup, if needed -process.on('SIGINT', quitting) // run signal handler when main process exits -// process.on('SIGTERM', quitting) // run signal handler when service exits. Need for Ubuntu?? +// Add graceful shutdown handlers +process.on('SIGINT', () => { + console.log('Received SIGINT. Shutting down gracefully...') + pppConnectionManager.quitting() + cloud.quitting() + logConversion.quitting() + console.log('---Shutdown Rpanion---') + process.exit(0) +}) -function quitting () { +process.on('SIGTERM', () => { + console.log('Received SIGTERM. Shutting down gracefully...') + pppConnectionManager.quitting() cloud.quitting() logConversion.quitting() console.log('---Shutdown Rpanion---') process.exit(0) -} +}) + +// Also good to handle uncaught exceptions +process.on('uncaughtException', (err) => { + console.error('Uncaught exception:', err) + pppConnectionManager.quitting() + cloud.quitting() + logConversion.quitting() + console.log('---Shutdown Rpanion---') + process.exit(1) +}) + +// Handle nodemon restarts +process.once('SIGUSR2', () => { + console.log('Received SIGUSR2. Shutting down gracefully...') + pppConnectionManager.quitting() + cloud.quitting() + logConversion.quitting() + console.log('---Shutdown Rpanion---') + process.kill(process.pid, 'SIGUSR2') +}) // Got an RTCM message, send to flight controller ntripClient.eventEmitter.on('rtcmpacket', (msg, seq) => { @@ -310,6 +340,62 @@ function authenticateToken(req, res, next) { }) } +//pppConnectionManager url endpoints +app.get('/api/pppstatus', authenticateToken, (req, res) => { + res.setHeader('Content-Type', 'application/json') + pppConnectionManager.getPPPSettings((err, settings) => { + if (err) { + console.log('Error in /api/pppstatus', { message: err }) + res.send(JSON.stringify({ error: err })) + return + } + res.send(JSON.stringify(settings)) + }) +}) + +app.post('/api/pppmodify', authenticateToken, [ + check('device').isJSON(), + check('baudrate').isJSON(), + check('localIP').isIP(), + check('remoteIP').isIP(), + check('enabled').isBoolean() +], (req, res) => { + const errors = validationResult(req) + if (!errors.isEmpty()) { + console.log('Bad POST vars in /api/pppmodify', { message: JSON.stringify(errors.array()) }) + return res.status(422).json({ error: JSON.stringify(errors.array()) }) + } + + if (req.body.enabled === true) { + console.log('Starting PPP connection'); + res.setHeader('Content-Type', 'application/json') + pppConnectionManager.startPPP(JSON.parse(req.body.device), JSON.parse(req.body.baudrate), req.body.localIP, req.body.remoteIP, (err, settings) => { + if (err !== null) { + console.log('Error in /api/pppmodify', { message: err }) + console.log(JSON.stringify({settings, error: err })) + res.send(JSON.stringify({settings, error: err.toString() })) + return + } else { + res.send(JSON.stringify({settings})) + return + } + }) + } + else if (req.body.enabled === false) { + pppConnectionManager.stopPPP((err, settings) => { + if (err) { + //console.log('Error in /api/pppmodify', { message: err }) + console.log(JSON.stringify({settings, error: err })) + res.send(JSON.stringify({settings, error: err })) + return + } else { + res.send(JSON.stringify({settings})) + return + } + }) + } +}) + // Serve the logfile app.get('/api/logfile', authenticateToken, (req, res) => { aboutPage.getsystemctllog((logStr) => { diff --git a/server/pppConnection.js b/server/pppConnection.js new file mode 100644 index 00000000..f6eeee56 --- /dev/null +++ b/server/pppConnection.js @@ -0,0 +1,340 @@ +/* + * PPPConnection.js + * This module manages a PPP connection using pppd. + * It allows setting device, baud rate, local and remote IPs, + * starting and stopping the PPP connection, and retrieving data transfer stats. + * Used for the PPP feature in ArduPilot +*/ +const { autoDetect } = require('@serialport/bindings-cpp') +const si = require('systeminformation') +const fs = require('fs'); +const { spawn, execSync } = require('child_process'); + +function isPi () { + let cpuInfo = '' + try { + cpuInfo = fs.readFileSync('/proc/device-tree/compatible', { encoding: 'utf8' }) + } catch (e) { + // if this fails, this is probably not a pi + return false + } + + const model = cpuInfo + .split(',') + .filter(line => line.length > 0) + + if (!model || model.length === 0) { + return false + } + + return model[0] === 'raspberrypi' +} + +class PPPConnection { + constructor(settings) { + this.settings = settings + this.isConnected = this.settings.value('ppp.enabled', false); + this.pppProcess = null; + this.device = this.settings.value('ppp.uart', null); + this.baudRate = this.settings.value('ppp.baud', { value: 921600, label: '921600' }) + this.localIP = this.settings.value('ppp.localIP', '192.168.144.14'); // default local IP + this.remoteIP = this.settings.value('ppp.remoteIP', '192.168.144.15'); // default remote IP + this.baudRates = [ + { value: 921600, label: '921600' }, + { value: 1500000, label: '1.5 MBaud' }, + { value: 2000000, label: '2 MBaud' }, + { value: 12500000, label: '12.5 MBaud' }]; + this.serialDevices = []; + + if (this.isConnected) { + const attemptPPPStart = () => { + this.startPPP(this.device, this.baudRate, this.localIP, this.remoteIP, (err, result) => { + if (err) { + if (err.message.includes('already connected')) { + console.log('PPP connection is already established. Retrying in 1 second...'); + this.isConnected = false; + this.setSettings(); + setTimeout(attemptPPPStart, 1000); // Retry after 1 second + } else { + console.error('Error starting PPP connection:', err); + this.isConnected = false; + this.setSettings(); + } + } else { + console.log('PPP connection started successfully:', result); + } + }); + }; + + attemptPPPStart(); + } + } + + setSettings() { + this.settings.setValue('ppp.uart', this.device); + this.settings.setValue('ppp.baud', this.baudRate); + this.settings.setValue('ppp.localIP', this.localIP); + this.settings.setValue('ppp.remoteIP', this.remoteIP); + this.settings.setValue('ppp.enabled', this.isConnected); + } + + quitting() { + // stop the PPP connection if rpanion is quitting + if (this.pppProcess) { + console.log('Stopping PPP connection on quit...'); + this.pppProcess.kill(); + execSync('sudo pkill -SIGTERM pppd && sleep 1'); + } + console.log('PPPConnection quitting'); + } + + async getDevices (callback) { + // get all serial devices + this.serialDevices = [] + let retError = null + + const Binding = autoDetect() + const ports = await Binding.list() + + for (let i = 0, len = ports.length; i < len; i++) { + if (ports[i].pnpId !== undefined) { + // usb-ArduPilot_Pixhawk1-1M_32002A000847323433353231-if00 + // console.log("Port: ", ports[i].pnpID); + let namePorts = '' + if (ports[i].pnpId.split('_').length > 2) { + namePorts = ports[i].pnpId.split('_')[1] + ' (' + ports[i].path + ')' + } else { + namePorts = ports[i].manufacturer + ' (' + ports[i].path + ')' + } + // console.log("Port: ", ports[i].pnpID); + this.serialDevices.push({ value: ports[i].path, label: namePorts, pnpId: ports[i].pnpId }) + } else if (ports[i].manufacturer !== undefined) { + // on recent RasPiOS, the pnpID is undefined :( + const nameports = ports[i].manufacturer + ' (' + ports[i].path + ')' + this.serialDevices.push({ value: ports[i].path, label: nameports, pnpId: nameports }) + } + } + + // for the Ras Pi's inbuilt UART + if (fs.existsSync('/dev/serial0') && isPi()) { + this.serialDevices.push({ value: '/dev/serial0', label: '/dev/serial0', pnpId: '/dev/serial0' }) + } + if (fs.existsSync('/dev/ttyAMA0') && isPi()) { + //Pi5 uses a different UART name. See https://forums.raspberrypi.com/viewtopic.php?t=359132 + this.serialDevices.push({ value: '/dev/ttyAMA0', label: '/dev/ttyAMA0', pnpId: '/dev/ttyAMA0' }) + } + if (fs.existsSync('/dev/ttyAMA1') && isPi()) { + this.serialDevices.push({ value: '/dev/ttyAMA1', label: '/dev/ttyAMA1', pnpId: '/dev/ttyAMA1' }) + } + if (fs.existsSync('/dev/ttyAMA2') && isPi()) { + this.serialDevices.push({ value: '/dev/ttyAMA2', label: '/dev/ttyAMA2', pnpId: '/dev/ttyAMA2' }) + } + if (fs.existsSync('/dev/ttyAMA3') && isPi()) { + this.serialDevices.push({ value: '/dev/ttyAMA3', label: '/dev/ttyAMA3', pnpId: '/dev/ttyAMA3' }) + } + if (fs.existsSync('/dev/ttyAMA4') && isPi()) { + this.serialDevices.push({ value: '/dev/ttyAMA4', label: '/dev/ttyAMA4', pnpId: '/dev/ttyAMA4' }) + } + // rpi uart has different name under Ubuntu + const data = await si.osInfo() + if (data.distro.toString().includes('Ubuntu') && fs.existsSync('/dev/ttyS0') && isPi()) { + // console.log("Running Ubuntu") + this.serialDevices.push({ value: '/dev/ttyS0', label: '/dev/ttyS0', pnpId: '/dev/ttyS0' }) + } + // jetson serial ports + if (fs.existsSync('/dev/ttyTHS1')) { + this.serialDevices.push({ value: '/dev/ttyTHS1', label: '/dev/ttyTHS1', pnpId: '/dev/ttyTHS1' }) + } + if (fs.existsSync('/dev/ttyTHS2')) { + this.serialDevices.push({ value: '/dev/ttyTHS2', label: '/dev/ttyTHS2', pnpId: '/dev/ttyTHS2' }) + } + if (fs.existsSync('/dev/ttyTHS3')) { + this.serialDevices.push({ value: '/dev/ttyTHS3', label: '/dev/ttyTHS3', pnpId: '/dev/ttyTHS3' }) + } + + return callback(retError, this.serialDevices); + } + + startPPP(device, baudRate, localIP, remoteIP, callback) { + if (this.isConnected) { + return callback(new Error('PPP is already connected'), { + selDevice: this.device, + selBaudRate: this.baudRate, + localIP: this.localIP, + remoteIP: this.remoteIP, + enabled: this.isConnected, + baudRates: this.baudRates, + serialDevices: this.serialDevices, + }); + } + if (!device) { + return callback(new Error('Device is required'), { + selDevice: this.device, + selBaudRate: this.baudRate, + localIP: this.localIP, + remoteIP: this.remoteIP, + enabled: this.isConnected, + baudRates: this.baudRates, + serialDevices: this.serialDevices, + }); + } + if (this.pppProcess) { + return callback(new Error('PPP still running. Please wait for it to finish.'), { + selDevice: this.device, + selBaudRate: this.baudRate, + localIP: this.localIP, + remoteIP: this.remoteIP, + enabled: this.isConnected, + baudRates: this.baudRates, + serialDevices: this.serialDevices, + }); + } + + this.device = device; + this.baudRate = baudRate; + this.localIP = localIP; + this.remoteIP = remoteIP; + + const args = [ + "pppd", + this.device.value, + this.baudRate.value, // baud rate + //'persist', // enables faster termination + //'holdoff', '1', // minimum delay of 1 second between connection attempts + this.localIP + ':' + this.remoteIP, // local and remote IPs + 'local', + 'noauth', + //'debug', + 'crtscts', + 'nodetach', + 'proxyarp', + 'ktune' + ]; + // if running in dev env, need to preload sudo login + if (process.env.NODE_ENV === 'development') { + execSync('sudo -v'); + } + console.log(`Starting PPP with args: ${args.join(' ')}`); + this.pppProcess = spawn('sudo', args, { + //detached: true, + stdio: ['ignore', 'pipe', 'pipe'] // or 'ignore' for all three to fully detach + }); + this.pppProcess.stdout.on('data', (data) => { + console.log("PPP Output: ", data.toString().trim()); + }); + this.pppProcess.stderr.on('data', (data) => { + console.log("PPP Error: ", data.toString().trim()); + }); + this.pppProcess.on('close', (code) => { + console.log("PPP process exited with code: ", code.toString().trim()); + this.isConnected = false; + this.pppProcess = null; // reset the process reference + this.setSettings(); + }); + this.isConnected = true; + this.setSettings(); + return callback(null, { + selDevice: this.device, + selBaudRate: this.baudRate, + localIP: this.localIP, + remoteIP: this.remoteIP, + enabled: this.isConnected, + baudRates: this.baudRates, + serialDevices: this.serialDevices, + }); + } + + stopPPP(callback) { + if (!this.isConnected) { + return callback(new Error('PPP is not connected'), { + selDevice: this.device, + selBaudRate: this.baudRate, + localIP: this.localIP, + remoteIP: this.remoteIP, + enabled: this.isConnected, + baudRates: this.baudRates, + serialDevices: this.serialDevices, + }); + } + if (this.pppProcess) { + // Gracefully kill the PPP process + console.log('Stopping PPP connection...'); + this.pppProcess.kill(); + execSync('sudo pkill -SIGTERM pppd'); + this.isConnected = false; + this.setSettings(); + } + return callback(null, { + selDevice: this.device, + selBaudRate: this.baudRate, + localIP: this.localIP, + remoteIP: this.remoteIP, + enabled: this.isConnected, + baudRates: this.baudRates, + serialDevices: this.serialDevices, + }); + } + + getPPPdatarate(callback) { + if (!this.isConnected) { + return callback(new Error('PPP is not connected')); + } + // get current data transfer stats for connected PPP session + return new Promise((resolve, reject) => { + exec('ifconfig ppp0', (error, stdout, stderr) => { + if (error) { + reject(`Error getting PPP data rate: ${stderr}`); + } else { + // match format RX packets 110580 bytes 132651067 (132.6 MB) + const match = stdout.match(/RX packets \d+ bytes (\d+) \(\d+\.\d+ MB\).*TX packets \d+ bytes (\d+) \(\d+\.\d+ MB\)/); + if (match) { + const rxBytes = parseInt(match[1], 10); + const txBytes = parseInt(match[5], 10); + console.log(`PPP Data Rate - RX: ${rxBytes} bytes, TX: ${txBytes} bytes`); + resolve({ rxBytes, txBytes }); + } else { + reject('Could not parse PPP data rate'); + } + } + }); + }); + } + + getPPPSettings(callback) { + this.getDevices((err, devices) => { + if (err) { + console.error('Error fetching serial devices:', err); + return callback(err, { + selDevice: null, + selBaudRate: this.baudRate, + localIP: this.localIP, + remoteIP: this.remoteIP, + enabled: this.isConnected, + baudRates: this.baudRates, + serialDevices: [], + }); + } else { + this.serialDevices = devices; + // Set default device if not already set + if (!this.device && this.serialDevices.length > 0) { + this.device = this.serialDevices[0]; // Set first available device as default + } + // if this.device is not in the list, set it to first available device + if (this.device && !this.serialDevices.some(d => d.value === this.device.value)) { + this.device = this.serialDevices[0]; + } + return callback(null, { + selDevice: this.device, + selBaudRate: this.baudRate, + localIP: this.localIP, + remoteIP: this.remoteIP, + enabled: this.isConnected, + baudRates: this.baudRates, + serialDevices: this.serialDevices, + }); + } + }); + } +} + +module.exports = PPPConnection; \ No newline at end of file diff --git a/server/pppConnection.test.js b/server/pppConnection.test.js new file mode 100644 index 00000000..2794244c --- /dev/null +++ b/server/pppConnection.test.js @@ -0,0 +1,187 @@ +const assert = require('assert'); +const sinon = require('sinon'); +const { describe, it, beforeEach, afterEach } = require('mocha'); +const PPPConnection = require('./pppConnection'); + +describe('PPPConnection', function() { + let pppConnection; + let mockSettings; + + beforeEach(function() { + // Create mock settings object + mockSettings = { + value: sinon.stub(), + setValue: sinon.stub() + }; + + // Configure mockSettings.value to return default values + mockSettings.value.withArgs('ppp.enabled', false).returns(false); + mockSettings.value.withArgs('ppp.uart', null).returns({ + value: '/dev/ttyUSB0', + label: '/dev/ttyUSB0' + }); + mockSettings.value.withArgs('ppp.baud', sinon.match.any).returns({ + value: 921600, + label: '921600' + }); + mockSettings.value.withArgs('ppp.localIP', '192.168.144.14').returns('192.168.144.14'); + mockSettings.value.withArgs('ppp.remoteIP', '192.168.144.15').returns('192.168.144.15'); + + // Create a new PPPConnection instance + pppConnection = new PPPConnection(mockSettings); + }); + + afterEach(function() { + // Restore all stubs + sinon.restore(); + }); + + describe('constructor', function() { + it('should initialize with default settings', function() { + assert.strictEqual(pppConnection.isConnected, false); + assert.deepStrictEqual(pppConnection.device, { value: '/dev/ttyUSB0', label: '/dev/ttyUSB0' }); + assert.deepStrictEqual(pppConnection.baudRate, { value: 921600, label: '921600' }); + assert.strictEqual(pppConnection.localIP, '192.168.144.14'); + assert.strictEqual(pppConnection.remoteIP, '192.168.144.15'); + }); + + it('should try to start PPP if enabled in settings', function() { + // Reset stubs + sinon.restore(); + + // Set up mocks for enabled PPP + mockSettings.value = sinon.stub(); + mockSettings.value.withArgs('ppp.enabled', false).returns(true); + mockSettings.value.withArgs('ppp.uart', null).returns({ + value: '/dev/ttyUSB0', + label: '/dev/ttyUSB0' + }); + mockSettings.value.withArgs('ppp.baud', sinon.match.any).returns({ + value: 921600, + label: '921600' + }); + mockSettings.value.withArgs('ppp.localIP', '192.168.144.14').returns('192.168.144.14'); + mockSettings.value.withArgs('ppp.remoteIP', '192.168.144.15').returns('192.168.144.15'); + + // Create instance with enabled PPP + const ppp = new PPPConnection(mockSettings); + + // It should have attempted to start PPP + //assert.strictEqual(ppp.isConnected, true); + }); + }); + + describe('setSettings', function() { + it('should save settings to the settings object', function() { + pppConnection.device = { value: '/dev/ttyS1', label: 'Serial 1' }; + pppConnection.baudRate = { value: 115200, label: '115200' }; + pppConnection.localIP = '192.168.1.1'; + pppConnection.remoteIP = '192.168.1.2'; + pppConnection.isConnected = true; + + pppConnection.setSettings(); + }); + }); + + describe('quitting', function() { + it('should kill the PPP process if running', function() { + pppConnection.quitting(); + }); + + it('should not attempt to kill the process if not running', function() { + pppConnection.pppProcess = null; + pppConnection.quitting(); + + }); + }); + + describe('getDevices', function() { + it('should retrieve serial devices', function(done) { + + pppConnection.getDevices((err, devices) => { + //assert.strictEqual(err, null); + //assert(Array.isArray(devices)); + //assert(devices.length == 0); + + done(); + }); + }); + }); + + describe('startPPP', function() { + it('should start PPP with correct parameters', function(done) { + const device = { value: '/dev/ttyUSB0', label: 'USB Serial' }; + const baudRate = { value: 115200, label: '115200' }; + const localIP = '192.168.1.1'; + const remoteIP = '192.168.1.2'; + + pppConnection.startPPP(device, baudRate, localIP, remoteIP, (err, result) => { + assert.strictEqual(err, null); + + assert.strictEqual(pppConnection.isConnected, true); + assert.strictEqual(pppConnection.device, device); + assert.strictEqual(pppConnection.baudRate, baudRate); + assert.strictEqual(pppConnection.localIP, localIP); + assert.strictEqual(pppConnection.remoteIP, remoteIP); + + done(); + }); + }); + + it('should handle errors when PPP is already connected', function(done) { + pppConnection.isConnected = true; + + pppConnection.startPPP({ value: '/dev/ttyUSB0' }, { value: 115200 }, '192.168.1.1', '192.168.1.2', (err, result) => { + assert(err instanceof Error); + assert.strictEqual(err.message, 'PPP is already connected'); + done(); + }); + }); + + it('should handle errors when device is not provided', function(done) { + pppConnection.startPPP(null, { value: 115200 }, '192.168.1.1', '192.168.1.2', (err, result) => { + assert(err instanceof Error); + assert.strictEqual(err.message, 'Device is required'); + done(); + }); + }); + }); + + describe('stopPPP', function() { + it('should stop PPP if connected', function(done) { + pppConnection.isConnected = true; + + pppConnection.stopPPP((err, result) => { + assert.strictEqual(err, null); + done(); + }); + }); + + it('should return error if PPP is not connected', function(done) { + pppConnection.isConnected = false; + + pppConnection.stopPPP((err, result) => { + assert(err instanceof Error); + assert.strictEqual(err.message, 'PPP is not connected'); + done(); + }); + }); + }); + + describe('getPPPSettings', function() { + it('should return current settings and available devices', function(done) { + pppConnection.getPPPSettings((err, settings) => { + assert.strictEqual(err, null); + //assert.deepStrictEqual(settings.selDevice, pppConnection.device); + //assert.deepStrictEqual(settings.selBaudRate, pppConnection.baudRate); + //assert.strictEqual(settings.localIP, pppConnection.localIP); + //assert.strictEqual(settings.remoteIP, pppConnection.remoteIP); + //assert.strictEqual(settings.enabled, pppConnection.isConnected); + //assert(Array.isArray(settings.baudRates)); + //assert(Array.isArray(settings.serialDevices)); + done(); + }); + }); + + }); +}); \ No newline at end of file diff --git a/src/App.test.jsx b/src/App.test.jsx index cb5827d1..fd513100 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -13,6 +13,7 @@ import NTRIPPage from './ntripcontroller.jsx' import AdhocConfig from './adhocwifi.jsx' import CloudConfig from './cloud.jsx' import UserManagement from './userManagement.jsx' +import PPPConnection from '../server/pppConnection.js' describe('#apptest()', function () { test('homepage renders without crashing', function () { @@ -84,4 +85,11 @@ describe('#apptest()', function () { root.render() root.unmount() }) + + test('PPP connection page renders without crashing', function () { + const div = document.createElement('div') + const root = createRoot(div) + root.render() + root.unmount() + }) }) \ No newline at end of file diff --git a/src/AppRouter.jsx b/src/AppRouter.jsx index 4b420589..21a839a5 100644 --- a/src/AppRouter.jsx +++ b/src/AppRouter.jsx @@ -14,6 +14,7 @@ import CloudConfig from './cloud.jsx' import VPN from './vpnconfig.jsx' import Logout from './logout.jsx' import UserManagement from './userManagement.jsx' +import PPPPage from './ppp.jsx' function AppRouter () { @@ -25,6 +26,7 @@ function AppRouter () { Home Flight Logs Flight Controller + PPP Config NTRIP Config Network Config Adhoc Wifi Config @@ -43,6 +45,7 @@ function AppRouter () { } /> } /> + } /> } /> } /> } /> diff --git a/src/components/IPAddressInput.jsx b/src/components/IPAddressInput.jsx new file mode 100644 index 00000000..272f84f7 --- /dev/null +++ b/src/components/IPAddressInput.jsx @@ -0,0 +1,88 @@ +import React, { useState, useEffect } from 'react'; +import { Form, InputGroup } from 'react-bootstrap'; + +const IPAddressInput = ({ value, onChange, disabled, name, isInvalid, feedback }) => { + // Parse initial IP string into array of octets + const parseIP = (ipString) => { + const octets = ipString ? ipString.split('.') : ['', '', '', '']; + // Ensure we always have 4 octets + return octets.length === 4 ? octets : [...octets, ...Array(4 - octets.length).fill('')]; + }; + + const [octets, setOctets] = useState(parseIP(value)); + + // Update local state when prop value changes + useEffect(() => { + setOctets(parseIP(value)); + }, [value]); + + // Handle change in one of the octet inputs + const handleOctetChange = (index, val) => { + // Only allow empty string or numbers 0-255 + if (val === '' || (/^\d+$/.test(val) && parseInt(val) <= 255)) { + const newOctets = [...octets]; + newOctets[index] = val; + setOctets(newOctets); + + // Call parent onChange with full IP if all octets are valid + if (newOctets.every(octet => octet !== '' && parseInt(octet) <= 255)) { + onChange({ target: { name, value: newOctets.join('.') } }); + } + } + }; + + // Handle keyboard navigation between inputs + const handleKeyDown = (index, event) => { + if (event.key === '.' || event.key === ' ') { + event.preventDefault(); + if (index < 3) { + document.getElementById(`ip-${name}-${index + 1}`).focus(); + } + } else if (event.key === 'Backspace' && octets[index] === '' && index > 0) { + document.getElementById(`ip-${name}-${index - 1}`).focus(); + } + }; + + // Handle pasting a complete IP address + const handlePaste = (event, index) => { + const pastedText = event.clipboardData.getData('text'); + + // Check if the pasted text looks like an IP address + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(pastedText)) { + event.preventDefault(); + const newOctets = pastedText.split('.'); + setOctets(newOctets); + onChange({ target: { name, value: pastedText } }); + } + }; + + return ( + + {octets.map((octet, index) => ( + + handleOctetChange(index, e.target.value)} + onKeyDown={(e) => handleKeyDown(index, e)} + onPaste={(e) => handlePaste(e, index)} + style={{ width: '4rem', textAlign: 'center' }} + maxLength={3} + disabled={disabled} + isInvalid={isInvalid} + /> + {index < 3 ? ( + . + ) : ( + + {feedback} + + )} + + ))} + + ); +}; + +export default IPAddressInput; \ No newline at end of file diff --git a/src/ppp.jsx b/src/ppp.jsx new file mode 100644 index 00000000..52b6de7c --- /dev/null +++ b/src/ppp.jsx @@ -0,0 +1,168 @@ +import React from 'react'; +import { Card, Form, Button, Alert } from 'react-bootstrap'; +import Select from 'react-select'; +import basePage from './basePage.jsx'; +import IPAddressInput from './components/IPAddressInput.jsx'; + +import './css/styles.css'; + +class PPPPage extends basePage { + constructor(props, useSocketIO = true) { + super(props, useSocketIO); + this.state = { + ...this.state, + pppStatus: "", + config: { + enabled: false, + selDevice: null, + selBaudRate: 115200, + serialDevices: [], + baudRates: [], + localIP: '', + remoteIP: '', + }, + }; + } + + componentDidMount() { + this.fetchPPPStatus(); + } + + fetchPPPStatus = async () => { + try { + const response = await fetch('/api/pppstatus'); + if (!response.ok) throw new Error('Network response was not ok'); + const data = await response.json(); + this.setState({ config: data}); + this.loadDone(); + } catch (error) { + this.setState({ error: 'Failed to fetch PPP status', isLoading: false }); + } + }; + + handleConfigChange = (selectedOption, actionMeta) => { + const name = actionMeta?.name || selectedOption?.target?.name; + const value = actionMeta ? selectedOption : selectedOption?.target?.value; + this.setState(prevState => ({ + config: { + ...prevState.config, + [name]: value + } + })); + }; + + handleBaudrateChange = (value) => { + this.setState(prevState => ({ + config: { + ...prevState.config, + selBaudRate: value + } + })); + } + + handleUartChange = (value) => { + this.setState(prevState => ({ + config: { + ...prevState.config, + selDevice: value + } + })); + } + + handleSubmit = async (event) => { + event.preventDefault(); + try { + const response = await fetch('/api/pppmodify', { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.state.token}` + }, + body: JSON.stringify({ + device: JSON.stringify(this.state.config.selDevice), + baudrate: JSON.stringify(this.state.config.selBaudRate), + localIP: this.state.config.localIP, + remoteIP: this.state.config.remoteIP, + enabled: !this.state.config.enabled + }) + }); + if (!response.ok && response.json) { + const data = await response.json(); + this.setState({ error: data.error || 'Failed to update PPP configuration' }); + } else { + const data = await response.json(); + // if error is present in the response, set it in state + if (data.error) { + this.setState({ error: data.error }); + } else { + this.setState({ error: null }); + } + this.setState({ config: data.settings}); + } + + } catch (error) { + console.error('Error updating PPP configuration:', error); + this.setState({ error: 'Failed to update PPP configuration' }); + } + }; + + renderTitle() { + return "PPP Configuration"; + } + + renderContent() { + return ( +
+

Configure a PPP connection to the flight controller. Requires SERIALn_PROTOCOL=48

+

Hardware flow control support is required on the Companion UART

+

Configuration

+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+

Status

+

{this.state.pppStatus}

+
+ ); + } +} + +export default PPPPage;