diff --git a/README.md b/README.md index 866d3b9..d5ffa17 100644 --- a/README.md +++ b/README.md @@ -125,15 +125,28 @@ Open the printed local URL (Vite default is `http://localhost:5173`). 3. Start streaming data - the app supports CSV, space, or tab-separated values ### Data Format -Your device should send lines of numeric data: +Your device should send lines of numeric data with optional bracket-based filtering: ```text -# Optional header defines series names +# Standard format (works with filters disabled) # time(ms), ax, ay, az 0, 0.01, 0.02, 0.98 10, 0.02, 0.01, 0.99 -20, 0.03, 0.00, 1.01 + +# Bracket format (recommended - filters enabled by default) +Debug: Sensor initialized +[# Temperature,Humidity,Pressure] +[22.45,65.23,1015.67] +Debug: Reading sensor... +[22.67,64.89,1015.23] +[23.01,65.45,1016.12] ``` +**Bracket Filtering (Enabled by Default):** +- Lines enclosed in `[brackets]` go to the chart +- Lines without brackets appear only in the console +- Perfect for mixing debug messages with sensor data +- Toggle filters in Settings panel if needed + ### Interactive Controls - **Pan**: Click and drag on the plot - **Zoom**: Ctrl+wheel or pinch on touch devices @@ -163,6 +176,21 @@ The Console tab provides direct device communication: - **Export Options**: Save console logs as TXT, CSV, or JSON files - **Configurable Buffer**: Adjust message history size (10-10,000 messages) +### Settings Profiles +Save and restore your preferred configurations: +- **Default Profile**: Standard settings for new sessions +- **Custom Profile**: Appears when you modify Default settings +- **Save Profile**: Create named profiles from Custom settings +- **Overwrite/Save As**: Update existing profiles or create new ones +- Profiles persist in browser storage across sessions + +### Data Filters +Control which data appears where: +- **Chart Filter**: Only plot lines enclosed in `[brackets]` (ON by default) +- **Console Filter**: Hide `[bracketed]` lines from console (ON by default) +- Use both to separate chart data from debug messages +- Disable both for legacy behavior (all data goes everywhere) + ## Browser Support The Web Serial API is supported in modern Chromium‑based browsers: diff --git a/example_firmware/README.md b/example_firmware/README.md index 5368434..fb6eb97 100644 --- a/example_firmware/README.md +++ b/example_firmware/README.md @@ -11,8 +11,9 @@ A basic example that generates simulated sensor data with 4 series: - Light (lux) ### Features -- Outputs data at 10 Hz (100ms intervals) +- Outputs data at 50 Hz (20ms intervals) - Uses sine waves with random noise for realistic sensor simulation +- Demonstrates bracket-based filtering with mixed debug/data output - Includes proper header line with series names - Compatible with standard Arduino boards (Uno, Nano, ESP32, etc.) @@ -27,18 +28,34 @@ A basic example that generates simulated sensor data with 4 series: ### Data Format -The sketch outputs data in the format expected by the Web Serial Plotter: +The sketch outputs data using the bracket-based filtering format: ``` -# Temperature,Humidity,Pressure,Light -22.45,65.23,1015.67,789.12 -22.67,64.89,1015.23,792.45 +Chart header: +[# Temperature,Humidity,Pressure,Light] +Chart values: +[22.45,65.23,1015.67,789.12] +[22.67,64.89,1015.23,792.45] +[23.01,65.45,1016.12,791.23] ... ``` -- Header line starts with `#` followed by comma-separated series names -- Data lines contain comma-separated numerical values -- Each line represents one time sample across all series +**Bracket Filtering (Enabled by Default):** +- Lines enclosed in `[brackets]` are sent to the chart +- Lines without brackets are shown only in the console +- This allows mixing debug messages with sensor data on the same serial connection + +**Format Rules:** +- Header line: `[# series1,series2,...]` with `#` prefix and comma-separated names +- Data lines: `[value1,value2,...]` with comma-separated numerical values +- Debug/status messages: plain text without brackets (console only) +- Each bracketed line represents one time sample across all series + +**Filter Settings:** +You can toggle the filters in the Settings panel: +- **Chart filter**: When ON (default), only `[bracketed]` lines are plotted +- **Console filter**: When ON (default), `[bracketed]` lines are hidden from console +- Turn both OFF to use the plotter without filtering (legacy behavior) ### Customization @@ -47,6 +64,8 @@ You can modify the sketch to: - Adjust sampling rate (modify `delay()` value) - Change data generation functions - Add real sensor readings instead of simulated data +- Add or remove debug messages +- Use without brackets for legacy behavior (disable filters in settings) ### Serial Settings diff --git a/example_firmware/basic_plotter/basic_plotter.ino b/example_firmware/basic_plotter/basic_plotter.ino index 6c5b641..6f5acfb 100644 --- a/example_firmware/basic_plotter/basic_plotter.ino +++ b/example_firmware/basic_plotter/basic_plotter.ino @@ -12,6 +12,8 @@ * Compatible with Web Serial Plotter at: * https://github.com/your-repo/web-serial-plotter */ +long lastDebug = 0; +int debugCount = 0; void setup() { Serial.begin(115200); @@ -21,13 +23,20 @@ void setup() { delay(10); } - Serial.println("# Temperature,Humidity,Pressure,Light"); + Serial.println("[# Temperature,Humidity,Pressure,Light]"); // Small delay to ensure header is processed delay(100); } void loop() { + // Send debug info every 1 second + if(millis() - lastDebug > 1000){ + Serial.print("Debug no. "); + Serial.println(++debugCount); + lastDebug = millis(); + } + // Generate sample sensor data float temperature = 20.0 + 15.0 * sin(millis() / 5000.0) + random(-100, 100) / 100.0; float humidity = 50.0 + 20.0 * cos(millis() / 3000.0) + random(-200, 200) / 100.0; @@ -35,14 +44,16 @@ void loop() { float light = 500.0 + 300.0 * sin(millis() / 2000.0) + random(-1000, 1000) / 100.0; // Output as comma-separated values + Serial.print("["); Serial.print(temperature, 2); Serial.print(","); Serial.print(humidity, 2); Serial.print(","); Serial.print(pressure, 2); Serial.print(","); - Serial.println(light, 2); + Serial.print(light, 2); + Serial.println("]"); - // Sample rate: 10 Hz (100ms interval) - delay(100); + // Sample rate: 50 Hz (20ms interval) + delay(20); } \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 71c777c..410d3e2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,6 +30,8 @@ function App() { const [timeMode, setTimeMode] = useState<'absolute' | 'relative'>('absolute') const [activeTab, setActiveTab] = useState<'chart' | 'console'>('chart') const [showSettingsPanel, setShowSettingsPanel] = useState(false) + const [filterChartData, setFilterChartData] = useState(true) + const [filterConsoleData, setFilterConsoleData] = useState(true) const tourSteps = useMemo(() => ([ { element: '#tour-connect-button', @@ -68,16 +70,33 @@ function App() { const handleIncomingLine = useCallback((line: string) => { setLastLine(line) - // Send to console store (always log all incoming data) - consoleStore.addIncoming(line) + const trimmedLine = line.trim() + const isBracketed = trimmedLine.startsWith('[') && trimmedLine.endsWith(']') - // Parse for chart (existing logic) - if (line.trim().startsWith('#')) { - const names = line.replace(/^\(/, '').replace(/\)$/, '').replace(/^\s*#+\s*/, '').split(/[\s,\t]+/).filter(Boolean) + // Determine if line should go to console + const sendToConsole = !filterConsoleData || !isBracketed + if (sendToConsole) { + consoleStore.addIncoming(line) + } + + // Determine if line should go to chart + const sendToChart = !filterChartData || isBracketed + if (!sendToChart) return + + // Extract content (remove brackets if present) + let content = trimmedLine + if (isBracketed) { + content = trimmedLine.slice(1, -1).trim() + } + + // Parse for chart + if (content.startsWith('#')) { + const names = content.replace(/^\s*#+\s*/, '').split(/[\s,\t]+/).filter(Boolean) if (names.length > 0) store.setSeries(names) return } - const parts = line.trim().replace(/^\(/, '').replace(/\)$/, '').split(/[\s,\t]+/).filter(Boolean) + + const parts = content.split(/[\s,\t]+/).filter(Boolean) if (parts.length === 0) return const values: number[] = [] for (const p of parts) { @@ -85,7 +104,7 @@ function App() { if (Number.isFinite(v)) values.push(v) } if (values.length > 0) store.append(values) - }, [store, consoleStore]) + }, [store, consoleStore, filterChartData, filterConsoleData]) const dataConnection = useDataConnection(handleIncomingLine) @@ -263,6 +282,8 @@ function App() { capacity: store.getCapacity(), maxViewPortSize: store.getMaxViewPortSize(), timeMode, + filterChartData, + filterConsoleData, }} onChange={{ setAutoscale, @@ -271,6 +292,8 @@ function App() { setCapacity: (v) => store.setCapacity(v), setMaxViewPortSize: (v) => store.setMaxViewPortSize(v), setTimeMode, + setFilterChartData, + setFilterConsoleData, }} onClose={() => setShowSettingsPanel(false)} /> diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 691ed85..8e08f5f 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -1,7 +1,10 @@ +import { useState, useEffect, useCallback } from 'react' import Button from './ui/Button' import Checkbox from './ui/Checkbox' import Input from './ui/Input' import Select from './ui/Select' +import Modal from './ui/Modal' +import Tooltip from './ui/Tooltip' interface Settings { autoscale: boolean @@ -10,6 +13,8 @@ interface Settings { capacity: number maxViewPortSize: number timeMode: 'absolute' | 'relative' + filterChartData: boolean + filterConsoleData: boolean } interface Props { @@ -22,20 +27,260 @@ interface Props { setCapacity: (v: number) => void setMaxViewPortSize: (v: number) => void setTimeMode: (v: 'absolute' | 'relative') => void + setFilterChartData: (v: boolean) => void + setFilterConsoleData: (v: boolean) => void } onClose: () => void } +interface SavedProfile extends Settings { + name: string +} + +const STORAGE_KEY = 'wsp.settings.profiles' +const DEFAULT_SETTINGS: Settings = { + autoscale: true, + manualMinInput: '-1', + manualMaxInput: '1', + capacity: 100000, + maxViewPortSize: 2000, + timeMode: 'absolute', + filterChartData: true, + filterConsoleData: true +} + export default function SettingsPanel({ open, settings, onChange, onClose }: Props) { + const [profiles, setProfiles] = useState([]) + const [currentProfile, setCurrentProfile] = useState('Default') + const [showSaveDialog, setShowSaveDialog] = useState(false) + const [showSaveAsDialog, setShowSaveAsDialog] = useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const [showOverwriteConfirm, setShowOverwriteConfirm] = useState(false) + const [profileToDelete, setProfileToDelete] = useState('') + const [newProfileName, setNewProfileName] = useState('') + const [saveError, setSaveError] = useState('') + const [isModified, setIsModified] = useState(false) + + // Load profiles from localStorage + useEffect(() => { + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) { + const parsed = JSON.parse(stored) as SavedProfile[] + setProfiles(parsed) + } + } catch (err) { + console.error('Failed to load profiles:', err) + } + }, []) + + // Check if current settings match any profile + useEffect(() => { + // Only run this check after initial render to avoid setting Custom on startup + if (!open) return + + // Check if matches default + const matchesDefault = + settings.autoscale === DEFAULT_SETTINGS.autoscale && + settings.manualMinInput === DEFAULT_SETTINGS.manualMinInput && + settings.manualMaxInput === DEFAULT_SETTINGS.manualMaxInput && + settings.capacity === DEFAULT_SETTINGS.capacity && + settings.maxViewPortSize === DEFAULT_SETTINGS.maxViewPortSize && + settings.timeMode === DEFAULT_SETTINGS.timeMode && + settings.filterChartData === DEFAULT_SETTINGS.filterChartData && + settings.filterConsoleData === DEFAULT_SETTINGS.filterConsoleData + + if (matchesDefault) { + setCurrentProfile('Default') + setIsModified(false) + return + } + + // Check if matches any saved profile + const matchingProfile = profiles.find(p => + p.autoscale === settings.autoscale && + p.manualMinInput === settings.manualMinInput && + p.manualMaxInput === settings.manualMaxInput && + p.capacity === settings.capacity && + p.maxViewPortSize === settings.maxViewPortSize && + p.timeMode === settings.timeMode && + p.filterChartData === settings.filterChartData && + p.filterConsoleData === settings.filterConsoleData + ) + + if (matchingProfile) { + setCurrentProfile(matchingProfile.name) + setIsModified(false) + } else { + // Settings don't match any profile + // If currently on Default, switch to Custom + // If on a saved profile, mark as modified but keep the name + if (currentProfile === 'Default') { + setCurrentProfile('Custom') + } + setIsModified(currentProfile !== 'Default' && currentProfile !== 'Custom') + } + }, [settings, profiles, open, currentProfile]) + + const loadProfile = useCallback((profileName: string) => { + if (profileName === 'Default') { + onChange.setAutoscale(DEFAULT_SETTINGS.autoscale) + onChange.setManualMinInput(DEFAULT_SETTINGS.manualMinInput) + onChange.setManualMaxInput(DEFAULT_SETTINGS.manualMaxInput) + onChange.setCapacity(DEFAULT_SETTINGS.capacity) + onChange.setMaxViewPortSize(DEFAULT_SETTINGS.maxViewPortSize) + onChange.setTimeMode(DEFAULT_SETTINGS.timeMode) + onChange.setFilterChartData(DEFAULT_SETTINGS.filterChartData) + onChange.setFilterConsoleData(DEFAULT_SETTINGS.filterConsoleData) + setIsModified(false) + return + } + + const profile = profiles.find(p => p.name === profileName) + if (profile) { + onChange.setAutoscale(profile.autoscale) + onChange.setManualMinInput(profile.manualMinInput) + onChange.setManualMaxInput(profile.manualMaxInput) + onChange.setCapacity(profile.capacity) + onChange.setMaxViewPortSize(profile.maxViewPortSize) + onChange.setTimeMode(profile.timeMode) + onChange.setFilterChartData(profile.filterChartData) + onChange.setFilterConsoleData(profile.filterConsoleData) + setIsModified(false) + } + }, [profiles, onChange]) + + const handleSaveProfile = useCallback(() => { + const trimmedName = newProfileName.trim() + + if (!trimmedName) { + setSaveError('Profile name cannot be empty') + return + } + + if (trimmedName.toLowerCase() === 'default' || trimmedName.toLowerCase() === 'custom') { + setSaveError('Cannot use "Default" or "Custom" as profile name') + return + } + + const newProfile: SavedProfile = { + name: trimmedName, + ...settings + } + + try { + // Remove existing profile with same name + const updatedProfiles = profiles.filter(p => p.name !== trimmedName) + updatedProfiles.push(newProfile) + + localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedProfiles)) + setProfiles(updatedProfiles) + setShowSaveDialog(false) + setShowSaveAsDialog(false) + setNewProfileName('') + setSaveError('') + setCurrentProfile(trimmedName) + setIsModified(false) + } catch (err) { + setSaveError('Failed to save profile. Storage may be full.') + console.error('Failed to save profile:', err) + } + }, [newProfileName, settings, profiles]) + + const handleOverwriteProfile = useCallback(() => { + // Overwrite the currently selected profile + const newProfile: SavedProfile = { + name: currentProfile, + ...settings + } + + try { + const updatedProfiles = profiles.filter(p => p.name !== currentProfile) + updatedProfiles.push(newProfile) + + localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedProfiles)) + setProfiles(updatedProfiles) + setIsModified(false) + setShowOverwriteConfirm(false) + } catch (err) { + console.error('Failed to overwrite profile:', err) + } + }, [currentProfile, settings, profiles]) + + const confirmOverwriteProfile = useCallback(() => { + setShowOverwriteConfirm(true) + }, []) + + const handleDeleteProfile = useCallback((profileName: string) => { + try { + const updatedProfiles = profiles.filter(p => p.name !== profileName) + localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedProfiles)) + setProfiles(updatedProfiles) + + if (currentProfile === profileName) { + setCurrentProfile('Default') + loadProfile('Default') + } + setShowDeleteConfirm(false) + setProfileToDelete('') + } catch (err) { + console.error('Failed to delete profile:', err) + } + }, [profiles, currentProfile, loadProfile]) + + const confirmDeleteProfile = useCallback((profileName: string) => { + setProfileToDelete(profileName) + setShowDeleteConfirm(true) + }, []) + + // Determine which buttons to show + const showSaveButton = isModified && currentProfile !== 'Default' && currentProfile !== 'Custom' + const showSaveAsButton = isModified && currentProfile !== 'Default' && currentProfile !== 'Custom' + const showSaveProfileButton = currentProfile === 'Custom' return ( - + + {/* Save Profile Dialog (for Custom) */} + { + setShowSaveDialog(false) + setNewProfileName('') + setSaveError('') + }} + title="Save Profile" + > +
+
+ + { + setNewProfileName(e.target.value) + setSaveError('') + }} + placeholder="Enter profile name" + className="w-full" + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleSaveProfile() + } + }} + /> + {saveError && ( +
{saveError}
+ )} +
+
+ + +
+
+
+ + {/* Save As Dialog (for modified saved profiles) */} + { + setShowSaveAsDialog(false) + setNewProfileName('') + setSaveError('') + }} + title="Save Profile As" + > +
+
+ + { + setNewProfileName(e.target.value) + setSaveError('') + }} + placeholder="Enter new profile name" + className="w-full" + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleSaveProfile() + } + }} + /> + {saveError && ( +
{saveError}
+ )} +
+
+ + +
+
+
+ + {/* Delete Confirmation Dialog */} + { + setShowDeleteConfirm(false) + setProfileToDelete('') + }} + title="Delete Profile" + > +
+

+ Are you sure you want to delete the profile "{profileToDelete}"? This action cannot be undone. +

+
+ + +
+
+
+ + {/* Overwrite Confirmation Dialog */} + setShowOverwriteConfirm(false)} + title="Overwrite Profile" + > +
+

+ Are you sure you want to overwrite the profile "{currentProfile}" with the current settings? +

+
+ + +
+
+
+ ) }