From b9d15844ab5b0bde9d99f58fbc1ce28e6ff1759f Mon Sep 17 00:00:00 2001 From: Tiago d'Avila Date: Fri, 7 Nov 2025 16:07:24 -0300 Subject: [PATCH 01/10] feat: Add UDP input support for live data stream :sparkles: :package: Add ability to receive data stream via UDP for wireless sensor analysis resolves: #13 --- package.json | 8 +- src/Components/GlobalSettingsPane.tsx | 91 ++++++++++--- src/Components/SerialPause.tsx | 67 +++++++--- src/Components/SerialSelect.tsx | 177 +++++++++----------------- src/Components/UDPSelect.tsx | 39 ++++++ src/Utils/SerialData.js | 153 ++++++++++++++++++++++ 6 files changed, 375 insertions(+), 160 deletions(-) create mode 100644 src/Components/UDPSelect.tsx diff --git a/package.json b/package.json index 165e5d6..036f217 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "serialanalyzer", - "version": "1.1.0", + "version": "1.2.0", "private": true, "main": "./public/electron.js", "homepage": "./", @@ -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 .\"", @@ -87,4 +87,4 @@ "target": "deb" } } -} +} \ No newline at end of file diff --git a/src/Components/GlobalSettingsPane.tsx b/src/Components/GlobalSettingsPane.tsx index 2c29fad..570ee92 100644 --- a/src/Components/GlobalSettingsPane.tsx +++ b/src/Components/GlobalSettingsPane.tsx @@ -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; @@ -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) => { @@ -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 (
- - ( -
  • {option}
  • - )} - renderInput={(params) => ( - - )} - /> + + Serial + UDP + + + { inputMode === 'serial' && ( + <> + + ( +
  • {option}
  • + )} + renderInput={(params) => ( + + )} + /> + + )} + { inputMode === 'udp' && ( + + )} { - console.log("Stop?" + err) - }); + if(SerialDataObject.inputMode === 'udp'){ + StopUDP(); + } else { + if(SerialDataObject.serialObj !== null){ + // Close the serial port + console.log(SerialDataObject.serialObj) + SerialDataObject.serialObj.close((err) => { + console.log("Stop?" + err) + }); + } + console.log(SerialDataObject.port) } - console.log(SerialDataObject.port) setStopped(true); setPaused(false); @@ -128,13 +142,24 @@ export default function SerialPause() { var timer = null; React.useEffect(() => { timer = setInterval(() => { - if(SerialDataObject.serialObj !== null ){ - // If the object is already open not paused, then allow the pause and stop buttons - if(SerialDataObject.serialObj.isOpen && SerialDataObject.pauseFlag !== true){ + const serialRunning = (SerialDataObject.serialObj !== null && SerialDataObject.serialObj.isOpen); + const udpRunning = (SerialDataObject.udpSocket !== null); + if (serialRunning || udpRunning) { + if (SerialDataObject.pauseFlag !== true) { setStopped(false); setPaused(false); setRunning(true); + } else { + // Paused while channel is active + setPaused(true); + setRunning(false); + setStopped(false); } + } else { + // No active channel: reflect fully stopped state + setStopped(true); + setPaused(false); + setRunning(false); } }, 1000); return () => { diff --git a/src/Components/SerialSelect.tsx b/src/Components/SerialSelect.tsx index bdb2a6b..8c8a63d 100644 --- a/src/Components/SerialSelect.tsx +++ b/src/Components/SerialSelect.tsx @@ -1,149 +1,90 @@ import * as React from 'react'; -import Button from '@mui/material/Button'; -import Avatar from '@mui/material/Avatar'; -import List from '@mui/material/List'; -import ListItem from '@mui/material/ListItem'; -import ListItemAvatar from '@mui/material/ListItemAvatar'; -import ListItemText from '@mui/material/ListItemText'; -import DialogTitle from '@mui/material/DialogTitle'; -import Dialog from '@mui/material/Dialog'; -import CableIcon from '@mui/icons-material/Cable'; import Typography from '@mui/material/Typography'; -import { indigo } from '@mui/material/colors'; +import Autocomplete from '@mui/material/Autocomplete'; +import TextField from '@mui/material/TextField'; import { SerialDataObject, StartSerial, GetPortName, GetPortShortName } from '../Utils/SerialData'; import { GlobalSettings } from "../Utils/GlobalSettings.js"; const { SerialPort } = window.require("serialport"); const titleFs = GlobalSettings.style.titleFs; -var portsDefault: any = [{ path: "None" }]; -var ports: any = portsDefault; - -export interface SerialDialogProps { - open: boolean; - onClose: (value: string) => void; -} - -/* These components create the dialog to select the serial port. - When a port is selected, the app will update to connect to the serialport -*/ - -function SimpleDialog(props: SerialDialogProps) { - // This dialog creates clickable items for each port - const { onClose, open } = props; - - const handleClose = () => { - onClose("none"); - }; - - const handleListItemClick = (value: string) => { - onClose(value); - }; - - return ( - - Select a port - - {ports.map((port) => ( - handleListItemClick(port)} key={port.path}> - - - - - - - - ))} - - - ); -} const selectStyles = { marginTop: '8px', marginBottom: '8px' }; -export function SerialPortsList() { - // This component includes the button that allows you to select the port - // It also renders the dialog when the state "open" is true - - // Is the dialog open? - const [open, setOpen] = React.useState(false); - // Is the serial port running currently? - const [serialOn, setSerialOn] = React.useState(false); - - const handleClickOpen = () => { - // Looks for serial ports, returns a promise - showSerialPorts().then((result) => { - // When the promise returns, open the dialog - setOpen(true); - }); - }; +const noneOption = { path: "None", friendlyName: "None" } as any; - // This is the main function that runs when the dialog closes - const handleClose = (port: any) => { - if (port !== "none") { +export function SerialPortSelect() { + const [options, setOptions] = React.useState([noneOption]); + const [value, setValue] = React.useState(SerialDataObject.port?.path ? SerialDataObject.port : noneOption); - // If serial is already open, close it - if (SerialDataObject.serialObj !== null) { - setSerialOn(false); - if (SerialDataObject.serialObj.isOpen) { - // Close the serial port - SerialDataObject.serialObj.close((err) => { - StartSerial(port); // Start up the serial port - setSerialOn(true); - }); - } - else { - StartSerial(port); // Start up the serial port + const refreshPorts = React.useCallback(() => { + SerialPort.list().then((portsLocal: any, err: any) => { + if (err) { + setOptions([noneOption]); + return; + } + const opts = portsLocal && portsLocal.length > 0 ? portsLocal : []; + const newOptions = [noneOption, ...opts]; + setOptions(newOptions); + + // Maintain selection if present, otherwise set to None + const currentPath = value?.path; + const exists = newOptions.some(p => p.path === currentPath); + if (!exists) { + // Close serial if it's running + if (SerialDataObject.serialObj && SerialDataObject.serialObj.isOpen) { + try { SerialDataObject.serialObj.close(); } catch (e) { console.log(e); } } + SerialDataObject.port = { path: null, friendlyName: "None" } as any; + setValue(noneOption); } - else { - StartSerial(port); // Start up the serial port + }); + }, [value]); + + const handleChange = (_event: any, newValue: any) => { + if (!newValue) { return; } + setValue(newValue); + if (newValue.path === "None") { + // Stop serial and set None + if (SerialDataObject.serialObj && SerialDataObject.serialObj.isOpen) { + try { SerialDataObject.serialObj.close(); } catch (e) { console.log(e); } } + SerialDataObject.port = { path: null, friendlyName: "None" } as any; + return; + } + // Start serial on selected port + try { + StartSerial(newValue); + } catch (e) { + console.log(e); } - // Always close the window - setOpen(false); }; return (
    - + GetPortName(option)} + isOptionEqualToValue={(option, val) => option.path === val.path} + renderInput={(params) => ( + + )} + renderOption={(props, option) => ( +
  • {GetPortName(option)}
  • + )} + size="small" + /> Current: {GetPortShortName(SerialDataObject.port)} -
    ); } -function SetSerialButton({ handleClickOpen }) { - return ( - - ) -} - -function showSerialPorts() { - // Updates the ports once the SerialPort library returns - // the promised list - return SerialPort.list().then((portsLocal: any, err: any) => { - if (err) { - ports = [{ path: "Error." }]; - } else { - if (portsLocal.length === 0) { - ports = portsLocal; - } - else { - ports = portsLocal; - } - } - return ports; - }) -} - diff --git a/src/Components/UDPSelect.tsx b/src/Components/UDPSelect.tsx new file mode 100644 index 0000000..f8c96ba --- /dev/null +++ b/src/Components/UDPSelect.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import { GlobalSettings } from "../Utils/GlobalSettings.js"; +import { SerialDataObject } from '../Utils/SerialData'; + +const titleFs = GlobalSettings.style.menuFs; + +const selectStyles = { + marginTop: '8px', + marginBottom: '8px' +}; + +export function UDPPortSelect() { + const [port, setPort] = React.useState(SerialDataObject.udpPort || 5000); + + return ( +
    + { + const val = parseInt(e.target.value); + if (!isNaN(val)) { + // Clamp to valid UDP port range + const clamped = Math.min(65535, Math.max(1, val)); + setPort(clamped); + SerialDataObject.udpPort = clamped; + } + }} + InputProps={{ inputProps: { min: 1, max: 65535, step: 1 }, style: { fontSize: GlobalSettings.style.menuFs } }} + /> +
    + ); +} \ No newline at end of file diff --git a/src/Utils/SerialData.js b/src/Utils/SerialData.js index c0625f1..126af17 100644 --- a/src/Utils/SerialData.js +++ b/src/Utils/SerialData.js @@ -5,6 +5,8 @@ import { writeDataIfRecording } from "./DataRecording.js"; const { SerialPort } = window.require("serialport"); const { ReadlineParser } = window.require('@serialport/parser-readline') const { performance } = window.require('perf_hooks'); +// Built-in Node UDP module (available with nodeIntegration) +const dgram = window.require('dgram'); /* Functions to handle the serial port communication. Uses node "serialport". @@ -29,6 +31,10 @@ export var SerialDataObject = { rawData: [], dataIdx: [], serialObj: null, + // UDP support + udpSocket: null, + udpPort: null, + inputMode: 'serial', // 'serial' | 'udp' chartHeightRatio: 1, chartMarginRatio: 0.0, Iter: 0, @@ -39,6 +45,11 @@ export var SerialDataObject = { } export function StartSerial(port) { + // Default to previously selected port if not provided + if (!port) { + port = SerialDataObject.port; + } + SerialDataObject.inputMode = 'serial'; // Clear the data when the serial first starts SerialDataObject.rawData = []; @@ -214,3 +225,145 @@ function idxData(n) { return (idxVec); } +// ---------------------- UDP Support ---------------------- // +export function StartUDP(portNumber) { + SerialDataObject.inputMode = 'udp'; + + // Clear data buffers and flags + SerialDataObject.rawData = []; + SerialDataObject.data = []; + SerialDataObject.dataIndex = []; + SerialDataObject.pauseFlag = false; + SerialDataObject.Iter = 0; + SerialDataObject.sampleHistory = []; + SerialDataObject.timeHistory = []; + + // Close serial if open + if (SerialDataObject.serialObj !== null) { + try { + SerialDataObject.serialObj.close(); + } catch (e) { + console.log('Error closing serial before UDP start:', e); + } + SerialDataObject.serialObj = null; + } + + // Close previous UDP socket if exists + if (SerialDataObject.udpSocket !== null) { + try { + SerialDataObject.udpSocket.close(); + } catch (e) { + console.log('Error closing previous UDP socket:', e); + } + SerialDataObject.udpSocket = null; + } + + // Create UDP socket and bind + let toastId = toast(`Starting UDP listener on port ${portNumber}`); + try { + const socket = dgram.createSocket('udp4'); + SerialDataObject.udpSocket = socket; + SerialDataObject.udpPort = portNumber; + + socket.on('error', (err) => { + toast.dismiss(toastId); + toast.error(`UDP socket error on port ${portNumber}\n\n${err}`); + console.log('UDP socket error:', err); + }); + + socket.on('message', (msg/* Buffer */, rinfo) => { + if (SerialDataObject.pauseFlag) { return; } + let dataStr = ''; + try { + dataStr = msg.toString('utf8').trim(); + } catch (e) { + // ignore malformed buffer + return; + } + // Allow multiple lines in one datagram + const lines = dataStr.split(/\r?\n/).filter(l => l.length > 0); + for (const line of lines) { + addUdpData(line); + } + }); + + socket.bind(portNumber, () => { + console.log('UDP socket bound on port', portNumber); + toast.update(toastId, { render: `UDP listening on port ${portNumber}`, type: toast.TYPE.INFO, autoClose: 2000 }); + }); + + } catch (e) { + toast.dismiss(toastId); + toast.error(`Failed to start UDP on port ${portNumber}\n\n${e}`); + console.log('Failed to start UDP:', e); + } + + function addUdpData(data) { + // Decimation + if (decIndex >= GlobalSettings.global.decimation) { + decIndex = 1; + } else { + decIndex += 1; + return; + } + + SerialDataObject.Iter += 1; + if (SerialDataObject.Iter % SerialDataObject.NsampleRateUpdate === 0) { + if (SerialDataObject.sampleHistory.length > 1) { + var deltaT = SerialDataObject.timeHistory[SerialDataObject.timeHistory.length - 1] - SerialDataObject.timeHistory[0]; + var samples = SerialDataObject.sampleHistory[SerialDataObject.sampleHistory.length - 1] - SerialDataObject.sampleHistory[0]; + SerialDataObject.sampleRate = samples / deltaT * 1000; + } + } + + SerialDataObject.rawData.push(data); + if (SerialDataObject.rawData.length >= SerialDataObject.bufferSize) { + SerialDataObject.rawData.shift(); + } + + writeDataIfRecording(data); + + var splitData = data.split(/\s+|,\s+/); + var nums = splitData.map(parseFloat); + var t = 0; + if (GlobalSettings.global.firstColumnTime) { + t = nums[0]; + nums = nums.slice(1, nums.length); + } else { + t = performance.now(); + } + + if (nums.every((value) => { return !isNaN(value) })) { + SerialDataObject.data.push(nums); + SerialDataObject.sampleHistory.push(SerialDataObject.Iter); + SerialDataObject.timeHistory.push(t); + } + + var n = SerialDataObject.data.length; + if (n > SerialDataObject.bufferSize) { + const resize = (v) => v.slice(v.length - SerialDataObject.bufferSize, v.length); + SerialDataObject.data = resize(SerialDataObject.data); + SerialDataObject.sampleHistory = resize(SerialDataObject.sampleHistory); + SerialDataObject.timeHistory = resize(SerialDataObject.timeHistory); + if (!GlobalSettings.timeSeries.scroll) { + SerialDataObject.data = []; + SerialDataObject.sampleHistory = []; + SerialDataObject.timeHistory = []; + } + } + SerialDataObject.dataIdx = idxData(SerialDataObject.data.length); + } +} + +export function StopUDP() { + if (SerialDataObject.udpSocket) { + try { + SerialDataObject.udpSocket.close(); + } catch (e) { + console.log('Error closing UDP socket:', e); + } + SerialDataObject.udpSocket = null; + SerialDataObject.udpPort = null; + } +} + From 4c7887f8574e62ad0bfe1015e9f261ce73f0ed0c Mon Sep 17 00:00:00 2001 From: Tiago d'Avila Date: Fri, 7 Nov 2025 18:02:08 -0300 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20Add=20optional=20timestamp=20to?= =?UTF-8?q?=20live=20monitor=20and=20recorded=20data=20=E2=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add toggleable timestamp for records in UI and recording resolves: #11 --- src/Components/Monitor.js | 24 +++++++++++++++++++++++- src/Components/MonitorSettings.tsx | 15 +++++++++++++++ src/Utils/DataRecording.js | 10 ++++++++++ src/Utils/GlobalSettings.js | 1 + src/Utils/SerialData.js | 9 ++++++++- 5 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/Components/Monitor.js b/src/Components/Monitor.js index 3df6dc4..b7289e3 100644 --- a/src/Components/Monitor.js +++ b/src/Components/Monitor.js @@ -46,7 +46,29 @@ 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}]`; + }; + let text = ''; + if (withTs && Array.isArray(SerialDataObject.rawDataTime)) { + const n = SerialDataObject.rawData.length; + const lines = new Array(n); + for (let i = 0; i < n; i++) { + const ts = SerialDataObject.rawDataTime[i]; + lines[i] = (ts !== undefined ? `${formatTs(ts)} ` : '') + SerialDataObject.rawData[i]; + } + text = lines.join('\n'); + } else { + text = SerialDataObject.rawData.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)"; diff --git a/src/Components/MonitorSettings.tsx b/src/Components/MonitorSettings.tsx index f0e7620..4531973 100644 --- a/src/Components/MonitorSettings.tsx +++ b/src/Components/MonitorSettings.tsx @@ -1,11 +1,17 @@ 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); return (
    + + { const v = e.target.checked; setShowTs(v); GlobalSettings.monitor.showTimestamp = v; }} + name="showTimestamp" + size="small" />} + label={Show timestamp} /> +
    ) } \ No newline at end of file diff --git a/src/Utils/DataRecording.js b/src/Utils/DataRecording.js index 07e3a1e..41ecaac 100644 --- a/src/Utils/DataRecording.js +++ b/src/Utils/DataRecording.js @@ -44,6 +44,16 @@ export function writeDataIfRecording(data){ if(GlobalSettings.record.recording && GlobalSettings.record.directory !== null){ let filename = path.join(GlobalSettings.record.directory,GlobalSettings.record.outputFilename); let dataWithNewline = data + "\r\n"; + if (GlobalSettings.monitor && GlobalSettings.monitor.showTimestamp) { + const d = new Date(); + 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); + const ts = `[${h}:${m}:${s}.${ms}]`; + dataWithNewline = `${ts} ${data}\r\n`; + } ipcRenderer.invoke('fse','appendFile',filename,dataWithNewline); } } diff --git a/src/Utils/GlobalSettings.js b/src/Utils/GlobalSettings.js index b400df6..b337555 100644 --- a/src/Utils/GlobalSettings.js +++ b/src/Utils/GlobalSettings.js @@ -39,6 +39,7 @@ export var GlobalSettings = { monitor: { fontSize: 12, refreshRate: 50, + showTimestamp: false, }, record: { recording: false, diff --git a/src/Utils/SerialData.js b/src/Utils/SerialData.js index 126af17..f9c67a2 100644 --- a/src/Utils/SerialData.js +++ b/src/Utils/SerialData.js @@ -29,6 +29,7 @@ export var SerialDataObject = { pauseFlag: false, // when this is true, the data continues to stream, but plots do not update data: [], rawData: [], + rawDataTime: [], dataIdx: [], serialObj: null, // UDP support @@ -53,6 +54,7 @@ export function StartSerial(port) { // Clear the data when the serial first starts SerialDataObject.rawData = []; + SerialDataObject.rawDataTime = []; SerialDataObject.data = []; SerialDataObject.dataIndex = []; SerialDataObject.pauseFlag = false; @@ -164,11 +166,13 @@ function serialSetup(port) { } } - // Push the raw data (unless its NaN) + // Push the raw data and a wall-clock timestamp SerialDataObject.rawData.push(data); + SerialDataObject.rawDataTime.push(Date.now()); if (SerialDataObject.rawData.length >= SerialDataObject.bufferSize) { // If the buffer is full, remove the first line of the raw data SerialDataObject.rawData.shift(); + SerialDataObject.rawDataTime.shift(); } // If we're recording, write each data row to the file @@ -231,6 +235,7 @@ export function StartUDP(portNumber) { // Clear data buffers and flags SerialDataObject.rawData = []; + SerialDataObject.rawDataTime = []; SerialDataObject.data = []; SerialDataObject.dataIndex = []; SerialDataObject.pauseFlag = false; @@ -317,8 +322,10 @@ export function StartUDP(portNumber) { } SerialDataObject.rawData.push(data); + SerialDataObject.rawDataTime.push(Date.now()); if (SerialDataObject.rawData.length >= SerialDataObject.bufferSize) { SerialDataObject.rawData.shift(); + SerialDataObject.rawDataTime.shift(); } writeDataIfRecording(data); From f787d6c8ed84f2bfdcbbfc84ae56aecdc84ee671 Mon Sep 17 00:00:00 2001 From: Tiago d'Avila Date: Sat, 8 Nov 2025 16:47:30 -0300 Subject: [PATCH 03/10] feat: Change parse :sparkles: Add labeled variable format with name and unit resolves: #10 --- src/Components/SerialChart.js | 13 +++++ src/Components/Spectrum.js | 6 +++ src/Utils/SerialData.js | 92 +++++++++++++++++++++++++++++++++-- 3 files changed, 107 insertions(+), 4 deletions(-) diff --git a/src/Components/SerialChart.js b/src/Components/SerialChart.js index ea2d7a2..2c9a2c4 100644 --- a/src/Components/SerialChart.js +++ b/src/Components/SerialChart.js @@ -141,6 +141,12 @@ export default class SerialChart extends React.Component { for (var i = 0; i < chart.data.datasets.length; i++) { chart.data.datasets[i].borderWidth = GlobalSettings.global.lineThickness; chart.data.datasets[i].pointRadius = GlobalSettings.global.pointRadius; + // Update label using parsed variable names/units if available + let lbl = (i + 1).toString(); + if (Array.isArray(SerialDataObject.varNames) && typeof SerialDataObject.varNames[i] !== 'undefined' && SerialDataObject.varNames[i] !== null) { + lbl = SerialDataObject.varNames[i]; + } + chart.data.datasets[i].label = lbl; } // Update with data from the serial port @@ -181,6 +187,13 @@ export default class SerialChart extends React.Component { } newOps.scales.y.max = GlobalSettings.timeSeries.ymax; newOps.scales.y.min = GlobalSettings.timeSeries.ymin; + // Apply unit to Y axis title using the first variable's unit, if available + let yUnit = (Array.isArray(SerialDataObject.varUnits) && SerialDataObject.varUnits.length > 0) ? SerialDataObject.varUnits[0] : null; + if (yUnit) { + newOps.scales.y.title = { display: true, text: yUnit }; + } else { + newOps.scales.y.title = { display: false }; + } newOps.plugins.title.text = SerialDataObject.port.friendlyName; chart.options = newOps; diff --git a/src/Components/Spectrum.js b/src/Components/Spectrum.js index 4fd5217..c62eaad 100644 --- a/src/Components/Spectrum.js +++ b/src/Components/Spectrum.js @@ -232,6 +232,12 @@ export default class Spectrum extends React.Component{ for (var i = 0; i < chart.data.datasets.length; i++){ chart.data.datasets[i].borderWidth = GlobalSettings.global.lineThickness; chart.data.datasets[i].pointRadius = GlobalSettings.global.pointRadius; + // Atualiza o rótulo usando apenas o nome parseado, sem unidade + let lbl = (i + 1).toString(); + if (Array.isArray(SerialDataObject.varNames) && typeof SerialDataObject.varNames[i] !== 'undefined' && SerialDataObject.varNames[i] !== null) { + lbl = SerialDataObject.varNames[i]; + } + chart.data.datasets[i].label = lbl; } // Compute the sample rate diff --git a/src/Utils/SerialData.js b/src/Utils/SerialData.js index f9c67a2..316da90 100644 --- a/src/Utils/SerialData.js +++ b/src/Utils/SerialData.js @@ -42,7 +42,10 @@ export var SerialDataObject = { sampleHistory: [], timeHistory: [], sampleRate: 0, - NsampleRateUpdate: 10 // Update the sample rate after this many samples + NsampleRateUpdate: 10, // Update the sample rate after this many samples + // Variable metadata (names/units) parsed from incoming stream + varNames: [], + varUnits: [], } export function StartSerial(port) { @@ -61,6 +64,12 @@ export function StartSerial(port) { SerialDataObject.Iter = 0; SerialDataObject.sampleHistory = []; SerialDataObject.timeHistory = []; + SerialDataObject.varNames = []; + SerialDataObject.varUnits = []; + SerialDataObject.varNames = []; + SerialDataObject.varUnits = []; + SerialDataObject.varNames = []; + SerialDataObject.varUnits = []; // Always try to close the serial object before starting one if (SerialDataObject.serialObj !== null) { @@ -178,13 +187,39 @@ function serialSetup(port) { // If we're recording, write each data row to the file writeDataIfRecording(data); - // Now parse the numeric data + // Now parse tokens supporting "name(unit): value" or plain numeric var splitData = data.split(/\s+|,\s+/); - var nums = splitData.map(parseFloat); + var namesParsed = []; + var unitsParsed = []; + var nums = []; + for (let tok of splitData) { + if (!tok) { continue; } + const colonIdx = tok.indexOf(":"); + if (colonIdx !== -1) { + const namePart = tok.slice(0, colonIdx).trim(); + const valPart = tok.slice(colonIdx + 1).trim(); + let name = namePart; + let unit = null; + const m = namePart.match(/^(.*?)\s*\((.*?)\)\s*$/); + if (m) { + name = m[1].trim(); + unit = m[2].trim(); + } + namesParsed.push(name); + unitsParsed.push(unit); + nums.push(parseFloat(valPart)); + } else { + namesParsed.push(null); + unitsParsed.push(null); + nums.push(parseFloat(tok)); + } + } var t = 0; if (GlobalSettings.global.firstColumnTime) { t = nums[0]; nums = nums.slice(1, nums.length); + namesParsed = namesParsed.slice(1, namesParsed.length); + unitsParsed = unitsParsed.slice(1, unitsParsed.length); } else { t = performance.now(); @@ -196,6 +231,18 @@ function serialSetup(port) { SerialDataObject.data.push(nums); SerialDataObject.sampleHistory.push(SerialDataObject.Iter); SerialDataObject.timeHistory.push(t); + // Update variable names/units (keep latest non-null names) + const nvars = nums.length; + const newNames = new Array(nvars); + const newUnits = new Array(nvars); + for (let i = 0; i < nvars; i++) { + const nm = namesParsed[i]; + const un = unitsParsed[i]; + newNames[i] = (nm && nm.length > 0) ? nm : (SerialDataObject.varNames[i] || null); + newUnits[i] = (un && un.length > 0) ? un : (SerialDataObject.varUnits[i] || null); + } + SerialDataObject.varNames = newNames; + SerialDataObject.varUnits = newUnits; } var n = SerialDataObject.data.length; @@ -331,11 +378,37 @@ export function StartUDP(portNumber) { writeDataIfRecording(data); var splitData = data.split(/\s+|,\s+/); - var nums = splitData.map(parseFloat); + var namesParsed = []; + var unitsParsed = []; + var nums = []; + for (let tok of splitData) { + if (!tok) { continue; } + const colonIdx = tok.indexOf(":"); + if (colonIdx !== -1) { + const namePart = tok.slice(0, colonIdx).trim(); + const valPart = tok.slice(colonIdx + 1).trim(); + let name = namePart; + let unit = null; + const m = namePart.match(/^(.*?)\s*\((.*?)\)\s*$/); + if (m) { + name = m[1].trim(); + unit = m[2].trim(); + } + namesParsed.push(name); + unitsParsed.push(unit); + nums.push(parseFloat(valPart)); + } else { + namesParsed.push(null); + unitsParsed.push(null); + nums.push(parseFloat(tok)); + } + } var t = 0; if (GlobalSettings.global.firstColumnTime) { t = nums[0]; nums = nums.slice(1, nums.length); + namesParsed = namesParsed.slice(1, namesParsed.length); + unitsParsed = unitsParsed.slice(1, unitsParsed.length); } else { t = performance.now(); } @@ -344,6 +417,17 @@ export function StartUDP(portNumber) { SerialDataObject.data.push(nums); SerialDataObject.sampleHistory.push(SerialDataObject.Iter); SerialDataObject.timeHistory.push(t); + const nvars = nums.length; + const newNames = new Array(nvars); + const newUnits = new Array(nvars); + for (let i = 0; i < nvars; i++) { + const nm = namesParsed[i]; + const un = unitsParsed[i]; + newNames[i] = (nm && nm.length > 0) ? nm : (SerialDataObject.varNames[i] || null); + newUnits[i] = (un && un.length > 0) ? un : (SerialDataObject.varUnits[i] || null); + } + SerialDataObject.varNames = newNames; + SerialDataObject.varUnits = newUnits; } var n = SerialDataObject.data.length; From 52260eec8208106948b5eb7ac3e023347d03c544 Mon Sep 17 00:00:00 2001 From: Tiago d'Avila Date: Sat, 8 Nov 2025 17:14:01 -0300 Subject: [PATCH 04/10] feat: Add toggle to show raw input or parsed variable values :sparkles: :lipstick: Add display mode: raw stream or cleaned variable values --- src/Components/Monitor.js | 39 ++++++++++++++++++++++-------- src/Components/MonitorSettings.tsx | 8 ++++++ src/Utils/GlobalSettings.js | 1 + 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/Components/Monitor.js b/src/Components/Monitor.js index b7289e3..195c780 100644 --- a/src/Components/Monitor.js +++ b/src/Components/Monitor.js @@ -56,18 +56,37 @@ export default class Monitor extends React.Component { const ms = pad(d.getMilliseconds(),3); return `[${h}:${m}:${s}.${ms}]`; }; - let text = ''; - if (withTs && Array.isArray(SerialDataObject.rawDataTime)) { - const n = SerialDataObject.rawData.length; - const lines = new Array(n); - for (let i = 0; i < n; i++) { - const ts = SerialDataObject.rawDataTime[i]; - lines[i] = (ts !== undefined ? `${formatTs(ts)} ` : '') + SerialDataObject.rawData[i]; + // 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); + } } - text = lines.join('\n'); - } else { - text = SerialDataObject.rawData.join('\n'); + 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){ diff --git a/src/Components/MonitorSettings.tsx b/src/Components/MonitorSettings.tsx index 4531973..e40784c 100644 --- a/src/Components/MonitorSettings.tsx +++ b/src/Components/MonitorSettings.tsx @@ -12,6 +12,7 @@ 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 (
    } label={Show timestamp} /> + { const v = e.target.checked; setShowValsOnly(v); GlobalSettings.monitor.showValuesOnly = v; }} + name="showRaw" + size="small" />} + label={RAW Values} />
    ) diff --git a/src/Utils/GlobalSettings.js b/src/Utils/GlobalSettings.js index b337555..b280b84 100644 --- a/src/Utils/GlobalSettings.js +++ b/src/Utils/GlobalSettings.js @@ -40,6 +40,7 @@ export var GlobalSettings = { fontSize: 12, refreshRate: 50, showTimestamp: false, + showValuesOnly: false, }, record: { recording: false, From 11a084554ebd0342572bda85700b802a013f7888 Mon Sep 17 00:00:00 2001 From: Tiago d'Avila Date: Sat, 8 Nov 2025 17:33:36 -0300 Subject: [PATCH 05/10] =?UTF-8?q?feat:=20Add=20support=20for=20loading=20p?= =?UTF-8?q?reviously=20recorded=20data=20files=20=E2=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolves: #12 --- src/Components/RecordSettings.tsx | 6 ++ src/Utils/SerialData.js | 137 ++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) diff --git a/src/Components/RecordSettings.tsx b/src/Components/RecordSettings.tsx index 49777b4..cbbfa0d 100644 --- a/src/Components/RecordSettings.tsx +++ b/src/Components/RecordSettings.tsx @@ -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'); @@ -56,6 +57,11 @@ export default function RecordSettings() { {directory} +
    + +
    ) } \ No newline at end of file diff --git a/src/Utils/SerialData.js b/src/Utils/SerialData.js index 316da90..460a119 100644 --- a/src/Utils/SerialData.js +++ b/src/Utils/SerialData.js @@ -46,6 +46,8 @@ export var SerialDataObject = { // Variable metadata (names/units) parsed from incoming stream varNames: [], varUnits: [], + // File import + dataSource: 'live', // 'live' | 'file' } export function StartSerial(port) { @@ -276,6 +278,141 @@ function idxData(n) { return (idxVec); } +// ---------------------- File Import ---------------------- // +const { ipcRenderer } = window.require('electron'); +const path = window.require('path'); + +function parseTimestampTag(line) { + // Matches [hh:mm:ss.mmm] prefix + const m = line.match(/^\[(\d{2}):(\d{2}):(\d{2})\.(\d{3})\]\s+(.*)$/); + if (!m) { return { ts: null, text: line }; } + const hh = parseInt(m[1], 10); + const mm = parseInt(m[2], 10); + const ss = parseInt(m[3], 10); + const ms = parseInt(m[4], 10); + const rest = m[5]; + const now = new Date(); + const d = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hh, mm, ss, ms); + return { ts: d.getTime(), text: rest }; +} + +function processDataLine(data, tsOverride = null) { + // Push raw line and timestamp (either override or wall clock) + SerialDataObject.rawData.push(data); + SerialDataObject.rawDataTime.push(tsOverride !== null ? tsOverride : Date.now()); + if (SerialDataObject.rawData.length >= SerialDataObject.bufferSize) { + SerialDataObject.rawData.shift(); + SerialDataObject.rawDataTime.shift(); + } + + // Parse tokens supporting "name(unit): value" or plain numeric + var splitData = data.split(/\s+|,\s+/); + var namesParsed = []; + var unitsParsed = []; + var nums = []; + for (let tok of splitData) { + if (!tok) { continue; } + const colonIdx = tok.indexOf(":"); + if (colonIdx !== -1) { + const namePart = tok.slice(0, colonIdx).trim(); + const valPart = tok.slice(colonIdx + 1).trim(); + let name = namePart; + let unit = null; + const m = namePart.match(/^(.*?)\s*\((.*?)\)\s*$/); + if (m) { + name = m[1].trim(); + unit = m[2].trim(); + } + namesParsed.push(name); + unitsParsed.push(unit); + nums.push(parseFloat(valPart)); + } else { + namesParsed.push(null); + unitsParsed.push(null); + nums.push(parseFloat(tok)); + } + } + var t = 0; + if (GlobalSettings.global.firstColumnTime) { + t = nums[0]; + nums = nums.slice(1, nums.length); + namesParsed = namesParsed.slice(1, namesParsed.length); + unitsParsed = unitsParsed.slice(1, unitsParsed.length); + } else { + t = performance.now(); + } + + if (nums.every((value) => { return !isNaN(value) })) { + SerialDataObject.data.push(nums); + SerialDataObject.Iter += 1; + SerialDataObject.sampleHistory.push(SerialDataObject.Iter); + SerialDataObject.timeHistory.push(t); + const nvars = nums.length; + const newNames = new Array(nvars); + const newUnits = new Array(nvars); + for (let i = 0; i < nvars; i++) { + const nm = namesParsed[i]; + const un = unitsParsed[i]; + newNames[i] = (nm && nm.length > 0) ? nm : (SerialDataObject.varNames[i] || null); + newUnits[i] = (un && un.length > 0) ? un : (SerialDataObject.varUnits[i] || null); + } + SerialDataObject.varNames = newNames; + SerialDataObject.varUnits = newUnits; + } + + // Buffer management + var n = SerialDataObject.data.length; + if (n > SerialDataObject.bufferSize) { + const resize = (v) => v.slice(v.length - SerialDataObject.bufferSize, v.length); + SerialDataObject.data = resize(SerialDataObject.data); + SerialDataObject.sampleHistory = resize(SerialDataObject.sampleHistory); + SerialDataObject.timeHistory = resize(SerialDataObject.timeHistory); + if (!GlobalSettings.timeSeries.scroll) { + SerialDataObject.data = []; + SerialDataObject.sampleHistory = []; + SerialDataObject.timeHistory = []; + } + } + SerialDataObject.dataIdx = idxData(SerialDataObject.data.length); +} + +export async function LoadFromFileDialog() { + const result = await ipcRenderer.invoke('dialog', 'showOpenDialog', { properties: ['openFile'], filters: [{ name: 'Text', extensions: ['txt'] }, { name: 'All Files', extensions: ['*'] }] }); + if (!result.canceled && result.filePaths && result.filePaths.length > 0) { + await LoadFromFile(result.filePaths[0]); + } +} + +export async function LoadFromFile(filePath) { + try { + // Read content + const content = await ipcRenderer.invoke('fse', 'readFile', filePath, 'utf8'); + const lines = content.split(/\r?\n/).filter(l => l.length > 0); + + // Reset buffers + SerialDataObject.dataSource = 'file'; + SerialDataObject.inputMode = 'serial'; + SerialDataObject.rawData = []; + SerialDataObject.rawDataTime = []; + SerialDataObject.data = []; + SerialDataObject.dataIdx = []; + SerialDataObject.pauseFlag = false; + SerialDataObject.Iter = 0; + SerialDataObject.sampleHistory = []; + SerialDataObject.timeHistory = []; + SerialDataObject.varNames = []; + SerialDataObject.varUnits = []; + SerialDataObject.port = { path: null, friendlyName: `File: ${path.basename(filePath)}` }; + + // Process lines + for (const line of lines) { + const { ts, text } = parseTimestampTag(line); + processDataLine(text, ts); + } + } catch (e) { + console.log('Failed to load file:', e); + } +} // ---------------------- UDP Support ---------------------- // export function StartUDP(portNumber) { SerialDataObject.inputMode = 'udp'; From eb322b6f41eb5210b1e713923d05794789a9adff Mon Sep 17 00:00:00 2001 From: Tiago d'Avila Date: Sat, 8 Nov 2025 20:19:09 -0300 Subject: [PATCH 06/10] feat: Add version info :lipstick: --- public/electron.js | 24 ++++++++++++++++++++++++ public/preload.js | 7 +++++++ src/App.js | 37 ++++++++++++++++++++++++++++++++++++- 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/public/electron.js b/public/electron.js index d36a93a..0b0928d 100644 --- a/public/electron.js +++ b/public/electron.js @@ -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, @@ -19,6 +21,7 @@ function createWindow() { color: 'rgb(37,37,38)', symbolColor: '#74b1be' }, + title: `${baseTitle} v${version}`, webPreferences: { nodeIntegration: true, enableRemoteModule: true, @@ -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" }); @@ -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. diff --git a/public/preload.js b/public/preload.js index 5daf1a5..3dfd294 100644 --- a/public/preload.js +++ b/public/preload.js @@ -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 +} \ No newline at end of file diff --git a/src/App.js b/src/App.js index 01c396d..f735978 100644 --- a/src/App.js +++ b/src/App.js @@ -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 (
    -
    +
    {titleText}
    From 2a717c1c3e29da32d854730eec2d9a739cdc18c1 Mon Sep 17 00:00:00 2001 From: Tiago d'Avila Date: Sat, 8 Nov 2025 22:32:38 -0300 Subject: [PATCH 07/10] docs: Add update log --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index 1e32ced..c4b976c 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,21 @@ Build the app:
        yarn electron:package:mac ### Updates: +V1.2.0 +* 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 + V1.1.0 * Added a record option * Only records the raw serial data to a text file From 4f3686ffab101a22f6af4d4e35f1af51c71d6078 Mon Sep 17 00:00:00 2001 From: Tiago d'Avila Date: Mon, 10 Nov 2025 14:35:56 -0300 Subject: [PATCH 08/10] =?UTF-8?q?fix:=20Enhance=20Time=20Series=20auto=20s?= =?UTF-8?q?cale=20to=20respect=20hidden=20series=20and=20improve=20precisi?= =?UTF-8?q?on=20=E2=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Excludes hidden series, applies 5% margin around visible data and Sets Y-axis unit based on first visible series --- src/Components/SerialChart.js | 34 ++++++++++++++++++++++++-- src/Utils/DataUtils.js | 46 +++++++++++++++++++++++++---------- src/Utils/SerialData.js | 2 ++ 3 files changed, 67 insertions(+), 15 deletions(-) diff --git a/src/Components/SerialChart.js b/src/Components/SerialChart.js index 2c9a2c4..e3a06e9 100644 --- a/src/Components/SerialChart.js +++ b/src/Components/SerialChart.js @@ -46,6 +46,19 @@ var defaultChartOptions = { position: 'right', labels: { color: plotFontColor + }, + onClick: (e, legendItem, legend) => { + const chart = legend.chart; + const idx = legendItem.datasetIndex; + const currentlyVisible = chart.isDatasetVisible(idx); + chart.setDatasetVisibility(idx, !currentlyVisible); + // Update visibility state for autoscale + const nvars = chart.data.datasets.length; + if (!Array.isArray(SerialDataObject.visibleVars) || SerialDataObject.visibleVars.length !== nvars) { + SerialDataObject.visibleVars = new Array(nvars).fill(true); + } + SerialDataObject.visibleVars[idx] = !currentlyVisible; + chart.update(); } }, title: { @@ -149,6 +162,15 @@ export default class SerialChart extends React.Component { chart.data.datasets[i].label = lbl; } + // Ensure visibility state length matches number of variables + if (!Array.isArray(SerialDataObject.visibleVars) || SerialDataObject.visibleVars.length !== chart.data.datasets.length) { + const current = Array.isArray(SerialDataObject.visibleVars) ? SerialDataObject.visibleVars : []; + const needed = chart.data.datasets.length - current.length; + SerialDataObject.visibleVars = needed > 0 + ? [...current, ...new Array(needed).fill(true)] + : current.slice(0, chart.data.datasets.length); + } + // Update with data from the serial port var tnow = SerialDataObject.timeHistory[SerialDataObject.timeHistory.length - 1]; var tvec = (SerialDataObject.timeHistory.map((x) => { return 1.0e-3 * (x - tnow) })); @@ -187,8 +209,16 @@ export default class SerialChart extends React.Component { } newOps.scales.y.max = GlobalSettings.timeSeries.ymax; newOps.scales.y.min = GlobalSettings.timeSeries.ymin; - // Apply unit to Y axis title using the first variable's unit, if available - let yUnit = (Array.isArray(SerialDataObject.varUnits) && SerialDataObject.varUnits.length > 0) ? SerialDataObject.varUnits[0] : null; + // Apply unit to Y axis title using the first VISIBLE variable's unit, if available + let yUnit = null; + if (Array.isArray(SerialDataObject.varUnits) && SerialDataObject.varUnits.length > 0) { + const vis = Array.isArray(SerialDataObject.visibleVars) ? SerialDataObject.visibleVars : []; + for (let i = 0; i < SerialDataObject.varUnits.length; i++) { + const unit = SerialDataObject.varUnits[i]; + const isVisible = (vis.length === 0) ? true : !!vis[i]; + if (isVisible && unit) { yUnit = unit; break; } + } + } if (yUnit) { newOps.scales.y.title = { display: true, text: yUnit }; } else { diff --git a/src/Utils/DataUtils.js b/src/Utils/DataUtils.js index 64d2b5c..16c2c9a 100644 --- a/src/Utils/DataUtils.js +++ b/src/Utils/DataUtils.js @@ -33,21 +33,41 @@ export function nextPowerOf2(x){ } export function autoResize(){ - if(GlobalSettings.timeSeries.autoScale && !SerialDataObject.pauseFlag){ - // Min and max of y - var ymin = Math.min(...SerialDataObject.data.flat()); - var ymax = Math.max(...SerialDataObject.data.flat()); - // Give it some standardized space - var ymid = (ymax+ymin)/2; - var dy = ymax - ymid; - var dyNext = nextPowerOf2(dy); - var nearestValue = 1; - if(dy > 2){ - nearestValue = 5; + // Collect values only from visible variables + const dataRows = SerialDataObject.data; + if(!Array.isArray(dataRows) || dataRows.length === 0){ return; } + const nvars = dataRows[dataRows.length - 1].length || 0; + let vis = SerialDataObject.visibleVars; + // Initialize visibility if missing or mismatched + if(!Array.isArray(vis) || vis.length !== nvars){ + vis = new Array(nvars).fill(true); + SerialDataObject.visibleVars = vis; + } + + const values = []; + for(const row of dataRows){ + for(let j=0;j Date: Wed, 12 Nov 2025 08:39:45 -0300 Subject: [PATCH 09/10] fix: Y-axis chart auto-scaling to use no decimal places --- src/Components/SerialChart.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Components/SerialChart.js b/src/Components/SerialChart.js index e3a06e9..0db622a 100644 --- a/src/Components/SerialChart.js +++ b/src/Components/SerialChart.js @@ -96,7 +96,12 @@ var defaultChartOptions = { }, ticks: { color: plotFontColor, - callback: (val) => (val.toString().padStart(padChars - val.toString().length, " ")) + callback: (val) => { + const v = Math.round(val); + const s = v.toString(); + const total = Math.max(0, padChars - s.length); + return s.padStart(total, " "); + } } } } From ecb0c25a0603f679f10b52f969e7d720ea3c5fd1 Mon Sep 17 00:00:00 2001 From: Tiago d'Avila Date: Fri, 16 Jan 2026 11:26:54 -0300 Subject: [PATCH 10/10] fix: UDP port change Connection stops and requires restart after update --- src/Components/UDPSelect.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Components/UDPSelect.tsx b/src/Components/UDPSelect.tsx index f8c96ba..2374360 100644 --- a/src/Components/UDPSelect.tsx +++ b/src/Components/UDPSelect.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; import { GlobalSettings } from "../Utils/GlobalSettings.js"; -import { SerialDataObject } from '../Utils/SerialData'; +import { SerialDataObject, StopUDP } from '../Utils/SerialData'; const titleFs = GlobalSettings.style.menuFs; @@ -28,6 +28,12 @@ export function UDPPortSelect() { if (!isNaN(val)) { // Clamp to valid UDP port range const clamped = Math.min(65535, Math.max(1, val)); + + // If connected, close the connection so the user must hit Play again + if (SerialDataObject.udpSocket) { + StopUDP(); + } + setPort(clamped); SerialDataObject.udpPort = clamped; }