Skip to content
Open
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,21 @@ Build the app:
<br> &nbsp; &nbsp; <code>yarn electron:package:mac</code>

### Updates:
<b>V1.2.0</b>
* Added UDP input support
* Stream data over network (e.g., from Wi-Fi sensors like ESP32 accelerometers) via configurable port
* Added optional timestamps
* Toggle to show timestamps in the live monitor
* Include timestamps in recorded files when enabled
* Added labeled variable format
* Support structured input with headers like time(s),accel_x(m/s²),temp(°C)
* Auto-generate plot legends and column names from headers
* Added display mode toggle
* Choose between RAW (original byte stream) and Variables (clean parsed values only) in the monitor
* Added recording playback
* Load previously saved .txt recordings for offline analysis, replay, or sharing
* Uses the same parsing and visualization pipeline as live data

<b>V1.1.0</b>
* Added a record option
* Only records the raw serial data to a text file
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "serialanalyzer",
"version": "1.1.0",
"version": "1.2.0",
"private": true,
"main": "./public/electron.js",
"homepage": "./",
Expand Down Expand Up @@ -32,8 +32,8 @@
"serialport": "10.4.0",
"typescript": "4.7.4",
"web-vitals": "2.1.4",
"path":"0.12.7",
"fs-extra":"11.1.1"
"path": "0.12.7",
"fs-extra": "11.1.1"
},
"scripts": {
"electron:start": "concurrently -k \"cross-env BROWSER=none yarn start\" \"wait-on http://localhost:3000 && electronmon .\"",
Expand Down Expand Up @@ -87,4 +87,4 @@
"target": "deb"
}
}
}
}
24 changes: 24 additions & 0 deletions public/electron.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ app.allowRendererProcessReuse = false

// Create the native browser window.
function createWindow() {
const version = app.getVersion();
const baseTitle = app.getName();
const mainWindow = new BrowserWindow({
width: 950,
height: 700,
Expand All @@ -19,6 +21,7 @@ function createWindow() {
color: 'rgb(37,37,38)',
symbolColor: '#74b1be'
},
title: `${baseTitle} v${version}`,
webPreferences: {
nodeIntegration: true,
enableRemoteModule: true,
Expand All @@ -39,6 +42,9 @@ function createWindow() {
: "http://localhost:3000";
mainWindow.loadURL(appURL);

// Ensure title reflects version even after navigation
mainWindow.setTitle(`${baseTitle} v${version}`);

// Automatically open Chrome's DevTools in development mode.
if (!app.isPackaged) {
mainWindow.webContents.openDevTools({ mode: "detach" });
Expand Down Expand Up @@ -78,6 +84,24 @@ app.whenReady().then(() => {
return res;
});

// Provide app version to renderer via IPC
ipcMain.handle('getAppVersion', async () => {
try {
return app.getVersion();
} catch (e) {
return '';
}
});

// Provide app name to renderer via IPC
ipcMain.handle('getAppName', async () => {
try {
return app.getName();
} catch (e) {
return '';
}
});

app.on("activate", function () {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
Expand Down
7 changes: 7 additions & 0 deletions public/preload.js
Original file line number Diff line number Diff line change
@@ -1,2 +1,9 @@
const { remote, ipcRenderer, dialog } = require('electron');

// Expose a safe method to get app version from renderer
try {
window.getAppVersion = () => ipcRenderer.invoke('getAppVersion');
window.getAppName = () => ipcRenderer.invoke('getAppName');
} catch (e) {
// no-op in browser environment
}
37 changes: 36 additions & 1 deletion src/App.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,46 @@
import './App.css';
import PersistentDrawerLeft from './Components/Drawer.tsx';
import { useEffect, useState } from 'react';

function useAppVersion() {
const [version, setVersion] = useState('');
useEffect(() => {
let mounted = true;
const getter = window.getAppVersion;
if (typeof getter === 'function') {
getter().then(v => {
if (mounted) setVersion(v || '');
}).catch(() => {});
}
return () => { mounted = false; };
}, []);
return version;
}

function useAppName() {
const [name, setName] = useState('');
useEffect(() => {
let mounted = true;
const getter = window.getAppName;
if (typeof getter === 'function') {
getter().then(n => {
if (mounted) setName(n || '');
}).catch(() => {});
}
return () => { mounted = false; };
}, []);
return name;
}

function App() {
const version = useAppVersion();
const name = useAppName();
const appName = name || 'Serial Analyzer';
const titleText = `${appName}${version ? ` v${version}` : ''}`;
return (
<div className="App">
<div className="topBar">
<div className="title"></div>
<div className="title">{titleText}</div>
</div>
<PersistentDrawerLeft />
</div>
Expand Down
91 changes: 74 additions & 17 deletions src/Components/GlobalSettingsPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ import FormGroup from '@mui/material/FormGroup';
import FormControlLabel from '@mui/material/FormControlLabel';
import Checkbox from '@mui/material/Checkbox';
import { Typography } from '@mui/material';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import { GlobalSettings } from "../Utils/GlobalSettings.js";
import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';

import BufferSizeSlider from './BufferSize.tsx';
import SliderInput from "./SliderInput.tsx";
import { SerialDataObject, StartSerial } from '../Utils/SerialData.js';
import { SerialPortsList } from './SerialSelect.tsx';
import { SerialDataObject, StartSerial, StartUDP, StopUDP } from '../Utils/SerialData.js';
import { SerialPortSelect } from './SerialSelect.tsx';
import { UDPPortSelect } from './UDPSelect.tsx';

const menuFs = GlobalSettings.style.menuFs;

Expand All @@ -32,6 +35,7 @@ export default function GlobalSettingsPane() {

const [baudRate, setBaudRate] = React.useState(SerialDataObject.baudRate);
const [firstColumnTime, setFirstColumnTime] = React.useState(GlobalSettings.global.firstColumnTime);
const [inputMode, setInputMode] = React.useState(SerialDataObject.inputMode);

const baudRateChange = ((event: any, newValue: any) => {

Expand All @@ -54,23 +58,76 @@ export default function GlobalSettingsPane() {
StartSerial();
};

const handleInputMode = (_event: any, newMode: 'serial' | 'udp') => {
if (!newMode) return;
// Close previous channel
try {
if (SerialDataObject.inputMode === 'udp' && SerialDataObject.udpSocket) {
// Stop UDP if switching away
StopUDP();
}
if (SerialDataObject.inputMode === 'serial' && SerialDataObject.serialObj && SerialDataObject.serialObj.isOpen) {
SerialDataObject.serialObj.close();
}
} catch (e) {
console.log('Error closing previous channel on mode switch:', e);
}

setInputMode(newMode);
SerialDataObject.inputMode = newMode;

// Open selected channel and auto play
try {
if (newMode === 'udp') {
const portToUse = SerialDataObject.udpPort || 5000;
StartUDP(portToUse);
SerialDataObject.pauseFlag = false;
} else if (newMode === 'serial') {
if (SerialDataObject.port && SerialDataObject.port.path) {
StartSerial(SerialDataObject.port);
}
SerialDataObject.pauseFlag = false;
}
} catch (e) {
console.log('Error starting channel on mode switch:', e);
}
};

return (
<div style={{ flexGrow: 1 }}>
<SerialPortsList />
<Autocomplete
onChange={baudRateChange}
options={baudOptions}
defaultValue={defaultBaud}
autoHighlight
sx={{ fontSize: "12px", marginTop: "8px" }}
renderOption={(props, option) => (
<li {...props} style={{ fontSize: menuFs }}> {option}</li>
)}
renderInput={(params) => (
<TextField {...params} label="Baud Rate" variant="standard"
InputProps={{ ...params.InputProps, style: { fontSize: menuFs } }} />
)}
/>
<ToggleButtonGroup
exclusive
value={inputMode}
onChange={handleInputMode}
size="small"
sx={{ marginTop: '8px' }}
>
<ToggleButton value="serial">Serial</ToggleButton>
<ToggleButton value="udp">UDP</ToggleButton>
</ToggleButtonGroup>

{ inputMode === 'serial' && (
<>
<SerialPortSelect />
<Autocomplete
onChange={baudRateChange}
options={baudOptions}
defaultValue={defaultBaud}
autoHighlight
sx={{ fontSize: "12px", marginTop: "8px" }}
renderOption={(props, option) => (
<li {...props} style={{ fontSize: menuFs }}> {option}</li>
)}
renderInput={(params) => (
<TextField {...params} label="Baud Rate" variant="standard"
InputProps={{ ...params.InputProps, style: { fontSize: menuFs } }} />
)}
/>
</>
)}
{ inputMode === 'udp' && (
<UDPPortSelect />
)}
<BufferSizeSlider />
<SliderInput
disabled={false}
Expand Down
43 changes: 42 additions & 1 deletion src/Components/Monitor.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,48 @@ export default class Monitor extends React.Component {
this.divRef.current.style.marginTop = 0.04 * parentHeight + 'px';
this.divRef.current.style.height = 0.9 * SerialDataObject.chartHeightRatio * parentHeight + 'px';
// this.divRef.current.innerText = this.divRef.current.innerText + ".... \n";
this.divRef.current.innerText = SerialDataObject.rawData.join('\n');
const withTs = GlobalSettings.monitor.showTimestamp;
const formatTs = (ts) => {
const d = new Date(ts);
const pad = (n, w=2) => String(n).padStart(w,'0');
const h = pad(d.getHours());
const m = pad(d.getMinutes());
const s = pad(d.getSeconds());
const ms = pad(d.getMilliseconds(),3);
return `[${h}:${m}:${s}.${ms}]`;
};
// Helper to extract only numeric values from a raw line
const valuesOnly = (line) => {
if (!line || typeof line !== 'string') return '';
const tokens = line.split(/[\,\t ]+/).filter(t => t.length > 0);
const vals = [];
for (let t of tokens) {
let vstr = t;
const coli = t.indexOf(':');
if (coli !== -1) {
vstr = t.slice(coli + 1).trim();
}
// Accept scientific notation and decimals
const v = parseFloat(vstr);
if (!Number.isNaN(v) && Number.isFinite(v)) {
vals.push(vstr);
}
}
return vals.join('\t');
};

let text = '';
const n = SerialDataObject.rawData.length;
const lines = new Array(n);
// Checkbox marcado => mostrar RAW; desmarcado => apenas valores
const onlyValues = !GlobalSettings.monitor.showValuesOnly;
for (let i = 0; i < n; i++) {
const tsPrefix = (withTs && Array.isArray(SerialDataObject.rawDataTime)) ? formatTs(SerialDataObject.rawDataTime[i]) + ' ' : '';
const body = onlyValues ? valuesOnly(SerialDataObject.rawData[i]) : SerialDataObject.rawData[i];
lines[i] = tsPrefix + body;
}
text = lines.join('\n');
this.divRef.current.innerText = text;
this.divRef.current.style.fontSize = GlobalSettings.monitor.fontSize / 12.0 * 0.85 + "rem";
if(GlobalSettings.global.drawerOpen){
this.divRef.current.style.maxWidth = "calc(86vw - " + GlobalSettings.style.drawerWidth + "px)";
Expand Down
23 changes: 23 additions & 0 deletions src/Components/MonitorSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import * as React from 'react';
import { useState } from 'react';
import { GlobalSettings } from "../Utils/GlobalSettings.js";
import SliderInput from "./SliderInput.tsx";
import FormGroup from '@mui/material/FormGroup';
import FormControlLabel from '@mui/material/FormControlLabel';
import Checkbox from '@mui/material/Checkbox';
import { Typography } from '@mui/material';

const menuFs = GlobalSettings.style.menuFs;

/* Settings pane for the serial monitor */
export default function MonitorSettings() {
const [showTs, setShowTs] = useState(!!GlobalSettings.monitor.showTimestamp);
const [showValsOnly, setShowValsOnly] = useState(!!GlobalSettings.monitor.showValuesOnly);
return (
<div>
<SliderInput
Expand All @@ -18,6 +25,22 @@ export default function MonitorSettings() {
setting={"fontSize"}
name={"Font size"}
/>
<FormGroup style={{ display: 'flex', flexDirection: 'row', marginTop: '8px' }}>
<FormControlLabel
control={<Checkbox
checked={showTs}
onChange={(e) => { const v = e.target.checked; setShowTs(v); GlobalSettings.monitor.showTimestamp = v; }}
name="showTimestamp"
size="small" />}
label={<Typography sx={{ fontSize: menuFs, userSelect: "none" }}>Show timestamp</Typography>} />
<FormControlLabel
control={<Checkbox
checked={showValsOnly}
onChange={(e) => { const v = e.target.checked; setShowValsOnly(v); GlobalSettings.monitor.showValuesOnly = v; }}
name="showRaw"
size="small" />}
label={<Typography sx={{ fontSize: menuFs, userSelect: "none" }}>RAW Values</Typography>} />
</FormGroup>
</div>
)
}
6 changes: 6 additions & 0 deletions src/Components/RecordSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { styled } from '@mui/material/styles';

import { GlobalSettings } from "../Utils/GlobalSettings.js";
import { selectOutputDirectory } from '../Utils/DataRecording.js'
import { LoadFromFileDialog } from '../Utils/SerialData.js'

const { ipcRenderer } = window.require('electron');

Expand Down Expand Up @@ -56,6 +57,11 @@ export default function RecordSettings() {
<LimitedLengthTypography>
{directory}
</LimitedLengthTypography>
<div style={{ marginTop: '8px' }}>
<Button variant="outlined" onClick={LoadFromFileDialog} size="medium" fullWidth>
Load recording file
</Button>
</div>
</div>
)
}
Loading