diff --git a/CHANGELOG.md b/CHANGELOG.md index aace310..6bd4dd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to Secure LSL will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.16.1-secure.1.1.0-alpha] - 2026-03-18 + +### Added +- **ESP32 support**: liblsl-ESP32, a clean-room C reimplementation of the LSL wire protocol for ESP32 microcontrollers with full secureLSL encryption +- ESP32 outlet and inlet with ChaCha20-Poly1305 encryption, wire-compatible with desktop +- Four ESP32 examples: basic_outlet, basic_inlet, secure_outlet, secure_inlet +- ESP32 benchmark suite: throughput firmware and desktop Python collection scripts +- ESP32 documentation integrated into mkdocs site + +### Verified +- Bidirectional encrypted interop: ESP32 to desktop and desktop to ESP32 +- Zero packet loss at 250/500 Hz, 0.02% at 1000 Hz +- Zero measurable encryption overhead on ESP32 push path (dual-core async) + ## [1.16.1-secure.1.0.0-alpha] - 2025-12-07 ### Added diff --git a/docs/esp32/overview.md b/docs/esp32/overview.md new file mode 100644 index 0000000..d6457ed --- /dev/null +++ b/docs/esp32/overview.md @@ -0,0 +1,212 @@ +# ESP32 Support + +Secure LSL includes a protocol-compatible implementation for ESP32 microcontrollers, enabling WiFi-connected embedded devices to participate in encrypted LSL lab networks. + +--- + +## Overview + +**liblsl-ESP32** is a clean-room C reimplementation of the LSL wire protocol for ESP32, with full secureLSL encryption support. It is not a port of desktop liblsl; it reimplements the protocol from scratch using ESP-IDF native APIs. + +### Scope + +liblsl-ESP32 provides the **communication layer** for streaming data over WiFi using the LSL protocol. While the ESP32 includes built-in ADC peripherals, this implementation focuses on the networking and protocol stack rather than signal acquisition. For biosignal applications (EEG, EMG, ECG), the ESP32 typically serves as a wireless bridge: a dedicated ADC IC (e.g., ADS1299, ADS1294) handles acquisition with the precision, noise floor, and simultaneous sampling required for research-grade recordings, while the ESP32 handles WiFi, LSL protocol, and encryption. This separation follows established practice in wireless biosignal systems. + +The current implementation uses 802.11 WiFi, but the protocol and encryption layers are transport-agnostic (standard BSD sockets). Developers can substitute alternative low-latency transports including Ethernet (SPI PHY), Bluetooth, or ESP-NOW, reusing the LSL protocol and secureLSL encryption modules. Note that LSL is designed for low-latency local network environments; high-latency transports are not suitable. + +### Why a Reimplementation? + +Desktop liblsl is ~50,000+ lines of C++ coupled to Boost, pugixml, and C++ features (exceptions, RTTI) that are impractical on a device with 520KB SRAM. The LSL wire protocol is simple (UDP discovery, TCP streamfeed, binary samples), making a clean C reimplementation (~4,000 lines) both smaller and more maintainable. + +### Features + +- **Full LSL protocol**: UDP multicast discovery + TCP data streaming (v1.10) +- **Bidirectional**: both outlet (push) and inlet (pull) +- **secureLSL encryption**: ChaCha20-Poly1305 authenticated encryption, X25519 key exchange (from Ed25519 identity keys) +- **Desktop interop**: verified with pylsl, LabRecorder, and desktop secureLSL +- **Real-time**: sustains up to 1000 Hz with near-zero packet loss +- **Lightweight**: ~200KB SRAM footprint, 300KB+ free for application + +### Hardware Requirements + +| Requirement | Minimum | Tested | +|------------|---------|--------| +| MCU | ESP32 (Xtensa LX6) | ESP32-WROOM-32 | +| SRAM | 520KB | ESP32-DevKitC v4 | +| Flash | 2MB+ | 4MB | +| WiFi | 802.11 b/g/n | 2.4GHz | + +--- + +## Quick Start + +### Prerequisites + +- [ESP-IDF v5.5+](https://docs.espressif.com/projects/esp-idf/en/stable/esp32/get-started/) +- ESP32 development board +- WiFi network shared with desktop +- For encrypted streaming: desktop Secure LSL (see [Installation](../getting-started/installation.md)) + +### 1. Flash the secure outlet example + +```bash +cd liblsl-ESP32/examples/secure_outlet +idf.py menuconfig +# Set WiFi credentials and secureLSL keypair +idf.py build +idf.py -p /dev/cu.usbserial-XXXX flash monitor +``` + +### 2. Receive on desktop with Secure LSL + +Ensure you have built Secure LSL with security enabled (see [Installation](../getting-started/installation.md)), then: + +```bash +./cpp_secure_inlet --stream ESP32Secure --samples 100 +``` + +### 3. Or use the unencrypted outlet + +```bash +cd liblsl-ESP32/examples/basic_outlet +idf.py menuconfig # Set WiFi +idf.py build && idf.py -p PORT flash monitor +``` + +```python +import pylsl +streams = pylsl.resolve_byprop('name', 'ESP32Test', timeout=10) +inlet = pylsl.StreamInlet(streams[0]) +sample, ts = inlet.pull_sample() +``` + +--- + +## Security Setup + +The ESP32 uses the same shared keypair model as desktop Secure LSL. All devices in a lab must share the same Ed25519 keypair. + +!!! warning "All devices must share the same keypair" + The ESP32 and desktop must have identical Ed25519 keypairs. Mismatched keys result in a 403 connection rejection (unanimous security enforcement). + +### Key Provisioning + +The recommended workflow is to generate keys on the desktop using `lsl-keygen`, then import them to the ESP32: + +```c +#include "lsl_esp32.h" +#include "nvs_flash.h" + +nvs_flash_init(); + +// Import the desktop keypair (recommended) +lsl_esp32_import_keypair("BASE64_PUBLIC_KEY", "BASE64_PRIVATE_KEY"); + +// Enable encryption for all subsequent outlets/inlets +lsl_esp32_enable_security(); +``` + +Alternatively, generate a new keypair on the ESP32 and distribute it to all devices: + +```c +lsl_esp32_generate_keypair(); + +// Export public key for distribution to desktop and other devices +char pubkey[LSL_ESP32_KEY_BASE64_SIZE]; +lsl_esp32_export_pubkey(pubkey, sizeof(pubkey)); +// Import the full keypair to desktop via lsl_api.cfg +``` + +!!! note "No passphrase support on ESP32" + The ESP32 stores raw (unencrypted) Ed25519 keys in NVS. It does not support passphrase-protected keys (`encrypted_private_key`). When configuring the desktop `lsl_api.cfg` for ESP32 interop, use the `private_key` field (unencrypted format, generated with `lsl-keygen --insecure`) rather than the default `encrypted_private_key`. + +### Desktop Configuration + +The desktop must have the matching keypair in `~/.lsl_api/lsl_api.cfg`: + +```ini +[security] +enabled = true +private_key = YOUR_BASE64_PRIVATE_KEY +``` + +For key extraction and distribution details, see the [ESP32 Security Guide](https://github.com/sccn/secureLSL/blob/main/liblsl-ESP32/docs/security.md). + +--- + +## API Overview + +```c +// Stream info +lsl_esp32_stream_info_t info = lsl_esp32_create_streaminfo( + "MyStream", "EEG", 8, 250.0, LSL_ESP32_FMT_FLOAT32, "source_id"); + +// Outlet (push) +lsl_esp32_outlet_t outlet = lsl_esp32_create_outlet(info, 0, 360); +lsl_esp32_push_sample_f(outlet, data, 0.0); + +// Inlet (pull) +lsl_esp32_stream_info_t found; +lsl_esp32_resolve_stream("name", "DesktopStream", 10.0, &found); +lsl_esp32_inlet_t inlet = lsl_esp32_create_inlet(found); +lsl_esp32_inlet_pull_sample_f(inlet, buf, buf_len, ×tamp, 5.0); + +// Security +lsl_esp32_generate_keypair(); +lsl_esp32_import_keypair(base64_pub, base64_priv); +lsl_esp32_export_pubkey(out, out_len); +lsl_esp32_has_keypair(); +lsl_esp32_enable_security(); +``` + +Full API: [lsl_esp32.h on GitHub](https://github.com/sccn/secureLSL/blob/main/liblsl-ESP32/components/liblsl_esp32/include/lsl_esp32.h) + +--- + +## Performance + +Benchmarked on ESP32-DevKitC v4 over WiFi (802.11n, RSSI -36 dBm): + +| Config | Rate | Packet Loss | Encryption Cost | +|--------|------|-------------|-----------------| +| 8ch float32 | 250 Hz | 0% | 2 KB heap (push async) | +| 8ch float32 | 500 Hz | 0% | 2 KB heap (push async) | +| 8ch float32 | 1000 Hz | 0.02% | 2 KB heap (push async) | +| 64ch float32 | 250 Hz | 0% | 2 KB heap (push async) | + +Encryption (ChaCha20-Poly1305) runs asynchronously on core 1 in the TCP feed task, while the application pushes to a lock-free ring buffer on core 0. The encryption overhead is not observable on the application push path; the 2 KB heap overhead for security sessions is the only measurable cost. + +--- + +## Protocol Compatibility + +| Feature | Desktop liblsl | liblsl-ESP32 | +|---------|---------------|-------------| +| Protocol version | 1.00 + 1.10 | 1.10 only | +| IP version | IPv4 + IPv6 | IPv4 only | +| Channel formats | All | float32, double64, int32, int16, int8 | +| secureLSL encryption | Yes | Yes (wire-compatible) | +| Max connections | Unlimited | 3 concurrent | +| Max channels | Unlimited | 128 | + +--- + +## Examples + +| Example | Description | +|---------|-------------| +| `basic_outlet` | Unencrypted 8-channel sine wave outlet | +| `basic_inlet` | Unencrypted stream receiver | +| `secure_outlet` | Encrypted outlet with key provisioning | +| `secure_inlet` | Encrypted receiver | + +--- + +## Documentation + +For detailed documentation, see the [liblsl-ESP32 repository](https://github.com/sccn/secureLSL/tree/main/liblsl-ESP32): + +- [Architecture](https://github.com/sccn/secureLSL/blob/main/liblsl-ESP32/docs/architecture.md) -- protocol layers, threading, memory +- [Security Guide](https://github.com/sccn/secureLSL/blob/main/liblsl-ESP32/docs/security.md) -- key provisioning, setup, troubleshooting +- [Benchmarks](https://github.com/sccn/secureLSL/blob/main/liblsl-ESP32/docs/benchmarks.md) -- methodology and full results +- [Changelog](https://github.com/sccn/secureLSL/blob/main/liblsl-ESP32/CHANGELOG.md) -- version history diff --git a/liblsl-ESP32/.clang-format b/liblsl-ESP32/.clang-format new file mode 100644 index 0000000..260f116 --- /dev/null +++ b/liblsl-ESP32/.clang-format @@ -0,0 +1,41 @@ +--- +BasedOnStyle: LLVM + +# Match .rules/c-esp32.md and ESP-IDF style +IndentWidth: 4 +TabWidth: 4 +UseTab: Never +ColumnLimit: 100 + +# Linux kernel brace style (function braces on new line, control on same line) +BreakBeforeBraces: Linux +AllowShortFunctionsOnASingleLine: None +AllowShortIfStatementsOnASingleLine: Never +AllowShortLoopsOnASingleLine: false + +# Pointer alignment +PointerAlignment: Right + +# Include ordering +SortIncludes: false +IncludeBlocks: Preserve + +# Spacing +SpaceAfterCStyleCast: false +SpaceBeforeParens: ControlStatements +SpacesInParentheses: false + +# Alignment +AlignAfterOpenBracket: Align +AlignConsecutiveMacros: Consecutive +AlignEscapedNewlines: Left +AlignOperands: Align +AlignTrailingComments: true + +# Other +AllowAllParametersOfDeclarationOnNextLine: true +BinPackArguments: true +BinPackParameters: true +IndentCaseLabels: false +MaxEmptyLinesToKeep: 2 +... diff --git a/liblsl-ESP32/.clang-tidy b/liblsl-ESP32/.clang-tidy new file mode 100644 index 0000000..ad638a3 --- /dev/null +++ b/liblsl-ESP32/.clang-tidy @@ -0,0 +1,30 @@ +--- +# clang-tidy checks for ESP32 C project +# Not yet integrated into CI; requires compilation database from idf.py +Checks: > + -*, + bugprone-*, + -bugprone-easily-swappable-parameters, + -bugprone-reserved-identifier, + cert-*, + -cert-dcl37-c, + -cert-dcl51-cpp, + clang-analyzer-*, + -clang-analyzer-security.insecureAPI.DeprecatedOrUnsafeBufferHandling, + misc-*, + -misc-unused-parameters, + performance-*, + readability-braces-around-statements, + readability-implicit-bool-conversion, + readability-misleading-indentation, + readability-redundant-declaration, + +# ESP-IDF uses many macros and patterns that trigger false positives +HeaderFilterRegex: '(benchmarks|components|examples)/.*\.h$' + +CheckOptions: + - key: readability-implicit-bool-conversion.AllowPointerConditions + value: true + - key: readability-implicit-bool-conversion.AllowIntegerConditions + value: true +... diff --git a/liblsl-ESP32/.githooks/pre-commit b/liblsl-ESP32/.githooks/pre-commit new file mode 100755 index 0000000..4088f60 --- /dev/null +++ b/liblsl-ESP32/.githooks/pre-commit @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# Pre-commit hook for liblsl-ESP32 +# Runs: clang-format check, cppcheck, typos +# Install: git config core.hooksPath .githooks +# +# Note: checks run against working tree files (not staged content). +# If you have unstaged changes, stage everything or stash before committing. + +set -euo pipefail + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' + +FAILED=0 +CHECKS_RAN=0 + +# Get staged C files +C_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(c|h)$' || true) + +# 1. clang-format check +if [ -n "$C_FILES" ]; then + if command -v clang-format &>/dev/null; then + echo -e "${YELLOW}Running clang-format...${NC}" + FORMAT_ERRORS="" + for file in $C_FILES; do + FMT_OUTPUT=$(clang-format --dry-run --Werror "$file" 2>&1) || { + FORMAT_ERRORS="$FORMAT_ERRORS $file" + if [ -n "$FMT_OUTPUT" ]; then + echo "$FMT_OUTPUT" + fi + } + done + if [ -n "$FORMAT_ERRORS" ]; then + echo -e "${RED}clang-format: formatting issues in:${NC}" + for file in $FORMAT_ERRORS; do + echo " $file" + done + echo -e " Run: clang-format -i$FORMAT_ERRORS" + FAILED=1 + else + echo -e "${GREEN}clang-format: OK${NC}" + fi + CHECKS_RAN=$((CHECKS_RAN + 1)) + else + echo -e "${YELLOW}clang-format not found, skipping${NC}" + fi + + # 2. cppcheck + if command -v cppcheck &>/dev/null; then + echo -e "${YELLOW}Running cppcheck...${NC}" + CPPCHECK_FAILED=0 + CPPCHECK_OUT=$(cppcheck --enable=warning,style,performance \ + --suppress=missingIncludeSystem \ + --suppress=unusedStructMember \ + --suppress=knownConditionTrueFalse \ + --std=c11 --language=c \ + --error-exitcode=1 \ + $C_FILES 2>&1) || { + echo -e "${RED}cppcheck: issues found:${NC}" + echo "$CPPCHECK_OUT" + CPPCHECK_FAILED=1 + FAILED=1 + } + if [ $CPPCHECK_FAILED -eq 0 ]; then + echo -e "${GREEN}cppcheck: OK${NC}" + fi + CHECKS_RAN=$((CHECKS_RAN + 1)) + else + echo -e "${YELLOW}cppcheck not found, skipping${NC}" + fi +fi + +# 3. typos (all staged files) +STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM || true) +if [ -n "$STAGED_FILES" ]; then + if command -v typos &>/dev/null; then + echo -e "${YELLOW}Running typos...${NC}" + if ! echo "$STAGED_FILES" | xargs typos; then + echo -e "${RED}typos: spelling errors found${NC}" + FAILED=1 + else + echo -e "${GREEN}typos: OK${NC}" + fi + CHECKS_RAN=$((CHECKS_RAN + 1)) + else + echo -e "${YELLOW}typos not found, skipping${NC}" + fi +fi + +if [ $FAILED -ne 0 ]; then + echo -e "\n${RED}Pre-commit checks failed. Fix issues above or use --no-verify to skip.${NC}" + exit 1 +fi + +if [ $CHECKS_RAN -eq 0 ]; then + echo -e "\n${YELLOW}No pre-commit checks ran (no tools installed or no files staged).${NC}" + echo -e "${YELLOW}Install: brew install clang-format cppcheck typos-cli${NC}" +else + echo -e "\n${GREEN}All pre-commit checks passed.${NC}" +fi diff --git a/liblsl-ESP32/.github/workflows/ci.yml b/liblsl-ESP32/.github/workflows/ci.yml new file mode 100644 index 0000000..b1b2d7c --- /dev/null +++ b/liblsl-ESP32/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + lint: + name: Lint & Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install tools + run: | + sudo apt-get update + sudo apt-get install -y clang-format cppcheck + + - name: Install typos + uses: crate-ci/typos@v1.33.1 + + - name: Check formatting (clang-format) + run: | + find . \( -name '*.c' -o -name '*.h' \) | \ + grep -v build/ | grep -v managed_components/ | \ + xargs clang-format --dry-run --Werror + + - name: Static analysis (cppcheck) + run: | + find . \( -name '*.c' -o -name '*.h' \) | \ + grep -v build/ | grep -v managed_components/ | \ + xargs cppcheck --enable=warning,style,performance \ + --suppress=missingIncludeSystem \ + --suppress=unusedStructMember \ + --suppress=knownConditionTrueFalse \ + --std=c11 --language=c \ + --error-exitcode=1 + + build: + name: ESP-IDF Build + runs-on: ubuntu-latest + container: + image: espressif/idf:v5.5.3 + steps: + - uses: actions/checkout@v4 + + - name: Build all projects + run: | + . $IDF_PATH/export.sh + for project in \ + tests/build_test \ + examples/basic_outlet \ + examples/basic_inlet \ + examples/secure_outlet \ + examples/secure_inlet \ + benchmarks/crypto_bench \ + benchmarks/throughput_bench; do + echo "::group::Building $project" + cd "$GITHUB_WORKSPACE/$project" + idf.py set-target esp32 + idf.py build + echo "::endgroup::" + done + shell: bash diff --git a/liblsl-ESP32/.gitignore b/liblsl-ESP32/.gitignore new file mode 100644 index 0000000..84db255 --- /dev/null +++ b/liblsl-ESP32/.gitignore @@ -0,0 +1,36 @@ +# Build artifacts +build/ +sdkconfig.old +sdkconfig + +# ESP-IDF managed components (fetched automatically) +managed_components/ +dependencies.lock + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# clangd cache +.cache/ + +# Python +__pycache__/ +*.pyc +.pytest_cache/ +.coverage + +# Environment +.env + +# macOS +.DS_Store + +# Benchmark results (keep raw data, ignore generated) +benchmarks/results/*.png +benchmarks/results/*.pdf +.serena/ +.cache/ diff --git a/liblsl-ESP32/.typos.toml b/liblsl-ESP32/.typos.toml new file mode 100644 index 0000000..76eb932 --- /dev/null +++ b/liblsl-ESP32/.typos.toml @@ -0,0 +1,3 @@ +[default.extend-words] +# "ser" is a common Python abbreviation for serial.Serial() +ser = "ser" diff --git a/liblsl-ESP32/CHANGELOG.md b/liblsl-ESP32/CHANGELOG.md new file mode 100644 index 0000000..bd60235 --- /dev/null +++ b/liblsl-ESP32/CHANGELOG.md @@ -0,0 +1,64 @@ +# Changelog + +All notable changes to liblsl-ESP32 are documented here. + +## [0.3.0] - 2026-03-18 -- secureLSL Encryption + +### Added +- **secureLSL encryption**: ChaCha20-Poly1305 authenticated encryption with Ed25519 key exchange +- Key management: NVS-backed keypair storage (generate, import, export) +- Security handshake: TCP header negotiation with unanimous enforcement +- Encrypted data framing: wire-compatible with desktop secureLSL +- Public API: `lsl_esp32_enable_security()`, `lsl_esp32_generate_keypair()`, etc. +- Shared TCP utilities module (`lsl_tcp_common`) +- Secure outlet and inlet examples with key provisioning via menuconfig +- Benchmark suite: throughput firmware + desktop Python scripts +- Testing walkthrough documentation +- Benchmark results: rate sweep (250-1000Hz), channel sweep (4-64ch) + +### Verified +- Bidirectional encrypted interop with desktop secureLSL +- Zero packet loss at 250Hz and 500Hz (encrypted and unencrypted) +- 0.02% loss at 1000Hz +- Zero measurable encryption overhead on push path (async on core 1) + +## [0.2.0] -- LSL Inlet + +### Added +- Stream resolver: UDP multicast discovery with XML response parsing +- TCP data client: connect, negotiate headers, validate test patterns +- Inlet core: FreeRTOS queue, receiver task, pull_sample with timeout +- Public API: `lsl_esp32_resolve_stream()`, `lsl_esp32_create_inlet()`, `lsl_esp32_inlet_pull_sample_f()` +- Stream info accessor functions +- Basic inlet example +- XML parser for `` schema +- Sample deserialization and test pattern validation + +### Verified +- Desktop pylsl outlet to ESP32 inlet: 250+ samples received +- Stream resolution in <0.1s on local network + +## [0.1.0] -- LSL Outlet + +### Added +- Stream info descriptors with XML serialization +- UDP multicast discovery server (239.255.172.215:16571) +- TCP data server with protocol 1.10 negotiation +- Binary sample serialization (float32, double64, int32, int16, int8) +- SPMC ring buffer (64 pre-allocated slots) +- Public API: `lsl_esp32_create_outlet()`, `lsl_esp32_push_sample_f/d/i/s/c()` +- Basic outlet example (8ch sine wave at 250Hz) +- Monotonic clock (`lsl_esp32_local_clock()`) + +### Verified +- pylsl discovers and receives ESP32 outlet: 100/100 samples +- Heap stable at ~207KB during streaming + +## [0.0.1] -- Project Setup + +### Added +- ESP-IDF project scaffold +- Crypto benchmarks (ChaCha20: 124us for 256B, Ed25519 keygen: 27ms) +- CI pipeline: clang-format, cppcheck, typos, ESP-IDF build +- Pre-commit hooks +- Getting started guide (macOS) diff --git a/liblsl-ESP32/LICENSE b/liblsl-ESP32/LICENSE new file mode 100644 index 0000000..a784672 --- /dev/null +++ b/liblsl-ESP32/LICENSE @@ -0,0 +1,251 @@ +Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. + +Author: Seyed Yahya Shirazi, SCCN, INC, UCSD + +SECURE LSL LICENSE + +PATENT NOTICE + +The algorithms, protocols, and methods embodied in this Software are the +subject of one or more pending patent applications. Patent Pending. + +OWNERSHIP + +Secure LSL is a proprietary software product created by Seyed Yahya Shirazi +at the Swartz Center for Computational Neuroscience (SCCN), Institute for +Neural Computation (INC), University of California San Diego (UCSD). +While Secure LSL incorporates certain open-source components, the deep +integration of security throughout the codebase, the architectural changes, +and the novel security implementations result in a fundamentally new and +distinct proprietary product. + +DEFINITIONS + +"Software" means Secure LSL in its entirety, including all source code, +object code, compiled binaries, algorithms, protocols, security +implementations, architectural designs, documentation, tests, benchmarks, +tools, scripts, configurations, build systems, and any related materials +contained in this repository. + +"Repository" means the secureLSL repository and all branches, forks, copies, +or reproductions thereof. + +"Licensee" means any individual or entity exercising rights under this license. + +"Academic Non-Profit Use" means use solely for non-commercial research, +teaching, or educational purposes at an accredited academic institution or +non-profit research organization. This includes academic research that +receives funding or sponsorship from commercial entities, provided that +the research is conducted at and directed by the academic institution, and +the resulting use of the Software does not directly produce commercial +products or services for the funding entity. + +"Commercial Use" means any use that is not Academic Non-Profit Use, including +but not limited to: use by for-profit entities, use in commercial products or +services, use that generates revenue, or use in government or industry +research that is not conducted at a non-profit academic institution. + +GRANT OF LICENSE FOR ACADEMIC NON-PROFIT USE + +Subject to the terms and conditions of this license, the copyright holder +grants a limited, non-exclusive, non-transferable, revocable license to use +and copy the Software solely for Academic Non-Profit Use, provided that: + +1. The Software is used exclusively for non-commercial academic research, + teaching, or educational purposes. +2. Any publications, presentations, or academic works that use or reference + the Software include proper citation and attribution to the copyright + holder and the Software. +3. The Software is not distributed or shared outside the Licensee's + institution, except as reasonably necessary for collaborative academic + research and only to parties who agree to abide by this license. +4. The Software is not sublicensed, sold, or transferred to any third party. +5. This license notice and all copyright, patent, and proprietary notices are + retained in all copies or substantial portions of the Software. +6. The Licensee does not use the Software, or information derived directly + from the Software's source code, to develop competing products or services. + +MODIFICATIONS + +Modification of the Software is not permitted under any license grant, whether +for Academic Non-Profit Use or otherwise. The sole mechanism for contributing +changes to the Software is by submitting a pull request to the official +Repository, or its successor location as designated by the copyright holder. +Upon review and acceptance by the copyright holder, merged contributions +become part of the Software and are governed by this same license. By +submitting a pull request, the contributor grants the copyright holder an +irrevocable, perpetual, worldwide, royalty-free license to use, reproduce, +modify, distribute, and sublicense the contributed changes, and acknowledges +that the contribution will be incorporated under the terms of this license. + +GENERAL RESTRICTIONS + +The following activities are prohibited for all Licensees, regardless of +whether the use qualifies as Academic Non-Profit Use or otherwise: + +1. Modifying, adapting, altering, or creating derivative works of the Software + (except through approved pull requests as described under MODIFICATIONS). +2. Removing, altering, or obscuring any proprietary, patent, or copyright + notices. +3. Claiming authorship or ownership of any part of the Software. +4. Extracting, separating, or isolating any component of the Software for + independent use outside the context of this Software. +5. Using the Software for benchmarking, comparison, or competitive analysis + without prior written permission of the copyright holder. +6. Hosting, storing, or making the Software accessible to unauthorized parties. + +ADDITIONAL RESTRICTIONS ON COMMERCIAL USE + +Any use of the Software that does not qualify as Academic Non-Profit Use, +including but not limited to for-profit use, commercial use, government use +outside of academic institutions, and non-academic use, requires the express +prior written permission of the copyright holder. Without such permission, +the following are also strictly prohibited: + +1. Any Commercial Use of the Software. +2. Distribution, sublicensing, selling, leasing, or transferring the Software + for non-academic purposes. +3. Incorporating the Software into commercial products, services, or systems. +4. Using the Software to provide services to third parties for compensation. +5. Reverse engineering, disassembling, or decompiling the Software, except + to the extent such restriction is prohibited by applicable law. +6. Using, implementing, or adapting any patented algorithms, protocols, or + designs from the Software for commercial purposes. +7. Training machine learning or AI models on the Software for commercial gain. +8. Creating competing implementations based on the Software for commercial + distribution. + +NON-ENDORSEMENT + +Neither the name of The Regents of the University of California, the +University of California San Diego, the Swartz Center for Computational +Neuroscience (SCCN), the Institute for Neural Computation (INC), nor the +names of the author or contributors may be used to endorse or promote products +or services that incorporate or make use of this software without specific +prior written permission. + +INTELLECTUAL PROPERTY RIGHTS + +All intellectual property rights in the Software, including but not limited to +copyrights, patents (pending or granted), trademarks, and any other proprietary +rights, are and shall remain the exclusive property of The Regents of the +University of California. + +The algorithms, methods, protocols, and architectural decisions embodied in +the Software are protected by pending patent applications. + +SCOPE OF PROPRIETARY RIGHTS + +This license covers the Software in its entirety, including but not limited to: + +1. The Complete Secure LSL Product + - The entire integrated codebase as a unified proprietary work + - All files, directories, and subdirectories in this repository + - The product architecture and design as a whole + +2. Security Implementation (specific integrations within this Software, not + the underlying standard algorithms themselves) + - All cryptographic implementations and integrations + - Ed25519 device authentication system + - X25519 key exchange implementation + - ChaCha20-Poly1305 authenticated encryption integration + - HKDF session key derivation + - Argon2id passphrase-based key protection + - Device-bound session token system + - Nonce tracking and replay prevention + - Security handshake protocols + - Key management and serialization + +3. Patented Algorithms and Methods (Patent Pending) + - Security architecture and design patterns + - Protocol specifications and implementations + - API designs and interfaces + - Integration approaches and techniques + - Novel algorithms and methods + +ACKNOWLEDGMENT OF INCORPORATED COMPONENTS + +The development of Secure LSL utilized certain open-source libraries and base +code. These incorporated components do not diminish the proprietary nature of +Secure LSL as a complete, integrated product: + +- liblsl base code: Originally by Christian A. Kothe (MIT License) + Available separately at: https://github.com/sccn/liblsl +- libsodium: Cryptographic primitives (ISC License) +- pugixml: XML parsing (MIT License) +- Boost: Utilities (Boost Software License) +- Loguru: Logging (Public Domain) +- Catch2: Testing framework (Boost Software License) + +IMPORTANT: The availability of these components under open-source licenses does +NOT grant any rights to Secure LSL beyond what is explicitly stated in this +license. The integration, modification, and novel implementations in this +Repository create a new proprietary product. To use the original open-source +components, obtain them from their original sources. + +TERMINATION + +This license is effective until terminated. The Licensee may terminate this +license at any time by ceasing all use of the Software and destroying all +copies in their possession. The copyright holder may revoke this license at +any time by providing thirty (30) days written notice. + +If the Licensee fails to comply with any term or condition of this license, +the copyright holder shall provide written notice specifying the breach. If +the Licensee fails to cure the breach within thirty (30) days of receiving +such notice, the license shall automatically terminate. + +Upon termination, the Licensee must cease all use of the Software and destroy +all copies in their possession. + +ENFORCEMENT + +The copyright holder reserves all legal rights and remedies for any +unauthorized use of the Software, including but not limited to: +- Injunctive relief +- Actual and statutory damages +- Recovery of profits +- Attorney's fees and costs +- Enforcement of patent rights + +DISCLAIMER OF WARRANTIES AND LIMITATION OF LIABILITY + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +SEVERABILITY + +If any provision of this license is held unenforceable, it shall be modified +to the minimum extent necessary, and remaining provisions shall continue in +full force and effect. + +GOVERNING LAW + +This license shall be governed by and construed in accordance with the laws +of the State of California, without regard to conflict of law principles. + +ENTIRE AGREEMENT + +This license constitutes the entire agreement regarding the Software and +supersedes all prior agreements, representations, and understandings. + +CONTACT + +For licensing inquiries, permission requests, or other matters: +Copyright Holder: The Regents of the University of California +Author: Seyed Yahya Shirazi, SCCN, INC, UCSD + +Office of Innovation and Commercialization (OIC) +University of California San Diego +9500 Gilman Dr., La Jolla, CA 92093 +Phone: (858) 534-2230 +Email: innovation@ucsd.edu +Web: https://innovation.ucsd.edu diff --git a/liblsl-ESP32/README.md b/liblsl-ESP32/README.md new file mode 100644 index 0000000..a0c200f --- /dev/null +++ b/liblsl-ESP32/README.md @@ -0,0 +1,202 @@ +# liblsl-ESP32 + +**Lab Streaming Layer protocol for ESP32 microcontrollers, with secureLSL encryption support** + +[![ESP-IDF](https://img.shields.io/badge/ESP--IDF-v5.5-blue)](https://github.com/espressif/esp-idf) +[![License](https://img.shields.io/badge/license-Secure%20LSL-orange)](LICENSE) + +A clean-room C reimplementation of the [Lab Streaming Layer (LSL)](https://github.com/sccn/liblsl) wire protocol for ESP32, enabling WiFi-connected microcontrollers to participate in LSL lab networks with optional end-to-end encryption via [secureLSL](https://github.com/sccn/secureLSL). + +## Scope and Intended Use + +liblsl-ESP32 provides the **communication layer** for streaming data over WiFi using the LSL protocol. While the ESP32 includes built-in ADC peripherals, this project focuses on the networking and protocol stack rather than signal acquisition. + +For biosignal applications (EEG, EMG, ECG), the ESP32 typically serves as a **wireless bridge**: a dedicated ADC integrated circuit (e.g., ADS1299, ADS1294) performs analog-to-digital conversion with the precision, noise floor, and simultaneous sampling required for research-grade recordings, while the ESP32 handles WiFi networking, LSL protocol, and optional encryption. This separation of concerns follows established practice in wireless biosignal systems and allows the communication stack to be reused across different acquisition front-ends. + +### Current Transport and Extensibility + +The current implementation uses **802.11 WiFi** as the transport layer, leveraging the ESP32's integrated WiFi radio and the lwIP TCP/IP stack. However, the protocol and encryption layers are transport-agnostic by design, operating on standard BSD sockets. Developers can replace the WiFi transport with any network interface that provides TCP/IP connectivity, including: + +- **Ethernet**: via SPI-connected PHY (e.g., W5500, LAN8720), providing lower latency and deterministic timing for wired lab environments +- **Bluetooth Classic (SPP)** or **BLE**: for short-range, low-power scenarios where WiFi infrastructure is unavailable +- **ESP-NOW**: Espressif's peer-to-peer protocol for low-latency ESP32-to-ESP32 communication without a WiFi access point +Note that LSL and secureLSL are designed for low-latency local network environments (lab, clinic). High-latency transports (cellular, LoRa, satellite) are not suitable for the real-time streaming guarantees the protocol assumes. + +These transport extensions require replacing only the socket/network initialization layer while reusing the existing LSL protocol serialization, stream discovery (adapted per transport), and secureLSL encryption modules. + +## Features + +- **Full LSL protocol**: UDP multicast discovery + TCP data streaming (protocol v1.10) +- **Bidirectional**: both outlet (push) and inlet (pull) support +- **secureLSL encryption**: ChaCha20-Poly1305 authenticated encryption, X25519 key exchange (Ed25519 identity) +- **Desktop interop**: verified with pylsl, LabRecorder, and desktop secureLSL +- **Lightweight**: ~4000 lines of C, ~200KB SRAM footprint (300KB+ free for application) +- **Real-time**: sustains up to 1000 Hz sampling with near-zero packet loss +- **ESP-IDF native**: pure C, FreeRTOS tasks, lwIP sockets, NVS key storage + +## Why a Reimplementation? + +Desktop liblsl is ~50K+ lines of C++ deeply coupled to Boost (Asio, Serialization, threading), pugixml, exceptions, and RTTI. While Espressif provides an [official Boost.Asio port](https://components.espressif.com/components/espressif/asio), desktop liblsl's dependencies extend far beyond Asio alone, and the C++ overhead (exceptions, RTTI, STL containers) is prohibitive on a device with 520KB SRAM. + +The LSL wire protocol is straightforward (UDP discovery, TCP streamfeed, binary samples), making a clean C reimplementation both smaller and more maintainable than attempting to port the desktop stack. This approach gives precise control over memory allocation with pre-allocated pools and no hidden heap usage. + +## Quick Start + +### Prerequisites + +- [ESP-IDF v5.5+](https://docs.espressif.com/projects/esp-idf/en/stable/esp32/get-started/) +- ESP32 development board (tested on ESP32-DevKitC v4) +- WiFi network accessible to both ESP32 and desktop + +### Flash the example outlet + +```bash +cd examples/basic_outlet +idf.py menuconfig # Set WiFi SSID and password +idf.py build +idf.py -p /dev/cu.usbserial-XXXX flash monitor +``` + +### Receive on desktop + +```python +import pylsl +streams = pylsl.resolve_byprop('name', 'ESP32Test', timeout=10) +inlet = pylsl.StreamInlet(streams[0]) +sample, timestamp = inlet.pull_sample() +print(f"Received: {sample}") +``` + +## Encrypted Streaming + +Enable secureLSL encryption for all traffic between ESP32 and desktop: + +```c +#include "lsl_esp32.h" +#include "nvs_flash.h" + +// 0. Initialize NVS (required for key storage) +nvs_flash_init(); + +// 1. Provision keys (once, stored in NVS) +lsl_esp32_generate_keypair(); + +// 2. Enable encryption (before creating outlets/inlets) +lsl_esp32_enable_security(); + +// 3. Create outlet as usual (encryption is automatic) +lsl_esp32_outlet_t outlet = lsl_esp32_create_outlet(info, 0, 360); +lsl_esp32_push_sample_f(outlet, data, 0.0); // encrypted on the wire +``` + +The desktop must have the same Ed25519 keypair configured in `lsl_api.cfg`. See [examples/secure_outlet](examples/secure_outlet/) for a complete example. + +## Hardware Requirements + +| Requirement | Minimum | Tested | +|------------|---------|--------| +| MCU | ESP32 (Xtensa LX6) | ESP32-WROOM-32 | +| SRAM | 520KB (liblsl uses ~200KB) | ESP32-DevKitC v4 | +| Flash | 2MB+ | 4MB | +| WiFi | 802.11 b/g/n | 2.4GHz | + +## Performance + +Benchmarked on ESP32-DevKitC v4 over WiFi (802.11n): + +| Config | Rate | Packet Loss | Push Timing | Heap Free | +|--------|------|-------------|-------------|-----------| +| 8ch float32, unencrypted | 250 Hz | 0% | 53 us | 113 KB | +| 8ch float32, encrypted | 250 Hz | 0% | 33 us | 111 KB | +| 8ch float32, unencrypted | 1000 Hz | 0.02% | 68 us | 115 KB | +| 64ch float32, encrypted | 250 Hz | 0% | 40 us | ~108 KB | + +Encryption overhead is invisible to the application: ChaCha20-Poly1305 runs asynchronously on core 1 in the TCP feed task, while the application pushes to a lock-free ring buffer on core 0. See [benchmarks/](benchmarks/) for full results. + +## API Overview + +```c +// Clock +double lsl_esp32_local_clock(void); + +// Stream info +lsl_esp32_stream_info_t lsl_esp32_create_streaminfo(name, type, channels, rate, format, source_id); + +// Outlet (push) +lsl_esp32_outlet_t lsl_esp32_create_outlet(info, chunk_size, max_buffered); +lsl_esp32_push_sample_f(outlet, data, timestamp); + +// Inlet (pull) +int lsl_esp32_resolve_stream(prop, value, timeout, result); +lsl_esp32_inlet_t lsl_esp32_create_inlet(info); +lsl_esp32_inlet_pull_sample_f(inlet, buf, buf_len, timestamp, timeout); + +// Security +lsl_esp32_generate_keypair(); +lsl_esp32_import_keypair(base64_pub, base64_priv); +lsl_esp32_enable_security(); +``` + +Full API in [include/lsl_esp32.h](components/liblsl_esp32/include/lsl_esp32.h). + +## Examples + +| Example | Description | +|---------|-------------| +| [basic_outlet](examples/basic_outlet/) | 8-channel sine wave outlet at 250 Hz | +| [basic_inlet](examples/basic_inlet/) | Stream receiver with auto-discovery | +| [secure_outlet](examples/secure_outlet/) | Encrypted outlet with key provisioning | +| [secure_inlet](examples/secure_inlet/) | Encrypted receiver | + +## Repository Structure + +``` +liblsl-ESP32/ + components/liblsl_esp32/ # Core library (ESP-IDF component) + include/lsl_esp32.h # Public API + src/ # Implementation (~4000 lines) + examples/ # 4 example projects + benchmarks/ # Throughput benchmarks + scripts + docs/ # Documentation + .rules/ # Development standards +``` + +## Protocol Compatibility + +| Feature | Desktop liblsl | liblsl-ESP32 | +|---------|---------------|-------------| +| Protocol version | 1.00 + 1.10 | 1.10 only | +| IP version | IPv4 + IPv6 | IPv4 only | +| Channel formats | All (incl. string, int64) | float32, double64, int32, int16, int8 | +| secureLSL encryption | ChaCha20-Poly1305 | ChaCha20-Poly1305 (wire-compatible) | +| Discovery | UDP multicast | UDP multicast | +| Max connections | Unlimited | 3 concurrent | +| Max channels | Unlimited | 128 | + +## Development + +```bash +# Source ESP-IDF +. ~/esp/esp-idf/export.sh + +# Build any example +cd examples/basic_outlet +idf.py build + +# Flash and monitor +idf.py -p /dev/cu.usbserial-XXXX flash monitor + +# Add as component dependency +idf.py add-dependency "espressif/libsodium^1.0.20~4" +``` + +## License + +Secure LSL License (UCSD/SCCN). See [LICENSE](LICENSE) for details. + +## Acknowledgments + +- [Lab Streaming Layer](https://github.com/sccn/liblsl) -- the desktop LSL library this reimplements +- [secureLSL](https://github.com/sccn/secureLSL) -- the encryption layer we're compatible with +- [libsodium](https://doc.libsodium.org/) -- cryptographic primitives +- [ESP-IDF](https://github.com/espressif/esp-idf) -- Espressif IoT Development Framework diff --git a/liblsl-ESP32/_typos.toml b/liblsl-ESP32/_typos.toml new file mode 100644 index 0000000..cdcc2db --- /dev/null +++ b/liblsl-ESP32/_typos.toml @@ -0,0 +1,19 @@ +[files] +extend-exclude = [ + "build/", + "managed_components/", + "sdkconfig", + "sdkconfig.old", + "LICENSE", +] + +[default.extend-words] +# ESP-IDF and libsodium terms that are not typos +srate = "srate" # sample rate (LSL convention) +nsec = "nsec" # nanoseconds / nonce section +baud = "baud" # serial baud rate +keygen = "keygen" # key generation +scalarmult = "scalarmult" # libsodium function name +generichash = "generichash" # libsodium BLAKE2b API +HKDF = "HKDF" # HMAC-based Key Derivation Function +ines = "ines" # as in "routines" diff --git a/liblsl-ESP32/benchmarks/README.md b/liblsl-ESP32/benchmarks/README.md new file mode 100644 index 0000000..f8ea59b --- /dev/null +++ b/liblsl-ESP32/benchmarks/README.md @@ -0,0 +1,177 @@ +# ESP32 LSL Benchmarks + +Systematic performance benchmarks for liblsl-ESP32, measuring throughput, jitter, and secureLSL encryption overhead. + +## Prerequisites + +### Hardware +- ESP32-DevKitC v4 connected via USB +- Desktop (Mac/Linux) on same WiFi network + +### Software +```bash +# ESP-IDF (for firmware) +. ~/esp/esp-idf/export.sh + +# Python dependencies (for desktop scripts) +cd benchmarks/scripts +uv pip install -r requirements.txt +``` + +## Quick Start + +### 1. Flash the benchmark firmware + +```bash +cd benchmarks/throughput_bench +idf.py menuconfig +# -> Benchmark Configuration: +# Mode: Outlet +# Channels: 8 +# Sample rate: 250 Hz +# Duration: 60 seconds +# Security: disabled +# -> WiFi SSID/Password + +idf.py build +idf.py -p /dev/cu.usbserial-XXXX flash +``` + +### 2. Run the desktop inlet + +Open two terminals: + +**Terminal 1** (serial monitor): +```bash +cd benchmarks/scripts +uv run python serial_monitor.py --port /dev/cu.usbserial-XXXX --output ../results/esp32_outlet.json +``` + +**Terminal 2** (LSL inlet): +```bash +cd benchmarks/scripts +uv run python esp32_benchmark_inlet.py --name ESP32Bench --duration 60 --output ../results/desktop_inlet.json +``` + +### 3. View results + +Both terminals show real-time progress and a final summary with: +- Push/pull timing (mean, std, p95, p99) +- Throughput (actual vs nominal rate) +- Jitter (inter-sample interval std dev) +- Heap and stack usage (ESP32 side) + +## Test Matrix + +### Encryption Overhead (primary comparison) + +| Test | Security | What to measure | +|------|----------|----------------| +| E1a | OFF | Baseline: 8ch 250Hz, no encryption | +| E1b | ON | Encrypted: same config with secureLSL | + +Compare `push_mean_us` between E1a and E1b to quantify encryption overhead. + +### Channel Sweep + +Flash with different `BENCH_CHANNELS` settings: + +| Channels | Rate | Security | +|----------|------|----------| +| 4 | 250 Hz | Both | +| 8 | 250 Hz | Both | +| 16 | 250 Hz | Both | +| 32 | 250 Hz | Both | +| 64 | 250 Hz | Both | + +### Rate Sweep + +Flash with different `BENCH_SAMPLE_RATE` settings: + +| Rate | Channels | Security | +|------|----------|----------| +| 100 Hz | 8 | Both | +| 250 Hz | 8 | Both | +| 500 Hz | 8 | Both | + +## ESP32 Inlet Testing + +### Desktop pushes to ESP32 + +```bash +# Flash ESP32 in inlet mode +idf.py menuconfig # -> Mode: Inlet, Target: DesktopBench + +# Run desktop outlet +uv run python esp32_benchmark_outlet.py --name DesktopBench --channels 8 --rate 250 --duration 60 + +# Monitor ESP32 serial +uv run python serial_monitor.py --port /dev/cu.usbserial-XXXX --output ../results/esp32_inlet.json +``` + +## Metrics + +### Clock-independent (primary) +- **Jitter**: std dev of inter-sample arrival intervals (us) +- **Throughput**: actual sample rate / nominal rate +- **Packet loss**: (expected - received) / expected +- **Push timing**: time per push_sample on ESP32 (us) +- **Pull timing**: time per pull_sample on desktop (us) +- **Encryption overhead**: secure vs insecure push_mean_us delta + +### ESP32-specific +- **Heap free**: available SRAM during streaming +- **Heap min**: minimum free heap observed +- **Stack HWM**: high-water mark for benchmark task +- **WiFi RSSI**: signal strength during test + +### Why no absolute latency? +ESP32 uses monotonic `lsl_esp32_local_clock()` (seconds since boot). +Desktop uses `time.time()` (unix wall clock). Without NTP sync or LSL +time correction (not implemented), absolute cross-machine latency +is meaningless. We focus on relative metrics instead. + +## Output Format + +All scripts produce JSON files compatible with the secureLSL analysis pipeline. +Key fields: + +```json +{ + "results": { + "samples_received": 15000, + "actual_rate": 249.8, + "packet_loss_pct": 0.13, + "pull_mean_us": 150.2, + "pull_p95_us": 320.5, + "jitter_std_us": 45.3 + } +} +``` + +ESP32-side metrics (from `serial_monitor.py`): +```json +{ + "summary": { + "push_mean_us": 42.1, + "push_p95_us": 55.3, + "heap_free": 210000, + "heap_min": 208000, + "wifi_rssi": -42 + } +} +``` + +## File Structure + +``` +benchmarks/ + throughput_bench/ # ESP32 firmware (configurable via menuconfig) + scripts/ + esp32_benchmark_inlet.py # Desktop receives from ESP32 + esp32_benchmark_outlet.py # Desktop pushes to ESP32 + serial_monitor.py # Parses ESP32 serial JSON + requirements.txt + results/ # Output directory (gitignored) + README.md # This file +``` diff --git a/liblsl-ESP32/benchmarks/crypto_bench/CMakeLists.txt b/liblsl-ESP32/benchmarks/crypto_bench/CMakeLists.txt new file mode 100644 index 0000000..9bd98a9 --- /dev/null +++ b/liblsl-ESP32/benchmarks/crypto_bench/CMakeLists.txt @@ -0,0 +1,5 @@ +# ESP-IDF project CMakeLists.txt for crypto benchmarks +cmake_minimum_required(VERSION 3.16) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(crypto_bench) diff --git a/liblsl-ESP32/benchmarks/crypto_bench/README.md b/liblsl-ESP32/benchmarks/crypto_bench/README.md new file mode 100644 index 0000000..f10e85b --- /dev/null +++ b/liblsl-ESP32/benchmarks/crypto_bench/README.md @@ -0,0 +1,144 @@ +# ESP32 Cryptographic Benchmarks for secureLSL + +Benchmarks all libsodium operations used by secureLSL on ESP32 hardware. + +## Operations Benchmarked + +| Operation | secureLSL Usage | Frequency | +|-----------|----------------|-----------| +| ChaCha20-Poly1305 encrypt/decrypt | Per-sample data encryption | Every sample | +| Ed25519 keygen | Device key generation | Once per device | +| Ed25519 sign/verify | (Future: signed discovery) | Per connection | +| X25519 scalar mult | Session key exchange (DH) | Per connection | +| BLAKE2b (generichash) | Session key derivation, fingerprints | Per connection | +| Base64 encode/decode | Key serialization in headers | Per connection | + +## Payload Sizes + +ChaCha20 is benchmarked at these payload sizes matching LSL configurations: + +| Bytes | LSL Configuration | +|-------|------------------| +| 4 | 1 channel, int32 | +| 32 | 8 channels, float32 | +| 64 | 16 channels, float32 | +| 256 | 64 channels, float32 (standard EEG) | +| 512 | 64 channels, double64 | +| 1024 | 128 channels, double64 | +| 4096 | Stress test | + +## Build and Run + +```bash +# Set up ESP-IDF environment +. ~/esp/esp-idf/export.sh + +# From this directory: +idf.py set-target esp32 +idf.py build +idf.py -p /dev/cu.usbserial-XXXX flash monitor +``` + +Press `Ctrl+]` to exit the serial monitor. The benchmark starts 2 seconds after +boot to allow the serial monitor to connect. + +## Hardware Results + +Measured on ESP32-DevKitC v4 (ESP32-D0WD-V3 rev 3.1, dual core, 240 MHz, 2 MB external flash; +some boards ship with 4 MB). ESP-IDF v5.5.3, libsodium 1.0.19 (ESP component 1.0.20~4), +compiler optimization: performance mode. +Each operation runs 1000 iterations after a 10-iteration warmup. + +### ChaCha20-Poly1305 IETF AEAD + +Throughput is based on encrypt time (decrypt is similar). + +| Payload | Encrypt (us) | Decrypt (us) | Encrypt Throughput (Mbps) | +|---------|-------------|-------------|---------------------------| +| 4 B | 48.8 | 50.3 | 0.66 | +| 32 B | 52.7 | 54.2 | 4.86 | +| 64 B | 55.1 | 56.7 | 9.29 | +| 256 B | 123.8 | 125.2 | 16.55 | +| 512 B | 215.5 | 216.9 | 19.00 | +| 1024 B | 398.8 | 400.3 | 20.54 | +| 4096 B | 1499.0 | 1500.4 | 21.86 | + +### Ed25519 Key Operations + +| Operation | Mean (us) | Ops/s | +|-----------|-----------|-------| +| Keygen | 9,821 | 102 | +| Sign (64 B msg) | 15,599 | 64 | +| Verify (64 B msg) | 16,209 | 62 | + +### BLAKE2b (generichash) + +| Operation | Mean (us) | Ops/s | Throughput (Mbps) | +|-----------|-----------|-------|-------------------| +| 64 B -> 32 B (session key) | 103.6 | 9,648 | 4.94 | +| 32 B -> 32 B (fingerprint) | 103.7 | 9,641 | 2.47 | + +### Base64 Encode/Decode + +| Operation | Mean (us) | Ops/s | Throughput (Mbps) | +|-----------|-----------|-------|-------------------| +| Encode (32 B key) | 12.4 | 80,515 | 20.61 | +| Decode (32 B key) | 18.7 | 53,593 | 13.72 | + +### X25519 Key Exchange + +| Operation | Mean (us) | Ops/s | +|-----------|-----------|-------| +| Ed25519 -> X25519 pk convert | 13,288 | 75 | +| Ed25519 -> X25519 sk convert | 34.0 | 29,433 | +| X25519 scalar mult (DH) | 12,456 | 80 | +| Full session key derivation | 30,059 | 33 | + +The full session key derivation (Ed25519 -> X25519 + DH + BLAKE2b) costs ~30 ms. +This is a one-time cost per LSL connection setup. + +### Memory + +| Measurement | Free Heap (bytes) | +|-------------|-------------------| +| Before sodium_init | 297,040 | +| After sodium_init | 297,040 | +| After all benchmarks | 296,580 | +| Minimum observed | 280,000 | + +sodium_init() has zero heap cost. The 297 KB available after boot leaves ample room +for liblsl-esp32 (~200 KB budget) plus user application code. + +Note: the 520 KB SRAM total includes memory used by the FreeRTOS kernel, WiFi/BT +stack reservations, and static allocations. The ~297 KB free heap is the actual +available dynamic memory after the OS boots. + +### Correctness Verification + +All correctness checks pass: + +- [x] ChaCha20 encrypt/decrypt roundtrip +- [x] Tampered ciphertext rejection +- [x] Ed25519 sign/verify roundtrip +- [x] Tampered signature rejection +- [x] X25519 shared secret agreement (both sides derive identical session keys) + +## Acceptance Criteria + +| Criterion | Target | Actual | Status | +|-----------|--------|--------|--------| +| ChaCha20 encrypt 256 B | < 1 ms | 123.8 us | PASS (8x margin) | +| sodium_init() heap cost | negligible | 0 bytes | PASS | +| Free heap after boot | >= 200 KB usable | 297 KB | PASS | +| Correctness checks | all pass | all pass | PASS | + +The original criterion "sodium_init leaves >= 350 KB free heap" assumed more +SRAM is available as heap. In practice, the ESP32's 520 KB SRAM minus OS +overhead yields ~297 KB free heap at boot, which is sufficient for our ~200 KB +liblsl-esp32 budget. + +## Output + +Results are printed via UART serial (115200 baud) as formatted log lines. +Each operation reports: mean, min, max, stddev (in microseconds), ops/sec, +and throughput (Mbps) for payload-based operations. diff --git a/liblsl-ESP32/benchmarks/crypto_bench/main/CMakeLists.txt b/liblsl-ESP32/benchmarks/crypto_bench/main/CMakeLists.txt new file mode 100644 index 0000000..b61ad8e --- /dev/null +++ b/liblsl-ESP32/benchmarks/crypto_bench/main/CMakeLists.txt @@ -0,0 +1,10 @@ +idf_component_register( + SRCS "crypto_bench_main.c" + "bench_chacha20.c" + "bench_ed25519.c" + "bench_x25519.c" + "bench_utils.c" + INCLUDE_DIRS "." + REQUIRES esp_timer + PRIV_REQUIRES nvs_flash libsodium spi_flash +) diff --git a/liblsl-ESP32/benchmarks/crypto_bench/main/bench_chacha20.c b/liblsl-ESP32/benchmarks/crypto_bench/main/bench_chacha20.c new file mode 100644 index 0000000..4bef99a --- /dev/null +++ b/liblsl-ESP32/benchmarks/crypto_bench/main/bench_chacha20.c @@ -0,0 +1,167 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#include "bench_chacha20.h" +#include "bench_utils.h" +#include "esp_log.h" +#include "sodium.h" +#include +#include + +static const char *TAG = "bench_chacha20"; + +/* Payload sizes to benchmark. + * These correspond to typical LSL sample sizes: + * 4B = 1ch int32 + * 32B = 8ch float32 + * 64B = 16ch float32 + * 256B = 64ch float32 (standard EEG) + * 512B = 64ch double64 + * 1024B = 128ch double64 + * 4096B = large payload stress test + */ +static const size_t PAYLOAD_SIZES[] = {4, 32, 64, 256, 512, 1024, 4096}; +static const size_t NUM_PAYLOADS = sizeof(PAYLOAD_SIZES) / sizeof(PAYLOAD_SIZES[0]); + +/* Context for a single encrypt/decrypt benchmark run */ +typedef struct { + uint8_t *plaintext; + uint8_t *ciphertext; + uint8_t key[crypto_aead_chacha20poly1305_ietf_KEYBYTES]; + uint8_t nonce[crypto_aead_chacha20poly1305_ietf_NPUBBYTES]; + size_t plaintext_len; + unsigned long long ciphertext_len; +} chacha20_ctx_t; + +/* Benchmark callback: return value intentionally unchecked in hot path. + * crypto_aead_chacha20poly1305_ietf_encrypt always returns 0 per libsodium docs. + * Correctness is verified in the verification section at the end of bench_chacha20_run. */ +static void do_encrypt(void *arg) +{ + chacha20_ctx_t *ctx = (chacha20_ctx_t *)arg; + crypto_aead_chacha20poly1305_ietf_encrypt(ctx->ciphertext, &ctx->ciphertext_len, ctx->plaintext, + ctx->plaintext_len, NULL, 0, /* no additional data */ + NULL, /* nsec unused */ + ctx->nonce, ctx->key); + /* Increment nonce per-sample (secureLSL uses a uint64_t counter; + * sodium_increment is equivalent for benchmarking purposes) */ + sodium_increment(ctx->nonce, sizeof(ctx->nonce)); +} + +static void do_decrypt(void *arg) +{ + chacha20_ctx_t *ctx = (chacha20_ctx_t *)arg; + unsigned long long decrypted_len; + if (crypto_aead_chacha20poly1305_ietf_decrypt( + ctx->plaintext, &decrypted_len, NULL, /* nsec unused */ + ctx->ciphertext, ctx->ciphertext_len, NULL, 0, /* no additional data */ + ctx->nonce, ctx->key) != 0) { + ESP_LOGE(TAG, "decrypt auth failed during benchmark"); + } +} + +void bench_chacha20_run(void) +{ + bench_print_header("ChaCha20-Poly1305 IETF AEAD"); + ESP_LOGI(TAG, "Key size: %d bytes, Nonce size: %d bytes, Auth tag: %d bytes", + crypto_aead_chacha20poly1305_ietf_KEYBYTES, + crypto_aead_chacha20poly1305_ietf_NPUBBYTES, crypto_aead_chacha20poly1305_ietf_ABYTES); + + for (size_t i = 0; i < NUM_PAYLOADS; i++) { + size_t payload_len = PAYLOAD_SIZES[i]; + size_t ciphertext_max = payload_len + crypto_aead_chacha20poly1305_ietf_ABYTES; + + /* Allocate buffers */ + chacha20_ctx_t ctx; + ctx.plaintext = malloc(payload_len); + ctx.ciphertext = malloc(ciphertext_max); + ctx.plaintext_len = payload_len; + + if (!ctx.plaintext || !ctx.ciphertext) { + ESP_LOGE(TAG, "Allocation failed for payload %zu", payload_len); + free(ctx.plaintext); + free(ctx.ciphertext); + continue; + } + + /* Generate random key, nonce, and plaintext */ + crypto_aead_chacha20poly1305_ietf_keygen(ctx.key); + randombytes_buf(ctx.nonce, sizeof(ctx.nonce)); + randombytes_buf(ctx.plaintext, payload_len); + + /* Benchmark encrypt */ + bench_result_t result; + char name[64]; + snprintf(name, sizeof(name), "encrypt %zu bytes", payload_len); + bench_run(name, do_encrypt, &ctx, BENCH_ITERATIONS, payload_len, &result); + bench_print_result(&result); + + /* Prepare valid ciphertext for decrypt benchmark. + * Encrypt once with current nonce; decrypt will reuse this + * nonce+ciphertext pair (each decrypt is independent). */ + if (crypto_aead_chacha20poly1305_ietf_encrypt(ctx.ciphertext, &ctx.ciphertext_len, + ctx.plaintext, ctx.plaintext_len, NULL, 0, + NULL, ctx.nonce, ctx.key) != 0) { + ESP_LOGE(TAG, "Failed to prepare ciphertext for decrypt benchmark (%zu bytes)", + payload_len); + sodium_memzero(ctx.key, sizeof(ctx.key)); + free(ctx.plaintext); + free(ctx.ciphertext); + continue; + } + + /* Benchmark decrypt */ + snprintf(name, sizeof(name), "decrypt %zu bytes", payload_len); + bench_run(name, do_decrypt, &ctx, BENCH_ITERATIONS, payload_len, &result); + bench_print_result(&result); + + sodium_memzero(ctx.key, sizeof(ctx.key)); + free(ctx.plaintext); + free(ctx.ciphertext); + } + + /* Verify correctness: encrypt then decrypt, compare */ + ESP_LOGI(TAG, ""); + ESP_LOGI(TAG, "Correctness verification..."); + uint8_t key[crypto_aead_chacha20poly1305_ietf_KEYBYTES]; + uint8_t nonce[crypto_aead_chacha20poly1305_ietf_NPUBBYTES]; + uint8_t original[256]; + uint8_t encrypted[256 + crypto_aead_chacha20poly1305_ietf_ABYTES]; + uint8_t decrypted[256]; + unsigned long long enc_len, dec_len; + + crypto_aead_chacha20poly1305_ietf_keygen(key); + randombytes_buf(nonce, sizeof(nonce)); + randombytes_buf(original, sizeof(original)); + + if (crypto_aead_chacha20poly1305_ietf_encrypt(encrypted, &enc_len, original, sizeof(original), + NULL, 0, NULL, nonce, key) != 0) { + ESP_LOGE(TAG, " FAIL: encryption step failed"); + sodium_memzero(key, sizeof(key)); + return; + } + + int ret = crypto_aead_chacha20poly1305_ietf_decrypt(decrypted, &dec_len, NULL, encrypted, + enc_len, NULL, 0, nonce, key); + + if (ret == 0 && dec_len == sizeof(original) && + memcmp(original, decrypted, sizeof(original)) == 0) { + ESP_LOGI(TAG, " PASS: encrypt/decrypt roundtrip correct"); + } else { + ESP_LOGE(TAG, " FAIL: encrypt/decrypt roundtrip mismatch!"); + } + + /* Tamper detection test */ + encrypted[10] ^= 0x01; /* flip one bit */ + ret = crypto_aead_chacha20poly1305_ietf_decrypt(decrypted, &dec_len, NULL, encrypted, enc_len, + NULL, 0, nonce, key); + + if (ret != 0) { + ESP_LOGI(TAG, " PASS: tampered ciphertext correctly rejected"); + } else { + ESP_LOGE(TAG, " FAIL: tampered ciphertext was NOT rejected!"); + } + + sodium_memzero(key, sizeof(key)); +} diff --git a/liblsl-ESP32/benchmarks/crypto_bench/main/bench_chacha20.h b/liblsl-ESP32/benchmarks/crypto_bench/main/bench_chacha20.h new file mode 100644 index 0000000..fbecbe0 --- /dev/null +++ b/liblsl-ESP32/benchmarks/crypto_bench/main/bench_chacha20.h @@ -0,0 +1,12 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#ifndef BENCH_CHACHA20_H +#define BENCH_CHACHA20_H + +/* Benchmark ChaCha20-Poly1305 IETF AEAD encrypt and decrypt + * at various payload sizes matching LSL channel configurations. */ +void bench_chacha20_run(void); + +#endif /* BENCH_CHACHA20_H */ diff --git a/liblsl-ESP32/benchmarks/crypto_bench/main/bench_ed25519.c b/liblsl-ESP32/benchmarks/crypto_bench/main/bench_ed25519.c new file mode 100644 index 0000000..ec3c06c --- /dev/null +++ b/liblsl-ESP32/benchmarks/crypto_bench/main/bench_ed25519.c @@ -0,0 +1,183 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#include "bench_ed25519.h" +#include "bench_utils.h" +#include "esp_log.h" +#include "sodium.h" +#include +#include + +static const char *TAG = "bench_ed25519"; + +/* Contexts for benchmarks */ +typedef struct { + uint8_t pk[crypto_sign_PUBLICKEYBYTES]; + uint8_t sk[crypto_sign_SECRETKEYBYTES]; +} keygen_ctx_t; + +typedef struct { + uint8_t pk[crypto_sign_PUBLICKEYBYTES]; + uint8_t sk[crypto_sign_SECRETKEYBYTES]; + uint8_t sig[crypto_sign_BYTES]; + uint8_t message[64]; + size_t message_len; +} sign_ctx_t; + +typedef struct { + uint8_t input[256]; + uint8_t output[crypto_generichash_BYTES_MAX]; + size_t input_len; + size_t output_len; +} hash_ctx_t; + +typedef struct { + uint8_t binary[64]; + char base64[128]; + size_t binary_len; +} b64_ctx_t; + +/* Ed25519 keygen */ +static void do_keygen(void *arg) +{ + keygen_ctx_t *ctx = (keygen_ctx_t *)arg; + crypto_sign_keypair(ctx->pk, ctx->sk); +} + +/* Benchmark callbacks: return values intentionally unchecked in hot path. + * These run 1000x in a tight loop; correctness is verified at the end + * of bench_ed25519_run. */ +static void do_sign(void *arg) +{ + sign_ctx_t *ctx = (sign_ctx_t *)arg; + crypto_sign_detached(ctx->sig, NULL, ctx->message, ctx->message_len, ctx->sk); +} + +static void do_verify(void *arg) +{ + sign_ctx_t *ctx = (sign_ctx_t *)arg; + crypto_sign_verify_detached(ctx->sig, ctx->message, ctx->message_len, ctx->pk); +} + +static void do_blake2b(void *arg) +{ + hash_ctx_t *ctx = (hash_ctx_t *)arg; + crypto_generichash(ctx->output, ctx->output_len, ctx->input, ctx->input_len, NULL, 0); +} + +/* Base64 encode */ +static void do_b64_encode(void *arg) +{ + b64_ctx_t *ctx = (b64_ctx_t *)arg; + sodium_bin2base64(ctx->base64, sizeof(ctx->base64), ctx->binary, ctx->binary_len, + sodium_base64_VARIANT_ORIGINAL); +} + +/* Base64 decode: return value intentionally unchecked in hot path. + * Input is always valid (produced by do_b64_encode). */ +static void do_b64_decode(void *arg) +{ + b64_ctx_t *ctx = (b64_ctx_t *)arg; + size_t bin_len; + sodium_base642bin(ctx->binary, sizeof(ctx->binary), ctx->base64, strlen(ctx->base64), NULL, + &bin_len, NULL, sodium_base64_VARIANT_ORIGINAL); +} + +void bench_ed25519_run(void) +{ + bench_result_t result; + + /* --- Ed25519 Key Generation --- */ + bench_print_header("Ed25519 Key Operations"); + + keygen_ctx_t kctx; + bench_run("Ed25519 keygen", do_keygen, &kctx, BENCH_ITERATIONS, 0, &result); + bench_print_result(&result); + + /* --- Ed25519 Sign --- */ + sign_ctx_t sctx; + crypto_sign_keypair(sctx.pk, sctx.sk); + randombytes_buf(sctx.message, sizeof(sctx.message)); + sctx.message_len = sizeof(sctx.message); + + bench_run("Ed25519 sign (64B msg)", do_sign, &sctx, BENCH_ITERATIONS, 0, &result); + bench_print_result(&result); + + /* --- Ed25519 Verify --- */ + if (crypto_sign_detached(sctx.sig, NULL, sctx.message, sctx.message_len, sctx.sk) != 0) { + ESP_LOGE(TAG, "Failed to produce signature, skipping verify benchmark"); + } else { + bench_run("Ed25519 verify (64B msg)", do_verify, &sctx, BENCH_ITERATIONS, 0, &result); + bench_print_result(&result); + } + + /* --- BLAKE2b (generichash) --- */ + bench_print_header("BLAKE2b (generichash)"); + + hash_ctx_t hctx; + randombytes_buf(hctx.input, sizeof(hctx.input)); + + hctx.input_len = 64; /* representative size for short-input hashing */ + hctx.output_len = 32; /* session key length */ + bench_run("BLAKE2b 64B->32B (session key)", do_blake2b, &hctx, BENCH_ITERATIONS, 64, &result); + bench_print_result(&result); + + /* Fingerprint: 32-byte input -> 32-byte output */ + hctx.input_len = 32; /* public key */ + hctx.output_len = 32; + bench_run("BLAKE2b 32B->32B (fingerprint)", do_blake2b, &hctx, BENCH_ITERATIONS, 32, &result); + bench_print_result(&result); + + /* --- Base64 encode/decode --- */ + bench_print_header("Base64 Encode/Decode"); + + b64_ctx_t bctx; + randombytes_buf(bctx.binary, 32); /* 32-byte public key */ + bctx.binary_len = 32; + + bench_run("base64 encode (32B key)", do_b64_encode, &bctx, BENCH_ITERATIONS, 32, &result); + bench_print_result(&result); + + /* Prepare encoded string for decode benchmark */ + sodium_bin2base64(bctx.base64, sizeof(bctx.base64), bctx.binary, bctx.binary_len, + sodium_base64_VARIANT_ORIGINAL); + + bench_run("base64 decode (32B key)", do_b64_decode, &bctx, BENCH_ITERATIONS, 32, &result); + bench_print_result(&result); + + /* --- Correctness verification --- */ + ESP_LOGI(TAG, ""); + ESP_LOGI(TAG, "Correctness verification..."); + + /* Sign/verify roundtrip */ + uint8_t pk[crypto_sign_PUBLICKEYBYTES]; + uint8_t sk[crypto_sign_SECRETKEYBYTES]; + uint8_t sig[crypto_sign_BYTES]; + uint8_t msg[] = "test message for ed25519"; + + crypto_sign_keypair(pk, sk); + if (crypto_sign_detached(sig, NULL, msg, sizeof(msg) - 1, sk) != 0) { + ESP_LOGE(TAG, " FAIL: signing step failed"); + sodium_memzero(sk, sizeof(sk)); + sodium_memzero(sctx.sk, sizeof(sctx.sk)); + return; + } + + if (crypto_sign_verify_detached(sig, msg, sizeof(msg) - 1, pk) == 0) { + ESP_LOGI(TAG, " PASS: Ed25519 sign/verify roundtrip correct"); + } else { + ESP_LOGE(TAG, " FAIL: Ed25519 sign/verify roundtrip failed!"); + } + + /* Tampered signature */ + sig[0] ^= 0x01; + if (crypto_sign_verify_detached(sig, msg, sizeof(msg) - 1, pk) != 0) { + ESP_LOGI(TAG, " PASS: tampered signature correctly rejected"); + } else { + ESP_LOGE(TAG, " FAIL: tampered signature was NOT rejected!"); + } + + sodium_memzero(sk, sizeof(sk)); + sodium_memzero(sctx.sk, sizeof(sctx.sk)); +} diff --git a/liblsl-ESP32/benchmarks/crypto_bench/main/bench_ed25519.h b/liblsl-ESP32/benchmarks/crypto_bench/main/bench_ed25519.h new file mode 100644 index 0000000..e8e19f5 --- /dev/null +++ b/liblsl-ESP32/benchmarks/crypto_bench/main/bench_ed25519.h @@ -0,0 +1,12 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#ifndef BENCH_ED25519_H +#define BENCH_ED25519_H + +/* Benchmark Ed25519 key generation, signing, and verification. + * Also benchmarks BLAKE2b (generichash) and base64 encode/decode. */ +void bench_ed25519_run(void); + +#endif /* BENCH_ED25519_H */ diff --git a/liblsl-ESP32/benchmarks/crypto_bench/main/bench_utils.c b/liblsl-ESP32/benchmarks/crypto_bench/main/bench_utils.c new file mode 100644 index 0000000..97cb20e --- /dev/null +++ b/liblsl-ESP32/benchmarks/crypto_bench/main/bench_utils.c @@ -0,0 +1,132 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#include "bench_utils.h" +#include "esp_timer.h" +#include "esp_log.h" +#include "esp_system.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include +#include +#include + +static const char *TAG = "bench"; + +int64_t bench_time_us(void) +{ + return esp_timer_get_time(); +} + +void bench_run(const char *name, bench_fn_t fn, void *arg, uint32_t iterations, + size_t payload_bytes, bench_result_t *result) +{ + /* Zero result so early return leaves a safe state. + * NOTE: result->name points to caller-owned memory; + * caller must ensure name outlives result. */ + memset(result, 0, sizeof(*result)); + result->name = name; + + if (iterations == 0) { + return; + } + + /* Allocate timing array on heap (too large for stack) */ + double *times_us = malloc(iterations * sizeof(double)); + if (!times_us) { + ESP_LOGE(TAG, "Failed to allocate timing array for %s", name); + return; + } + + /* Warmup: run a few iterations to prime caches */ + for (uint32_t i = 0; i < 10 && i < iterations; i++) { + fn(arg); + } + + /* Timed runs. + * vTaskDelay(1) every 50 iterations lets the IDLE task run so the + * task watchdog (which monitors IDLE0) doesn't trigger. taskYIELD() + * alone is insufficient because IDLE runs at lower priority. + * For slow operations (Ed25519/X25519 at ~10-16ms each), 50 iterations + * is ~500-800ms of CPU time, well within the WDT timeout configured + * in sdkconfig.defaults (CONFIG_ESP_TASK_WDT_TIMEOUT_S=30). */ + for (uint32_t i = 0; i < iterations; i++) { + int64_t start = bench_time_us(); + fn(arg); + int64_t end = bench_time_us(); + times_us[i] = (double)(end - start); + + if ((i + 1) % 50 == 0) { + vTaskDelay(1); + } + } + + /* Compute statistics */ + double sum = 0.0; + double min_val = times_us[0]; + double max_val = times_us[0]; + + for (uint32_t i = 0; i < iterations; i++) { + sum += times_us[i]; + if (times_us[i] < min_val) + min_val = times_us[i]; + if (times_us[i] > max_val) + max_val = times_us[i]; + } + + double mean = sum / iterations; + + double var_sum = 0.0; + for (uint32_t i = 0; i < iterations; i++) { + double diff = times_us[i] - mean; + var_sum += diff * diff; + } + double stddev = sqrt(var_sum / iterations); + + /* Fill result */ + result->iterations = iterations; + result->mean_us = mean; + result->min_us = min_val; + result->max_us = max_val; + result->stddev_us = stddev; + result->ops_per_sec = (mean > 0) ? (1000000.0 / mean) : 0; + result->payload_bytes = payload_bytes; + + if (payload_bytes > 0 && mean > 0) { + /* throughput: (bytes * 8 bits/byte) / mean_us = Mbps */ + result->throughput_mbps = (payload_bytes * 8.0) / mean; + } + + free(times_us); +} + +void bench_print_result(const bench_result_t *result) +{ + if (result->iterations == 0) { + ESP_LOGW(TAG, " %-40s SKIPPED (no results)", result->name); + return; + } + ESP_LOGI(TAG, " %-40s %8.1f us (min=%.1f, max=%.1f, std=%.1f) %8.0f ops/s", result->name, + result->mean_us, result->min_us, result->max_us, result->stddev_us, + result->ops_per_sec); + if (result->payload_bytes > 0) { + ESP_LOGI(TAG, " payload=%zu bytes, throughput=%.2f Mbps", result->payload_bytes, + result->throughput_mbps); + } +} + +void bench_print_header(const char *section) +{ + ESP_LOGI(TAG, ""); + ESP_LOGI(TAG, "============================================================"); + ESP_LOGI(TAG, " %s", section); + ESP_LOGI(TAG, "============================================================"); +} + +void bench_print_memory(const char *label) +{ + ESP_LOGI(TAG, "[MEM] %s: free_heap=%lu, min_free_heap=%lu", label, + (unsigned long)esp_get_free_heap_size(), + (unsigned long)esp_get_minimum_free_heap_size()); +} diff --git a/liblsl-ESP32/benchmarks/crypto_bench/main/bench_utils.h b/liblsl-ESP32/benchmarks/crypto_bench/main/bench_utils.h new file mode 100644 index 0000000..251ac98 --- /dev/null +++ b/liblsl-ESP32/benchmarks/crypto_bench/main/bench_utils.h @@ -0,0 +1,48 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#ifndef BENCH_UTILS_H +#define BENCH_UTILS_H + +#include +#include + +/* Number of iterations for each benchmark */ +#define BENCH_ITERATIONS 1000 + +/* Benchmark result for a single operation */ +typedef struct { + const char *name; + uint32_t iterations; + double mean_us; + double min_us; + double max_us; + double stddev_us; + double ops_per_sec; + /* For throughput benchmarks */ + size_t payload_bytes; + double throughput_mbps; +} bench_result_t; + +/* Get current time in microseconds (monotonic) */ +int64_t bench_time_us(void); + +/* Run a benchmark and compute statistics. + * Performs a warmup phase (10 iterations), then invokes fn + * iterations times. Timing is per-call. + * The result struct is filled with statistics. */ +typedef void (*bench_fn_t)(void *arg); +void bench_run(const char *name, bench_fn_t fn, void *arg, uint32_t iterations, + size_t payload_bytes, bench_result_t *result); + +/* Print a benchmark result to serial */ +void bench_print_result(const bench_result_t *result); + +/* Print a section header */ +void bench_print_header(const char *section); + +/* Print memory stats (free heap, min free heap) */ +void bench_print_memory(const char *label); + +#endif /* BENCH_UTILS_H */ diff --git a/liblsl-ESP32/benchmarks/crypto_bench/main/bench_x25519.c b/liblsl-ESP32/benchmarks/crypto_bench/main/bench_x25519.c new file mode 100644 index 0000000..d28b520 --- /dev/null +++ b/liblsl-ESP32/benchmarks/crypto_bench/main/bench_x25519.c @@ -0,0 +1,197 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#include "bench_x25519.h" +#include "bench_utils.h" +#include "esp_log.h" +#include "sodium.h" +#include + +static const char *TAG = "bench_x25519"; + +/* Context for key conversion benchmark */ +typedef struct { + uint8_t ed25519_pk[crypto_sign_PUBLICKEYBYTES]; + uint8_t ed25519_sk[crypto_sign_SECRETKEYBYTES]; + uint8_t x25519_pk[crypto_scalarmult_BYTES]; + uint8_t x25519_sk[crypto_scalarmult_SCALARBYTES]; +} convert_ctx_t; + +/* Context for scalar multiplication (DH) */ +typedef struct { + uint8_t our_sk[crypto_scalarmult_SCALARBYTES]; + uint8_t peer_pk[crypto_scalarmult_BYTES]; + uint8_t shared_secret[crypto_scalarmult_BYTES]; +} scalarmult_ctx_t; + +/* Context for full session key derivation */ +typedef struct { + uint8_t our_ed25519_pk[crypto_sign_PUBLICKEYBYTES]; + uint8_t our_ed25519_sk[crypto_sign_SECRETKEYBYTES]; + uint8_t peer_ed25519_pk[crypto_sign_PUBLICKEYBYTES]; + uint8_t session_key[32]; +} session_ctx_t; + +/* Benchmark callbacks: return values intentionally unchecked in hot path. + * These run 1000x in a tight loop with known-valid inputs from setup code. + * Correctness is verified at the end of bench_x25519_run. */ +static void do_pk_convert(void *arg) +{ + convert_ctx_t *ctx = (convert_ctx_t *)arg; + crypto_sign_ed25519_pk_to_curve25519(ctx->x25519_pk, ctx->ed25519_pk); +} + +static void do_sk_convert(void *arg) +{ + convert_ctx_t *ctx = (convert_ctx_t *)arg; + crypto_sign_ed25519_sk_to_curve25519(ctx->x25519_sk, ctx->ed25519_sk); +} + +static void do_scalarmult(void *arg) +{ + scalarmult_ctx_t *ctx = (scalarmult_ctx_t *)arg; + crypto_scalarmult(ctx->shared_secret, ctx->our_sk, ctx->peer_pk); +} + +/* Domain separator matching secureLSL's lsl_security.h */ +static const char HKDF_CONTEXT[] = "lsl-sess"; + +/* Full session key derivation (matching secureLSL's derive_session_key): + * 1. Convert both Ed25519 keys to X25519 + * 2. Compute shared secret via scalar multiplication + * 3. Derive session key via BLAKE2b with HKDF_CONTEXT and canonical key order + * + * Note: secureLSL pre-converts our own sk during key loading (one-time cost). + * This benchmark includes that conversion to measure total cost. */ +static void do_full_session_derive(void *arg) +{ + session_ctx_t *ctx = (session_ctx_t *)arg; + + uint8_t our_x25519_sk[crypto_scalarmult_SCALARBYTES]; + uint8_t peer_x25519_pk[crypto_scalarmult_BYTES]; + uint8_t shared_secret[crypto_scalarmult_BYTES]; + + /* Step 1: Convert keys */ + crypto_sign_ed25519_sk_to_curve25519(our_x25519_sk, ctx->our_ed25519_sk); + if (crypto_sign_ed25519_pk_to_curve25519(peer_x25519_pk, ctx->peer_ed25519_pk) != 0) { + ESP_LOGE(TAG, "pk conversion failed"); + sodium_memzero(our_x25519_sk, sizeof(our_x25519_sk)); + return; + } + + /* Step 2: DH key exchange */ + if (crypto_scalarmult(shared_secret, our_x25519_sk, peer_x25519_pk) != 0) { + ESP_LOGE(TAG, "DH key exchange produced degenerate shared secret"); + sodium_memzero(our_x25519_sk, sizeof(our_x25519_sk)); + return; + } + + /* Step 3: Derive session key with BLAKE2b. + * Hash: shared_secret || HKDF_CONTEXT || pk_smaller || pk_larger + * Canonical key ordering (smaller first) ensures both parties + * derive the same key, matching secureLSL. */ + crypto_generichash_state state; + if (crypto_generichash_init(&state, NULL, 0, 32) != 0) { + ESP_LOGE(TAG, "BLAKE2b init failed"); + sodium_memzero(our_x25519_sk, sizeof(our_x25519_sk)); + sodium_memzero(shared_secret, sizeof(shared_secret)); + return; + } + crypto_generichash_update(&state, shared_secret, sizeof(shared_secret)); + crypto_generichash_update(&state, (const uint8_t *)HKDF_CONTEXT, sizeof(HKDF_CONTEXT) - 1); + + /* Order public keys consistently (smaller first) */ + if (memcmp(ctx->our_ed25519_pk, ctx->peer_ed25519_pk, crypto_sign_PUBLICKEYBYTES) < 0) { + crypto_generichash_update(&state, ctx->our_ed25519_pk, crypto_sign_PUBLICKEYBYTES); + crypto_generichash_update(&state, ctx->peer_ed25519_pk, crypto_sign_PUBLICKEYBYTES); + } else { + crypto_generichash_update(&state, ctx->peer_ed25519_pk, crypto_sign_PUBLICKEYBYTES); + crypto_generichash_update(&state, ctx->our_ed25519_pk, crypto_sign_PUBLICKEYBYTES); + } + + crypto_generichash_final(&state, ctx->session_key, 32); + + /* Clear sensitive data */ + sodium_memzero(our_x25519_sk, sizeof(our_x25519_sk)); + sodium_memzero(peer_x25519_pk, sizeof(peer_x25519_pk)); + sodium_memzero(shared_secret, sizeof(shared_secret)); +} + +void bench_x25519_run(void) +{ + bench_result_t result; + + bench_print_header("X25519 Key Exchange"); + + /* Generate test keypairs */ + convert_ctx_t cctx; + crypto_sign_keypair(cctx.ed25519_pk, cctx.ed25519_sk); + + /* Benchmark pk conversion */ + bench_run("Ed25519->X25519 pk convert", do_pk_convert, &cctx, BENCH_ITERATIONS, 0, &result); + bench_print_result(&result); + + /* Benchmark sk conversion */ + bench_run("Ed25519->X25519 sk convert", do_sk_convert, &cctx, BENCH_ITERATIONS, 0, &result); + bench_print_result(&result); + + /* Benchmark scalar multiplication */ + scalarmult_ctx_t sctx; + uint8_t peer_ed_pk[crypto_sign_PUBLICKEYBYTES]; + uint8_t peer_ed_sk[crypto_sign_SECRETKEYBYTES]; + crypto_sign_keypair(peer_ed_pk, peer_ed_sk); + crypto_sign_ed25519_sk_to_curve25519(sctx.our_sk, cctx.ed25519_sk); + if (crypto_sign_ed25519_pk_to_curve25519(sctx.peer_pk, peer_ed_pk) != 0) { + ESP_LOGE(TAG, "pk conversion failed, skipping DH benchmark"); + } else { + bench_run("X25519 scalar mult (DH)", do_scalarmult, &sctx, BENCH_ITERATIONS, 0, &result); + bench_print_result(&result); + } + sodium_memzero(peer_ed_sk, sizeof(peer_ed_sk)); + + /* Benchmark full session key derivation */ + bench_print_header("Full Session Key Derivation"); + ESP_LOGI(TAG, "(Ed25519->X25519 + DH + BLAKE2b, as in secureLSL)"); + + session_ctx_t sess; + crypto_sign_keypair(sess.our_ed25519_pk, sess.our_ed25519_sk); + memcpy(sess.peer_ed25519_pk, peer_ed_pk, crypto_sign_PUBLICKEYBYTES); + + bench_run("Full session key derivation", do_full_session_derive, &sess, BENCH_ITERATIONS, 0, + &result); + bench_print_result(&result); + + /* This is the one-time cost per LSL connection */ + ESP_LOGI(TAG, " (This cost is paid once per LSL connection setup)"); + + ESP_LOGI(TAG, ""); + ESP_LOGI(TAG, "Correctness verification..."); + + /* Both sides derive a session key with different keypairs. + * With canonical key ordering, both sides should derive the + * same session key regardless of which is "our" vs "peer". */ + session_ctx_t side_a, side_b; + crypto_sign_keypair(side_a.our_ed25519_pk, side_a.our_ed25519_sk); + crypto_sign_keypair(side_b.our_ed25519_pk, side_b.our_ed25519_sk); + + memcpy(side_a.peer_ed25519_pk, side_b.our_ed25519_pk, crypto_sign_PUBLICKEYBYTES); + memcpy(side_b.peer_ed25519_pk, side_a.our_ed25519_pk, crypto_sign_PUBLICKEYBYTES); + + do_full_session_derive(&side_a); + do_full_session_derive(&side_b); + + /* With canonical key ordering (smaller pk first), both sides + * should derive the same session key. */ + if (sodium_is_zero(side_a.session_key, 32) || sodium_is_zero(side_b.session_key, 32)) { + ESP_LOGE(TAG, " FAIL: session key derivation produced zero key!"); + } else if (memcmp(side_a.session_key, side_b.session_key, 32) == 0) { + ESP_LOGI(TAG, " PASS: both sides derived identical session keys"); + } else { + ESP_LOGE(TAG, " FAIL: sides derived different session keys!"); + } + + /* Clear secret keys */ + sodium_memzero(side_a.our_ed25519_sk, sizeof(side_a.our_ed25519_sk)); + sodium_memzero(side_b.our_ed25519_sk, sizeof(side_b.our_ed25519_sk)); +} diff --git a/liblsl-ESP32/benchmarks/crypto_bench/main/bench_x25519.h b/liblsl-ESP32/benchmarks/crypto_bench/main/bench_x25519.h new file mode 100644 index 0000000..5677302 --- /dev/null +++ b/liblsl-ESP32/benchmarks/crypto_bench/main/bench_x25519.h @@ -0,0 +1,16 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#ifndef BENCH_X25519_H +#define BENCH_X25519_H + +/* Benchmark X25519 key exchange operations: + * - Ed25519 to X25519 key conversion + * - X25519 scalar multiplication (DH) + * - Full session key derivation (convert + DH + BLAKE2b) + * + * These operations happen once per LSL connection setup. */ +void bench_x25519_run(void); + +#endif /* BENCH_X25519_H */ diff --git a/liblsl-ESP32/benchmarks/crypto_bench/main/crypto_bench_main.c b/liblsl-ESP32/benchmarks/crypto_bench/main/crypto_bench_main.c new file mode 100644 index 0000000..2af10cf --- /dev/null +++ b/liblsl-ESP32/benchmarks/crypto_bench/main/crypto_bench_main.c @@ -0,0 +1,99 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +/* ESP32 Cryptographic Benchmark Suite for secureLSL + * + * Benchmarks all libsodium operations used by secureLSL: + * - ChaCha20-Poly1305 IETF AEAD (per-sample encryption) + * - Ed25519 keygen, sign, verify (device identity) + * - X25519 scalar mult (session key exchange) + * - BLAKE2b / generichash (session key derivation, fingerprints) + * - Base64 encode/decode (key serialization in headers) + * + * Results are printed via UART serial (baud rate set in sdkconfig). + * + * Build and run: + * idf.py set-target esp32 + * idf.py build + * idf.py -p /dev/cu.usbserial-XXXX flash monitor + */ + +#include "bench_chacha20.h" +#include "bench_ed25519.h" +#include "bench_x25519.h" +#include "bench_utils.h" + +#include "esp_flash.h" +#include "esp_log.h" +#include "esp_system.h" +#include "esp_chip_info.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "nvs_flash.h" +#include "sodium.h" + +static const char *TAG = "crypto_bench"; + +static void print_system_info(void) +{ + esp_chip_info_t chip_info; + esp_chip_info(&chip_info); + + ESP_LOGI(TAG, "============================================================"); + ESP_LOGI(TAG, " ESP32 Crypto Benchmark for secureLSL"); + ESP_LOGI(TAG, "============================================================"); + ESP_LOGI(TAG, "Chip: ESP32 rev %d, %d cores, WiFi%s%s", chip_info.revision, chip_info.cores, + (chip_info.features & CHIP_FEATURE_BT) ? "/BT" : "", + (chip_info.features & CHIP_FEATURE_BLE) ? "/BLE" : ""); + uint32_t flash_size = 0; + if (esp_flash_get_size(NULL, &flash_size) != ESP_OK) { + ESP_LOGW(TAG, "Failed to read flash size"); + } + ESP_LOGI(TAG, "Flash: %lu MB %s", (unsigned long)(flash_size / (1024 * 1024)), + (chip_info.features & CHIP_FEATURE_EMB_FLASH) ? "(embedded)" : "(external)"); + ESP_LOGI(TAG, "libsodium version: %s", sodium_version_string()); + ESP_LOGI(TAG, "Iterations per benchmark: %d", BENCH_ITERATIONS); + ESP_LOGI(TAG, ""); +} + +void app_main(void) +{ + esp_err_t ret = nvs_flash_init(); + if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_LOGW(TAG, "NVS partition issue (%s), erasing...", esp_err_to_name(ret)); + ESP_ERROR_CHECK(nvs_flash_erase()); + ret = nvs_flash_init(); + } + if (ret != ESP_OK) { + ESP_LOGE(TAG, "NVS init failed: %s (non-fatal, continuing)", esp_err_to_name(ret)); + } + + /* Brief delay so serial monitor can connect before output starts */ + ESP_LOGI(TAG, "Starting in 2 seconds..."); + vTaskDelay(pdMS_TO_TICKS(2000)); + + print_system_info(); + + bench_print_memory("Before sodium_init"); + + if (sodium_init() < 0) { + ESP_LOGE(TAG, "FATAL: sodium_init() failed!"); + return; + } + ESP_LOGI(TAG, "sodium_init() OK"); + + bench_print_memory("After sodium_init"); + + bench_chacha20_run(); + bench_ed25519_run(); + bench_x25519_run(); + + bench_print_memory("After all benchmarks"); + + bench_print_header("BENCHMARK COMPLETE"); + ESP_LOGI(TAG, "All benchmarks finished. Results above."); + ESP_LOGI(TAG, "Key metric: ChaCha20 encrypt 256B (64ch float32) should be < 1ms"); + ESP_LOGI(TAG, ""); + ESP_LOGI(TAG, "To re-run, press the EN (reset) button on the ESP32 board."); +} diff --git a/liblsl-ESP32/benchmarks/crypto_bench/main/idf_component.yml b/liblsl-ESP32/benchmarks/crypto_bench/main/idf_component.yml new file mode 100644 index 0000000..ca2c093 --- /dev/null +++ b/liblsl-ESP32/benchmarks/crypto_bench/main/idf_component.yml @@ -0,0 +1,3 @@ +dependencies: + espressif/libsodium: + version: "^1.0.20~4" diff --git a/liblsl-ESP32/benchmarks/crypto_bench/sdkconfig.defaults b/liblsl-ESP32/benchmarks/crypto_bench/sdkconfig.defaults new file mode 100644 index 0000000..4c5a29c --- /dev/null +++ b/liblsl-ESP32/benchmarks/crypto_bench/sdkconfig.defaults @@ -0,0 +1,20 @@ +# ESP32 target configuration +CONFIG_IDF_TARGET="esp32" + +# CPU frequency: max performance for benchmarking +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y + +# Task watchdog: increase timeout for long benchmarks +CONFIG_ESP_TASK_WDT_TIMEOUT_S=30 + +# Serial output baud rate +CONFIG_ESP_CONSOLE_UART_BAUDRATE=115200 + +# Optimization: performance mode +CONFIG_COMPILER_OPTIMIZATION_PERF=y + +# Stack size for main task (libsodium needs stack space) +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 + +# Logging level: info (not debug/verbose) +CONFIG_LOG_DEFAULT_LEVEL_INFO=y diff --git a/liblsl-ESP32/benchmarks/scripts/bench_utils.py b/liblsl-ESP32/benchmarks/scripts/bench_utils.py new file mode 100644 index 0000000..2848ac7 --- /dev/null +++ b/liblsl-ESP32/benchmarks/scripts/bench_utils.py @@ -0,0 +1,66 @@ +# Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +# Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +# See LICENSE in the repository root for terms. + +"""Shared utilities for ESP32 benchmark scripts.""" + +import json +import platform +from pathlib import Path + +import numpy as np + + +def get_system_info(): + """Collect system information for benchmark records.""" + info = { + "platform": platform.platform(), + "processor": platform.processor(), + "python_version": platform.python_version(), + "machine": platform.machine(), + "hostname": platform.node(), + } + try: + if platform.system() == "Darwin": + import subprocess + result = subprocess.run( + ["sysctl", "-n", "machdep.cpu.brand_string"], + capture_output=True, text=True, + ) + info["cpu_model"] = result.stdout.strip() + elif platform.system() == "Linux": + with open("/proc/cpuinfo") as f: + for line in f: + if "model name" in line: + info["cpu_model"] = line.split(":")[1].strip() + break + except Exception: + pass + return info + + +def compute_timing_stats(values, prefix): + """Compute timing statistics from a list of microsecond values. + + Returns a dict with keys like {prefix}_mean_us, {prefix}_std_us, etc. + """ + arr = np.array(values) if values else np.array([0]) + return { + f"{prefix}_mean_us": round(float(np.mean(arr)), 2), + f"{prefix}_std_us": round(float(np.std(arr)), 2), + f"{prefix}_median_us": round(float(np.median(arr)), 2), + f"{prefix}_min_us": round(float(np.min(arr)), 2), + f"{prefix}_max_us": round(float(np.max(arr)), 2), + f"{prefix}_p95_us": round(float(np.percentile(arr, 95)), 2), + f"{prefix}_p99_us": round(float(np.percentile(arr, 99)), 2), + } + + +def save_results(data, output_file): + """Save benchmark results to a JSON file.""" + if not output_file: + return + Path(output_file).parent.mkdir(parents=True, exist_ok=True) + with open(output_file, "w") as f: + json.dump(data, f, indent=2) + print(f"Results saved to {output_file}") diff --git a/liblsl-ESP32/benchmarks/scripts/esp32_benchmark_inlet.py b/liblsl-ESP32/benchmarks/scripts/esp32_benchmark_inlet.py new file mode 100644 index 0000000..ba122dc --- /dev/null +++ b/liblsl-ESP32/benchmarks/scripts/esp32_benchmark_inlet.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +# Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +# See LICENSE in the repository root for terms. + +"""ESP32 Benchmark - Desktop Inlet. + +Receives samples from an ESP32 LSL outlet and measures: +- Pull timing (time per pull_sample call, includes blocking wait) +- Inter-sample jitter (variability of arrival intervals) +- Throughput (actual vs nominal sample rate) +- Packet loss (samples missed) + +Does NOT compute absolute cross-machine latency (clocks unsynchronized). +Records embedded timestamps from channel 0 for drift analysis. + +Usage: + uv run python esp32_benchmark_inlet.py --duration 60 + uv run python esp32_benchmark_inlet.py --name ESP32Bench --duration 60 -o results/test.json +""" + +import argparse +import time +from datetime import datetime + +import numpy as np +import pylsl + +from bench_utils import compute_timing_stats, get_system_info, save_results + + +def run_benchmark(stream_name, duration, output_file): + """Run the desktop inlet benchmark against an ESP32 outlet.""" + print(f"Resolving stream '{stream_name}'...") + try: + streams = pylsl.resolve_byprop("name", stream_name, timeout=30) + except Exception as exc: + print(f"ERROR: Stream resolution failed: {exc}") + return None + + if not streams: + print(f"ERROR: Stream '{stream_name}' not found within 30s") + return None + + stream_info = streams[0] + channels = stream_info.channel_count() + nominal_rate = stream_info.nominal_srate() + print(f"Found: {stream_info.name()} ({channels}ch @ {nominal_rate}Hz) " + f"from {stream_info.hostname()}") + + try: + inlet = pylsl.StreamInlet(stream_info, max_buflen=360) + except Exception as exc: + print(f"ERROR: Failed to create inlet: {exc}") + return None + + # Warmup: 2 seconds + print("Warmup (2s)...") + warmup_end = time.time() + 2.0 + while time.time() < warmup_end: + inlet.pull_sample(timeout=1.0) + + # Measurement + print(f"Measuring for {duration}s...") + pull_durations_us = [] + inter_sample_intervals_us = [] + embedded_timestamps = [] + last_pull_time = None + consecutive_timeouts = 0 + + start_time = time.time() + start_iso = datetime.now().isoformat() + received = 0 + progress_interval = max(int(nominal_rate * 5), 1) + + try: + while time.time() - start_time < duration: + t0 = time.perf_counter() + sample, _ = inlet.pull_sample(timeout=5.0) + t1 = time.perf_counter() + + if sample is None: + consecutive_timeouts += 1 + if consecutive_timeouts == 1: + elapsed = time.time() - start_time + print(f" WARNING: No sample (5s timeout at t={elapsed:.0f}s)") + if consecutive_timeouts >= 3: + print(f" ERROR: {consecutive_timeouts} consecutive timeouts, aborting") + break + continue + + consecutive_timeouts = 0 + received += 1 + pull_durations_us.append((t1 - t0) * 1e6) + + # Record embedded timestamp from channel 0 + embedded_timestamps.append(sample[0]) + + # Inter-sample interval + if last_pull_time is not None: + inter_sample_intervals_us.append((t1 - last_pull_time) * 1e6) + last_pull_time = t1 + + # Progress + if received % progress_interval == 0: + elapsed = time.time() - start_time + print(f" {received} samples, {received / elapsed:.1f} Hz actual") + + except KeyboardInterrupt: + print("\nInterrupted, saving partial results...") + + end_time = time.time() + end_iso = datetime.now().isoformat() + actual_duration = end_time - start_time + actual_rate = received / actual_duration if actual_duration > 0 else 0 + expected = int(nominal_rate * actual_duration) if nominal_rate > 0 else received + loss_pct = 100 * (expected - received) / expected if expected > 0 else 0 + + # Compute statistics + results = { + "samples_received": received, + "expected_samples": expected, + "actual_duration": round(actual_duration, 2), + "actual_rate": round(actual_rate, 2), + "rate_accuracy": round(actual_rate / nominal_rate, 4) if nominal_rate > 0 else 0, + "packet_loss_pct": round(loss_pct, 2), + **compute_timing_stats(pull_durations_us, "pull"), + "jitter_std_us": round(float(np.std(inter_sample_intervals_us)), 2) + if inter_sample_intervals_us else 0, + "interval_mean_us": round(float(np.mean(inter_sample_intervals_us)), 2) + if inter_sample_intervals_us else 0, + } + + print(f"\n{'='*60}") + print(f"Benchmark Results: {stream_name}") + print(f"{'='*60}") + print(f"Samples: {received}/{expected} ({loss_pct:.1f}% loss)") + print(f"Rate: {actual_rate:.1f} Hz (nominal {nominal_rate:.0f})") + print(f"Pull: {results['pull_mean_us']:.1f} +/- {results['pull_std_us']:.1f} us " + f"(p95={results['pull_p95_us']:.1f})") + print(f"Jitter: {results['jitter_std_us']:.1f} us std") + print(f"{'='*60}") + + output = { + "system_info": get_system_info(), + "stream_info": { + "name": stream_info.name(), + "type": stream_info.type(), + "channels": channels, + "nominal_rate": nominal_rate, + "hostname": stream_info.hostname(), + }, + "device": "ESP32", + "is_remote": True, + "duration": duration, + "start_time": start_iso, + "end_time": end_iso, + "pull_durations_us": [round(x, 2) for x in pull_durations_us], + "inter_sample_intervals_us": [round(x, 2) for x in inter_sample_intervals_us], + "embedded_timestamps": embedded_timestamps[:1000], + "latencies_ms": [], # not computed (cross-machine, clocks unsynchronized) + "results": results, + } + + save_results(output, output_file) + return output + + +def main(): + parser = argparse.ArgumentParser(description="ESP32 Benchmark - Desktop Inlet") + parser.add_argument("--name", default="ESP32Bench", help="Stream name to resolve") + parser.add_argument("--duration", type=int, default=60, help="Test duration (seconds)") + parser.add_argument("--output", "-o", help="Output JSON file") + args = parser.parse_args() + + result = run_benchmark(args.name, args.duration, args.output) + if not result: + exit(1) + + +if __name__ == "__main__": + main() diff --git a/liblsl-ESP32/benchmarks/scripts/esp32_benchmark_outlet.py b/liblsl-ESP32/benchmarks/scripts/esp32_benchmark_outlet.py new file mode 100644 index 0000000..9e4015c --- /dev/null +++ b/liblsl-ESP32/benchmarks/scripts/esp32_benchmark_outlet.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +# Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +# See LICENSE in the repository root for terms. + +"""ESP32 Benchmark - Desktop Outlet. + +Pushes samples at a specified rate for ESP32 inlet testing. +Embeds timestamps in channel 0 for timing analysis. + +Usage: + uv run python esp32_benchmark_outlet.py --channels 8 --rate 250 --duration 60 + uv run python esp32_benchmark_outlet.py --channels 8 --rate 250 --duration 60 -o results/out.json +""" + +import argparse +import math +import os +import time +from datetime import datetime + +import pylsl + +from bench_utils import compute_timing_stats, get_system_info, save_results + + +def run_benchmark(channels, rate, duration, stream_name, output_file): + """Run the desktop outlet benchmark for ESP32 inlet testing.""" + if rate <= 0: + print("ERROR: rate must be > 0") + return None + + print(f"Desktop outlet: {channels}ch @ {rate}Hz for {duration}s") + print(f"Stream name: {stream_name}") + + try: + info = pylsl.StreamInfo( + stream_name, "Benchmark", channels, rate, + pylsl.cf_float32, f"desktop_bench_{os.getpid()}" + ) + outlet = pylsl.StreamOutlet(info) + except Exception as exc: + print(f"ERROR: Failed to create outlet: {exc}") + return None + + print("Outlet created, pushing samples...") + + push_durations_us = [] + start_time = time.time() + start_iso = datetime.now().isoformat() + sample = [0.0] * channels + pushed = 0 + + target_interval = 1.0 / rate + next_push = time.perf_counter() + + try: + while time.time() - start_time < duration: + now = time.perf_counter() + if now < next_push: + sleep_time = next_push - now + if sleep_time > 0.0001: + time.sleep(sleep_time - 0.0001) + while time.perf_counter() < next_push: + pass + + t = time.time() + sample[0] = t + for ch in range(1, channels): + sample[ch] = math.sin(2 * math.pi * ch * t * 0.01) + + t0 = time.perf_counter() + outlet.push_sample(sample) + t1 = time.perf_counter() + + push_durations_us.append((t1 - t0) * 1e6) + pushed += 1 + next_push += target_interval + + if pushed % (rate * 5) == 0: + elapsed = time.time() - start_time + print(f" {pushed} samples, {pushed / elapsed:.1f} Hz") + + except KeyboardInterrupt: + print("\nInterrupted, saving partial results...") + + end_iso = datetime.now().isoformat() + actual_duration = time.time() - start_time + actual_rate = pushed / actual_duration if actual_duration > 0 else 0 + + results = { + "samples_sent": pushed, + "actual_duration": round(actual_duration, 2), + "actual_rate": round(actual_rate, 2), + **compute_timing_stats(push_durations_us, "push"), + } + + print(f"\n{'='*60}") + print(f"Desktop Outlet Results") + print(f"{'='*60}") + print(f"Pushed: {pushed} samples in {actual_duration:.1f}s ({actual_rate:.1f} Hz)") + print(f"Push: {results['push_mean_us']:.1f} +/- {results['push_std_us']:.1f} us") + print(f"{'='*60}") + + output = { + "system_info": get_system_info(), + "channels": channels, + "rate": rate, + "duration": duration, + "stream_name": stream_name, + "start_time": start_iso, + "end_time": end_iso, + "push_durations_us": [round(x, 2) for x in push_durations_us[:10000]], + "results": results, + } + + save_results(output, output_file) + return output + + +def main(): + parser = argparse.ArgumentParser(description="ESP32 Benchmark - Desktop Outlet") + parser.add_argument("--channels", type=int, default=8, help="Channel count") + parser.add_argument("--rate", type=int, default=250, help="Sample rate (Hz)") + parser.add_argument("--duration", type=int, default=60, help="Duration (seconds)") + parser.add_argument("--name", default="DesktopBench", help="Stream name") + parser.add_argument("--output", "-o", help="Output JSON file") + args = parser.parse_args() + + run_benchmark(args.channels, args.rate, args.duration, args.name, args.output) + + +if __name__ == "__main__": + main() diff --git a/liblsl-ESP32/benchmarks/scripts/requirements.txt b/liblsl-ESP32/benchmarks/scripts/requirements.txt new file mode 100644 index 0000000..3a7d918 --- /dev/null +++ b/liblsl-ESP32/benchmarks/scripts/requirements.txt @@ -0,0 +1,3 @@ +numpy>=1.24 +pylsl>=1.16 +pyserial>=3.5 diff --git a/liblsl-ESP32/benchmarks/scripts/serial_monitor.py b/liblsl-ESP32/benchmarks/scripts/serial_monitor.py new file mode 100644 index 0000000..18c7ccf --- /dev/null +++ b/liblsl-ESP32/benchmarks/scripts/serial_monitor.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +# Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +# See LICENSE in the repository root for terms. + +"""ESP32 Serial Monitor for Benchmark Telemetry. + +Reads JSON lines from ESP32 serial port during benchmarks. +Collects progress reports and final summary, saves to JSON. + +Usage: + uv run python serial_monitor.py --port /dev/cu.usbserial-0001 + uv run python serial_monitor.py --port /dev/cu.usbserial-0001 -o results/esp32.json +""" + +import argparse +import json +import sys +import time + +import serial + +from bench_utils import save_results + + +def _fmt(val, spec=".1f"): + """Format a value safely, returning '?' if not numeric.""" + try: + return f"{val:{spec}}" + except (TypeError, ValueError): + return str(val) + + +def monitor(port, baud=115200, timeout=120, output=None): + """Read ESP32 serial output, parse JSON lines, save results.""" + try: + port_handle = serial.Serial(port, baud, timeout=1) + except (serial.SerialException, OSError) as exc: + print(f"ERROR: Cannot open serial port '{port}': {exc}") + print("Check: is the device connected? Is another process using the port?") + print(" List available ports: ls /dev/cu.usbserial-*") + return None + + print(f"Monitoring {port} at {baud} baud (timeout={timeout}s)") + + config = None + progress = [] + summary = None + monitor_data = None + start = time.time() + any_data = False + summary_time = None + json_errors = 0 + + try: + while time.time() - start < timeout: + # If we got summary, wait max 10s more for monitor data + if summary_time and time.time() - summary_time > 10: + print("Monitor data not received within 10s of summary, finishing.") + break + + try: + raw = port_handle.readline() + except (serial.SerialException, OSError) as exc: + print(f"WARNING: Serial read error: {exc}") + break + + if not raw: + continue + + line = raw.decode("utf-8", errors="replace").strip() + if not line: + continue + + # Try to parse as JSON + if line.startswith("{"): + try: + data = json.loads(line) + any_data = True + json_errors = 0 # reset consecutive error count + msg_type = data.get("type", "") + + if msg_type == "config": + config = data + print(f"[config] {data.get('mode')} {data.get('channels')}ch " + f"@ {data.get('rate')}Hz, security={data.get('security')}") + elif msg_type == "progress": + progress.append(data) + print(f"[progress] t={_fmt(data.get('t', 0), '.0f')}s " + f"samples={data.get('samples', 0)} " + f"rate={_fmt(data.get('rate_hz', 0))}Hz " + f"heap={data.get('heap', 0)}") + elif msg_type == "summary": + summary = data + summary_time = time.time() + mode = data.get("mode", "?") + if mode == "outlet": + print(f"\n[SUMMARY] {data.get('samples_pushed', 0)} samples, " + f"push={_fmt(data.get('push_mean_us', 0))} +/- " + f"{_fmt(data.get('push_std_us', 0))} us, " + f"p95={data.get('push_p95_us', 0)} us") + else: + print(f"\n[SUMMARY] {data.get('samples_received', 0)} samples, " + f"loss={_fmt(data.get('packet_loss_pct', 0))}%, " + f"jitter={_fmt(data.get('jitter_std_us', 0))} us") + elif msg_type == "monitor": + monitor_data = data + print(f"[monitor] heap={data.get('heap_mean', 0)} " + f"(min={data.get('heap_min', 0)}), " + f"rssi={data.get('rssi_mean', 0)} dBm") + + # Stop after summary + monitor + if summary and monitor_data: + print("\nBenchmark complete.") + break + + continue + except json.JSONDecodeError: + json_errors += 1 + if json_errors <= 3: + print(f" [WARN] Malformed JSON: {line[:80]}") + elif json_errors == 4: + print(" [WARN] Suppressing further JSON parse warnings") + continue + + # Print non-JSON lines that look interesting + if any(k in line for k in ["bench", "Benchmark", "Outlet", "Inlet", + "Security", "WiFi", "connected"]): + print(f" {line}") + + except KeyboardInterrupt: + print("\nInterrupted by user") + finally: + port_handle.close() + + # Build result + result = { + "device": "ESP32-WROOM-32", + "config": config, + "progress": progress, + "summary": summary, + "monitor": monitor_data, + "collection_time": time.strftime("%Y-%m-%dT%H:%M:%S"), + } + + save_results(result, output) + + if not any_data: + print("WARNING: No data received. Check serial port, baud rate, and ESP32 firmware.") + elif not summary: + if progress: + print(f"WARNING: Received {len(progress)} progress reports but no summary. " + "Benchmark may still be running; increase --timeout.") + else: + print("WARNING: No benchmark data received. ESP32 may not be running benchmark firmware.") + + return result + + +def main(): + parser = argparse.ArgumentParser(description="ESP32 Benchmark Serial Monitor") + parser.add_argument("--port", required=True, help="Serial port") + parser.add_argument("--baud", type=int, default=115200, help="Baud rate") + parser.add_argument("--timeout", type=int, default=120, help="Max time (seconds)") + parser.add_argument("--output", "-o", help="Output JSON file path") + args = parser.parse_args() + + result = monitor(args.port, args.baud, args.timeout, args.output) + + if not result or not result.get("summary"): + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/liblsl-ESP32/benchmarks/throughput_bench/CMakeLists.txt b/liblsl-ESP32/benchmarks/throughput_bench/CMakeLists.txt new file mode 100644 index 0000000..b8c472c --- /dev/null +++ b/liblsl-ESP32/benchmarks/throughput_bench/CMakeLists.txt @@ -0,0 +1,6 @@ +cmake_minimum_required(VERSION 3.16) + +set(EXTRA_COMPONENT_DIRS "../../components") + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(throughput_bench) diff --git a/liblsl-ESP32/benchmarks/throughput_bench/main/CMakeLists.txt b/liblsl-ESP32/benchmarks/throughput_bench/main/CMakeLists.txt new file mode 100644 index 0000000..7f3b2bd --- /dev/null +++ b/liblsl-ESP32/benchmarks/throughput_bench/main/CMakeLists.txt @@ -0,0 +1,9 @@ +idf_component_register( + SRCS "throughput_bench_main.c" + "bench_outlet.c" + "bench_inlet.c" + "bench_monitor.c" + "wifi_helper.c" + INCLUDE_DIRS "." + REQUIRES liblsl_esp32 esp_wifi esp_netif nvs_flash esp_event esp_timer +) diff --git a/liblsl-ESP32/benchmarks/throughput_bench/main/Kconfig.projbuild b/liblsl-ESP32/benchmarks/throughput_bench/main/Kconfig.projbuild new file mode 100644 index 0000000..f7a947c --- /dev/null +++ b/liblsl-ESP32/benchmarks/throughput_bench/main/Kconfig.projbuild @@ -0,0 +1,85 @@ +menu "Benchmark Configuration" + + choice BENCH_MODE + prompt "Benchmark mode" + default BENCH_MODE_OUTLET + help + Select whether to run as outlet (push) or inlet (pull). + + config BENCH_MODE_OUTLET + bool "Outlet (push samples)" + + config BENCH_MODE_INLET + bool "Inlet (pull samples)" + endchoice + + config BENCH_CHANNELS + int "Number of channels" + default 8 + range 1 128 + help + Number of float32 channels per sample. + + config BENCH_SAMPLE_RATE + int "Sampling rate (Hz)" + default 250 + range 1 1000 + help + Nominal sampling rate in Hz. + + config BENCH_DURATION + int "Test duration (seconds)" + default 60 + range 0 3600 + help + Duration of the benchmark. 0 = run until reset. + + config BENCH_REPORT_INTERVAL + int "Report interval (seconds)" + default 5 + range 1 60 + help + Seconds between serial progress reports. + + config BENCH_STREAM_NAME + string "Stream name (outlet mode)" + default "ESP32Bench" + help + LSL stream name when running as outlet. + + config BENCH_TARGET_STREAM + string "Target stream name (inlet mode)" + default "DesktopBench" + help + LSL stream name to resolve when running as inlet. + + config BENCH_SECURITY_ENABLE + bool "Enable secureLSL encryption" + default n + help + Enable encrypted streaming. Requires keypair in NVS + or configured below. + + config ESP_WIFI_SSID + string "WiFi SSID" + default "your_ssid" + + config ESP_WIFI_PASSWORD + string "WiFi Password" + default "your_password" + + config ESP_MAXIMUM_RETRY + int "Maximum WiFi retries" + default 10 + + config LSL_SECURITY_PUBKEY + string "secureLSL public key (base64)" + default "" + depends on BENCH_SECURITY_ENABLE + + config LSL_SECURITY_PRIVKEY + string "secureLSL private key (base64)" + default "" + depends on BENCH_SECURITY_ENABLE + +endmenu diff --git a/liblsl-ESP32/benchmarks/throughput_bench/main/bench_inlet.c b/liblsl-ESP32/benchmarks/throughput_bench/main/bench_inlet.c new file mode 100644 index 0000000..19bddd8 --- /dev/null +++ b/liblsl-ESP32/benchmarks/throughput_bench/main/bench_inlet.c @@ -0,0 +1,237 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#include "bench_inlet.h" +#include "bench_monitor.h" +#include "lsl_esp32.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include +#include +#include + +static const char *TAG = "bench_in"; + +/* Timing ring buffer for percentile computation */ +#define TIMING_RING_SIZE 10000 +static uint32_t s_pull_times[TIMING_RING_SIZE]; +static uint32_t s_intervals[TIMING_RING_SIZE]; +static uint32_t s_sort_buf[TIMING_RING_SIZE]; /* pre-allocated sort buffer */ +static int s_pull_count = 0; + +static int cmp_uint32(const void *a, const void *b) +{ + uint32_t va = *(const uint32_t *)a; + uint32_t vb = *(const uint32_t *)b; + return (va > vb) - (va < vb); +} + +static uint32_t percentile(const uint32_t *ring, int count, int pct) +{ + int n = (count < TIMING_RING_SIZE) ? count : TIMING_RING_SIZE; + if (n == 0) { + return 0; + } + /* Sort pre-allocated copy (no heap allocation) */ + memcpy(s_sort_buf, ring, n * sizeof(uint32_t)); + qsort(s_sort_buf, n, sizeof(uint32_t), cmp_uint32); + int idx = (int)((double)pct / 100.0 * (n - 1)); + return s_sort_buf[idx]; +} + +void bench_inlet_run(void) +{ + int channels = CONFIG_BENCH_CHANNELS; + int rate = CONFIG_BENCH_SAMPLE_RATE; + int duration = CONFIG_BENCH_DURATION; + int report_interval = CONFIG_BENCH_REPORT_INTERVAL; +#ifdef CONFIG_BENCH_SECURITY_ENABLE + int secure = 1; +#else + int secure = 0; +#endif + + ESP_LOGI(TAG, "Inlet benchmark: %dch @ %dHz, %ds, security=%s", channels, rate, duration, + secure ? "on" : "off"); + + /* Resolve target stream */ + ESP_LOGI(TAG, "Resolving '%s'...", CONFIG_BENCH_TARGET_STREAM); + lsl_esp32_stream_info_t info = NULL; + int found = lsl_esp32_resolve_stream("name", CONFIG_BENCH_TARGET_STREAM, 30.0, &info); + if (!found || !info) { + ESP_LOGE(TAG, "Stream not found within 30s"); + return; + } + ESP_LOGI(TAG, "Found: %s (%dch @ %.0fHz)", lsl_esp32_get_name(info), + lsl_esp32_get_channel_count(info), lsl_esp32_get_nominal_srate(info)); + + /* Create inlet */ + lsl_esp32_inlet_t inlet = lsl_esp32_create_inlet(info); + if (!inlet) { + ESP_LOGE(TAG, "Failed to create inlet"); + lsl_esp32_destroy_streaminfo(info); + return; + } + + /* Allocate sample buffer */ + float *sample = calloc(channels, sizeof(float)); + if (!sample) { + ESP_LOGE(TAG, "Failed to allocate sample buffer"); + lsl_esp32_destroy_inlet(inlet); + return; + } + + /* Warmup: 2 seconds */ + ESP_LOGI(TAG, "Warmup (2s)..."); + for (int i = 0; i < rate * 2; i++) { + double ts; + lsl_esp32_inlet_pull_sample_f(inlet, sample, channels * (int)sizeof(float), &ts, 2.0); + } + + /* Measurement loop */ + s_pull_count = 0; + int received = 0; + int timeouts = 0; + int64_t bench_start = esp_timer_get_time(); + int64_t next_report = bench_start + (int64_t)report_interval * 1000000; + int64_t last_pull_time = 0; + + /* Running stats for pull timing */ + uint64_t pull_sum = 0; + uint64_t pull_sum_sq = 0; + uint32_t pull_min = UINT32_MAX; + uint32_t pull_max = 0; + + /* Running stats for intervals */ + uint64_t interval_sum = 0; + uint64_t interval_sum_sq = 0; + int interval_count = 0; + + ESP_LOGI(TAG, "Measurement started"); + + while (1) { + /* Check duration */ + int64_t now = esp_timer_get_time(); + if (duration > 0 && (now - bench_start) >= (int64_t)duration * 1000000) { + break; + } + + /* Time the pull */ + double ts; + int64_t t0 = esp_timer_get_time(); + lsl_esp32_err_t err = + lsl_esp32_inlet_pull_sample_f(inlet, sample, channels * (int)sizeof(float), &ts, 5.0); + int64_t t1 = esp_timer_get_time(); + + if (err == LSL_ESP32_OK) { + uint32_t pull_us = (uint32_t)(t1 - t0); + pull_sum += pull_us; + pull_sum_sq += (uint64_t)pull_us * pull_us; + if (pull_us < pull_min) { + pull_min = pull_us; + } + if (pull_us > pull_max) { + pull_max = pull_us; + } + s_pull_times[s_pull_count % TIMING_RING_SIZE] = pull_us; + + /* Inter-sample interval */ + if (last_pull_time > 0) { + uint32_t interval_us = (uint32_t)(t1 - last_pull_time); + s_intervals[interval_count % TIMING_RING_SIZE] = interval_us; + interval_sum += interval_us; + interval_sum_sq += (uint64_t)interval_us * interval_us; + interval_count++; + } + last_pull_time = t1; + + s_pull_count++; + received++; + } else if (err == LSL_ESP32_ERR_TIMEOUT) { + timeouts++; + if (timeouts > 12) { + ESP_LOGW(TAG, "Too many timeouts (%d), stopping", timeouts); + break; + } + } else { + ESP_LOGE(TAG, "Pull error: %d", err); + break; + } + + /* Periodic report */ + if (t1 >= next_report) { + double elapsed = (double)(t1 - bench_start) / 1000000.0; + double actual_rate = (double)received / elapsed; + printf("{\"type\":\"progress\"," + "\"t\":%.0f," + "\"samples\":%d," + "\"rate_hz\":%.1f," + "\"pull_mean_us\":%lu," + "\"timeouts\":%d," + "\"heap\":%lu}\n", + elapsed, received, actual_rate, + (unsigned long)(received > 0 ? pull_sum / received : 0), timeouts, + (unsigned long)esp_get_free_heap_size()); + next_report = t1 + (int64_t)report_interval * 1000000; + } + } + + int64_t bench_end = esp_timer_get_time(); + double actual_duration = (double)(bench_end - bench_start) / 1000000.0; + double actual_rate = (double)received / actual_duration; + int expected = (int)(rate * actual_duration); + double loss_pct = (expected > 0) ? 100.0 * (expected - received) / expected : 0; + + /* Compute stats */ + double pull_mean = (received > 0) ? (double)pull_sum / received : 0; + double pull_var = (received > 0) ? (double)pull_sum_sq / received - pull_mean * pull_mean : 0; + double pull_std = (pull_var > 0) ? sqrt(pull_var) : 0; + + double interval_mean = (interval_count > 0) ? (double)interval_sum / interval_count : 0; + double interval_var = (interval_count > 0) ? (double)interval_sum_sq / interval_count - + interval_mean * interval_mean + : 0; + double jitter_std = (interval_var > 0) ? sqrt(interval_var) : 0; + + /* Final summary */ + printf("{\"type\":\"summary\"," + "\"mode\":\"inlet\"," + "\"channels\":%d," + "\"rate\":%d," + "\"security\":%s," + "\"duration\":%.1f," + "\"samples_received\":%d," + "\"expected_samples\":%d," + "\"actual_rate\":%.1f," + "\"packet_loss_pct\":%.2f," + "\"pull_mean_us\":%.1f," + "\"pull_std_us\":%.1f," + "\"pull_min_us\":%lu," + "\"pull_max_us\":%lu," + "\"pull_p95_us\":%lu," + "\"pull_p99_us\":%lu," + "\"jitter_std_us\":%.1f," + "\"interval_mean_us\":%.1f," + "\"timeouts\":%d," + "\"heap_free\":%lu," + "\"heap_min\":%lu," + "\"stack_hwm\":%u}\n", + channels, rate, secure ? "true" : "false", actual_duration, received, expected, + actual_rate, loss_pct, pull_mean, pull_std, (unsigned long)(received > 0 ? pull_min : 0), + (unsigned long)pull_max, (unsigned long)percentile(s_pull_times, s_pull_count, 95), + (unsigned long)percentile(s_pull_times, s_pull_count, 99), jitter_std, interval_mean, + timeouts, (unsigned long)esp_get_free_heap_size(), + (unsigned long)bench_monitor_get_heap_min(), + (unsigned)uxTaskGetStackHighWaterMark(NULL)); + + free(sample); + bench_monitor_print_summary(); + + ESP_LOGI(TAG, "Benchmark complete: %d/%d samples (%.1f%% loss), jitter=%.1f us", received, + expected, loss_pct, jitter_std); + + lsl_esp32_destroy_inlet(inlet); +} diff --git a/liblsl-ESP32/benchmarks/throughput_bench/main/bench_inlet.h b/liblsl-ESP32/benchmarks/throughput_bench/main/bench_inlet.h new file mode 100644 index 0000000..2135049 --- /dev/null +++ b/liblsl-ESP32/benchmarks/throughput_bench/main/bench_inlet.h @@ -0,0 +1,12 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#ifndef BENCH_INLET_H +#define BENCH_INLET_H + +/* Run the inlet benchmark. Resolves and receives from a target stream, + * reports pull timing and jitter via serial JSON. */ +void bench_inlet_run(void); + +#endif /* BENCH_INLET_H */ diff --git a/liblsl-ESP32/benchmarks/throughput_bench/main/bench_monitor.c b/liblsl-ESP32/benchmarks/throughput_bench/main/bench_monitor.c new file mode 100644 index 0000000..ae7ec0f --- /dev/null +++ b/liblsl-ESP32/benchmarks/throughput_bench/main/bench_monitor.c @@ -0,0 +1,132 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#include "bench_monitor.h" +#include "esp_log.h" +#include "esp_system.h" +#include "esp_wifi.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include +#include + +static const char *TAG = "bench_mon"; + +#define MONITOR_RING_SIZE 120 /* 2 minutes at 1 sample/second */ +#define MONITOR_STACK_SIZE 4096 + +typedef struct { + uint32_t heap_free; + int8_t rssi; +} monitor_sample_t; + +static monitor_sample_t s_ring[MONITOR_RING_SIZE]; +static volatile int s_ring_count = 0; +static volatile uint32_t s_heap_min = UINT32_MAX; +static volatile uint32_t s_heap_max = 0; +static volatile bool s_running = false; +static volatile bool s_exited = false; +static TaskHandle_t s_task = NULL; + +static void monitor_task(void *arg) +{ + (void)arg; + while (s_running) { + monitor_sample_t sample; + sample.heap_free = esp_get_free_heap_size(); + + /* WiFi RSSI */ + wifi_ap_record_t ap_info; + if (esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK) { + sample.rssi = ap_info.rssi; + } else { + sample.rssi = 0; + } + + /* Update min/max (atomic on 32-bit ESP32) */ + if (sample.heap_free < s_heap_min) { + s_heap_min = sample.heap_free; + } + if (sample.heap_free > s_heap_max) { + s_heap_max = sample.heap_free; + } + + /* Store in ring */ + int idx = s_ring_count % MONITOR_RING_SIZE; + s_ring[idx] = sample; + s_ring_count++; + + vTaskDelay(pdMS_TO_TICKS(1000)); + } + + s_exited = true; + vTaskDelete(NULL); +} + +void bench_monitor_start(void) +{ + s_ring_count = 0; + s_heap_min = UINT32_MAX; + s_heap_max = 0; + s_exited = false; + s_running = true; + + xTaskCreatePinnedToCore(monitor_task, "bench_mon", MONITOR_STACK_SIZE, NULL, 3, &s_task, 0); + ESP_LOGI(TAG, "Resource monitor started"); +} + +void bench_monitor_stop(void) +{ + s_running = false; + /* Wait for task to confirm exit (up to 3s) */ + for (int i = 0; i < 30 && !s_exited; i++) { + vTaskDelay(pdMS_TO_TICKS(100)); + } + if (!s_exited) { + ESP_LOGW(TAG, "Monitor task did not exit within 3s"); + } + s_task = NULL; + ESP_LOGI(TAG, "Resource monitor stopped (%d samples)", s_ring_count); +} + +uint32_t bench_monitor_get_heap_min(void) +{ + return s_heap_min; +} + +void bench_monitor_print_summary(void) +{ + if (s_ring_count == 0) { + return; + } + + /* Compute averages (safe to read: monitor task has exited) */ + int count = (s_ring_count < MONITOR_RING_SIZE) ? s_ring_count : MONITOR_RING_SIZE; + uint64_t heap_sum = 0; + int32_t rssi_sum = 0; + int8_t rssi_min = INT8_MAX; + int8_t rssi_max = INT8_MIN; + + for (int i = 0; i < count; i++) { + heap_sum += s_ring[i].heap_free; + rssi_sum += s_ring[i].rssi; + if (s_ring[i].rssi < rssi_min) { + rssi_min = s_ring[i].rssi; + } + if (s_ring[i].rssi > rssi_max) { + rssi_max = s_ring[i].rssi; + } + } + + printf("{\"type\":\"monitor\"," + "\"heap_mean\":%lu," + "\"heap_min\":%lu," + "\"heap_max\":%lu," + "\"rssi_mean\":%d," + "\"rssi_min\":%d," + "\"rssi_max\":%d," + "\"samples\":%d}\n", + (unsigned long)(heap_sum / count), (unsigned long)s_heap_min, (unsigned long)s_heap_max, + (int)(rssi_sum / count), (int)rssi_min, (int)rssi_max, s_ring_count); +} diff --git a/liblsl-ESP32/benchmarks/throughput_bench/main/bench_monitor.h b/liblsl-ESP32/benchmarks/throughput_bench/main/bench_monitor.h new file mode 100644 index 0000000..6486fe0 --- /dev/null +++ b/liblsl-ESP32/benchmarks/throughput_bench/main/bench_monitor.h @@ -0,0 +1,23 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#ifndef BENCH_MONITOR_H +#define BENCH_MONITOR_H + +#include + +/* Start the background resource monitor task (core 0, priority 3). + * Samples heap, stack HWM, and WiFi RSSI every second. */ +void bench_monitor_start(void); + +/* Stop the monitor task. */ +void bench_monitor_stop(void); + +/* Print a JSON summary of collected resource metrics. */ +void bench_monitor_print_summary(void); + +/* Get the current minimum free heap observed. */ +uint32_t bench_monitor_get_heap_min(void); + +#endif /* BENCH_MONITOR_H */ diff --git a/liblsl-ESP32/benchmarks/throughput_bench/main/bench_outlet.c b/liblsl-ESP32/benchmarks/throughput_bench/main/bench_outlet.c new file mode 100644 index 0000000..58e31db --- /dev/null +++ b/liblsl-ESP32/benchmarks/throughput_bench/main/bench_outlet.c @@ -0,0 +1,235 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#include "bench_outlet.h" +#include "bench_monitor.h" +#include "lsl_esp32.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include +#include +#include + +static const char *TAG = "bench_out"; + +/* Timing ring buffer for percentile computation */ +#define TIMING_RING_SIZE 10000 +static uint32_t s_push_times[TIMING_RING_SIZE]; +static uint32_t s_sort_buf[TIMING_RING_SIZE]; /* pre-allocated sort buffer */ +static int s_push_count = 0; + +/* Running statistics (no heap allocation in hot path) */ +typedef struct { + uint64_t sum; + uint64_t sum_sq; + uint32_t min; + uint32_t max; + uint32_t count; +} running_stats_t; + +static void stats_reset(running_stats_t *s) +{ + memset(s, 0, sizeof(*s)); + s->min = UINT32_MAX; +} + +static void stats_add(running_stats_t *s, uint32_t val) +{ + s->sum += val; + s->sum_sq += (uint64_t)val * val; + if (val < s->min) { + s->min = val; + } + if (val > s->max) { + s->max = val; + } + s->count++; +} + +static int cmp_uint32(const void *a, const void *b) +{ + uint32_t va = *(const uint32_t *)a; + uint32_t vb = *(const uint32_t *)b; + return (va > vb) - (va < vb); +} + +/* Compute percentile from the timing ring buffer */ +static uint32_t compute_percentile(int pct) +{ + int count = (s_push_count < TIMING_RING_SIZE) ? s_push_count : TIMING_RING_SIZE; + if (count == 0) { + return 0; + } + + /* Sort pre-allocated copy (no heap allocation) */ + memcpy(s_sort_buf, s_push_times, count * sizeof(uint32_t)); + qsort(s_sort_buf, count, sizeof(uint32_t), cmp_uint32); + + int idx = (int)((double)pct / 100.0 * (count - 1)); + return s_sort_buf[idx]; +} + +void bench_outlet_run(void) +{ + int channels = CONFIG_BENCH_CHANNELS; + int rate = CONFIG_BENCH_SAMPLE_RATE; + int duration = CONFIG_BENCH_DURATION; + int report_interval = CONFIG_BENCH_REPORT_INTERVAL; +#ifdef CONFIG_BENCH_SECURITY_ENABLE + int secure = 1; +#else + int secure = 0; +#endif + + ESP_LOGI(TAG, "Outlet benchmark: %dch @ %dHz, %ds, security=%s", channels, rate, duration, + secure ? "on" : "off"); + + /* Create stream info */ + lsl_esp32_stream_info_t info = + lsl_esp32_create_streaminfo(CONFIG_BENCH_STREAM_NAME, "Benchmark", channels, (double)rate, + LSL_ESP32_FMT_FLOAT32, "esp32_bench_outlet"); + if (!info) { + ESP_LOGE(TAG, "Failed to create stream info"); + return; + } + + /* Create outlet */ + lsl_esp32_outlet_t outlet = lsl_esp32_create_outlet(info, 0, 360); + if (!outlet) { + ESP_LOGE(TAG, "Failed to create outlet"); + lsl_esp32_destroy_streaminfo(info); + return; + } + + ESP_LOGI(TAG, "Outlet created, waiting for consumer..."); + while (!lsl_esp32_have_consumers(outlet)) { + vTaskDelay(pdMS_TO_TICKS(500)); + } + ESP_LOGI(TAG, "Consumer connected, starting benchmark"); + + /* Allocate sample buffer */ + float *sample = calloc(channels, sizeof(float)); + if (!sample) { + ESP_LOGE(TAG, "Failed to allocate sample buffer"); + lsl_esp32_destroy_outlet(outlet); + return; + } + + /* Warmup: 2 seconds */ + ESP_LOGI(TAG, "Warmup (2s)..."); + TickType_t pace = xTaskGetTickCount(); + TickType_t delay_ticks = pdMS_TO_TICKS(1000 / rate); + for (int i = 0; i < rate * 2; i++) { + double ts = lsl_esp32_local_clock(); + sample[0] = (float)ts; + for (int ch = 1; ch < channels; ch++) { + sample[ch] = sinf((float)ts * 2.0f * 3.14159f * (float)ch); + } + lsl_esp32_push_sample_f(outlet, sample, 0.0); + vTaskDelayUntil(&pace, delay_ticks); + } + + /* Measurement loop */ + running_stats_t stats; + stats_reset(&stats); + s_push_count = 0; + + int total_samples = (duration > 0) ? rate * duration : 0; + int samples_pushed = 0; + int64_t bench_start = esp_timer_get_time(); + int64_t next_report = bench_start + (int64_t)report_interval * 1000000; + + ESP_LOGI(TAG, "Measurement started"); + + pace = xTaskGetTickCount(); + while (1) { + /* Check duration */ + if (total_samples > 0 && samples_pushed >= total_samples) { + break; + } + + /* Generate sample with embedded timestamp */ + double ts = lsl_esp32_local_clock(); + sample[0] = (float)ts; + for (int ch = 1; ch < channels; ch++) { + sample[ch] = sinf((float)ts * 2.0f * 3.14159f * (float)ch); + } + + /* Time the push */ + int64_t t0 = esp_timer_get_time(); + lsl_esp32_push_sample_f(outlet, sample, 0.0); + int64_t t1 = esp_timer_get_time(); + + uint32_t push_us = (uint32_t)(t1 - t0); + stats_add(&stats, push_us); + + /* Store in ring buffer for percentiles */ + s_push_times[s_push_count % TIMING_RING_SIZE] = push_us; + s_push_count++; + samples_pushed++; + + /* Periodic report */ + if (t1 >= next_report) { + double elapsed = (double)(t1 - bench_start) / 1000000.0; + double actual_rate = (double)samples_pushed / elapsed; + printf("{\"type\":\"progress\"," + "\"t\":%.0f," + "\"samples\":%d," + "\"rate_hz\":%.1f," + "\"push_mean_us\":%lu," + "\"push_max_us\":%lu," + "\"heap\":%lu}\n", + elapsed, samples_pushed, actual_rate, (unsigned long)(stats.sum / stats.count), + (unsigned long)stats.max, (unsigned long)esp_get_free_heap_size()); + next_report = t1 + (int64_t)report_interval * 1000000; + } + + vTaskDelayUntil(&pace, delay_ticks); + } + + int64_t bench_end = esp_timer_get_time(); + double actual_duration = (double)(bench_end - bench_start) / 1000000.0; + double actual_rate = (double)samples_pushed / actual_duration; + + /* Compute stats */ + double mean = (double)stats.sum / stats.count; + double variance = (double)stats.sum_sq / stats.count - mean * mean; + double stddev = (variance > 0) ? sqrt(variance) : 0; + + /* Final summary */ + printf("{\"type\":\"summary\"," + "\"mode\":\"outlet\"," + "\"channels\":%d," + "\"rate\":%d," + "\"security\":%s," + "\"duration\":%.1f," + "\"samples_pushed\":%d," + "\"actual_rate\":%.1f," + "\"push_mean_us\":%.1f," + "\"push_std_us\":%.1f," + "\"push_min_us\":%lu," + "\"push_max_us\":%lu," + "\"push_p95_us\":%lu," + "\"push_p99_us\":%lu," + "\"heap_free\":%lu," + "\"heap_min\":%lu," + "\"stack_hwm\":%u}\n", + channels, rate, secure ? "true" : "false", actual_duration, samples_pushed, actual_rate, + mean, stddev, (unsigned long)stats.min, (unsigned long)stats.max, + (unsigned long)compute_percentile(95), (unsigned long)compute_percentile(99), + (unsigned long)esp_get_free_heap_size(), (unsigned long)bench_monitor_get_heap_min(), + (unsigned)uxTaskGetStackHighWaterMark(NULL)); + + free(sample); + bench_monitor_print_summary(); + + ESP_LOGI(TAG, "Benchmark complete: %d samples in %.1fs (%.1f Hz)", samples_pushed, + actual_duration, actual_rate); + ESP_LOGI(TAG, "Push: mean=%.1f us, std=%.1f us, p95=%lu us", mean, stddev, + (unsigned long)compute_percentile(95)); + + lsl_esp32_destroy_outlet(outlet); +} diff --git a/liblsl-ESP32/benchmarks/throughput_bench/main/bench_outlet.h b/liblsl-ESP32/benchmarks/throughput_bench/main/bench_outlet.h new file mode 100644 index 0000000..2118b85 --- /dev/null +++ b/liblsl-ESP32/benchmarks/throughput_bench/main/bench_outlet.h @@ -0,0 +1,13 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#ifndef BENCH_OUTLET_H +#define BENCH_OUTLET_H + +/* Run the outlet benchmark. Pushes samples at the configured rate + * and reports timing statistics via serial JSON. Blocks until + * the configured duration expires. */ +void bench_outlet_run(void); + +#endif /* BENCH_OUTLET_H */ diff --git a/liblsl-ESP32/benchmarks/throughput_bench/main/throughput_bench_main.c b/liblsl-ESP32/benchmarks/throughput_bench/main/throughput_bench_main.c new file mode 100644 index 0000000..c1ce4c4 --- /dev/null +++ b/liblsl-ESP32/benchmarks/throughput_bench/main/throughput_bench_main.c @@ -0,0 +1,133 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +/* LSL Throughput Benchmark for ESP32 + * + * Measures push/pull timing, throughput, jitter, and resource usage + * for both encrypted and unencrypted LSL streaming. + * + * Configure via: idf.py menuconfig -> Benchmark Configuration + * + * Output: JSON lines on serial for desktop collection. + * + * Usage: + * Terminal 1: idf.py -p PORT flash monitor + * Terminal 2: uv run python serial_monitor.py --port PORT + * Terminal 3: uv run python esp32_benchmark_inlet.py (if outlet mode) + */ + +#include "bench_inlet.h" +#include "bench_monitor.h" +#include "bench_outlet.h" +#include "lsl_esp32.h" +#include "wifi_helper.h" +#include "esp_log.h" +#include "esp_system.h" +#include "nvs_flash.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +static const char *TAG = "bench_main"; + +/* Key provisioning (same pattern as secure examples) */ +static int provision_keys(void) +{ +#if CONFIG_BENCH_SECURITY_ENABLE + if (lsl_esp32_has_keypair()) { + ESP_LOGI(TAG, "Keypair already in NVS"); + return 0; + } + + const char *pub = CONFIG_LSL_SECURITY_PUBKEY; + const char *priv = CONFIG_LSL_SECURITY_PRIVKEY; + + if (pub[0] != '\0' && priv[0] != '\0') { + ESP_LOGI(TAG, "Importing keypair from config..."); + if (lsl_esp32_import_keypair(pub, priv) == LSL_ESP32_OK) { + return 0; + } + ESP_LOGE(TAG, "Key import failed"); + return -1; + } + + ESP_LOGI(TAG, "Generating new keypair..."); + if (lsl_esp32_generate_keypair() != LSL_ESP32_OK) { + ESP_LOGE(TAG, "Key generation failed"); + return -1; + } + char pk[LSL_ESP32_KEY_BASE64_SIZE]; + if (lsl_esp32_export_pubkey(pk, sizeof(pk)) == LSL_ESP32_OK) { + ESP_LOGI(TAG, "Public key: %s", pk); + } +#endif + return 0; +} + +void app_main(void) +{ + /* NVS init */ + esp_err_t ret = nvs_flash_init(); + if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_ERROR_CHECK(nvs_flash_erase()); + ret = nvs_flash_init(); + } + ESP_ERROR_CHECK(ret); + + /* Security setup */ +#if CONFIG_BENCH_SECURITY_ENABLE + if (provision_keys() != 0) { + ESP_LOGE(TAG, "Key provisioning failed"); + return; + } + lsl_esp32_err_t sec_err = lsl_esp32_enable_security(); + if (sec_err != LSL_ESP32_OK) { + ESP_LOGE(TAG, "Failed to enable security: %d", sec_err); + return; + } + ESP_LOGI(TAG, "Security enabled"); +#else + ESP_LOGI(TAG, "Security disabled (plaintext mode)"); +#endif + + /* WiFi */ + ESP_LOGI(TAG, "Connecting to WiFi..."); + if (wifi_helper_init_sta() != ESP_OK) { + ESP_LOGE(TAG, "WiFi failed"); + return; + } + + /* Print config header as JSON */ + printf("{\"type\":\"config\"," + "\"mode\":\"%s\"," + "\"channels\":%d," + "\"rate\":%d," + "\"duration\":%d," + "\"security\":%s," + "\"heap_at_start\":%lu}\n", +#if CONFIG_BENCH_MODE_OUTLET + "outlet", +#else + "inlet", +#endif + CONFIG_BENCH_CHANNELS, CONFIG_BENCH_SAMPLE_RATE, CONFIG_BENCH_DURATION, +#if CONFIG_BENCH_SECURITY_ENABLE + "true", +#else + "false", +#endif + (unsigned long)esp_get_free_heap_size()); + + /* Start resource monitor */ + bench_monitor_start(); + + /* Run benchmark */ +#if CONFIG_BENCH_MODE_OUTLET + bench_outlet_run(); +#else + bench_inlet_run(); +#endif + + bench_monitor_stop(); + ESP_LOGI(TAG, "Benchmark finished"); +} diff --git a/liblsl-ESP32/benchmarks/throughput_bench/main/wifi_helper.c b/liblsl-ESP32/benchmarks/throughput_bench/main/wifi_helper.c new file mode 100644 index 0000000..c5ab11a --- /dev/null +++ b/liblsl-ESP32/benchmarks/throughput_bench/main/wifi_helper.c @@ -0,0 +1,128 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#include "wifi_helper.h" +#include "esp_log.h" +#include "esp_wifi.h" +#include "esp_event.h" +#include "esp_netif.h" +#include "freertos/FreeRTOS.h" +#include "freertos/event_groups.h" +#include + +static const char *TAG = "wifi_helper"; + +#define WIFI_CONNECTED_BIT BIT0 +#define WIFI_FAIL_BIT BIT1 +#define WIFI_CONNECT_TIMEOUT_MS 30000 + +static EventGroupHandle_t s_wifi_event_group; +static int s_retry_num = 0; +static esp_netif_t *s_sta_netif = NULL; + +static void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *data) +{ + if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) { + esp_err_t err = esp_wifi_connect(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_wifi_connect failed on STA_START: %s", esp_err_to_name(err)); + xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT); + } + } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { + wifi_event_sta_disconnected_t *disc = (wifi_event_sta_disconnected_t *)data; + ESP_LOGW(TAG, "Disconnected: reason=%d", disc->reason); + if (s_retry_num < CONFIG_ESP_MAXIMUM_RETRY) { + esp_err_t err = esp_wifi_connect(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_wifi_connect retry failed: %s", esp_err_to_name(err)); + xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT); + return; + } + s_retry_num++; + ESP_LOGI(TAG, "Retrying WiFi connection (%d/%d)", s_retry_num, + CONFIG_ESP_MAXIMUM_RETRY); + } else { + ESP_LOGE(TAG, "WiFi connection failed after %d retries", CONFIG_ESP_MAXIMUM_RETRY); + s_retry_num = 0; /* allow future reconnection attempts */ + xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT); + } + } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { + ip_event_got_ip_t *event = (ip_event_got_ip_t *)data; + ESP_LOGI(TAG, "Connected. IP: " IPSTR, IP2STR(&event->ip_info.ip)); + s_retry_num = 0; + xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT); + } +} + +esp_err_t wifi_helper_init_sta(void) +{ + s_wifi_event_group = xEventGroupCreate(); + if (!s_wifi_event_group) { + ESP_LOGE(TAG, "Failed to create WiFi event group"); + return ESP_ERR_NO_MEM; + } + + ESP_ERROR_CHECK(esp_netif_init()); + ESP_ERROR_CHECK(esp_event_loop_create_default()); + s_sta_netif = esp_netif_create_default_wifi_sta(); + if (!s_sta_netif) { + ESP_LOGE(TAG, "Failed to create default WiFi STA netif"); + vEventGroupDelete(s_wifi_event_group); + return ESP_ERR_NO_MEM; + } + + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + + ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, + &event_handler, NULL, NULL)); + ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, + &event_handler, NULL, NULL)); + + wifi_config_t wifi_config = { + .sta = + { + .ssid = CONFIG_ESP_WIFI_SSID, + .password = CONFIG_ESP_WIFI_PASSWORD, + }, + }; + + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); + ESP_ERROR_CHECK(esp_wifi_start()); + + ESP_LOGI(TAG, "Connecting to %s...", CONFIG_ESP_WIFI_SSID); + + /* Wait with timeout to avoid permanent hang */ + EventBits_t bits = + xEventGroupWaitBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, pdFALSE, + pdFALSE, pdMS_TO_TICKS(WIFI_CONNECT_TIMEOUT_MS)); + + if (bits & WIFI_CONNECTED_BIT) { + return ESP_OK; + } + if (bits & WIFI_FAIL_BIT) { + ESP_LOGE(TAG, "WiFi connection failed after retries"); + return ESP_FAIL; + } + + ESP_LOGE(TAG, "WiFi connection timed out (%d ms)", WIFI_CONNECT_TIMEOUT_MS); + return ESP_ERR_TIMEOUT; +} + +esp_err_t wifi_helper_get_ip_str(char *out, size_t out_len) +{ + if (!s_sta_netif || !out || out_len < 16) { + return ESP_ERR_INVALID_ARG; + } + + esp_netif_ip_info_t ip_info; + esp_err_t err = esp_netif_get_ip_info(s_sta_netif, &ip_info); + if (err != ESP_OK) { + return err; + } + + snprintf(out, out_len, IPSTR, IP2STR(&ip_info.ip)); + return ESP_OK; +} diff --git a/liblsl-ESP32/benchmarks/throughput_bench/main/wifi_helper.h b/liblsl-ESP32/benchmarks/throughput_bench/main/wifi_helper.h new file mode 100644 index 0000000..cf88092 --- /dev/null +++ b/liblsl-ESP32/benchmarks/throughput_bench/main/wifi_helper.h @@ -0,0 +1,20 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#ifndef WIFI_HELPER_H +#define WIFI_HELPER_H + +#include "esp_err.h" + +/* Initialize WiFi in station mode and connect to the configured AP. + * Blocks until connected, max retries exhausted, or 30s timeout. + * Returns ESP_OK on success, ESP_FAIL on connection failure, + * ESP_ERR_TIMEOUT on timeout, ESP_ERR_NO_MEM on resource failure. */ +esp_err_t wifi_helper_init_sta(void); + +/* Get the local IPv4 address as a string (e.g., "192.168.1.50"). + * out must be at least 16 bytes. Returns ESP_OK on success. */ +esp_err_t wifi_helper_get_ip_str(char *out, size_t out_len); + +#endif /* WIFI_HELPER_H */ diff --git a/liblsl-ESP32/benchmarks/throughput_bench/sdkconfig.defaults b/liblsl-ESP32/benchmarks/throughput_bench/sdkconfig.defaults new file mode 100644 index 0000000..2ada8dd --- /dev/null +++ b/liblsl-ESP32/benchmarks/throughput_bench/sdkconfig.defaults @@ -0,0 +1,25 @@ +# ESP32 target +CONFIG_IDF_TARGET="esp32" +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y + +# Stack size for main task +CONFIG_ESP_MAIN_TASK_STACK_SIZE=10240 + +# Logging (INFO for benchmark output) +CONFIG_LOG_DEFAULT_LEVEL_INFO=y + +# WiFi +CONFIG_ESP_WIFI_SSID="your_ssid" +CONFIG_ESP_WIFI_PASSWORD="your_password" + +# IGMP for multicast (LSL discovery) +CONFIG_LWIP_IGMP=y + +# 1000Hz tick rate for precise timing +CONFIG_FREERTOS_HZ=1000 + +# Extended watchdog timeout for long benchmarks +CONFIG_ESP_TASK_WDT_TIMEOUT_S=120 + +# Performance optimization +CONFIG_COMPILER_OPTIMIZATION_PERF=y diff --git a/liblsl-ESP32/components/liblsl_esp32/CMakeLists.txt b/liblsl-ESP32/components/liblsl_esp32/CMakeLists.txt new file mode 100644 index 0000000..70c584d --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/CMakeLists.txt @@ -0,0 +1,21 @@ +idf_component_register( + SRCS "src/lsl_clock.c" + "src/lsl_stream_info.c" + "src/lsl_sample.c" + "src/lsl_udp_server.c" + "src/lsl_ring_buffer.c" + "src/lsl_tcp_server.c" + "src/lsl_outlet.c" + "src/lsl_xml_parser.c" + "src/lsl_resolver.c" + "src/lsl_esp32.c" + "src/lsl_tcp_client.c" + "src/lsl_inlet.c" + "src/lsl_key_manager.c" + "src/lsl_security.c" + "src/lsl_tcp_common.c" + INCLUDE_DIRS "include" + PRIV_INCLUDE_DIRS "src" + REQUIRES esp_timer lwip esp_netif + PRIV_REQUIRES nvs_flash libsodium +) diff --git a/liblsl-ESP32/components/liblsl_esp32/idf_component.yml b/liblsl-ESP32/components/liblsl_esp32/idf_component.yml new file mode 100644 index 0000000..d9ca53c --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/idf_component.yml @@ -0,0 +1,4 @@ +version: "0.1.0" +description: "LSL protocol implementation for ESP32" +dependencies: + espressif/libsodium: "^1.0.20~4" diff --git a/liblsl-ESP32/components/liblsl_esp32/include/lsl_esp32.h b/liblsl-ESP32/components/liblsl_esp32/include/lsl_esp32.h new file mode 100644 index 0000000..ba5a116 --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/include/lsl_esp32.h @@ -0,0 +1,170 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#ifndef LSL_ESP32_H +#define LSL_ESP32_H + +#include "lsl_esp32_types.h" + +/* --- Clock --- */ + +/* Returns the current time in seconds (monotonic, high-resolution). + * This is the timestamp source for LSL samples on ESP32. */ +double lsl_esp32_local_clock(void); + +/* --- Resolver --- */ + +/* Resolve a stream by property. + * prop: "name", "type", or "source_id" (NULL means find all) + * value: value to match (NULL means find all) + * timeout: seconds to wait for discovery responses + * result: receives the first matching stream info (caller must free with + * lsl_esp32_destroy_streaminfo if not passed to create_inlet) + * + * Returns 1 if a stream was found, 0 if not found within timeout. */ +int lsl_esp32_resolve_stream(const char *prop, const char *value, double timeout, + lsl_esp32_stream_info_t *result); + +/* Resolve all visible streams. + * results: array of stream info pointers (caller must free each) + * max_results: size of results array + * + * Returns number of streams found. */ +int lsl_esp32_resolve_streams(double timeout, lsl_esp32_stream_info_t *results, int max_results); + +/* --- Stream info --- */ + +/* Create a stream info descriptor. + * name: human-readable stream name, must not be NULL (e.g., "EEG_Stream") + * type: content type, or NULL for empty (e.g., "EEG", "Markers") + * channel_count: number of channels (1 to LSL_ESP32_MAX_CHANNELS) + * nominal_srate: nominal sampling rate in Hz (0 for irregular, must be >= 0) + * channel_format: data type per channel (must be a valid LSL_ESP32_FMT_* value) + * source_id: unique identifier for this data source, or NULL for empty */ +lsl_esp32_stream_info_t lsl_esp32_create_streaminfo(const char *name, const char *type, + int channel_count, double nominal_srate, + lsl_esp32_channel_format_t channel_format, + const char *source_id); + +/* Stream info accessors (read-only access to opaque handle fields). + * All are NULL-safe: string accessors return "" if info is NULL, + * get_channel_count returns 0, get_nominal_srate returns 0.0, + * get_channel_format returns 0 (not a valid enum value). */ +const char *lsl_esp32_get_name(lsl_esp32_stream_info_t info); +const char *lsl_esp32_get_type(lsl_esp32_stream_info_t info); +int lsl_esp32_get_channel_count(lsl_esp32_stream_info_t info); +double lsl_esp32_get_nominal_srate(lsl_esp32_stream_info_t info); +lsl_esp32_channel_format_t lsl_esp32_get_channel_format(lsl_esp32_stream_info_t info); + +/* Free a stream info descriptor. + * Do not call if the info was passed to lsl_esp32_create_outlet + * (the outlet takes ownership and frees it on destroy). */ +void lsl_esp32_destroy_streaminfo(lsl_esp32_stream_info_t info); + +/* --- Security --- */ + +/* --- Key Management --- */ + +/* Key sizes for Ed25519 */ +#define LSL_ESP32_KEY_PUBLIC_SIZE 32 +#define LSL_ESP32_KEY_SECRET_SIZE 64 +#define LSL_ESP32_KEY_BASE64_SIZE 45 /* base64 of 32-byte public key + null */ +#define LSL_ESP32_KEY_SBASE64_SIZE 89 /* base64 of 64-byte secret key + null */ + +/* Generate a new Ed25519 keypair and store in NVS. + * Initializes libsodium internally if needed. + * Returns LSL_ESP32_OK on success. */ +lsl_esp32_err_t lsl_esp32_generate_keypair(void); + +/* Import a keypair from base64-encoded strings and store in NVS. + * Both keys must be valid base64 with correct sizes. + * Returns LSL_ESP32_OK on success. */ +lsl_esp32_err_t lsl_esp32_import_keypair(const char *base64_pub, const char *base64_priv); + +/* Export the public key as a base64 string. + * out must be at least LSL_ESP32_KEY_BASE64_SIZE bytes. + * Returns LSL_ESP32_OK on success, LSL_ESP32_ERR_NOT_FOUND if no key. */ +lsl_esp32_err_t lsl_esp32_export_pubkey(char *out, size_t out_len); + +/* Check if a keypair is provisioned in NVS. + * Returns 1 if keys exist, 0 if not. */ +int lsl_esp32_has_keypair(void); + +/* Enable secureLSL encryption for all subsequent outlets and inlets. + * Must be called BEFORE creating outlets/inlets. Requires a keypair + * to be provisioned in NVS (via key_manager_generate or key_manager_import). + * This is a one-way toggle for the process lifetime; there is no + * disable function. ESP32 lab devices typically run one security mode + * for their entire uptime. To switch modes, reboot. + * Returns LSL_ESP32_OK on success, LSL_ESP32_ERR_NOT_FOUND if no keypair, + * or LSL_ESP32_ERR_SECURITY if libsodium initialization fails. */ +lsl_esp32_err_t lsl_esp32_enable_security(void); + +/* Check if security is currently enabled. + * Returns 1 if enabled, 0 if not. */ +int lsl_esp32_security_enabled(void); + +/* --- Outlet --- */ + +/* Create an outlet for pushing samples to the network. + * Starts UDP discovery and TCP data servers automatically. + * Requires WiFi to be connected (uses STA interface for IP). + * + * info: stream info descriptor (ownership transferred to outlet on success; + * caller must free on failure) + * chunk_size: preferred chunk size (0 = per-sample) + * max_buffered: reserved for future use (currently ignored; fixed 64-slot buffer) */ +lsl_esp32_outlet_t lsl_esp32_create_outlet(lsl_esp32_stream_info_t info, int chunk_size, + int max_buffered); + +/* Destroy the outlet, stopping all network servers and freeing all resources + * including the associated stream info. */ +void lsl_esp32_destroy_outlet(lsl_esp32_outlet_t outlet); + +/* Push a single sample. timestamp=0 means use local_clock(). + * The type suffix must match the outlet's channel_format: + * _f = float32, _d = double64, _i = int32, _s = int16, _c = int8. + * Returns LSL_ESP32_ERR_INVALID_ARG on format mismatch. */ +lsl_esp32_err_t lsl_esp32_push_sample_f(lsl_esp32_outlet_t outlet, const float *data, + double timestamp); + +lsl_esp32_err_t lsl_esp32_push_sample_d(lsl_esp32_outlet_t outlet, const double *data, + double timestamp); + +lsl_esp32_err_t lsl_esp32_push_sample_i(lsl_esp32_outlet_t outlet, const int32_t *data, + double timestamp); + +lsl_esp32_err_t lsl_esp32_push_sample_s(lsl_esp32_outlet_t outlet, const int16_t *data, + double timestamp); + +lsl_esp32_err_t lsl_esp32_push_sample_c(lsl_esp32_outlet_t outlet, const int8_t *data, + double timestamp); + +/* Returns 1 if any consumers are connected, 0 otherwise */ +int lsl_esp32_have_consumers(lsl_esp32_outlet_t outlet); + +/* --- Inlet --- */ + +/* Create an inlet to receive samples from a remote outlet. + * info: stream info from resolve (ownership transferred to inlet on success; + * caller must free on failure) + * Connects to the outlet's TCP port and validates test patterns. + * Returns the inlet handle on success, or NULL on failure. */ +lsl_esp32_inlet_t lsl_esp32_create_inlet(lsl_esp32_stream_info_t info); + +/* Destroy the inlet, closing the TCP connection and freeing all resources + * including the associated stream info. */ +void lsl_esp32_destroy_inlet(lsl_esp32_inlet_t inlet); + +/* Pull a single float32 sample from the inlet. + * buf: output buffer (must hold channel_count floats) + * buf_len: size of buf in bytes + * timestamp: receives the sample timestamp + * timeout: seconds to wait (0 = non-blocking) + * Returns LSL_ESP32_OK, LSL_ESP32_ERR_TIMEOUT, LSL_ESP32_ERR_INVALID_ARG, + * or LSL_ESP32_ERR_NO_MEMORY. */ +lsl_esp32_err_t lsl_esp32_inlet_pull_sample_f(lsl_esp32_inlet_t inlet, float *buf, int buf_len, + double *timestamp, double timeout); + +#endif /* LSL_ESP32_H */ diff --git a/liblsl-ESP32/components/liblsl_esp32/include/lsl_esp32_types.h b/liblsl-ESP32/components/liblsl_esp32/include/lsl_esp32_types.h new file mode 100644 index 0000000..36dbf08 --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/include/lsl_esp32_types.h @@ -0,0 +1,52 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#ifndef LSL_ESP32_TYPES_H +#define LSL_ESP32_TYPES_H + +#include +#include + +/* --- Error codes --- */ +typedef enum { + LSL_ESP32_OK = 0, + LSL_ESP32_ERR_NO_MEMORY, + LSL_ESP32_ERR_NETWORK, + LSL_ESP32_ERR_PROTOCOL, + LSL_ESP32_ERR_SECURITY, + LSL_ESP32_ERR_INVALID_ARG, + LSL_ESP32_ERR_TIMEOUT, + LSL_ESP32_ERR_NOT_FOUND, +} lsl_esp32_err_t; + +/* --- Channel formats (matching liblsl wire values) --- + * Values 0 (undefined), 3 (string32), 7 (int64) intentionally absent; + * ESP32 implementation supports numeric types only. */ +typedef enum { + LSL_ESP32_FMT_FLOAT32 = 1, + LSL_ESP32_FMT_DOUBLE64 = 2, + /* 3 = cft_string32, not supported */ + LSL_ESP32_FMT_INT32 = 4, + LSL_ESP32_FMT_INT16 = 5, + LSL_ESP32_FMT_INT8 = 6, + /* 7 = cft_int64, not supported */ +} lsl_esp32_channel_format_t; + +/* --- Public protocol constants --- */ +#define LSL_ESP32_MULTICAST_ADDR "239.255.172.215" +#define LSL_ESP32_MULTICAST_PORT 16571 +#define LSL_ESP32_PROTOCOL_VERSION 110 +#define LSL_ESP32_MAX_CONNECTIONS 3 +#define LSL_ESP32_SAMPLE_POOL_SIZE 64 +#define LSL_ESP32_MAX_CHANNELS 128 +#define LSL_ESP32_UUID_STR_LEN 37 /* 36 chars + null */ +#define LSL_ESP32_TCP_PORT_MIN 16572 +#define LSL_ESP32_TCP_PORT_MAX 16604 + +/* --- Opaque handles --- */ +typedef struct lsl_esp32_stream_info *lsl_esp32_stream_info_t; +typedef struct lsl_esp32_outlet *lsl_esp32_outlet_t; +typedef struct lsl_esp32_inlet *lsl_esp32_inlet_t; + +#endif /* LSL_ESP32_TYPES_H */ diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_clock.c b/liblsl-ESP32/components/liblsl_esp32/src/lsl_clock.c new file mode 100644 index 0000000..0750da0 --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_clock.c @@ -0,0 +1,17 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#include "lsl_clock.h" +#include "lsl_esp32.h" +#include "esp_timer.h" + +double clock_get_time(void) +{ + return (double)esp_timer_get_time() / 1000000.0; +} + +double lsl_esp32_local_clock(void) +{ + return clock_get_time(); +} diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_clock.h b/liblsl-ESP32/components/liblsl_esp32/src/lsl_clock.h new file mode 100644 index 0000000..6e05cd7 --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_clock.h @@ -0,0 +1,12 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#ifndef LSL_CLOCK_H +#define LSL_CLOCK_H + +/* Internal clock interface. + * Returns monotonic time in seconds as a double. */ +double clock_get_time(void); + +#endif /* LSL_CLOCK_H */ diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_esp32.c b/liblsl-ESP32/components/liblsl_esp32/src/lsl_esp32.c new file mode 100644 index 0000000..6985ddd --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_esp32.c @@ -0,0 +1,136 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +/* Public API entry points for liblsl_esp32. + * Thin wrappers that delegate to internal modules. */ + +#include "lsl_esp32.h" +#include "lsl_resolver.h" +#include "lsl_stream_info.h" +#include "lsl_key_manager.h" +#include "esp_log.h" +#include "sodium.h" +#include +#include + +static const char *TAG = "lsl_esp32"; + +/* Global security state. Not thread-safe; must be set before creating outlets/inlets. */ +static volatile int s_security_enabled = 0; + +lsl_esp32_err_t lsl_esp32_generate_keypair(void) +{ + if (sodium_init() < 0) { + return LSL_ESP32_ERR_SECURITY; + } + return key_manager_generate(); +} + +lsl_esp32_err_t lsl_esp32_import_keypair(const char *base64_pub, const char *base64_priv) +{ + return key_manager_import(base64_pub, base64_priv); +} + +lsl_esp32_err_t lsl_esp32_export_pubkey(char *out, size_t out_len) +{ + return key_manager_export_pubkey(out, out_len); +} + +int lsl_esp32_has_keypair(void) +{ + return key_manager_is_enabled(); +} + +lsl_esp32_err_t lsl_esp32_enable_security(void) +{ + if (sodium_init() < 0) { + ESP_LOGE(TAG, "sodium_init failed"); + return LSL_ESP32_ERR_SECURITY; + } + + if (!key_manager_is_enabled()) { + ESP_LOGE(TAG, "No keypair provisioned in NVS; call key_manager_generate first"); + return LSL_ESP32_ERR_NOT_FOUND; + } + + /* Verify we can actually load the keys */ + uint8_t pk[LSL_KEY_PUBLIC_SIZE]; + uint8_t sk[LSL_KEY_SECRET_SIZE]; + lsl_esp32_err_t err = key_manager_load(pk, sk); + sodium_memzero(sk, sizeof(sk)); + sodium_memzero(pk, sizeof(pk)); + + if (err != LSL_ESP32_OK) { + ESP_LOGE(TAG, "Failed to load keypair from NVS"); + return err; + } + + s_security_enabled = 1; + ESP_LOGI(TAG, "Security enabled globally"); + return LSL_ESP32_OK; +} + +int lsl_esp32_security_enabled(void) +{ + return s_security_enabled; +} + +int lsl_esp32_resolve_stream(const char *prop, const char *value, double timeout, + lsl_esp32_stream_info_t *result) +{ + if (!result) { + return 0; + } + + /* Allocate heap struct and pass directly to resolver (avoids double-copy) */ + struct lsl_esp32_stream_info *info = malloc(sizeof(*info)); + if (!info) { + ESP_LOGE(TAG, "Failed to allocate stream info for resolve result"); + *result = NULL; + return 0; + } + + int n = resolver_find(prop, value, timeout, info, 1); + if (n < 1) { + free(info); + *result = NULL; + return 0; + } + + *result = info; + return 1; +} + +int lsl_esp32_resolve_streams(double timeout, lsl_esp32_stream_info_t *results, int max_results) +{ + if (!results || max_results < 1) { + return 0; + } + + /* Clamp to resolver maximum to bound memory usage */ + if (max_results > LSL_RESOLVER_MAX_RESULTS) { + max_results = LSL_RESOLVER_MAX_RESULTS; + } + + /* Use stack-allocated buffer for resolver results */ + struct lsl_esp32_stream_info found[LSL_RESOLVER_MAX_RESULTS]; + + int n = resolver_find_all(timeout, found, max_results); + + /* Allocate individual heap handles for each result */ + for (int i = 0; i < n; i++) { + struct lsl_esp32_stream_info *info = malloc(sizeof(*info)); + if (!info) { + for (int j = 0; j < i; j++) { + free(results[j]); + results[j] = NULL; + } + return 0; + } + memcpy(info, &found[i], sizeof(*info)); + results[i] = info; + } + + return n; +} diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_inlet.c b/liblsl-ESP32/components/liblsl_esp32/src/lsl_inlet.c new file mode 100644 index 0000000..180f655 --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_inlet.c @@ -0,0 +1,307 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#include "lsl_inlet.h" +#include "lsl_esp32.h" +#include "lsl_tcp_client.h" +#include "lsl_tcp_common.h" +#include "lsl_protocol.h" +#include "lsl_sample.h" +#include "lsl_clock.h" +#include "esp_log.h" +#include "sodium.h" + +#include "lwip/sockets.h" +#include +#include + +static const char *TAG = "lsl_inlet"; + +#define INLET_RECV_STACK 6144 +#define INLET_RECV_PRIO 7 + +/* Queue item layout: [double timestamp][channel_data bytes] */ + +static void inlet_recv_task(void *arg) +{ + struct lsl_esp32_inlet *inlet = (struct lsl_esp32_inlet *)arg; + /* Session state is fixed for the connection's lifetime: established + * before this task starts, or never activated. Does not change mid-stream. */ + int encrypted = inlet->session.active; + size_t wire_size = 1 + 8 + inlet->sample_data_size; /* max: tag + ts + data */ + uint8_t *wire_buf = malloc(wire_size); + uint8_t *queue_buf = malloc(inlet->queue_item_size); + + /* Allocate ciphertext buffer only when encrypted */ + size_t ct_size = wire_size + LSL_SECURITY_AUTH_TAG_SIZE; + uint8_t *ct_buf = encrypted ? malloc(ct_size) : NULL; + + if (!wire_buf || !queue_buf || (encrypted && !ct_buf)) { + ESP_LOGE(TAG, "Failed to allocate receive buffers"); + free(wire_buf); + free(queue_buf); + free(ct_buf); + inlet->connected = false; + xEventGroupSetBits(inlet->events, INLET_STOPPED_BIT); + vTaskDelete(NULL); + return; + } + + ESP_LOGI(TAG, "Inlet receiver task started (sample_size=%zu, encrypted=%d)", + inlet->sample_data_size, encrypted); + + while (inlet->active && inlet->connected) { + size_t sample_len; + + if (encrypted) { + /* Read encrypted chunk and decrypt */ + int pt_len = tcp_recv_encrypted_chunk(inlet->sock, &inlet->session, wire_buf, wire_size, + ct_buf, ct_size); + if (pt_len < 0) { + ESP_LOGW(TAG, "Encrypted stream error: connection lost or auth failure"); + break; + } + sample_len = (size_t)pt_len; + } else { + /* Read unencrypted: tag byte first, then rest */ + uint8_t tag; + if (tcp_recv_exact(inlet->sock, &tag, 1) < 0) { + ESP_LOGI(TAG, "Connection lost (tag read failed)"); + break; + } + + size_t to_read = 0; + if (tag == LSL_ESP32_TAG_TRANSMITTED) { + to_read = 8 + inlet->sample_data_size; + } else if (tag == LSL_ESP32_TAG_DEDUCED) { + to_read = inlet->sample_data_size; + } else { + ESP_LOGW(TAG, "Unknown tag byte: 0x%02x", tag); + break; + } + + wire_buf[0] = tag; + if (tcp_recv_exact(inlet->sock, wire_buf + 1, to_read) < 0) { + ESP_LOGI(TAG, "Connection lost (data read failed)"); + break; + } + sample_len = 1 + to_read; + } + + /* Deserialize */ + double timestamp = 0.0; + int consumed = sample_deserialize(wire_buf, sample_len, inlet->info->channel_count, + inlet->info->channel_format, queue_buf + sizeof(double), + inlet->sample_data_size, ×tamp); + if (consumed <= 0) { + if (encrypted) { + /* Auth-decrypt succeeded but deserialization failed: protocol mismatch */ + ESP_LOGE(TAG, "Deserialization failed after decryption (len=%zu), disconnecting", + sample_len); + break; + } + ESP_LOGW(TAG, "Failed to deserialize sample"); + continue; + } + + /* Pack timestamp + channel data into queue item */ + memcpy(queue_buf, ×tamp, sizeof(double)); + + /* Push to queue (non-blocking; drop oldest if full) */ + if (xQueueSend(inlet->sample_queue, queue_buf, 0) != pdTRUE) { + xQueueReceive(inlet->sample_queue, wire_buf, 0); /* reuse wire_buf as discard */ + xQueueSend(inlet->sample_queue, queue_buf, 0); + inlet->drop_count++; + if (inlet->drop_count == 1 || inlet->drop_count % 100 == 0) { + ESP_LOGW(TAG, "Queue overflow: %lu samples dropped total", + (unsigned long)inlet->drop_count); + } + } + } + + free(wire_buf); + free(queue_buf); + free(ct_buf); + inlet->connected = false; + + ESP_LOGI(TAG, "Inlet receiver task stopped"); + xEventGroupSetBits(inlet->events, INLET_STOPPED_BIT); + vTaskDelete(NULL); +} + +lsl_esp32_inlet_t lsl_esp32_create_inlet(lsl_esp32_stream_info_t info) +{ + if (!info) { + ESP_LOGE(TAG, "NULL stream info"); + return NULL; + } + + size_t bpc = stream_info_bytes_per_channel(info->channel_format); + if (bpc == 0) { + ESP_LOGE(TAG, "Invalid channel format: %d", info->channel_format); + return NULL; + } + + struct lsl_esp32_inlet *inlet = calloc(1, sizeof(*inlet)); + if (!inlet) { + ESP_LOGE(TAG, "Failed to allocate inlet"); + return NULL; + } + + /* Take ownership of stream info */ + inlet->info = info; + inlet->sample_data_size = (size_t)info->channel_count * bpc; + inlet->queue_item_size = sizeof(double) + inlet->sample_data_size; + + /* Create event group for shutdown */ + inlet->events = xEventGroupCreate(); + if (!inlet->events) { + ESP_LOGE(TAG, "Failed to create event group"); + free(inlet); + return NULL; + } + + /* Create sample queue */ + inlet->sample_queue = xQueueCreate(INLET_QUEUE_SLOTS, inlet->queue_item_size); + if (!inlet->sample_queue) { + ESP_LOGE(TAG, "Failed to create sample queue (%d x %zu bytes)", INLET_QUEUE_SLOTS, + inlet->queue_item_size); + vEventGroupDelete(inlet->events); + free(inlet); + return NULL; + } + + /* Load security configuration (if globally enabled via lsl_esp32_enable_security) */ + security_session_init(&inlet->session); + lsl_esp32_err_t sec_err = security_config_load(&inlet->security); + if (sec_err != LSL_ESP32_OK) { + vQueueDelete(inlet->sample_queue); + vEventGroupDelete(inlet->events); + free(inlet); + return NULL; + } + if (inlet->security.enabled) { + ESP_LOGI(TAG, "Security enabled for inlet"); + } else { + ESP_LOGI(TAG, "Security not enabled for inlet (plaintext mode)"); + } + + /* Connect to outlet via TCP */ + const lsl_security_config_t *sec_ptr = inlet->security.enabled ? &inlet->security : NULL; + inlet->sock = tcp_client_connect(info, sec_ptr, &inlet->session); + if (inlet->sock < 0) { + ESP_LOGE(TAG, "Failed to connect to outlet"); + security_config_clear(&inlet->security); + security_session_clear(&inlet->session); + vQueueDelete(inlet->sample_queue); + vEventGroupDelete(inlet->events); + free(inlet); + return NULL; + } + + inlet->active = true; + inlet->connected = true; + + /* Start receiver task */ + BaseType_t ret = xTaskCreatePinnedToCore(inlet_recv_task, "lsl_inlet", INLET_RECV_STACK, + (void *)inlet, INLET_RECV_PRIO, &inlet->recv_task, 1); + if (ret != pdPASS) { + ESP_LOGE(TAG, "Failed to create receiver task"); + close(inlet->sock); + security_config_clear(&inlet->security); + security_session_clear(&inlet->session); + vQueueDelete(inlet->sample_queue); + vEventGroupDelete(inlet->events); + free(inlet); + return NULL; + } + + ESP_LOGI(TAG, "Inlet created: %s (%dch %s @ %.0fHz)", info->name, info->channel_count, + stream_info_format_string(info->channel_format), info->nominal_srate); + + return inlet; +} + +void lsl_esp32_destroy_inlet(lsl_esp32_inlet_t inlet) +{ + if (!inlet) { + return; + } + + ESP_LOGI(TAG, "Destroying inlet: %s", inlet->info->name); + inlet->active = false; + __sync_synchronize(); + + /* Close socket to unblock recv */ + if (inlet->sock >= 0) { + close(inlet->sock); + inlet->sock = -1; + } + + /* Wait for receiver task to exit */ + if (inlet->events) { + EventBits_t bits = xEventGroupWaitBits(inlet->events, INLET_STOPPED_BIT, pdFALSE, pdFALSE, + pdMS_TO_TICKS(5000)); + if (!(bits & INLET_STOPPED_BIT)) { + ESP_LOGE(TAG, "Receiver task did not stop within 5s, resources may leak"); + /* Do not free resources the task may still be using */ + return; + } + vEventGroupDelete(inlet->events); + } + + if (inlet->sample_queue) { + vQueueDelete(inlet->sample_queue); + } + + security_session_clear(&inlet->session); + security_config_clear(&inlet->security); + lsl_esp32_destroy_streaminfo(inlet->info); + free(inlet); +} + +lsl_esp32_err_t lsl_esp32_inlet_pull_sample_f(lsl_esp32_inlet_t inlet, float *buf, int buf_len, + double *timestamp, double timeout) +{ + if (!inlet || !inlet->active || !buf || !timestamp) { + return LSL_ESP32_ERR_INVALID_ARG; + } + if (inlet->info->channel_format != LSL_ESP32_FMT_FLOAT32) { + return LSL_ESP32_ERR_INVALID_ARG; + } + + size_t expected = (size_t)inlet->info->channel_count * sizeof(float); + if ((size_t)buf_len < expected) { + return LSL_ESP32_ERR_INVALID_ARG; + } + + /* Stack buffer for queue item (timestamp + channel data). + * Capped at 520 bytes (128ch float32); larger formats use heap. */ + uint8_t item_stack[520]; + uint8_t *item; + uint8_t *item_heap = NULL; + + if (inlet->queue_item_size <= sizeof(item_stack)) { + item = item_stack; + } else { + item_heap = malloc(inlet->queue_item_size); + if (!item_heap) { + return LSL_ESP32_ERR_NO_MEMORY; + } + item = item_heap; + } + TickType_t ticks = (timeout <= 0) ? 0 : pdMS_TO_TICKS((uint32_t)(timeout * 1000.0)); + + if (xQueueReceive(inlet->sample_queue, item, ticks) != pdTRUE) { + free(item_heap); + return LSL_ESP32_ERR_TIMEOUT; + } + + /* Extract timestamp and channel data from queue item */ + memcpy(timestamp, item, sizeof(double)); + memcpy(buf, item + sizeof(double), expected); + + free(item_heap); + return LSL_ESP32_OK; +} diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_inlet.h b/liblsl-ESP32/components/liblsl_esp32/src/lsl_inlet.h new file mode 100644 index 0000000..78abb51 --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_inlet.h @@ -0,0 +1,35 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#ifndef LSL_INLET_H +#define LSL_INLET_H + +#include "lsl_esp32_types.h" +#include "lsl_stream_info.h" +#include "lsl_security.h" +#include "freertos/FreeRTOS.h" +#include "freertos/event_groups.h" +#include "freertos/queue.h" +#include "freertos/task.h" +#include + +#define INLET_STOPPED_BIT BIT0 +#define INLET_QUEUE_SLOTS 32 + +struct lsl_esp32_inlet { + struct lsl_esp32_stream_info *info; /* owned stream info (transferred from caller) */ + int sock; /* TCP socket to outlet */ + QueueHandle_t sample_queue; /* deserialized samples */ + TaskHandle_t recv_task; /* receiver task handle */ + EventGroupHandle_t events; /* shutdown coordination */ + size_t sample_data_size; /* channel_count * bytes_per_channel */ + size_t queue_item_size; /* sizeof(double) + sample_data_size */ + lsl_security_config_t security; + lsl_security_session_t session; /* per-connection security session */ + volatile bool active; + volatile bool connected; + uint32_t drop_count; /* samples dropped due to queue overflow */ +}; + +#endif /* LSL_INLET_H */ diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_key_manager.c b/liblsl-ESP32/components/liblsl_esp32/src/lsl_key_manager.c new file mode 100644 index 0000000..70ab2a9 --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_key_manager.c @@ -0,0 +1,215 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#include "lsl_key_manager.h" +#include "esp_log.h" +#include "nvs_flash.h" +#include "nvs.h" +#include "sodium.h" +#include + +static const char *TAG = "lsl_key_mgr"; +static const char *NVS_NAMESPACE = "lsl_security"; + +lsl_esp32_err_t key_manager_generate(void) +{ + if (sodium_init() < 0) { + ESP_LOGE(TAG, "sodium_init failed"); + return LSL_ESP32_ERR_SECURITY; + } + + uint8_t pk[LSL_KEY_PUBLIC_SIZE]; + uint8_t sk[LSL_KEY_SECRET_SIZE]; + crypto_sign_keypair(pk, sk); + + /* Store in NVS */ + nvs_handle_t handle; + esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to open NVS: %s", esp_err_to_name(err)); + sodium_memzero(sk, sizeof(sk)); + return LSL_ESP32_ERR_SECURITY; + } + + err = nvs_set_blob(handle, "public_key", pk, sizeof(pk)); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to store public key: %s", esp_err_to_name(err)); + nvs_close(handle); + sodium_memzero(sk, sizeof(sk)); + return LSL_ESP32_ERR_SECURITY; + } + + err = nvs_set_blob(handle, "private_key", sk, sizeof(sk)); + sodium_memzero(sk, sizeof(sk)); /* zero immediately after use */ + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to store private key: %s", esp_err_to_name(err)); + nvs_close(handle); + return LSL_ESP32_ERR_SECURITY; + } + + err = nvs_set_u8(handle, "enabled", 1); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to set enabled flag: %s", esp_err_to_name(err)); + nvs_close(handle); + return LSL_ESP32_ERR_SECURITY; + } + err = nvs_commit(handle); + nvs_close(handle); + + if (err != ESP_OK) { + ESP_LOGE(TAG, "NVS commit failed: %s", esp_err_to_name(err)); + return LSL_ESP32_ERR_SECURITY; + } + + /* Log truncated fingerprint, not the full key (shared keypair model: + * the public key is the authorization credential) */ + char b64[LSL_KEY_BASE64_SIZE]; + sodium_bin2base64(b64, sizeof(b64), pk, sizeof(pk), sodium_base64_VARIANT_ORIGINAL); + ESP_LOGI(TAG, "Generated keypair. Public key fingerprint: %.8s...", b64); + + return LSL_ESP32_OK; +} + +lsl_esp32_err_t key_manager_import(const char *base64_pub, const char *base64_priv) +{ + if (!base64_pub || !base64_priv) { + return LSL_ESP32_ERR_INVALID_ARG; + } + + if (sodium_init() < 0) { + return LSL_ESP32_ERR_SECURITY; + } + + uint8_t pk[LSL_KEY_PUBLIC_SIZE]; + uint8_t sk[LSL_KEY_SECRET_SIZE]; + size_t pk_len, sk_len; + + if (sodium_base642bin(pk, sizeof(pk), base64_pub, strlen(base64_pub), NULL, &pk_len, NULL, + sodium_base64_VARIANT_ORIGINAL) != 0 || + pk_len != LSL_KEY_PUBLIC_SIZE) { + ESP_LOGE(TAG, "Invalid base64 public key"); + sodium_memzero(pk, sizeof(pk)); + return LSL_ESP32_ERR_INVALID_ARG; + } + + if (sodium_base642bin(sk, sizeof(sk), base64_priv, strlen(base64_priv), NULL, &sk_len, NULL, + sodium_base64_VARIANT_ORIGINAL) != 0 || + sk_len != LSL_KEY_SECRET_SIZE) { + ESP_LOGE(TAG, "Invalid base64 private key"); + sodium_memzero(sk, sizeof(sk)); + return LSL_ESP32_ERR_INVALID_ARG; + } + + /* Store in NVS */ + nvs_handle_t handle; + esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &handle); + if (err != ESP_OK) { + sodium_memzero(sk, sizeof(sk)); + return LSL_ESP32_ERR_SECURITY; + } + + err = nvs_set_blob(handle, "public_key", pk, sizeof(pk)); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to store imported public key: %s", esp_err_to_name(err)); + nvs_close(handle); + sodium_memzero(sk, sizeof(sk)); + return LSL_ESP32_ERR_SECURITY; + } + + err = nvs_set_blob(handle, "private_key", sk, sizeof(sk)); + sodium_memzero(sk, sizeof(sk)); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to store imported private key: %s", esp_err_to_name(err)); + nvs_close(handle); + return LSL_ESP32_ERR_SECURITY; + } + + err = nvs_set_u8(handle, "enabled", 1); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to set enabled flag: %s", esp_err_to_name(err)); + nvs_close(handle); + return LSL_ESP32_ERR_SECURITY; + } + err = nvs_commit(handle); + nvs_close(handle); + + if (err != ESP_OK) { + ESP_LOGE(TAG, "NVS commit failed: %s", esp_err_to_name(err)); + return LSL_ESP32_ERR_SECURITY; + } + + ESP_LOGI(TAG, "Imported keypair successfully"); + return LSL_ESP32_OK; +} + +lsl_esp32_err_t key_manager_load(uint8_t *pk_out, uint8_t *sk_out) +{ + if (!pk_out || !sk_out) { + return LSL_ESP32_ERR_INVALID_ARG; + } + + nvs_handle_t handle; + esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READONLY, &handle); + if (err != ESP_OK) { + if (err != ESP_ERR_NVS_NOT_FOUND) { + ESP_LOGE(TAG, "Failed to open NVS: %s", esp_err_to_name(err)); + } + return LSL_ESP32_ERR_NOT_FOUND; + } + + size_t pk_len = LSL_KEY_PUBLIC_SIZE; + size_t sk_len = LSL_KEY_SECRET_SIZE; + + err = nvs_get_blob(handle, "public_key", pk_out, &pk_len); + if (err != ESP_OK || pk_len != LSL_KEY_PUBLIC_SIZE) { + if (err != ESP_OK && err != ESP_ERR_NVS_NOT_FOUND) { + ESP_LOGE(TAG, "Failed to read public key: %s", esp_err_to_name(err)); + } + nvs_close(handle); + sodium_memzero(pk_out, LSL_KEY_PUBLIC_SIZE); + sodium_memzero(sk_out, LSL_KEY_SECRET_SIZE); + return LSL_ESP32_ERR_NOT_FOUND; + } + + err = nvs_get_blob(handle, "private_key", sk_out, &sk_len); + if (err != ESP_OK || sk_len != LSL_KEY_SECRET_SIZE) { + nvs_close(handle); + sodium_memzero(sk_out, LSL_KEY_SECRET_SIZE); + return LSL_ESP32_ERR_NOT_FOUND; + } + + nvs_close(handle); + return LSL_ESP32_OK; +} + +lsl_esp32_err_t key_manager_export_pubkey(char *out, size_t out_len) +{ + if (!out || out_len < LSL_KEY_BASE64_SIZE) { + return LSL_ESP32_ERR_INVALID_ARG; + } + + uint8_t pk[LSL_KEY_PUBLIC_SIZE]; + uint8_t sk[LSL_KEY_SECRET_SIZE]; + lsl_esp32_err_t ret = key_manager_load(pk, sk); + sodium_memzero(sk, sizeof(sk)); /* don't need secret key for export */ + if (ret != LSL_ESP32_OK) { + return ret; + } + + sodium_bin2base64(out, out_len, pk, sizeof(pk), sodium_base64_VARIANT_ORIGINAL); + return LSL_ESP32_OK; +} + +int key_manager_is_enabled(void) +{ + nvs_handle_t handle; + if (nvs_open(NVS_NAMESPACE, NVS_READONLY, &handle) != ESP_OK) { + return 0; + } + + uint8_t enabled = 0; + nvs_get_u8(handle, "enabled", &enabled); + nvs_close(handle); + return enabled ? 1 : 0; +} diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_key_manager.h b/liblsl-ESP32/components/liblsl_esp32/src/lsl_key_manager.h new file mode 100644 index 0000000..a4b605e --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_key_manager.h @@ -0,0 +1,39 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#ifndef LSL_KEY_MANAGER_H +#define LSL_KEY_MANAGER_H + +#include "lsl_esp32_types.h" +#include + +/* Ed25519 key sizes (from libsodium) */ +#define LSL_KEY_PUBLIC_SIZE 32 +#define LSL_KEY_SECRET_SIZE 64 +#define LSL_KEY_BASE64_SIZE 45 /* sodium_base64_ENCODED_LEN(32, VARIANT_ORIGINAL) */ +#define LSL_KEY_SBASE64_SIZE 89 /* sodium_base64_ENCODED_LEN(64, VARIANT_ORIGINAL) */ + +/* Generate a new Ed25519 keypair and store in NVS. + * Returns LSL_ESP32_OK on success. */ +lsl_esp32_err_t key_manager_generate(void); + +/* Import a keypair from base64-encoded strings and store in NVS. + * Returns LSL_ESP32_OK on success. */ +lsl_esp32_err_t key_manager_import(const char *base64_pub, const char *base64_priv); + +/* Load the stored keypair from NVS. + * pk_out must be LSL_KEY_PUBLIC_SIZE bytes, sk_out must be LSL_KEY_SECRET_SIZE bytes. + * Returns LSL_ESP32_OK on success, LSL_ESP32_ERR_NOT_FOUND if no key stored. */ +lsl_esp32_err_t key_manager_load(uint8_t *pk_out, uint8_t *sk_out); + +/* Export the public key as base64 string. + * out must be at least LSL_KEY_BASE64_SIZE bytes. + * Returns LSL_ESP32_OK on success. */ +lsl_esp32_err_t key_manager_export_pubkey(char *out, size_t out_len); + +/* Check if security is enabled (keypair exists in NVS). + * Returns 1 if enabled, 0 if not. */ +int key_manager_is_enabled(void); + +#endif /* LSL_KEY_MANAGER_H */ diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_outlet.c b/liblsl-ESP32/components/liblsl_esp32/src/lsl_outlet.c new file mode 100644 index 0000000..479adc9 --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_outlet.c @@ -0,0 +1,290 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#include "lsl_outlet.h" +#include "lsl_esp32.h" +#include "lsl_clock.h" +#include "lsl_protocol.h" +#include "lsl_sample.h" +#include "esp_log.h" +#include "esp_netif.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include +#include + +static const char *TAG = "lsl_outlet"; + +/* Get the local IPv4 address from the default STA interface */ +static int get_local_ip(char *out, size_t out_len) +{ + esp_netif_t *netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); + if (!netif) { + ESP_LOGW(TAG, "No WiFi STA interface found"); + return -1; + } + + esp_netif_ip_info_t ip_info; + if (esp_netif_get_ip_info(netif, &ip_info) != ESP_OK) { + ESP_LOGW(TAG, "Failed to get IP info"); + return -1; + } + + snprintf(out, out_len, IPSTR, IP2STR(&ip_info.ip)); + return 0; +} + +lsl_esp32_outlet_t lsl_esp32_create_outlet(lsl_esp32_stream_info_t info, int chunk_size, + int max_buffered) +{ + if (!info) { + ESP_LOGE(TAG, "NULL stream info"); + return NULL; + } + + struct lsl_esp32_outlet *outlet = calloc(1, sizeof(*outlet)); + if (!outlet) { + ESP_LOGE(TAG, "Failed to allocate outlet"); + return NULL; + } + + outlet->info = info; + outlet->chunk_size = chunk_size; + + /* Compute sample slot size: tag + timestamp + channel data */ + size_t bpc = stream_info_bytes_per_channel(info->channel_format); + if (bpc == 0) { + ESP_LOGE(TAG, "Invalid channel format: %d", info->channel_format); + free(outlet); + return NULL; + } + outlet->sample_bytes = 1 + 8 + (size_t)info->channel_count * bpc; + + /* Set local IP in stream info (required for discovery) */ + if (get_local_ip(info->v4addr, sizeof(info->v4addr)) < 0) { + ESP_LOGE(TAG, "Could not determine local IP; ensure WiFi is connected"); + free(outlet); + return NULL; + } + + /* Initialize ring buffer. + * TODO: use max_buffered * nominal_srate to compute slot count, + * capped by memory budget. For now, fixed at SAMPLE_POOL_SIZE. */ + (void)max_buffered; + size_t slot_count = LSL_ESP32_SAMPLE_POOL_SIZE; + if (ring_buffer_init(&outlet->ring, outlet->sample_bytes, slot_count) != 0) { + ESP_LOGE(TAG, "Failed to init ring buffer"); + free(outlet); + return NULL; + } + + /* Load security configuration (if globally enabled via lsl_esp32_enable_security) */ + lsl_esp32_err_t sec_err = security_config_load(&outlet->security); + if (sec_err != LSL_ESP32_OK) { + ring_buffer_deinit(&outlet->ring); + free(outlet); + return NULL; + } + if (outlet->security.enabled) { + ESP_LOGI(TAG, "Security enabled for outlet"); + } else { + ESP_LOGI(TAG, "Security not enabled for outlet (plaintext mode)"); + } + + /* Start TCP data server (must start before UDP so v4data_port is set) */ + const lsl_security_config_t *sec_ptr = outlet->security.enabled ? &outlet->security : NULL; + lsl_esp32_err_t err = tcp_server_start(&outlet->tcp, info, &outlet->ring, sec_ptr); + if (err != LSL_ESP32_OK) { + ESP_LOGE(TAG, "Failed to start TCP server: %d", err); + security_config_clear(&outlet->security); + ring_buffer_deinit(&outlet->ring); + free(outlet); + return NULL; + } + + /* Start UDP discovery server */ + err = udp_server_start(&outlet->udp, info); + if (err != LSL_ESP32_OK) { + ESP_LOGE(TAG, "Failed to start UDP server: %d", err); + tcp_server_stop(&outlet->tcp); + security_config_clear(&outlet->security); + ring_buffer_deinit(&outlet->ring); + free(outlet); + return NULL; + } + + outlet->active = true; + + ESP_LOGI(TAG, "Outlet created: %s (%dch %s @ %.0fHz) on %s:%d", info->name, info->channel_count, + stream_info_format_string(info->channel_format), info->nominal_srate, info->v4addr, + info->v4data_port); + + return outlet; +} + +void lsl_esp32_destroy_outlet(lsl_esp32_outlet_t outlet) +{ + if (!outlet) { + return; + } + + ESP_LOGI(TAG, "Destroying outlet: %s", outlet->info->name); + outlet->active = false; + __sync_synchronize(); /* ensure visibility across cores */ + + /* Allow any in-flight push_sample calls to complete */ + vTaskDelay(pdMS_TO_TICKS(10)); + + udp_server_stop(&outlet->udp); + int remaining = tcp_server_stop(&outlet->tcp); + if (remaining > 0) { + ESP_LOGE(TAG, "Feed tasks did not exit (%d remaining), leaking outlet to avoid UAF", + remaining); + /* Do not free resources that feed tasks may still reference */ + return; + } + ring_buffer_deinit(&outlet->ring); + security_config_clear(&outlet->security); + lsl_esp32_destroy_streaminfo(outlet->info); + free(outlet); +} + +lsl_esp32_err_t lsl_esp32_push_sample_f(lsl_esp32_outlet_t outlet, const float *data, + double timestamp) +{ + if (!outlet || !outlet->active || !data) { + return LSL_ESP32_ERR_INVALID_ARG; + } + if (outlet->info->channel_format != LSL_ESP32_FMT_FLOAT32) { + return LSL_ESP32_ERR_INVALID_ARG; + } + + if (timestamp == 0.0) { + timestamp = clock_get_time(); + } + + uint8_t buf[LSL_SAMPLE_MAX_BYTES]; + size_t data_len = (size_t)outlet->info->channel_count * sizeof(float); + int nbytes = sample_serialize(data, data_len, timestamp, buf, sizeof(buf)); + if (nbytes <= 0) { + return LSL_ESP32_ERR_PROTOCOL; + } + + ring_buffer_push(&outlet->ring, buf, (size_t)nbytes); + return LSL_ESP32_OK; +} + +lsl_esp32_err_t lsl_esp32_push_sample_d(lsl_esp32_outlet_t outlet, const double *data, + double timestamp) +{ + if (!outlet || !outlet->active || !data) { + return LSL_ESP32_ERR_INVALID_ARG; + } + if (outlet->info->channel_format != LSL_ESP32_FMT_DOUBLE64) { + return LSL_ESP32_ERR_INVALID_ARG; + } + + if (timestamp == 0.0) { + timestamp = clock_get_time(); + } + + uint8_t buf[LSL_SAMPLE_MAX_BYTES]; + size_t data_len = (size_t)outlet->info->channel_count * sizeof(double); + int nbytes = sample_serialize(data, data_len, timestamp, buf, sizeof(buf)); + if (nbytes <= 0) { + return LSL_ESP32_ERR_PROTOCOL; + } + + ring_buffer_push(&outlet->ring, buf, (size_t)nbytes); + return LSL_ESP32_OK; +} + +lsl_esp32_err_t lsl_esp32_push_sample_i(lsl_esp32_outlet_t outlet, const int32_t *data, + double timestamp) +{ + if (!outlet || !outlet->active || !data) { + return LSL_ESP32_ERR_INVALID_ARG; + } + if (outlet->info->channel_format != LSL_ESP32_FMT_INT32) { + return LSL_ESP32_ERR_INVALID_ARG; + } + + if (timestamp == 0.0) { + timestamp = clock_get_time(); + } + + uint8_t buf[LSL_SAMPLE_MAX_BYTES]; + size_t data_len = (size_t)outlet->info->channel_count * sizeof(int32_t); + int nbytes = sample_serialize(data, data_len, timestamp, buf, sizeof(buf)); + if (nbytes <= 0) { + return LSL_ESP32_ERR_PROTOCOL; + } + + ring_buffer_push(&outlet->ring, buf, (size_t)nbytes); + return LSL_ESP32_OK; +} + +lsl_esp32_err_t lsl_esp32_push_sample_s(lsl_esp32_outlet_t outlet, const int16_t *data, + double timestamp) +{ + if (!outlet || !outlet->active || !data) { + return LSL_ESP32_ERR_INVALID_ARG; + } + if (outlet->info->channel_format != LSL_ESP32_FMT_INT16) { + return LSL_ESP32_ERR_INVALID_ARG; + } + + if (timestamp == 0.0) { + timestamp = clock_get_time(); + } + + uint8_t buf[LSL_SAMPLE_MAX_BYTES]; + size_t data_len = (size_t)outlet->info->channel_count * sizeof(int16_t); + int nbytes = sample_serialize(data, data_len, timestamp, buf, sizeof(buf)); + if (nbytes <= 0) { + return LSL_ESP32_ERR_PROTOCOL; + } + + ring_buffer_push(&outlet->ring, buf, (size_t)nbytes); + return LSL_ESP32_OK; +} + +lsl_esp32_err_t lsl_esp32_push_sample_c(lsl_esp32_outlet_t outlet, const int8_t *data, + double timestamp) +{ + if (!outlet || !outlet->active || !data) { + return LSL_ESP32_ERR_INVALID_ARG; + } + if (outlet->info->channel_format != LSL_ESP32_FMT_INT8) { + return LSL_ESP32_ERR_INVALID_ARG; + } + + if (timestamp == 0.0) { + timestamp = clock_get_time(); + } + + uint8_t buf[LSL_SAMPLE_MAX_BYTES]; + size_t data_len = (size_t)outlet->info->channel_count * sizeof(int8_t); + int nbytes = sample_serialize(data, data_len, timestamp, buf, sizeof(buf)); + if (nbytes <= 0) { + return LSL_ESP32_ERR_PROTOCOL; + } + + ring_buffer_push(&outlet->ring, buf, (size_t)nbytes); + return LSL_ESP32_OK; +} + +int lsl_esp32_have_consumers(lsl_esp32_outlet_t outlet) +{ + if (!outlet || !outlet->active) { + return 0; + } + int count = 0; + if (outlet->tcp.conn_mutex && + xSemaphoreTake(outlet->tcp.conn_mutex, pdMS_TO_TICKS(100)) == pdTRUE) { + count = outlet->tcp.active_connections; + xSemaphoreGive(outlet->tcp.conn_mutex); + } + return count > 0 ? 1 : 0; +} diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_outlet.h b/liblsl-ESP32/components/liblsl_esp32/src/lsl_outlet.h new file mode 100644 index 0000000..8cb3c75 --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_outlet.h @@ -0,0 +1,27 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#ifndef LSL_OUTLET_H +#define LSL_OUTLET_H + +#include "lsl_esp32_types.h" +#include +#include "lsl_stream_info.h" +#include "lsl_ring_buffer.h" +#include "lsl_udp_server.h" +#include "lsl_tcp_server.h" +#include "lsl_security.h" + +struct lsl_esp32_outlet { + struct lsl_esp32_stream_info *info; + lsl_ring_buffer_t ring; + lsl_udp_server_t udp; + lsl_tcp_server_t tcp; + lsl_security_config_t security; + int chunk_size; + size_t sample_bytes; /* bytes per serialized sample (tag + ts + data) */ + volatile bool active; +}; + +#endif /* LSL_OUTLET_H */ diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_protocol.h b/liblsl-ESP32/components/liblsl_esp32/src/lsl_protocol.h new file mode 100644 index 0000000..09d21a1 --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_protocol.h @@ -0,0 +1,29 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#ifndef LSL_PROTOCOL_H +#define LSL_PROTOCOL_H + +/* Internal protocol constants. These are implementation details + * that library users should not depend on. */ + +/* Sample tag bytes (protocol 1.10) */ +#define LSL_ESP32_TAG_DEDUCED 1 /* timestamp deduced by receiver */ +#define LSL_ESP32_TAG_TRANSMITTED 2 /* timestamp included in sample */ + +/* Test pattern constants (matching liblsl sample.cpp) */ +#define LSL_ESP32_TEST_TIMESTAMP 123456.789 +#define LSL_ESP32_TEST_OFFSET_1 4 +#define LSL_ESP32_TEST_OFFSET_2 2 + +/* Wire format assumes little-endian byte order (ESP32 Xtensa is LE) */ +#if defined(__BYTE_ORDER__) && __BYTE_ORDER__ != __ORDER_LITTLE_ENDIAN__ +#error "LSL wire format implementation assumes little-endian byte order" +#endif + +/* Internal buffer sizes */ +#define LSL_ESP32_SHORTINFO_MAX 1024 +#define LSL_ESP32_FULLINFO_MAX 2048 + +#endif /* LSL_PROTOCOL_H */ diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_resolver.c b/liblsl-ESP32/components/liblsl_esp32/src/lsl_resolver.c new file mode 100644 index 0000000..0f30f75 --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_resolver.c @@ -0,0 +1,202 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#include "lsl_resolver.h" +#include "lsl_xml_parser.h" +#include "lsl_protocol.h" +#include "lsl_clock.h" +#include "esp_log.h" +#include "esp_random.h" + +#include "lwip/sockets.h" +#include "lwip/igmp.h" +#include +#include + +static const char *TAG = "lsl_resolver"; + +#define RESOLVER_BUF_SIZE 1500 +#define RESOLVER_QUERY_INTERVAL_MS 500 + +/* Check if a UID is already in the results (deduplication) */ +static int uid_seen(const struct lsl_esp32_stream_info *results, int count, const char *uid) +{ + for (int i = 0; i < count; i++) { + if (strcmp(results[i].uid, uid) == 0) { + return 1; + } + } + return 0; +} + +static int resolver_run(const char *query, double timeout_sec, + struct lsl_esp32_stream_info *results, int max_results) +{ + if (!results || max_results < 1) { + return 0; + } + + /* Create UDP socket for sending queries and receiving responses */ + int sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (sock < 0) { + ESP_LOGE(TAG, "Failed to create resolver socket: errno %d", errno); + return 0; + } + + /* Bind to ephemeral port (OS picks a free port) */ + struct sockaddr_in bind_addr = { + .sin_family = AF_INET, + .sin_port = htons(0), + .sin_addr.s_addr = htonl(INADDR_ANY), + }; + if (bind(sock, (struct sockaddr *)&bind_addr, sizeof(bind_addr)) < 0) { + ESP_LOGE(TAG, "Failed to bind resolver socket: errno %d", errno); + close(sock); + return 0; + } + + /* Get the actual port that was assigned */ + struct sockaddr_in bound_addr; + socklen_t bound_len = sizeof(bound_addr); + if (getsockname(sock, (struct sockaddr *)&bound_addr, &bound_len) < 0) { + ESP_LOGE(TAG, "Failed to get bound port: errno %d", errno); + close(sock); + return 0; + } + int return_port = ntohs(bound_addr.sin_port); + + /* Set receive timeout for polling */ + struct timeval tv = {.tv_sec = 0, .tv_usec = 100000}; /* 100ms */ + if (setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) < 0) { + ESP_LOGW(TAG, "SO_RCVTIMEO failed: errno %d (resolve may block)", errno); + } + + /* Generate a unique query ID */ + uint32_t rand_val = esp_random(); + char query_id[16]; + snprintf(query_id, sizeof(query_id), "%lu", (unsigned long)rand_val); + + /* Build the query packet: + * "LSL:shortinfo\r\n\r\n \r\n" */ + char query_pkt[512]; + int query_len = snprintf(query_pkt, sizeof(query_pkt), "LSL:shortinfo\r\n%s\r\n%d %s\r\n", + query ? query : "", return_port, query_id); + if (query_len < 0 || (size_t)query_len >= sizeof(query_pkt)) { + ESP_LOGE(TAG, "Query packet too large"); + close(sock); + return 0; + } + + /* Multicast destination */ + struct sockaddr_in mcast_addr = { + .sin_family = AF_INET, + .sin_port = htons(LSL_ESP32_MULTICAST_PORT), + .sin_addr.s_addr = inet_addr(LSL_ESP32_MULTICAST_ADDR), + }; + + ESP_LOGI(TAG, "Resolving streams (query='%s', timeout=%.1fs, port=%d)", query ? query : "", + timeout_sec, return_port); + + char recv_buf[RESOLVER_BUF_SIZE]; + + int found = 0; + double start_time = clock_get_time(); + double last_query_time = 0; + + while (clock_get_time() - start_time < timeout_sec && found < max_results) { + /* Send query periodically */ + double now = clock_get_time(); + if (now - last_query_time >= RESOLVER_QUERY_INTERVAL_MS / 1000.0) { + int sent = sendto(sock, query_pkt, query_len, 0, (struct sockaddr *)&mcast_addr, + sizeof(mcast_addr)); + if (sent < 0) { + ESP_LOGW(TAG, "Failed to send discovery query: errno %d", errno); + } + last_query_time = now; + } + + /* Receive responses */ + struct sockaddr_in sender; + socklen_t sender_len = sizeof(sender); + int len = recvfrom(sock, recv_buf, RESOLVER_BUF_SIZE - 1, 0, (struct sockaddr *)&sender, + &sender_len); + if (len <= 0) { + continue; /* timeout or error, retry */ + } + recv_buf[len] = '\0'; + + /* Parse response: "\r\n" */ + const char *crlf = strstr(recv_buf, "\r\n"); + if (!crlf) { + ESP_LOGD(TAG, "Malformed response (no CRLF)"); + continue; + } + + /* Verify query ID matches */ + size_t id_len = (size_t)(crlf - recv_buf); + if (id_len != strlen(query_id) || strncmp(recv_buf, query_id, id_len) != 0) { + ESP_LOGD(TAG, "Query ID mismatch, ignoring response"); + continue; + } + + /* Parse XML portion */ + const char *xml_start = crlf + 2; + size_t xml_len = (size_t)(len - (xml_start - recv_buf)); + + struct lsl_esp32_stream_info info; + if (xml_parse_stream_info(xml_start, xml_len, &info) != 0) { + ESP_LOGW(TAG, "Failed to parse discovery response XML"); + continue; + } + + /* Use sender's IP as fallback if v4addr is empty (common for desktop outlets) */ + if (info.v4addr[0] == '\0') { + snprintf(info.v4addr, sizeof(info.v4addr), "%s", inet_ntoa(sender.sin_addr)); + ESP_LOGD(TAG, "Using sender IP as v4addr: %s", info.v4addr); + } + + /* Deduplicate by UID */ + if (uid_seen(results, found, info.uid)) { + ESP_LOGD(TAG, "Duplicate stream (uid=%s), skipping", info.uid); + continue; + } + + /* Store result */ + memcpy(&results[found], &info, sizeof(info)); + found++; + + ESP_LOGI(TAG, "Found stream: %s (%s, %dch %s @ %.0fHz) at %s:%d", info.name, info.type, + info.channel_count, stream_info_format_string(info.channel_format), + info.nominal_srate, info.v4addr, info.v4data_port); + } + + close(sock); + + ESP_LOGI(TAG, "Resolve complete: found %d stream(s) in %.1fs", found, + clock_get_time() - start_time); + return found; +} + +int resolver_find(const char *prop, const char *value, double timeout_sec, + struct lsl_esp32_stream_info *results, int max_results) +{ + if (!prop || !value) { + return resolver_find_all(timeout_sec, results, max_results); + } + + /* Build query string: "prop='value'" */ + char query[256]; + int qlen = snprintf(query, sizeof(query), "%s='%s'", prop, value); + if (qlen < 0 || (size_t)qlen >= sizeof(query)) { + ESP_LOGE(TAG, "Query string too long (prop='%s', value truncated)", prop); + return 0; + } + + return resolver_run(query, timeout_sec, results, max_results); +} + +int resolver_find_all(double timeout_sec, struct lsl_esp32_stream_info *results, int max_results) +{ + return resolver_run("", timeout_sec, results, max_results); +} diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_resolver.h b/liblsl-ESP32/components/liblsl_esp32/src/lsl_resolver.h new file mode 100644 index 0000000..cded84d --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_resolver.h @@ -0,0 +1,31 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#ifndef LSL_RESOLVER_H +#define LSL_RESOLVER_H + +#include "lsl_stream_info.h" + +#define LSL_RESOLVER_MAX_RESULTS 8 + +/* Resolve streams matching a property=value query. + * Sends multicast discovery queries and collects responses. + * Blocks for up to timeout_sec seconds. + * + * prop: property name ("name", "type", or "source_id") + * value: value to match + * timeout_sec: maximum time to wait for responses + * results: output array for discovered stream infos + * max_results: size of results array + * + * Returns number of unique streams found (deduplicated by UID). */ +int resolver_find(const char *prop, const char *value, double timeout_sec, + struct lsl_esp32_stream_info *results, int max_results); + +/* Resolve all visible streams (empty query matches everything). + * Blocks for up to timeout_sec seconds. + * Returns number of unique streams found. */ +int resolver_find_all(double timeout_sec, struct lsl_esp32_stream_info *results, int max_results); + +#endif /* LSL_RESOLVER_H */ diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_ring_buffer.c b/liblsl-ESP32/components/liblsl_esp32/src/lsl_ring_buffer.c new file mode 100644 index 0000000..4d1a3bb --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_ring_buffer.c @@ -0,0 +1,142 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#include "lsl_ring_buffer.h" +#include "esp_log.h" +#include +#include + +static const char *TAG = "lsl_ring_buf"; + +int ring_buffer_init(lsl_ring_buffer_t *rb, size_t slot_size, size_t slot_count) +{ + if (!rb || slot_size == 0 || slot_count == 0) { + ESP_LOGE(TAG, "Invalid args: rb=%p, slot_size=%zu, slot_count=%zu", (void *)rb, slot_size, + slot_count); + return -1; + } + + /* Overflow check for allocation size */ + if (slot_count > SIZE_MAX / slot_size) { + ESP_LOGE(TAG, "Ring buffer size overflow: %zu * %zu", slot_size, slot_count); + return -1; + } + + rb->slot_size = slot_size; + rb->slot_count = slot_count; + rb->write_idx = 0; + + size_t total = slot_size * slot_count; + rb->buffer = calloc(1, total); + if (!rb->buffer) { + ESP_LOGE(TAG, "Failed to allocate ring buffer (%zu bytes)", total); + return -1; + } + + ESP_LOGI(TAG, "Ring buffer: %zu slots x %zu bytes = %zu bytes total", slot_count, slot_size, + total); + return 0; +} + +void ring_buffer_deinit(lsl_ring_buffer_t *rb) +{ + if (rb && rb->buffer) { + free(rb->buffer); + rb->buffer = NULL; + } +} + +void ring_buffer_push(lsl_ring_buffer_t *rb, const uint8_t *data, size_t data_len) +{ + if (!rb || !rb->buffer || !data || data_len == 0) { + ESP_LOGW(TAG, "ring_buffer_push: invalid args"); + return; + } + + /* Write to current slot (wrapping within the buffer) */ + size_t slot_idx = (size_t)(rb->write_idx % rb->slot_count); + uint8_t *dst = rb->buffer + (slot_idx * rb->slot_size); + + if (data_len <= rb->slot_size) { + memcpy(dst, data, data_len); + if (data_len < rb->slot_size) { + memset(dst + data_len, 0, rb->slot_size - data_len); + } + } else { + ESP_LOGW(TAG, "Data truncated: data_len=%zu > slot_size=%zu", data_len, rb->slot_size); + memcpy(dst, data, rb->slot_size); + } + + /* Full memory barrier: ensure data is written before index advances. + * Required for cross-core visibility on ESP32 (Xtensa dual-core). */ + __sync_synchronize(); + rb->write_idx++; +} + +void ring_buffer_consumer_init(const lsl_ring_buffer_t *rb, lsl_ring_consumer_t *consumer) +{ + if (!rb || !consumer) { + return; + } + consumer->read_idx = rb->write_idx; +} + +size_t ring_buffer_read(const lsl_ring_buffer_t *rb, lsl_ring_consumer_t *consumer, + uint8_t *out_buf, size_t out_len) +{ + if (!rb || !rb->buffer || !consumer || !out_buf) { + return 0; + } + + uint64_t w = rb->write_idx; + __sync_synchronize(); /* ensure we see latest write_idx and data */ + + if (consumer->read_idx >= w) { + return 0; /* no new data */ + } + + /* If consumer fell behind, skip to oldest available */ + if (w - consumer->read_idx > rb->slot_count) { + ESP_LOGD(TAG, "Consumer fell behind, skipping %llu samples", + (unsigned long long)(w - consumer->read_idx - rb->slot_count)); + consumer->read_idx = w - rb->slot_count; + } + + size_t slot_idx = (size_t)(consumer->read_idx % rb->slot_count); + const uint8_t *src = rb->buffer + (slot_idx * rb->slot_size); + size_t copy_len = (rb->slot_size < out_len) ? rb->slot_size : out_len; + memcpy(out_buf, src, copy_len); + + /* Double-check: verify the slot wasn't overwritten while we were reading. + * This prevents returning a mix of old and new data when the producer + * wraps around and overwrites the slot we just read. */ + __sync_synchronize(); + uint64_t w2 = rb->write_idx; + if (w2 - consumer->read_idx > rb->slot_count) { + /* Slot was overwritten during our read; data is invalid */ + consumer->read_idx = w2 - rb->slot_count; + return 0; + } + + consumer->read_idx++; + return copy_len; +} + +uint64_t ring_buffer_available(const lsl_ring_buffer_t *rb, const lsl_ring_consumer_t *consumer) +{ + if (!rb || !consumer) { + return 0; + } + + uint64_t w = rb->write_idx; + if (consumer->read_idx >= w) { + return 0; + } + + uint64_t avail = w - consumer->read_idx; + if (avail > rb->slot_count) { + avail = rb->slot_count; + } + return avail; +} diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_ring_buffer.h b/liblsl-ESP32/components/liblsl_esp32/src/lsl_ring_buffer.h new file mode 100644 index 0000000..26428e6 --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_ring_buffer.h @@ -0,0 +1,53 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#ifndef LSL_RING_BUFFER_H +#define LSL_RING_BUFFER_H + +#include +#include + +/* Single-producer, multi-consumer ring buffer for LSL samples. + * + * The producer (push_sample) writes serialized samples sequentially. + * Each consumer maintains its own read cursor. If a consumer falls + * behind (more than slot_count samples), it skips to the latest data + * (same behavior as desktop liblsl). */ + +typedef struct { + uint8_t *buffer; /* pre-allocated byte array */ + size_t slot_size; /* bytes per sample slot */ + size_t slot_count; /* number of slots */ + volatile uint64_t write_idx; /* producer write position (monotonic, never wraps) */ +} lsl_ring_buffer_t; + +/* Per-consumer read cursor */ +typedef struct { + uint64_t read_idx; /* consumer's current read position */ +} lsl_ring_consumer_t; + +/* Initialize the ring buffer. Allocates slot_count * slot_size bytes. + * Returns 0 on success, -1 on allocation failure. */ +int ring_buffer_init(lsl_ring_buffer_t *rb, size_t slot_size, size_t slot_count); + +/* Free the ring buffer memory. */ +void ring_buffer_deinit(lsl_ring_buffer_t *rb); + +/* Push a serialized sample into the ring buffer. + * data_len must be <= slot_size. Overwrites oldest slot if full. */ +void ring_buffer_push(lsl_ring_buffer_t *rb, const uint8_t *data, size_t data_len); + +/* Initialize a consumer cursor at the current write position. */ +void ring_buffer_consumer_init(const lsl_ring_buffer_t *rb, lsl_ring_consumer_t *consumer); + +/* Read the next available sample into out_buf. + * Returns bytes copied (> 0) if a sample was available, 0 if no new data. + * If consumer fell behind, skips to latest available data. */ +size_t ring_buffer_read(const lsl_ring_buffer_t *rb, lsl_ring_consumer_t *consumer, + uint8_t *out_buf, size_t out_len); + +/* Returns number of unread samples available to this consumer. */ +uint64_t ring_buffer_available(const lsl_ring_buffer_t *rb, const lsl_ring_consumer_t *consumer); + +#endif /* LSL_RING_BUFFER_H */ diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_sample.c b/liblsl-ESP32/components/liblsl_esp32/src/lsl_sample.c new file mode 100644 index 0000000..9b1a2db --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_sample.c @@ -0,0 +1,210 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#include "lsl_sample.h" +#include "lsl_stream_info.h" +#include "esp_log.h" +#include + +static const char *TAG = "lsl_sample"; + +int sample_serialize(const void *channel_data, size_t channel_data_len, double timestamp, + uint8_t *out, size_t out_len) +{ + if (!channel_data || !out || channel_data_len == 0) { + ESP_LOGE(TAG, "Invalid args: data=%p, out=%p, data_len=%zu", (void *)channel_data, + (void *)out, channel_data_len); + return -1; + } + + int deduced = (timestamp == 0.0); + size_t need = 1 + (deduced ? 0 : 8) + channel_data_len; + if (need > out_len) { + ESP_LOGE(TAG, "Buffer too small: need %zu, have %zu", need, out_len); + return -1; + } + + size_t pos = 0; + + /* Tag byte */ + out[pos++] = deduced ? LSL_ESP32_TAG_DEDUCED : LSL_ESP32_TAG_TRANSMITTED; + + /* Timestamp (native byte order double, only if transmitted). + * ESP32 (Xtensa LX6) is little-endian, matching x86 desktop liblsl. */ + if (!deduced) { + memcpy(&out[pos], ×tamp, 8); + pos += 8; + } + + /* Channel data (already in native byte order = little-endian on ESP32) */ + memcpy(&out[pos], channel_data, channel_data_len); + pos += channel_data_len; + + return (int)pos; +} + +int sample_generate_test_pattern(int channel_count, lsl_esp32_channel_format_t channel_format, + int offset, double timestamp, uint8_t *out, size_t out_len) +{ + size_t bpc = stream_info_bytes_per_channel(channel_format); + if (bpc == 0 || channel_count < 1 || channel_count > LSL_ESP32_MAX_CHANNELS) { + ESP_LOGE(TAG, "Invalid format (%d) or channel count (%d, max %d)", channel_format, + channel_count, LSL_ESP32_MAX_CHANNELS); + return -1; + } + + size_t data_len = (size_t)channel_count * bpc; + /* Temporary buffer for channel data (stack-allocated, 1024 bytes max) */ + uint8_t data[LSL_ESP32_MAX_CHANNELS * 8]; + if (data_len > sizeof(data)) { + ESP_LOGE(TAG, "Channel data too large: %zu bytes", data_len); + return -1; + } + + /* Fill test pattern per liblsl sample.cpp:356-406. + * float32: value = (k + offset) * sign + * double64: value = (k + offset + 16777217) * sign + * int types: value = ((k + offset + adj) % max_val) * sign + * where adj = 65537 (int32), 257 (int16), 1 (int8) + * sign = (k % 2 == 0) ? 1 : -1 */ + for (int k = 0; k < channel_count; k++) { + int sign = (k % 2 == 0) ? 1 : -1; + + switch (channel_format) { + case LSL_ESP32_FMT_FLOAT32: { + float val = (float)((k + offset) * sign); + memcpy(&data[k * bpc], &val, bpc); + break; + } + case LSL_ESP32_FMT_DOUBLE64: { + double val = (double)(((long long)k + offset + 16777217) * sign); + memcpy(&data[k * bpc], &val, bpc); + break; + } + case LSL_ESP32_FMT_INT32: { + size_t v = ((size_t)k + (size_t)offset + 65537) % 2147483647u; + int32_t val = (int32_t)(v)*sign; + memcpy(&data[k * bpc], &val, bpc); + break; + } + case LSL_ESP32_FMT_INT16: { + size_t v = ((size_t)k + (size_t)offset + 257) % 32767u; + int16_t val = (int16_t)(v) * (int16_t)sign; + memcpy(&data[k * bpc], &val, bpc); + break; + } + case LSL_ESP32_FMT_INT8: { + size_t v = ((size_t)k + (size_t)offset + 1) % 127u; + int8_t val = (int8_t)(v) * (int8_t)sign; + memcpy(&data[k * bpc], &val, bpc); + break; + } + default: + ESP_LOGE(TAG, "Unsupported channel format in test pattern: %d", channel_format); + return -1; + } + } + + return sample_serialize(data, data_len, timestamp, out, out_len); +} + +int sample_deserialize(const uint8_t *in, size_t in_len, int channel_count, + lsl_esp32_channel_format_t fmt, void *channel_data_out, + size_t channel_data_len, double *timestamp_out) +{ + if (!in || !channel_data_out || !timestamp_out || in_len == 0) { + ESP_LOGE(TAG, "Invalid args to sample_deserialize"); + return -1; + } + + size_t bpc = stream_info_bytes_per_channel(fmt); + if (bpc == 0 || channel_count < 1) { + ESP_LOGE(TAG, "Invalid format (%d) or channel count (%d)", fmt, channel_count); + return -1; + } + + size_t expected_data = (size_t)channel_count * bpc; + if (expected_data > channel_data_len) { + ESP_LOGE(TAG, "Channel data buffer too small: need %zu, have %zu", expected_data, + channel_data_len); + return -1; + } + + size_t pos = 0; + + /* Tag byte (in_len >= 1 guaranteed by check above) */ + uint8_t tag = in[pos++]; + + /* Timestamp */ + if (tag == LSL_ESP32_TAG_TRANSMITTED) { + if (pos + 8 > in_len) { + ESP_LOGE(TAG, "Truncated timestamp"); + return -1; + } + memcpy(timestamp_out, &in[pos], 8); + pos += 8; + } else if (tag == LSL_ESP32_TAG_DEDUCED) { + *timestamp_out = 0.0; + } else { + ESP_LOGE(TAG, "Unknown sample tag: 0x%02x", tag); + return -1; + } + + /* Channel data */ + if (pos + expected_data > in_len) { + ESP_LOGE(TAG, "Truncated channel data: need %zu, have %zu", expected_data, in_len - pos); + return -1; + } + memcpy(channel_data_out, &in[pos], expected_data); + pos += expected_data; + + return (int)pos; +} + +int sample_validate_test_pattern(int channel_count, lsl_esp32_channel_format_t fmt, int offset, + double expected_timestamp, const void *channel_data, + double actual_timestamp) +{ + if (!channel_data) { + return -1; + } + + /* Check timestamp */ + if (expected_timestamp != 0.0 && actual_timestamp != expected_timestamp) { + ESP_LOGW(TAG, "Test pattern timestamp mismatch: expected=%.6f actual=%.6f", + expected_timestamp, actual_timestamp); + return -1; + } + + /* Generate expected pattern and compare */ + size_t bpc = stream_info_bytes_per_channel(fmt); + if (bpc == 0) { + return -1; + } + size_t data_len = (size_t)channel_count * bpc; + + uint8_t expected[LSL_ESP32_MAX_CHANNELS * 8]; + uint8_t serialized[LSL_SAMPLE_MAX_BYTES]; + + int n = sample_generate_test_pattern(channel_count, fmt, offset, expected_timestamp, serialized, + sizeof(serialized)); + if (n <= 0) { + return -1; + } + + /* Extract channel data from serialized pattern (skip tag + optional timestamp) */ + size_t skip = (expected_timestamp != 0.0) ? (1 + 8) : 1; + if ((size_t)n < skip + data_len) { + return -1; + } + memcpy(expected, serialized + skip, data_len); + + if (memcmp(channel_data, expected, data_len) != 0) { + ESP_LOGW(TAG, "Test pattern data mismatch (offset=%d, ch=%d, fmt=%d)", offset, + channel_count, fmt); + return -1; + } + + return 0; +} diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_sample.h b/liblsl-ESP32/components/liblsl_esp32/src/lsl_sample.h new file mode 100644 index 0000000..e1d5abd --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_sample.h @@ -0,0 +1,73 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#ifndef LSL_SAMPLE_H +#define LSL_SAMPLE_H + +#include "lsl_esp32_types.h" +#include "lsl_protocol.h" +#include + +/* Maximum serialized sample size: + * 1 (tag) + 8 (timestamp) + MAX_CHANNELS * 8 (double64) */ +#define LSL_SAMPLE_MAX_BYTES (1 + 8 + LSL_ESP32_MAX_CHANNELS * 8) + +/* Serialize a sample into the LSL binary wire format (protocol 1.10). + * + * Format: [1-byte tag][optional 8-byte LE double timestamp][channel data] + * Tag 0x01 = deduced timestamp (no timestamp field) + * Tag 0x02 = transmitted timestamp (8-byte double follows) + * + * channel_data: raw sample data (channel_count * bytes_per_channel) + * channel_data_len: size of channel_data in bytes + * timestamp: sample timestamp (0 = deduced, omit from wire) + * out: output buffer (must be at least 1 + 8 + channel_data_len bytes) + * out_len: size of output buffer + * + * Returns number of bytes written, or -1 on error. */ +int sample_serialize(const void *channel_data, size_t channel_data_len, double timestamp, + uint8_t *out, size_t out_len); + +/* Generate a test-pattern sample matching liblsl's sample.cpp. + * + * For numeric types, each channel value is: + * value = (channel_index + offset) * ((channel_index % 2 == 0) ? 1 : -1) + * + * For int types, offset is adjusted: +65537 (int32), +257 (int16), +1 (int8). + * + * channel_count: number of channels + * channel_format: data type + * offset: test pattern offset (typically 4 or 2) + * timestamp: timestamp to include (typically 123456.789) + * out: output buffer for serialized sample + * out_len: size of output buffer + * + * Returns number of bytes written, or -1 on error. */ +int sample_generate_test_pattern(int channel_count, lsl_esp32_channel_format_t channel_format, + int offset, double timestamp, uint8_t *out, size_t out_len); + +/* Deserialize a sample from the LSL binary wire format. + * + * Parses the tag byte, optional timestamp, and channel data. + * + * in: input buffer containing the serialized sample + * in_len: number of bytes available in input buffer + * channel_count: expected number of channels + * fmt: expected channel format + * channel_data_out: output buffer for channel data (channel_count * bpc bytes) + * channel_data_len: size of channel_data_out + * timestamp_out: receives the sample timestamp (0 if deduced) + * + * Returns number of bytes consumed from input, or -1 on error. */ +int sample_deserialize(const uint8_t *in, size_t in_len, int channel_count, + lsl_esp32_channel_format_t fmt, void *channel_data_out, + size_t channel_data_len, double *timestamp_out); + +/* Validate a deserialized sample against the expected test pattern. + * Returns 0 if the pattern matches, -1 if it does not. */ +int sample_validate_test_pattern(int channel_count, lsl_esp32_channel_format_t fmt, int offset, + double expected_timestamp, const void *channel_data, + double actual_timestamp); + +#endif /* LSL_SAMPLE_H */ diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_security.c b/liblsl-ESP32/components/liblsl_esp32/src/lsl_security.c new file mode 100644 index 0000000..d09db12 --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_security.c @@ -0,0 +1,258 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#include "lsl_security.h" +#include "lsl_esp32.h" +#include "lsl_key_manager.h" +#include "lsl_protocol.h" /* LE compile-time check for nonce byte order */ +#include "esp_log.h" +#include "sodium.h" +#include + +static const char *TAG = "lsl_security"; + +void security_session_init(lsl_security_session_t *session) +{ + if (session) { + sodium_memzero(session, sizeof(*session)); + } +} + +void security_session_clear(lsl_security_session_t *session) +{ + if (session) { + sodium_memzero(session, sizeof(*session)); + } +} + +lsl_esp32_err_t security_derive_session_key(const uint8_t *our_ed25519_pk, + const uint8_t *our_ed25519_sk, + const uint8_t *peer_ed25519_pk, + lsl_security_session_t *session) +{ + if (!our_ed25519_pk || !our_ed25519_sk || !peer_ed25519_pk || !session) { + return LSL_ESP32_ERR_INVALID_ARG; + } + + /* Step 1: Convert Ed25519 keys to X25519 */ + uint8_t our_x25519_sk[crypto_scalarmult_SCALARBYTES]; + uint8_t peer_x25519_pk[crypto_scalarmult_BYTES]; + + if (crypto_sign_ed25519_sk_to_curve25519(our_x25519_sk, our_ed25519_sk) != 0) { + ESP_LOGE(TAG, "Failed to convert our secret key to X25519 (key corrupted?)"); + sodium_memzero(our_x25519_sk, sizeof(our_x25519_sk)); + return LSL_ESP32_ERR_SECURITY; + } + if (crypto_sign_ed25519_pk_to_curve25519(peer_x25519_pk, peer_ed25519_pk) != 0) { + ESP_LOGE(TAG, "Failed to convert peer public key to X25519"); + sodium_memzero(our_x25519_sk, sizeof(our_x25519_sk)); + return LSL_ESP32_ERR_SECURITY; + } + + /* Step 2: X25519 Diffie-Hellman */ + uint8_t shared_secret[crypto_scalarmult_BYTES]; + if (crypto_scalarmult(shared_secret, our_x25519_sk, peer_x25519_pk) != 0) { + ESP_LOGE(TAG, "DH key exchange produced degenerate shared secret"); + sodium_memzero(our_x25519_sk, sizeof(our_x25519_sk)); + sodium_memzero(shared_secret, sizeof(shared_secret)); + return LSL_ESP32_ERR_SECURITY; + } + + /* Step 3: Derive session key with BLAKE2b. + * Hash: shared_secret || HKDF_CONTEXT || pk_smaller || pk_larger + * Canonical key ordering ensures both sides derive the same key. */ + crypto_generichash_state state; + if (crypto_generichash_init(&state, NULL, 0, LSL_SECURITY_SESSION_KEY_SIZE) != 0) { + sodium_memzero(our_x25519_sk, sizeof(our_x25519_sk)); + sodium_memzero(shared_secret, sizeof(shared_secret)); + return LSL_ESP32_ERR_SECURITY; + } + + crypto_generichash_update(&state, shared_secret, sizeof(shared_secret)); + crypto_generichash_update(&state, (const uint8_t *)LSL_SECURITY_HKDF_CONTEXT, + sizeof(LSL_SECURITY_HKDF_CONTEXT) - 1); + + /* Order public keys consistently (smaller first) */ + if (memcmp(our_ed25519_pk, peer_ed25519_pk, LSL_KEY_PUBLIC_SIZE) < 0) { + crypto_generichash_update(&state, our_ed25519_pk, LSL_KEY_PUBLIC_SIZE); + crypto_generichash_update(&state, peer_ed25519_pk, LSL_KEY_PUBLIC_SIZE); + } else { + crypto_generichash_update(&state, peer_ed25519_pk, LSL_KEY_PUBLIC_SIZE); + crypto_generichash_update(&state, our_ed25519_pk, LSL_KEY_PUBLIC_SIZE); + } + + crypto_generichash_final(&state, session->session_key, LSL_SECURITY_SESSION_KEY_SIZE); + + /* Clear sensitive intermediates */ + sodium_memzero(our_x25519_sk, sizeof(our_x25519_sk)); + sodium_memzero(peer_x25519_pk, sizeof(peer_x25519_pk)); + sodium_memzero(shared_secret, sizeof(shared_secret)); + + session->send_nonce = 1; /* nonce 0 is reserved (desktop secureLSL rejects it) */ + session->recv_nonce_high = 0; + session->active = 1; + + ESP_LOGI(TAG, "Session key derived successfully"); + return LSL_ESP32_OK; +} + +int security_encrypt(lsl_security_session_t *session, const uint8_t *plaintext, + size_t plaintext_len, uint8_t *ciphertext_out, size_t ciphertext_max, + uint64_t *nonce_out) +{ + if (!session || !session->active || !plaintext || !ciphertext_out || !nonce_out) { + ESP_LOGE(TAG, "security_encrypt: invalid args (session=%p, active=%d)", (void *)session, + session ? session->active : -1); + return -1; + } + + if (session->send_nonce == UINT64_MAX) { + ESP_LOGE(TAG, "Send nonce exhausted; session must be rekeyed"); + session->active = 0; + return -1; + } + + size_t ct_len = plaintext_len + LSL_SECURITY_AUTH_TAG_SIZE; + if (ct_len > ciphertext_max) { + ESP_LOGE(TAG, "Ciphertext buffer too small: need %zu, have %zu", ct_len, ciphertext_max); + return -1; + } + + /* Build 12-byte IETF nonce from 8-byte counter (zero-padded) */ + uint8_t nonce_bytes[crypto_aead_chacha20poly1305_ietf_NPUBBYTES]; + memset(nonce_bytes, 0, sizeof(nonce_bytes)); + memcpy(nonce_bytes, &session->send_nonce, 8); /* LE uint64 in first 8 bytes */ + + unsigned long long actual_ct_len; + if (crypto_aead_chacha20poly1305_ietf_encrypt(ciphertext_out, &actual_ct_len, plaintext, + plaintext_len, NULL, 0, NULL, nonce_bytes, + session->session_key) != 0) { + ESP_LOGE(TAG, "Encryption failed"); + return -1; + } + + *nonce_out = session->send_nonce; + session->send_nonce++; + + return (int)actual_ct_len; +} + +int security_decrypt(lsl_security_session_t *session, uint64_t wire_nonce, + const uint8_t *ciphertext, size_t ciphertext_len, uint8_t *plaintext_out, + size_t plaintext_max) +{ + if (!session || !session->active || !ciphertext || !plaintext_out) { + return -1; + } + + if (ciphertext_len < LSL_SECURITY_AUTH_TAG_SIZE) { + ESP_LOGE(TAG, "Ciphertext too short: %zu bytes", ciphertext_len); + return -1; + } + + size_t pt_len = ciphertext_len - LSL_SECURITY_AUTH_TAG_SIZE; + if (pt_len > plaintext_max) { + ESP_LOGE(TAG, "Plaintext buffer too small: need %zu, have %zu", pt_len, plaintext_max); + return -1; + } + + /* Nonce 0 is reserved (matching desktop secureLSL) */ + if (wire_nonce == 0) { + ESP_LOGW(TAG, "Rejected reserved nonce 0"); + return -1; + } + + /* Replay prevention: nonce must be strictly increasing. + * This is stricter than desktop secureLSL's windowed NonceTracker + * (which allows out-of-order within 64 nonces). The strict policy + * is correct for TCP (ordered delivery) and simpler for ESP32. + * A windowed tracker would be needed for UDP transport. */ + if (session->recv_nonce_valid && wire_nonce <= session->recv_nonce_high) { + ESP_LOGW(TAG, "Nonce replay detected: received %llu, high=%llu", + (unsigned long long)wire_nonce, (unsigned long long)session->recv_nonce_high); + return -1; + } + + /* Build 12-byte IETF nonce from 8-byte wire nonce */ + uint8_t nonce_bytes[crypto_aead_chacha20poly1305_ietf_NPUBBYTES]; + memset(nonce_bytes, 0, sizeof(nonce_bytes)); + memcpy(nonce_bytes, &wire_nonce, 8); + + unsigned long long actual_pt_len; + if (crypto_aead_chacha20poly1305_ietf_decrypt(plaintext_out, &actual_pt_len, NULL, ciphertext, + ciphertext_len, NULL, 0, nonce_bytes, + session->session_key) != 0) { + ESP_LOGE(TAG, "Decryption failed: authentication error"); + return -1; + } + + session->recv_nonce_high = wire_nonce; + session->recv_nonce_valid = 1; + return (int)actual_pt_len; +} + +lsl_esp32_err_t security_config_load(lsl_security_config_t *cfg) +{ + if (!cfg) { + return LSL_ESP32_ERR_INVALID_ARG; + } + + sodium_memzero(cfg, sizeof(*cfg)); + + if (!lsl_esp32_security_enabled()) { + return LSL_ESP32_OK; /* security not requested; cfg->enabled stays 0 */ + } + + lsl_esp32_err_t err = key_manager_load(cfg->public_key, cfg->secret_key); + if (err != LSL_ESP32_OK) { + ESP_LOGE(TAG, "Security enabled but keys not loadable"); + sodium_memzero(cfg, sizeof(*cfg)); + return err; + } + + cfg->enabled = 1; + return LSL_ESP32_OK; +} + +void security_config_clear(lsl_security_config_t *cfg) +{ + if (cfg) { + sodium_memzero(cfg, sizeof(*cfg)); + } +} + +lsl_esp32_err_t security_handshake_verify(const lsl_security_config_t *our_config, + const char *peer_pubkey_b64, + lsl_security_session_t *session_out) +{ + if (!our_config || !session_out) { + return LSL_ESP32_ERR_INVALID_ARG; + } + + /* Decode peer's public key from base64 */ + uint8_t peer_pk[LSL_KEY_PUBLIC_SIZE]; + size_t peer_pk_len = 0; + + if (!peer_pubkey_b64 || peer_pubkey_b64[0] == '\0' || + sodium_base642bin(peer_pk, sizeof(peer_pk), peer_pubkey_b64, strlen(peer_pubkey_b64), NULL, + &peer_pk_len, NULL, sodium_base64_VARIANT_ORIGINAL) != 0 || + peer_pk_len != LSL_KEY_PUBLIC_SIZE) { + ESP_LOGW(TAG, "Peer public key missing or invalid base64"); + return LSL_ESP32_ERR_INVALID_ARG; + } + + /* Shared keypair model: authorization = public key match (constant-time) */ + if (sodium_memcmp(peer_pk, our_config->public_key, LSL_KEY_PUBLIC_SIZE) != 0) { + ESP_LOGW(TAG, "Peer public key does not match (different keypair)"); + sodium_memzero(peer_pk, sizeof(peer_pk)); + return LSL_ESP32_ERR_SECURITY; + } + + /* Derive per-connection session key */ + security_session_init(session_out); + lsl_esp32_err_t err = security_derive_session_key(our_config->public_key, + our_config->secret_key, peer_pk, session_out); + sodium_memzero(peer_pk, sizeof(peer_pk)); + return err; +} diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_security.h b/liblsl-ESP32/components/liblsl_esp32/src/lsl_security.h new file mode 100644 index 0000000..6b0f308 --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_security.h @@ -0,0 +1,87 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#ifndef LSL_SECURITY_H +#define LSL_SECURITY_H + +#include "lsl_esp32_types.h" +#include "lsl_key_manager.h" +#include +#include + +/* Crypto sizes (from libsodium ChaCha20-Poly1305 IETF) */ +#define LSL_SECURITY_SESSION_KEY_SIZE 32 +#define LSL_SECURITY_AUTH_TAG_SIZE 16 +#define LSL_SECURITY_NONCE_WIRE_SIZE 8 /* nonce on wire is 8 bytes (uint64_t LE) */ + +/* Security session state for one TCP connection */ +typedef struct { + uint8_t session_key[LSL_SECURITY_SESSION_KEY_SIZE]; + uint64_t send_nonce; + uint64_t recv_nonce_high; + int active; /* 1 if session key is derived */ + int recv_nonce_valid; /* 1 after first message received */ +} lsl_security_session_t; + +/* Security configuration (loaded from NVS, shared across connections) */ +typedef struct { + int enabled; + uint8_t public_key[LSL_KEY_PUBLIC_SIZE]; + uint8_t secret_key[LSL_KEY_SECRET_SIZE]; +} lsl_security_config_t; + +/* Domain separator for BLAKE2b key derivation (matches secureLSL) */ +#define LSL_SECURITY_HKDF_CONTEXT "lsl-sess" + +/* Derive a session key from our Ed25519 secret key and the peer's Ed25519 public key. + * Uses Ed25519->X25519 conversion + DH + BLAKE2b with canonical key ordering. + * Returns LSL_ESP32_OK on success. */ +lsl_esp32_err_t security_derive_session_key(const uint8_t *our_ed25519_pk, + const uint8_t *our_ed25519_sk, + const uint8_t *peer_ed25519_pk, + lsl_security_session_t *session); + +/* Encrypt plaintext using ChaCha20-Poly1305 with the session's send nonce. + * Increments send_nonce after encryption. + * ciphertext_out must be at least plaintext_len + LSL_SECURITY_AUTH_TAG_SIZE bytes. + * nonce_out receives the 8-byte nonce used (for wire framing). + * Returns number of ciphertext bytes, or -1 on error. */ +int security_encrypt(lsl_security_session_t *session, const uint8_t *plaintext, + size_t plaintext_len, uint8_t *ciphertext_out, size_t ciphertext_max, + uint64_t *nonce_out); + +/* Decrypt ciphertext using ChaCha20-Poly1305 with the given nonce. + * Verifies nonce is greater than recv_nonce_high (replay prevention). + * plaintext_out must be at least ciphertext_len - LSL_SECURITY_AUTH_TAG_SIZE bytes. + * Returns number of plaintext bytes, or -1 on error (auth failure or replay). */ +int security_decrypt(lsl_security_session_t *session, uint64_t wire_nonce, + const uint8_t *ciphertext, size_t ciphertext_len, uint8_t *plaintext_out, + size_t plaintext_max); + +/* Initialize a session to inactive state */ +void security_session_init(lsl_security_session_t *session); + +/* Clear session key material */ +void security_session_clear(lsl_security_session_t *session); + +/* Load security config from NVS if globally enabled. + * On return: cfg->enabled=1 with keys if security active, + * cfg->enabled=0 with zeroed keys if security not active. + * Returns LSL_ESP32_OK on success (including security-not-enabled), + * or error if security is enabled but keys cannot be loaded. */ +lsl_esp32_err_t security_config_load(lsl_security_config_t *cfg); + +/* Clear security config, zeroing all key material */ +void security_config_clear(lsl_security_config_t *cfg); + +/* Verify peer's base64-encoded public key matches ours and derive session key. + * Returns LSL_ESP32_OK on success (session_out populated), + * LSL_ESP32_ERR_INVALID_ARG if peer key is missing or invalid base64, + * LSL_ESP32_ERR_SECURITY if peer key does not match (unauthorized), + * or other error on derivation failure. */ +lsl_esp32_err_t security_handshake_verify(const lsl_security_config_t *our_config, + const char *peer_pubkey_b64, + lsl_security_session_t *session_out); + +#endif /* LSL_SECURITY_H */ diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_stream_info.c b/liblsl-ESP32/components/liblsl_esp32/src/lsl_stream_info.c new file mode 100644 index 0000000..47df141 --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_stream_info.c @@ -0,0 +1,338 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#include "lsl_stream_info.h" +#include "lsl_protocol.h" +#include "lsl_clock.h" +#include "lsl_esp32.h" +#include "esp_log.h" +#include "esp_random.h" +#include +#include +#include +#include + +static const char *TAG = "lsl_stream_info"; + +void stream_info_generate_uuid4(char *out) +{ + uint8_t bytes[16]; + /* Use ESP32 hardware RNG for all 16 bytes */ + uint32_t r; + for (int i = 0; i < 16; i += 4) { + r = esp_random(); + memcpy(&bytes[i], &r, 4); + } + /* Set version 4 (two MSBs of time_hi = 0100b, per RFC 4122) */ + bytes[6] = (bytes[6] & 0x0F) | 0x40; + /* Set variant 1 (two MSBs of clock_seq_hi_and_reserved = 10b, per RFC 4122) */ + bytes[8] = (bytes[8] & 0x3F) | 0x80; + + snprintf(out, LSL_ESP32_UUID_STR_LEN, + "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", bytes[0], + bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], bytes[8], + bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]); +} + +const char *stream_info_format_string(lsl_esp32_channel_format_t fmt) +{ + switch (fmt) { + case LSL_ESP32_FMT_FLOAT32: + return "float32"; + case LSL_ESP32_FMT_DOUBLE64: + return "double64"; + case LSL_ESP32_FMT_INT32: + return "int32"; + case LSL_ESP32_FMT_INT16: + return "int16"; + case LSL_ESP32_FMT_INT8: + return "int8"; + default: + ESP_LOGW(TAG, "Unknown channel format: %d", fmt); + return "undefined"; + } +} + +size_t stream_info_bytes_per_channel(lsl_esp32_channel_format_t fmt) +{ + switch (fmt) { + case LSL_ESP32_FMT_FLOAT32: + return 4; + case LSL_ESP32_FMT_DOUBLE64: + return 8; + case LSL_ESP32_FMT_INT32: + return 4; + case LSL_ESP32_FMT_INT16: + return 2; + case LSL_ESP32_FMT_INT8: + return 1; + default: + return 0; + } +} + +/* Check if snprintf truncated and log a warning. Returns 1 if truncated. */ +static int check_truncation(const char *field, int written, size_t max_len) +{ + if (written >= 0 && (size_t)written >= max_len) { + ESP_LOGW(TAG, "Field '%s' truncated (%d chars, max %zu)", field, written, max_len - 1); + return 1; + } + return 0; +} + +lsl_esp32_stream_info_t lsl_esp32_create_streaminfo(const char *name, const char *type, + int channel_count, double nominal_srate, + lsl_esp32_channel_format_t channel_format, + const char *source_id) +{ + if (!name || channel_count < 1 || channel_count > LSL_ESP32_MAX_CHANNELS) { + ESP_LOGE(TAG, "Invalid arguments: name=%p, channels=%d (max %d)", (void *)name, + channel_count, LSL_ESP32_MAX_CHANNELS); + return NULL; + } + + if (stream_info_bytes_per_channel(channel_format) == 0) { + ESP_LOGE(TAG, "Invalid channel format: %d", channel_format); + return NULL; + } + + if (nominal_srate < 0.0) { + ESP_LOGE(TAG, "Invalid nominal_srate: %g", nominal_srate); + return NULL; + } + + struct lsl_esp32_stream_info *info = calloc(1, sizeof(*info)); + if (!info) { + ESP_LOGE(TAG, "Failed to allocate stream info"); + return NULL; + } + + int n; + n = snprintf(info->name, sizeof(info->name), "%s", name); + check_truncation("name", n, sizeof(info->name)); + n = snprintf(info->type, sizeof(info->type), "%s", type ? type : ""); + check_truncation("type", n, sizeof(info->type)); + info->channel_count = channel_count; + info->nominal_srate = nominal_srate; + info->channel_format = channel_format; + n = snprintf(info->source_id, sizeof(info->source_id), "%s", source_id ? source_id : ""); + check_truncation("source_id", n, sizeof(info->source_id)); + stream_info_generate_uuid4(info->uid); + snprintf(info->hostname, sizeof(info->hostname), "ESP32"); + snprintf(info->session_id, sizeof(info->session_id), "default"); + info->created_at = clock_get_time(); + info->protocol_version = LSL_ESP32_PROTOCOL_VERSION; + info->v4service_port = LSL_ESP32_MULTICAST_PORT; + info->v4data_port = 0; /* set later when TCP server starts */ + + ESP_LOGI(TAG, "Created stream: name=%s type=%s ch=%d fmt=%s srate=%.1f uid=%s", info->name, + info->type, info->channel_count, stream_info_format_string(info->channel_format), + info->nominal_srate, info->uid); + + return info; +} + +const char *lsl_esp32_get_name(lsl_esp32_stream_info_t info) +{ + return info ? info->name : ""; +} + +const char *lsl_esp32_get_type(lsl_esp32_stream_info_t info) +{ + return info ? info->type : ""; +} + +int lsl_esp32_get_channel_count(lsl_esp32_stream_info_t info) +{ + return info ? info->channel_count : 0; +} + +double lsl_esp32_get_nominal_srate(lsl_esp32_stream_info_t info) +{ + return info ? info->nominal_srate : 0.0; +} + +lsl_esp32_channel_format_t lsl_esp32_get_channel_format(lsl_esp32_stream_info_t info) +{ + return info ? info->channel_format : (lsl_esp32_channel_format_t)0; +} + +void lsl_esp32_destroy_streaminfo(lsl_esp32_stream_info_t info) +{ + if (info) { + ESP_LOGD(TAG, "Destroying stream: %s", info->name); + } + free(info); +} + +/* XML field order matches desktop liblsl stream_info_impl.cpp:write_xml(). + * Both shortinfo and fullinfo include (empty if no description set). */ + +int stream_info_to_shortinfo_xml(const struct lsl_esp32_stream_info *info, char *buf, + size_t buf_len) +{ + if (!info || !buf || buf_len == 0) { + ESP_LOGE(TAG, "Invalid args to shortinfo_xml"); + return -1; + } + + int n = snprintf(buf, buf_len, + "" + "" + "%s" + "%s" + "%d" + "%s" + "%s" + "%g" + "1.10" + "%g" + "%s" + "%s" + "%s" + "%s" + "%d" + "%d" + "" + "0" + "0" + "" + "", + info->name, info->type, info->channel_count, + stream_info_format_string(info->channel_format), info->source_id, + info->nominal_srate, info->created_at, info->uid, info->session_id, + info->hostname, info->v4addr, info->v4data_port, info->v4service_port); + if (n < 0 || (size_t)n >= buf_len) { + ESP_LOGE(TAG, "shortinfo XML truncated: need %d, have %zu", n, buf_len); + return -1; + } + return n; +} + +int stream_info_to_fullinfo_xml(const struct lsl_esp32_stream_info *info, char *buf, size_t buf_len) +{ + if (!info || !buf || buf_len == 0) { + ESP_LOGE(TAG, "Invalid args to fullinfo_xml"); + return -1; + } + + int n = snprintf(buf, buf_len, + "" + "" + "%s" + "%s" + "%d" + "%s" + "%s" + "%g" + "1.10" + "%g" + "%s" + "%s" + "%s" + "%s" + "%d" + "%d" + "" + "0" + "0" + "" + "", + info->name, info->type, info->channel_count, + stream_info_format_string(info->channel_format), info->source_id, + info->nominal_srate, info->created_at, info->uid, info->session_id, + info->hostname, info->v4addr, info->v4data_port, info->v4service_port); + if (n < 0 || (size_t)n >= buf_len) { + ESP_LOGE(TAG, "fullinfo XML truncated: need %d, have %zu", n, buf_len); + return -1; + } + return n; +} + +/* Parse a query string with AND semantics. + * Supports: "name='X'", "type='Y'", "source_id='Z'", empty (match all). + * Compound queries: "name='X' and type='Y'" requires ALL conditions to match. + * Fields not mentioned in the query are not checked (implicit match). + * Returns 1 if all conditions match, 0 if any condition fails. */ +int stream_info_match_query(const struct lsl_esp32_stream_info *info, const char *query) +{ + if (!info) { + ESP_LOGE(TAG, "NULL stream info in match_query"); + return 0; + } + + if (!query || query[0] == '\0') { + return 1; /* empty query matches everything */ + } + + /* Known queryable fields */ + const char *field_names[] = {"name", "type", "source_id"}; + const size_t field_lens[] = {4, 4, 9}; + const char *field_values[] = {info->name, info->type, info->source_id}; + int num_fields = 3; + + int conditions_found = 0; + int conditions_matched = 0; + + for (int i = 0; i < num_fields; i++) { + /* Search for this field in the query, checking word boundaries + * to avoid matching "name" inside "hostname". */ + const char *search = query; + while ((search = strstr(search, field_names[i])) != NULL) { + /* Check word boundary: character before must not be alphanumeric */ + if (search != query && isalnum((unsigned char)search[-1])) { + search += field_lens[i]; + continue; + } + + /* Skip field name */ + const char *pos = search + field_lens[i]; + while (*pos == ' ') { + pos++; + } + if (*pos != '=') { + search = pos; + continue; + } + pos++; + while (*pos == ' ') { + pos++; + } + + /* Extract quoted value */ + char quote_char = *pos; + if (quote_char != '\'' && quote_char != '"') { + search = pos; + continue; + } + pos++; + const char *end = strchr(pos, quote_char); + if (!end) { + search = pos; + continue; + } + + /* Compare field value */ + size_t vlen = (size_t)(end - pos); + conditions_found++; + if (strlen(field_values[i]) == vlen && strncmp(field_values[i], pos, vlen) == 0) { + conditions_matched++; + } + + /* Move past this match to avoid re-matching */ + search = end + 1; + break; + } + } + + /* AND semantics: all found conditions must match */ + if (conditions_found == 0) { + /* Query had no recognized fields; treat as no match */ + ESP_LOGD(TAG, "No recognized fields in query: '%s'", query); + return 0; + } + + return (conditions_matched == conditions_found) ? 1 : 0; +} diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_stream_info.h b/liblsl-ESP32/components/liblsl_esp32/src/lsl_stream_info.h new file mode 100644 index 0000000..9b1e0a4 --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_stream_info.h @@ -0,0 +1,61 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#ifndef LSL_STREAM_INFO_H +#define LSL_STREAM_INFO_H + +#include "lsl_esp32_types.h" + +/* Maximum length for string fields in stream info */ +#define STREAM_INFO_NAME_MAX 64 +#define STREAM_INFO_TYPE_MAX 32 +#define STREAM_INFO_SOURCE_ID_MAX 64 +#define STREAM_INFO_HOSTNAME_MAX 32 +#define STREAM_INFO_SESSION_MAX 32 +#define STREAM_INFO_ADDR_MAX 16 /* "xxx.xxx.xxx.xxx" */ + +/* Internal stream info structure */ +struct lsl_esp32_stream_info { + char name[STREAM_INFO_NAME_MAX]; + char type[STREAM_INFO_TYPE_MAX]; + int channel_count; + double nominal_srate; + lsl_esp32_channel_format_t channel_format; + char source_id[STREAM_INFO_SOURCE_ID_MAX]; + char uid[LSL_ESP32_UUID_STR_LEN]; + char hostname[STREAM_INFO_HOSTNAME_MAX]; + char session_id[STREAM_INFO_SESSION_MAX]; + double created_at; + int protocol_version; + char v4addr[STREAM_INFO_ADDR_MAX]; + int v4data_port; + int v4service_port; +}; + +/* Generate a UUID4 string using ESP32 hardware RNG. + * out must be at least LSL_ESP32_UUID_STR_LEN bytes. */ +void stream_info_generate_uuid4(char *out); + +/* Serialize stream info to shortinfo XML (for discovery responses). + * Returns number of bytes written (excluding null), or -1 on error. */ +int stream_info_to_shortinfo_xml(const struct lsl_esp32_stream_info *info, char *buf, + size_t buf_len); + +/* Serialize stream info to fullinfo XML (for detailed queries). + * Returns number of bytes written (excluding null), or -1 on error. */ +int stream_info_to_fullinfo_xml(const struct lsl_esp32_stream_info *info, char *buf, + size_t buf_len); + +/* Check if a stream info matches a query string. + * Query format: "name='X'" or "type='Y'" or "source_id='Z'" or empty (match all). + * Returns 1 if match, 0 if no match. */ +int stream_info_match_query(const struct lsl_esp32_stream_info *info, const char *query); + +/* Return the channel format as a string (e.g., "float32") */ +const char *stream_info_format_string(lsl_esp32_channel_format_t fmt); + +/* Return bytes per channel for a given format */ +size_t stream_info_bytes_per_channel(lsl_esp32_channel_format_t fmt); + +#endif /* LSL_STREAM_INFO_H */ diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_tcp_client.c b/liblsl-ESP32/components/liblsl_esp32/src/lsl_tcp_client.c new file mode 100644 index 0000000..7bcf8e6 --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_tcp_client.c @@ -0,0 +1,300 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#include "lsl_tcp_client.h" +#include "lsl_tcp_common.h" +#include "lsl_protocol.h" +#include "lsl_sample.h" +#include "lsl_security.h" +#include "lsl_key_manager.h" +#include "esp_log.h" +#include "sodium.h" + +#include "lwip/sockets.h" +#include "lwip/netdb.h" +#include +#include + +static const char *TAG = "lsl_tcp_client"; + +/* Read and discard a null-terminated string from socket (fullinfo XML). + * Desktop liblsl sends this after response headers. */ +static int consume_null_terminated(int sock, size_t max_bytes) +{ + for (size_t i = 0; i < max_bytes; i++) { + char c; + int n = recv(sock, &c, 1, 0); + if (n <= 0) { + return -1; + } + if (c == '\0') { + return (int)i; + } + } + ESP_LOGW(TAG, "Null-terminated string exceeded %zu bytes", max_bytes); + return -1; +} + +int tcp_client_connect(const struct lsl_esp32_stream_info *info, + const lsl_security_config_t *security, lsl_security_session_t *session_out) +{ + if (!info || info->v4addr[0] == '\0' || info->v4data_port == 0) { + ESP_LOGE(TAG, "Invalid stream info for TCP connection"); + return -1; + } + + ESP_LOGI(TAG, "Connecting to %s:%d (uid=%s)", info->v4addr, info->v4data_port, info->uid); + + /* Create TCP socket */ + int sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (sock < 0) { + ESP_LOGE(TAG, "Failed to create socket: errno %d", errno); + return -1; + } + + /* Set timeouts */ + struct timeval tv = {.tv_sec = 10, .tv_usec = 0}; + setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + + /* Connect */ + struct sockaddr_in dest = { + .sin_family = AF_INET, + .sin_port = htons(info->v4data_port), + }; + if (inet_aton(info->v4addr, &dest.sin_addr) == 0) { + ESP_LOGE(TAG, "Invalid address: %s", info->v4addr); + close(sock); + return -1; + } + + if (connect(sock, (struct sockaddr *)&dest, sizeof(dest)) < 0) { + ESP_LOGE(TAG, "Connect failed: errno %d", errno); + close(sock); + return -1; + } + + /* Set TCP_NODELAY for low latency */ + int nodelay = 1; + setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, &nodelay, sizeof(nodelay)); + + ESP_LOGI(TAG, "Connected to %s:%d", info->v4addr, info->v4data_port); + + /* Send request: request line + headers + blank line. + * Desktop liblsl tcp_server expects at minimum the request line + * and reads headers until a blank line. We send the standard + * negotiation headers matching what desktop data_receiver sends. */ + int our_security_enabled = (security && security->enabled) ? 1 : 0; + + char request[768]; + int req_len; + if (our_security_enabled) { + char our_pubkey_b64[LSL_KEY_BASE64_SIZE]; + sodium_bin2base64(our_pubkey_b64, sizeof(our_pubkey_b64), security->public_key, + LSL_KEY_PUBLIC_SIZE, sodium_base64_VARIANT_ORIGINAL); + req_len = snprintf(request, sizeof(request), + "LSL:streamfeed/%d %s\r\n" + "Native-Byte-Order: 1234\r\n" + "Has-Clock-Offsets: 0\r\n" + "Endian-Performance: 0\r\n" + "Has-IEEE754-Floats: 1\r\n" + "Supports-Subnormals: 0\r\n" + "Value-Size: %zu\r\n" + "Max-Buffer-Length: 360\r\n" + "Max-Chunk-Length: 0\r\n" + "Protocol-Version: %d\r\n" + "Security-Enabled: true\r\n" + "Security-Public-Key: %s\r\n" + "\r\n", + LSL_ESP32_PROTOCOL_VERSION, info->uid, + stream_info_bytes_per_channel(info->channel_format), + LSL_ESP32_PROTOCOL_VERSION, our_pubkey_b64); + } else { + req_len = snprintf(request, sizeof(request), + "LSL:streamfeed/%d %s\r\n" + "Native-Byte-Order: 1234\r\n" + "Has-Clock-Offsets: 0\r\n" + "Endian-Performance: 0\r\n" + "Has-IEEE754-Floats: 1\r\n" + "Supports-Subnormals: 0\r\n" + "Value-Size: %zu\r\n" + "Max-Buffer-Length: 360\r\n" + "Max-Chunk-Length: 0\r\n" + "Protocol-Version: %d\r\n" + "Security-Enabled: false\r\n" + "\r\n", + LSL_ESP32_PROTOCOL_VERSION, info->uid, + stream_info_bytes_per_channel(info->channel_format), + LSL_ESP32_PROTOCOL_VERSION); + } + if (req_len < 0 || (size_t)req_len >= sizeof(request)) { + ESP_LOGE(TAG, "Request too long"); + close(sock); + return -1; + } + + if (tcp_send_all(sock, request, (size_t)req_len) < 0) { + ESP_LOGE(TAG, "Failed to send request"); + close(sock); + return -1; + } + + /* Read response status line: "LSL/110 200 OK\r\n" */ + char line[512]; + int line_len = tcp_recv_line(sock, line, sizeof(line)); + if (line_len < 0) { + ESP_LOGE(TAG, "Failed to read response line"); + close(sock); + return -1; + } + + /* Check for success */ + if (strncmp(line, "LSL/", 4) != 0 || !strstr(line, "200")) { + ESP_LOGE(TAG, "Server rejected connection: %s", line); + close(sock); + return -1; + } + + ESP_LOGI(TAG, "Response: %s", line); + + /* Read response headers until empty line */ + int server_security_enabled = 0; + char server_pubkey_b64[LSL_KEY_BASE64_SIZE] = {0}; + + while (1) { + line_len = tcp_recv_line(sock, line, sizeof(line)); + if (line_len < 0) { + ESP_LOGE(TAG, "Failed to read response headers"); + close(sock); + return -1; + } + if (line_len == 0) { + break; /* empty line = end of headers */ + } + + /* Parse security headers */ + const char *val = tcp_parse_header_value(line, "Security-Enabled"); + if (val) { + server_security_enabled = (strcmp(val, "true") == 0) ? 1 : 0; + } + val = tcp_parse_header_value(line, "Security-Public-Key"); + if (val) { + if (strlen(val) >= sizeof(server_pubkey_b64)) { + ESP_LOGW(TAG, "Security-Public-Key too long, will be truncated"); + } + strncpy(server_pubkey_b64, val, sizeof(server_pubkey_b64) - 1); + } + + ESP_LOGD(TAG, "Response header: %s", line); + } + + /* Security verification (client side) */ + if (our_security_enabled != server_security_enabled) { + ESP_LOGE(TAG, "Security mismatch: client=%s, server=%s", + our_security_enabled ? "enabled" : "disabled", + server_security_enabled ? "enabled" : "disabled"); + close(sock); + return -1; + } + + if (our_security_enabled) { + if (!session_out) { + ESP_LOGE(TAG, "Security enabled but session_out is NULL (caller bug)"); + close(sock); + return -1; + } + + if (strlen(server_pubkey_b64) >= sizeof(server_pubkey_b64) - 1) { + ESP_LOGW(TAG, "Security-Public-Key header may be truncated"); + } + + lsl_esp32_err_t sec_err = + security_handshake_verify(security, server_pubkey_b64, session_out); + if (sec_err != LSL_ESP32_OK) { + ESP_LOGE(TAG, "Security handshake failed: %d", sec_err); + close(sock); + return -1; + } + + ESP_LOGI(TAG, "Security handshake: session key derived"); + } + + /* Check if fullinfo XML follows (desktop liblsl sends it, ESP32 outlet does not). + * Peek at the first byte: '<' means XML, 0x01/0x02 means test pattern tag. + * Note: in encrypted mode, desktop secureLSL sends fullinfo as plaintext before + * encrypted data begins. ESP32 outlets do not send fullinfo, so the peek is safe + * for both encrypted and unencrypted ESP32-to-ESP32 connections. Desktop interop + * in encrypted mode may need encrypted fullinfo handling in the future. */ + int encrypted = (session_out && session_out->active); + + uint8_t peek_byte; + int peek_n = recv(sock, &peek_byte, 1, MSG_PEEK); + if (peek_n <= 0) { + ESP_LOGE(TAG, "Connection lost during handshake (peek failed: %d)", peek_n); + close(sock); + return -1; + } + if (peek_byte == '<') { + /* Consume null-terminated fullinfo XML (always plaintext, even in encrypted mode; + * encryption only starts after test patterns, matching desktop secureLSL) */ + int xml_bytes = consume_null_terminated(sock, LSL_ESP32_FULLINFO_MAX); + if (xml_bytes < 0) { + ESP_LOGE(TAG, "Failed to consume fullinfo XML, stream desynchronized"); + close(sock); + return -1; + } + ESP_LOGD(TAG, "Consumed fullinfo XML (%d bytes)", xml_bytes); + } else { + ESP_LOGD(TAG, "No fullinfo XML (ESP32 outlet), proceeding to test patterns"); + } + + /* Receive and validate 2 test-pattern samples (always plaintext). + * Desktop secureLSL sends test patterns unencrypted even when security + * is enabled; encryption starts only for streaming data that follows. */ + size_t bpc = stream_info_bytes_per_channel(info->channel_format); + size_t sample_wire_size = 1 + 8 + (size_t)info->channel_count * bpc; + uint8_t sample_buf[LSL_SAMPLE_MAX_BYTES]; + + for (int pat = 0; pat < 2; pat++) { + int offset = (pat == 0) ? LSL_ESP32_TEST_OFFSET_1 : LSL_ESP32_TEST_OFFSET_2; + + if (tcp_recv_exact(sock, sample_buf, sample_wire_size) < 0) { + ESP_LOGE(TAG, "Failed to receive test pattern %d", pat + 1); + close(sock); + return -1; + } + + /* Deserialize */ + uint8_t channel_data[LSL_ESP32_MAX_CHANNELS * 8]; + double timestamp; + int consumed = sample_deserialize(sample_buf, sample_wire_size, info->channel_count, + info->channel_format, channel_data, sizeof(channel_data), + ×tamp); + if (consumed <= 0) { + ESP_LOGE(TAG, "Failed to deserialize test pattern %d", pat + 1); + close(sock); + return -1; + } + + /* Validate */ + if (sample_validate_test_pattern(info->channel_count, info->channel_format, offset, + LSL_ESP32_TEST_TIMESTAMP, channel_data, timestamp) != 0) { + ESP_LOGE(TAG, "Test pattern %d validation failed", pat + 1); + close(sock); + return -1; + } + + ESP_LOGI(TAG, "Test pattern %d: OK", pat + 1); + } + + /* Clear receive timeout for streaming phase. + * Irregular or low-rate streams may have long gaps between samples; + * a timeout would cause spurious disconnections. */ + struct timeval no_timeout = {.tv_sec = 0, .tv_usec = 0}; + setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &no_timeout, sizeof(no_timeout)); + + ESP_LOGI(TAG, "Handshake complete, ready to receive samples%s", + encrypted ? " (encrypted streaming)" : ""); + return sock; +} diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_tcp_client.h b/liblsl-ESP32/components/liblsl_esp32/src/lsl_tcp_client.h new file mode 100644 index 0000000..3bf45a9 --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_tcp_client.h @@ -0,0 +1,22 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#ifndef LSL_TCP_CLIENT_H +#define LSL_TCP_CLIENT_H + +#include "lsl_esp32_types.h" +#include "lsl_stream_info.h" +#include "lsl_security.h" + +/* Connect to an LSL outlet via TCP and perform the protocol handshake. + * Returns the connected socket fd on success, -1 on failure. + * The handshake includes: sending streamfeed request, reading response + * headers, consuming fullinfo XML, and validating test pattern samples. + * If security is non-NULL and enabled, sends security headers and + * derives session key on success (written to session_out). + * session_out must not be NULL when security is enabled. */ +int tcp_client_connect(const struct lsl_esp32_stream_info *info, + const lsl_security_config_t *security, lsl_security_session_t *session_out); + +#endif /* LSL_TCP_CLIENT_H */ diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_tcp_common.c b/liblsl-ESP32/components/liblsl_esp32/src/lsl_tcp_common.c new file mode 100644 index 0000000..a8621f4 --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_tcp_common.c @@ -0,0 +1,164 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#include "lsl_tcp_common.h" +#include "lsl_security.h" +#include "lsl_sample.h" +#include "lsl_protocol.h" /* LE compile-time check for nonce byte order */ +#include "esp_log.h" + +#include "lwip/sockets.h" +#include +#include /* strncasecmp */ + +static const char *TAG = "lsl_tcp"; + +int tcp_send_all(int sock, const void *data, size_t len) +{ + const uint8_t *ptr = (const uint8_t *)data; + size_t remaining = len; + while (remaining > 0) { + int sent = send(sock, ptr, remaining, 0); + if (sent <= 0) { + return -1; + } + ptr += sent; + remaining -= (size_t)sent; + } + return 0; +} + +int tcp_recv_exact(int sock, void *buf, size_t n) +{ + uint8_t *ptr = (uint8_t *)buf; + size_t remaining = n; + while (remaining > 0) { + int received = recv(sock, ptr, remaining, 0); + if (received <= 0) { + return -1; + } + ptr += received; + remaining -= (size_t)received; + } + return 0; +} + +int tcp_recv_line(int sock, char *buf, size_t buf_len) +{ + size_t pos = 0; + while (pos < buf_len - 1) { + char c; + int n = recv(sock, &c, 1, 0); + if (n <= 0) { + return -1; + } + buf[pos++] = c; + if (pos >= 2 && buf[pos - 2] == '\r' && buf[pos - 1] == '\n') { + buf[pos - 2] = '\0'; /* strip CRLF */ + return (int)(pos - 2); + } + } + buf[pos] = '\0'; + ESP_LOGW(TAG, "Header line too long (%zu bytes), no CRLF found", pos); + return -1; +} + +const char *tcp_parse_header_value(const char *line, const char *key) +{ + size_t key_len = strlen(key); + if (strncasecmp(line, key, key_len) != 0) { + return NULL; + } + if (line[key_len] != ':') { + return NULL; + } + const char *p = line + key_len + 1; + while (*p == ' ') { + p++; + } + return p; +} + +/* Maximum valid encrypted payload: nonce + max_sample + auth_tag */ +#define MAX_ENCRYPTED_PAYLOAD \ + (LSL_SECURITY_NONCE_WIRE_SIZE + LSL_SAMPLE_MAX_BYTES + LSL_SECURITY_AUTH_TAG_SIZE) + +int tcp_recv_encrypted_chunk(int sock, lsl_security_session_t *session, uint8_t *plaintext_out, + size_t plaintext_max, uint8_t *ct_buf, size_t ct_buf_size) +{ + /* Read 4-byte big-endian payload length */ + uint8_t len_buf[4]; + if (tcp_recv_exact(sock, len_buf, 4) < 0) { + ESP_LOGW(TAG, "Failed to read encrypted chunk length header"); + return -1; + } + uint32_t payload_len = ((uint32_t)len_buf[0] << 24) | ((uint32_t)len_buf[1] << 16) | + ((uint32_t)len_buf[2] << 8) | (uint32_t)len_buf[3]; + + /* Must contain at least nonce + auth tag + 1 byte plaintext */ + if (payload_len < LSL_SECURITY_NONCE_WIRE_SIZE + LSL_SECURITY_AUTH_TAG_SIZE + 1) { + ESP_LOGE(TAG, "Encrypted chunk too short: %lu bytes", (unsigned long)payload_len); + return -1; + } + + /* Reject obviously oversized payloads (protocol desync or malicious peer) */ + if (payload_len > MAX_ENCRYPTED_PAYLOAD) { + ESP_LOGE(TAG, "Encrypted payload too large: %lu bytes (max %d)", (unsigned long)payload_len, + MAX_ENCRYPTED_PAYLOAD); + return -1; + } + + /* Read 8-byte little-endian nonce */ + uint64_t nonce; + if (tcp_recv_exact(sock, &nonce, LSL_SECURITY_NONCE_WIRE_SIZE) < 0) { + ESP_LOGW(TAG, "Failed to read encrypted chunk nonce"); + return -1; + } + + /* Read ciphertext (payload_len - 8 bytes nonce) */ + size_t ct_len = payload_len - LSL_SECURITY_NONCE_WIRE_SIZE; + if (ct_len > ct_buf_size) { + ESP_LOGE(TAG, "Ciphertext too large for buffer: %zu bytes (max %zu)", ct_len, ct_buf_size); + return -1; + } + if (tcp_recv_exact(sock, ct_buf, ct_len) < 0) { + ESP_LOGW(TAG, "Failed to read encrypted chunk ciphertext (%zu bytes)", ct_len); + return -1; + } + + return security_decrypt(session, nonce, ct_buf, ct_len, plaintext_out, plaintext_max); +} + +int tcp_send_encrypted_chunk(int sock, lsl_security_session_t *session, const uint8_t *plaintext, + size_t plaintext_len, uint8_t *ct_buf, size_t ct_buf_size) +{ + uint64_t nonce; + int ct_len = security_encrypt(session, plaintext, plaintext_len, ct_buf, ct_buf_size, &nonce); + if (ct_len < 0) { + ESP_LOGE(TAG, "Encryption failed for %zu byte plaintext (session active=%d)", plaintext_len, + session ? session->active : -1); + return -1; + } + + /* Payload = nonce(8) + ciphertext(ct_len) */ + uint32_t payload_len = (uint32_t)(LSL_SECURITY_NONCE_WIRE_SIZE + ct_len); + uint8_t header[4 + LSL_SECURITY_NONCE_WIRE_SIZE]; + + /* 4 bytes big-endian payload length */ + header[0] = (uint8_t)(payload_len >> 24); + header[1] = (uint8_t)(payload_len >> 16); + header[2] = (uint8_t)(payload_len >> 8); + header[3] = (uint8_t)(payload_len); + + /* 8 bytes little-endian nonce */ + memcpy(header + 4, &nonce, 8); + + if (tcp_send_all(sock, header, sizeof(header)) < 0) { + return -1; + } + if (tcp_send_all(sock, ct_buf, (size_t)ct_len) < 0) { + return -1; + } + return 0; +} diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_tcp_common.h b/liblsl-ESP32/components/liblsl_esp32/src/lsl_tcp_common.h new file mode 100644 index 0000000..4d9507d --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_tcp_common.h @@ -0,0 +1,35 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#ifndef LSL_TCP_COMMON_H +#define LSL_TCP_COMMON_H + +#include "lsl_security.h" +#include +#include + +/* Send all bytes, handling partial writes. Returns 0 on success, -1 on error. */ +int tcp_send_all(int sock, const void *data, size_t len); + +/* Receive exactly n bytes from socket. Returns 0 on success, -1 on error. */ +int tcp_recv_exact(int sock, void *buf, size_t n); + +/* Read a line (up to CRLF) from socket. CRLF is stripped. + * Returns line length (excluding CRLF) or -1 on error. */ +int tcp_recv_line(int sock, char *buf, size_t buf_len); + +/* Parse "Key: Value" from a header line. Returns pointer to value or NULL. */ +const char *tcp_parse_header_value(const char *line, const char *key); + +/* Read one encrypted chunk: [4B BE len][8B LE nonce][ciphertext+tag]. + * Decrypts into plaintext_out. Returns plaintext length, or -1 on error. */ +int tcp_recv_encrypted_chunk(int sock, lsl_security_session_t *session, uint8_t *plaintext_out, + size_t plaintext_max, uint8_t *ct_buf, size_t ct_buf_size); + +/* Send plaintext wrapped in an encrypted chunk: [4B BE len][8B LE nonce][ciphertext+tag]. + * Returns 0 on success, -1 on error. */ +int tcp_send_encrypted_chunk(int sock, lsl_security_session_t *session, const uint8_t *plaintext, + size_t plaintext_len, uint8_t *ct_buf, size_t ct_buf_size); + +#endif /* LSL_TCP_COMMON_H */ diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_tcp_server.c b/liblsl-ESP32/components/liblsl_esp32/src/lsl_tcp_server.c new file mode 100644 index 0000000..d84544b --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_tcp_server.c @@ -0,0 +1,531 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#include "lsl_tcp_server.h" +#include "lsl_tcp_common.h" +#include "lsl_clock.h" +#include "lsl_protocol.h" +#include "lsl_sample.h" +#include "lsl_security.h" +#include "lsl_key_manager.h" +#include "esp_log.h" +#include "sodium.h" + +#include "lwip/sockets.h" +#include "lwip/netdb.h" +#include +#include +#include + +static const char *TAG = "lsl_tcp_server"; + +#define TCP_ACCEPT_STACK 4096 +#define TCP_ACCEPT_PRIO 6 +#define TCP_FEED_STACK 8192 +#define TCP_FEED_PRIO 7 +#define TCP_RECV_BUF 2048 +#define TCP_HEADER_MAX 1024 + +/* Error response strings (use sizeof-1 to avoid manual length counting) */ +static const char RESP_400[] = "LSL/110 400 Bad Request\r\n\r\n"; +static const char RESP_403[] = "LSL/110 403 Forbidden\r\n\r\n"; +static const char RESP_404[] = "LSL/110 404 Not found\r\n\r\n"; +static const char RESP_500[] = "LSL/110 500 Internal Error\r\n\r\n"; + +/* Context passed to each feed task */ +typedef struct { + lsl_tcp_server_t *server; + int client_sock; + lsl_ring_consumer_t consumer; + lsl_security_session_t session; /* per-connection session (inactive when security disabled) */ +} feed_ctx_t; + +/* Handle one connected client: protocol negotiation, test patterns, streaming */ +static void feed_task(void *arg) +{ + feed_ctx_t *ctx = (feed_ctx_t *)arg; + lsl_tcp_server_t *server = ctx->server; + int sock = ctx->client_sock; + char line_buf[TCP_HEADER_MAX]; + uint8_t *ct_buf = NULL; /* heap-allocated if encrypted; freed in cleanup */ + + ESP_LOGI(TAG, "Feed task started for client fd=%d", sock); + + /* Read request line: "LSL:streamfeed/110 \r\n" */ + int line_len = tcp_recv_line(sock, line_buf, sizeof(line_buf)); + if (line_len < 0) { + ESP_LOGW(TAG, "Failed to read request line"); + goto cleanup; + } + + /* Validate request */ + if (strncmp(line_buf, "LSL:streamfeed/", 15) != 0) { + ESP_LOGW(TAG, "Invalid request: %s", line_buf); + tcp_send_all(sock, RESP_400, sizeof(RESP_400) - 1); + goto cleanup; + } + + /* Check protocol version */ + int proto_ver = 0; + char req_uid[64] = {0}; + if (sscanf(line_buf + 15, "%d %63s", &proto_ver, req_uid) < 1) { + ESP_LOGW(TAG, "Cannot parse protocol version from: %s", line_buf); + tcp_send_all(sock, RESP_400, sizeof(RESP_400) - 1); + goto cleanup; + } + + ESP_LOGI(TAG, "Request: proto=%d uid=%s", proto_ver, req_uid); + + /* Validate UID if provided */ + if (req_uid[0] != '\0' && strcmp(req_uid, server->info->uid) != 0) { + ESP_LOGW(TAG, "UID mismatch: got '%s', expected '%s'", req_uid, server->info->uid); + tcp_send_all(sock, RESP_404, sizeof(RESP_404) - 1); + goto cleanup; + } + + /* Read request headers until empty line (CRLF CRLF) */ + int client_security_enabled = 0; + char client_pubkey_b64[LSL_KEY_BASE64_SIZE] = {0}; + + while (1) { + line_len = tcp_recv_line(sock, line_buf, sizeof(line_buf)); + if (line_len < 0) { + goto cleanup; + } + if (line_len == 0) { + break; /* empty line = end of headers */ + } + + /* Parse security headers */ + const char *val = tcp_parse_header_value(line_buf, "Security-Enabled"); + if (val) { + client_security_enabled = (strcmp(val, "true") == 0) ? 1 : 0; + } + val = tcp_parse_header_value(line_buf, "Security-Public-Key"); + if (val) { + if (strlen(val) >= sizeof(client_pubkey_b64)) { + ESP_LOGW(TAG, "Security-Public-Key too long, will be truncated"); + } + strncpy(client_pubkey_b64, val, sizeof(client_pubkey_b64) - 1); + } + + ESP_LOGD(TAG, "Client header: %s", line_buf); + } + + /* Security negotiation */ + int our_security_enabled = (server->security && server->security->enabled) ? 1 : 0; + + /* Unanimous enforcement: both must agree */ + if (our_security_enabled != client_security_enabled) { + ESP_LOGW(TAG, "Security mismatch: server=%s, client=%s", + our_security_enabled ? "enabled" : "disabled", + client_security_enabled ? "enabled" : "disabled"); + if (tcp_send_all(sock, RESP_403, sizeof(RESP_403) - 1) < 0) { + ESP_LOGW(TAG, "Failed to send 403 rejection"); + } + goto cleanup; + } + + /* If security enabled, verify public key match and derive session key */ + if (our_security_enabled) { + if (strlen(client_pubkey_b64) >= sizeof(client_pubkey_b64) - 1) { + ESP_LOGW(TAG, "Security-Public-Key header may be truncated"); + } + + lsl_esp32_err_t sec_err = + security_handshake_verify(server->security, client_pubkey_b64, &ctx->session); + if (sec_err != LSL_ESP32_OK) { + /* Invalid key or mismatch = 403; derivation failure = 500 */ + const char *resp_err = + (sec_err == LSL_ESP32_ERR_INVALID_ARG || sec_err == LSL_ESP32_ERR_SECURITY) + ? RESP_403 + : RESP_500; + size_t resp_err_len = + (resp_err == RESP_403) ? sizeof(RESP_403) - 1 : sizeof(RESP_500) - 1; + if (tcp_send_all(sock, resp_err, resp_err_len) < 0) { + ESP_LOGW(TAG, "Failed to send rejection response"); + } + goto cleanup; + } + + ESP_LOGI(TAG, "Security handshake: session key derived"); + } + + /* Build response headers */ + char resp[512]; + int resp_len; + if (our_security_enabled) { + char our_pubkey_b64[LSL_KEY_BASE64_SIZE]; + sodium_bin2base64(our_pubkey_b64, sizeof(our_pubkey_b64), server->security->public_key, + LSL_KEY_PUBLIC_SIZE, sodium_base64_VARIANT_ORIGINAL); + resp_len = snprintf(resp, sizeof(resp), + "LSL/110 200 OK\r\n" + "UID: %s\r\n" + "Byte-Order: 1234\r\n" + "Data-Protocol-Version: 110\r\n" + "Security-Enabled: true\r\n" + "Security-Public-Key: %s\r\n" + "\r\n", + server->info->uid, our_pubkey_b64); + } else { + resp_len = snprintf(resp, sizeof(resp), + "LSL/110 200 OK\r\n" + "UID: %s\r\n" + "Byte-Order: 1234\r\n" + "Data-Protocol-Version: 110\r\n" + "Security-Enabled: false\r\n" + "\r\n", + server->info->uid); + } + + if (resp_len < 0 || (size_t)resp_len >= sizeof(resp)) { + ESP_LOGE(TAG, "Response header truncated (need %d, have %zu)", resp_len, sizeof(resp)); + goto cleanup; + } + if (tcp_send_all(sock, resp, (size_t)resp_len) < 0) { + ESP_LOGE(TAG, "Failed to send response headers"); + goto cleanup; + } + + ESP_LOGI(TAG, "Sent response headers (%d bytes, security=%s)", resp_len, + our_security_enabled ? "on" : "off"); + + /* Note: desktop liblsl's tcp_server sends fullinfo XML after headers, + * but the desktop inlet (data_receiver) reads it as a null-terminated + * string before expecting test samples. However, testing shows pylsl + * works without it (it gets stream info from the discovery response). + * Adding it here breaks test pattern validation, so we skip it for now + * and will investigate the exact protocol sequence if needed. */ + + /* Send 2 test-pattern samples (always plaintext, matching desktop secureLSL). + * Desktop sends test patterns unencrypted even when security is enabled; + * encryption starts only for the streaming data that follows. */ + uint8_t sample_buf[LSL_SAMPLE_MAX_BYTES]; + int sample_len; + /* Session state is fixed: established during handshake or never. Does not change mid-stream. */ + int encrypted = ctx->session.active; + + for (int pat = 0; pat < 2; pat++) { + int offset = (pat == 0) ? LSL_ESP32_TEST_OFFSET_1 : LSL_ESP32_TEST_OFFSET_2; + sample_len = sample_generate_test_pattern( + server->info->channel_count, server->info->channel_format, offset, + LSL_ESP32_TEST_TIMESTAMP, sample_buf, sizeof(sample_buf)); + if (sample_len <= 0) { + ESP_LOGE(TAG, "Failed to generate test pattern %d (ret=%d)", pat + 1, sample_len); + goto cleanup; + } + if (tcp_send_all(sock, sample_buf, (size_t)sample_len) < 0) { + ESP_LOGE(TAG, "Failed to send test pattern %d", pat + 1); + goto cleanup; + } + } + + ESP_LOGI(TAG, "Sent 2 test-pattern samples, starting data stream"); + + /* Log stack high-water mark after handshake (most stack-intensive phase) */ + ESP_LOGD(TAG, "Feed task stack HWM: %u words free", + (unsigned)uxTaskGetStackHighWaterMark(NULL)); + + /* Allocate ciphertext buffer for streaming (only when encrypted) */ + size_t ct_size = LSL_SAMPLE_MAX_BYTES + LSL_SECURITY_AUTH_TAG_SIZE; + ct_buf = encrypted ? malloc(ct_size) : NULL; + if (encrypted && !ct_buf) { + ESP_LOGE(TAG, "Failed to allocate ciphertext buffer"); + goto cleanup; + } + + /* Initialize consumer at current ring buffer position */ + ring_buffer_consumer_init(server->ring, &ctx->consumer); + + /* Stream samples from ring buffer (encrypted if session active) */ + while (server->running) { + size_t nbytes = + ring_buffer_read(server->ring, &ctx->consumer, sample_buf, sizeof(sample_buf)); + if (nbytes > 0) { + int send_ret; + if (encrypted) { + send_ret = tcp_send_encrypted_chunk(sock, &ctx->session, sample_buf, nbytes, ct_buf, + ct_size); + } else { + send_ret = tcp_send_all(sock, sample_buf, nbytes); + } + if (send_ret < 0) { + ESP_LOGI(TAG, "Client disconnected (send failed)"); + break; + } + } else { + /* No data available; yield briefly */ + vTaskDelay(1); + } + } + +cleanup: + free(ct_buf); + security_session_clear(&ctx->session); + close(sock); + ESP_LOGI(TAG, "Feed task ended for client fd=%d", ctx->client_sock); + + /* Decrement connection count (mandatory; use portMAX_DELAY to prevent leak) */ + if (server->conn_mutex && xSemaphoreTake(server->conn_mutex, portMAX_DELAY) == pdTRUE) { + server->active_connections--; + ESP_LOGI(TAG, "Active connections: %d", server->active_connections); + xSemaphoreGive(server->conn_mutex); + } + + free(ctx); + vTaskDelete(NULL); +} + +static void accept_task(void *arg) +{ + lsl_tcp_server_t *server = (lsl_tcp_server_t *)arg; + + ESP_LOGI(TAG, "TCP accept task started on port %d", server->info->v4data_port); + + while (server->running) { + struct sockaddr_in client_addr; + socklen_t addr_len = sizeof(client_addr); + + int client_sock = accept(server->listen_sock, (struct sockaddr *)&client_addr, &addr_len); + if (client_sock < 0) { + if (!server->running) { + break; + } + if (errno == EAGAIN || errno == EWOULDBLOCK) { + continue; + } + ESP_LOGE(TAG, "accept error: errno %d", errno); + vTaskDelay(pdMS_TO_TICKS(100)); + continue; + } + + ESP_LOGI(TAG, "New connection from %s:%d", inet_ntoa(client_addr.sin_addr), + ntohs(client_addr.sin_port)); + + /* Check connection limit */ + int can_accept = 0; + if (xSemaphoreTake(server->conn_mutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + if (server->active_connections < LSL_ESP32_MAX_CONNECTIONS) { + server->active_connections++; + can_accept = 1; + } + xSemaphoreGive(server->conn_mutex); + } + + if (!can_accept) { + ESP_LOGW(TAG, "Max connections (%d) reached, rejecting", LSL_ESP32_MAX_CONNECTIONS); + send(client_sock, "LSL/110 503 Service Unavailable\r\n\r\n", 35, 0); + close(client_sock); + continue; + } + + /* Set TCP_NODELAY for low latency */ + int nodelay = 1; + setsockopt(client_sock, IPPROTO_TCP, TCP_NODELAY, &nodelay, sizeof(nodelay)); + + /* Set keepalive */ + int keepalive = 1; + setsockopt(client_sock, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive)); + + /* Set receive timeout to prevent feed task from blocking forever + * on a slow/malicious client during header parsing */ + struct timeval client_tv = {.tv_sec = 10, .tv_usec = 0}; + setsockopt(client_sock, SOL_SOCKET, SO_RCVTIMEO, &client_tv, sizeof(client_tv)); + + /* Set send timeout to prevent feed task from blocking indefinitely + * on a stalled client (critical for clean shutdown) */ + struct timeval send_tv = {.tv_sec = 5, .tv_usec = 0}; + setsockopt(client_sock, SOL_SOCKET, SO_SNDTIMEO, &send_tv, sizeof(send_tv)); + + /* Spawn feed task */ + feed_ctx_t *ctx = calloc(1, sizeof(feed_ctx_t)); + if (!ctx) { + ESP_LOGE(TAG, "Failed to allocate feed context"); + close(client_sock); + if (xSemaphoreTake(server->conn_mutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + server->active_connections--; + xSemaphoreGive(server->conn_mutex); + } + continue; + } + + ctx->server = server; + ctx->client_sock = client_sock; + + char task_name[16]; + snprintf(task_name, sizeof(task_name), "lsl_feed_%d", server->active_connections); + + BaseType_t ret = xTaskCreatePinnedToCore(feed_task, task_name, TCP_FEED_STACK, (void *)ctx, + TCP_FEED_PRIO, NULL, 1); + if (ret != pdPASS) { + ESP_LOGE(TAG, "Failed to create feed task"); + close(client_sock); + free(ctx); + if (xSemaphoreTake(server->conn_mutex, pdMS_TO_TICKS(1000)) == pdTRUE) { + server->active_connections--; + xSemaphoreGive(server->conn_mutex); + } + } + } + + ESP_LOGI(TAG, "TCP accept task stopping"); + + /* Close listen socket */ + if (server->listen_sock >= 0) { + close(server->listen_sock); + server->listen_sock = -1; + } + + xEventGroupSetBits(server->events, TCP_SERVER_STOPPED_BIT); + vTaskDelete(NULL); +} + +lsl_esp32_err_t tcp_server_start(lsl_tcp_server_t *server, struct lsl_esp32_stream_info *info, + lsl_ring_buffer_t *ring, const lsl_security_config_t *security) +{ + if (!server || !info || !ring) { + return LSL_ESP32_ERR_INVALID_ARG; + } + + memset(server, 0, sizeof(*server)); + server->info = info; + server->ring = ring; + server->security = security; + server->listen_sock = -1; + + server->events = xEventGroupCreate(); + if (!server->events) { + ESP_LOGE(TAG, "Failed to create event group"); + return LSL_ESP32_ERR_NO_MEMORY; + } + + server->conn_mutex = xSemaphoreCreateMutex(); + if (!server->conn_mutex) { + ESP_LOGE(TAG, "Failed to create connection mutex"); + vEventGroupDelete(server->events); + return LSL_ESP32_ERR_NO_MEMORY; + } + + /* Create TCP socket */ + server->listen_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (server->listen_sock < 0) { + ESP_LOGE(TAG, "Failed to create TCP socket: errno %d", errno); + vSemaphoreDelete(server->conn_mutex); + vEventGroupDelete(server->events); + return LSL_ESP32_ERR_NETWORK; + } + + int reuse = 1; + setsockopt(server->listen_sock, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); + + /* Try ports in range until one works */ + int port; + for (port = LSL_ESP32_TCP_PORT_MIN; port <= LSL_ESP32_TCP_PORT_MAX; port++) { + struct sockaddr_in bind_addr = { + .sin_family = AF_INET, + .sin_port = htons(port), + .sin_addr.s_addr = htonl(INADDR_ANY), + }; + if (bind(server->listen_sock, (struct sockaddr *)&bind_addr, sizeof(bind_addr)) == 0) { + break; + } + } + + if (port > LSL_ESP32_TCP_PORT_MAX) { + ESP_LOGE(TAG, "Failed to bind to any port in range %d-%d", LSL_ESP32_TCP_PORT_MIN, + LSL_ESP32_TCP_PORT_MAX); + close(server->listen_sock); + server->listen_sock = -1; + vSemaphoreDelete(server->conn_mutex); + vEventGroupDelete(server->events); + return LSL_ESP32_ERR_NETWORK; + } + + info->v4data_port = port; + ESP_LOGI(TAG, "TCP server bound to port %d", port); + + if (listen(server->listen_sock, LSL_ESP32_MAX_CONNECTIONS) < 0) { + ESP_LOGE(TAG, "listen() failed: errno %d", errno); + close(server->listen_sock); + server->listen_sock = -1; + vSemaphoreDelete(server->conn_mutex); + vEventGroupDelete(server->events); + return LSL_ESP32_ERR_NETWORK; + } + + /* Set accept timeout (critical for clean shutdown) */ + struct timeval tv = {.tv_sec = 1, .tv_usec = 0}; + if (setsockopt(server->listen_sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) < 0) { + ESP_LOGE(TAG, "Failed to set SO_RCVTIMEO on listen socket: errno %d", errno); + close(server->listen_sock); + server->listen_sock = -1; + vSemaphoreDelete(server->conn_mutex); + vEventGroupDelete(server->events); + return LSL_ESP32_ERR_NETWORK; + } + + server->running = true; + BaseType_t ret = + xTaskCreatePinnedToCore(accept_task, "lsl_tcp", TCP_ACCEPT_STACK, (void *)server, + TCP_ACCEPT_PRIO, &server->accept_task, 1); + if (ret != pdPASS) { + ESP_LOGE(TAG, "Failed to create accept task"); + close(server->listen_sock); + server->listen_sock = -1; + server->running = false; + vSemaphoreDelete(server->conn_mutex); + vEventGroupDelete(server->events); + return LSL_ESP32_ERR_NO_MEMORY; + } + + ESP_LOGI(TAG, "TCP data server started on port %d", port); + return LSL_ESP32_OK; +} + +int tcp_server_stop(lsl_tcp_server_t *server) +{ + if (!server || !server->running) { + return 0; + } + + server->running = false; + __sync_synchronize(); /* ensure visibility across cores */ + + /* Wait for accept task to exit */ + if (server->events) { + xEventGroupWaitBits(server->events, TCP_SERVER_STOPPED_BIT, pdFALSE, pdFALSE, + pdMS_TO_TICKS(3000)); + vEventGroupDelete(server->events); + server->events = NULL; + } + + /* Wait for feed tasks to exit (SO_SNDTIMEO ensures they unblock within 5s). + * Poll active_connections with a bounded timeout. */ + int remaining = 0; + if (server->conn_mutex) { + for (int i = 0; i < 20; i++) { /* up to 10 seconds */ + if (xSemaphoreTake(server->conn_mutex, pdMS_TO_TICKS(100)) == pdTRUE) { + remaining = server->active_connections; + xSemaphoreGive(server->conn_mutex); + if (remaining == 0) { + break; + } + } + vTaskDelay(pdMS_TO_TICKS(500)); + } + if (remaining == 0) { + vSemaphoreDelete(server->conn_mutex); + server->conn_mutex = NULL; + } + /* If remaining > 0, leave mutex alive for feed tasks still running */ + } + + server->accept_task = NULL; + if (remaining > 0) { + ESP_LOGW(TAG, "TCP server stopped with %d feed tasks still running", remaining); + } else { + ESP_LOGI(TAG, "TCP data server stopped"); + } + return remaining; +} diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_tcp_server.h b/liblsl-ESP32/components/liblsl_esp32/src/lsl_tcp_server.h new file mode 100644 index 0000000..beb8044 --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_tcp_server.h @@ -0,0 +1,44 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#ifndef LSL_TCP_SERVER_H +#define LSL_TCP_SERVER_H + +#include "lsl_esp32_types.h" +#include "lsl_stream_info.h" +#include "lsl_ring_buffer.h" +#include "lsl_security.h" +#include "freertos/FreeRTOS.h" +#include "freertos/event_groups.h" +#include "freertos/semphr.h" +#include "freertos/task.h" + +#define TCP_SERVER_STOPPED_BIT BIT0 + +typedef struct { + struct lsl_esp32_stream_info *info; + lsl_ring_buffer_t *ring; + const lsl_security_config_t *security; /* NULL = disabled; must outlive server */ + TaskHandle_t accept_task; + EventGroupHandle_t events; + int listen_sock; + int active_connections; + SemaphoreHandle_t conn_mutex; + volatile bool running; +} lsl_tcp_server_t; + +/* Start the TCP data server. Listens for LSL streamfeed connections. + * Spawns an accept task and per-connection feed tasks. + * The chosen TCP port is written to info->v4data_port. + * security may be NULL to disable encryption. */ +lsl_esp32_err_t tcp_server_start(lsl_tcp_server_t *server, struct lsl_esp32_stream_info *info, + lsl_ring_buffer_t *ring, const lsl_security_config_t *security); + +/* Stop the TCP server and close all connections. + * Returns 0 if all feed tasks exited cleanly, or the number of + * feed tasks still running after a bounded wait (10s). If >0, + * the caller must not free resources that feed tasks may reference. */ +int tcp_server_stop(lsl_tcp_server_t *server); + +#endif /* LSL_TCP_SERVER_H */ diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_udp_server.c b/liblsl-ESP32/components/liblsl_esp32/src/lsl_udp_server.c new file mode 100644 index 0000000..d43f0e6 --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_udp_server.c @@ -0,0 +1,311 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#include "lsl_udp_server.h" +#include "lsl_clock.h" +#include "esp_log.h" + +#include "lwip/sockets.h" +#include "lwip/igmp.h" +#include "lwip/netdb.h" +#include +#include + +static const char *TAG = "lsl_udp_server"; + +#define UDP_BUF_SIZE 1500 +#define UDP_TASK_STACK 4096 +#define UDP_TASK_PRIO 5 + +/* Parse a LSL:shortinfo query and respond if this stream matches. + * Query format: "LSL:shortinfo\r\n\r\n \r\n" + * Response sent via mcast_sock to sender IP on return-port. */ +static void handle_shortinfo_query(lsl_udp_server_t *server, const char *buf, int len, + const struct sockaddr_in *sender) +{ + const char *line1_end = strstr(buf, "\r\n"); + if (!line1_end) { + ESP_LOGW(TAG, "Malformed shortinfo: no CRLF after header (%d bytes)", len); + return; + } + + const char *query_start = line1_end + 2; + const char *line2_end = strstr(query_start, "\r\n"); + if (!line2_end) { + ESP_LOGW(TAG, "Malformed shortinfo: no CRLF after query string"); + return; + } + + /* Extract query string */ + size_t query_len = (size_t)(line2_end - query_start); + char query[256]; + if (query_len >= sizeof(query)) { + query_len = sizeof(query) - 1; + } + memcpy(query, query_start, query_len); + query[query_len] = '\0'; + + /* Extract return-port and query-id from third line */ + const char *line3_start = line2_end + 2; + int return_port = 0; + char query_id[64] = {0}; + if (sscanf(line3_start, "%d %63s", &return_port, query_id) < 2) { + ESP_LOGW(TAG, "Malformed shortinfo query (missing port/id)"); + return; + } + + if (return_port <= 0 || return_port > 65535) { + ESP_LOGW(TAG, "Invalid return port: %d", return_port); + return; + } + + if (!stream_info_match_query(server->info, query)) { + ESP_LOGD(TAG, "Query not matched: '%s'", query); + return; + } + + ESP_LOGI(TAG, "Discovery query matched: id=%s port=%d", query_id, return_port); + + /* Build response in pre-allocated buffer: "\r\n" */ + int hdr_len = snprintf(server->response_buf, 128, "%s\r\n", query_id); + if (hdr_len >= 128) { + ESP_LOGW(TAG, "Query ID truncated in response header"); + hdr_len = 127; + } + int xml_len = stream_info_to_shortinfo_xml(server->info, server->response_buf + hdr_len, + LSL_ESP32_SHORTINFO_MAX); + if (xml_len < 0) { + ESP_LOGE(TAG, "Failed to serialize shortinfo XML for query id=%s", query_id); + return; + } + + int total_len = hdr_len + xml_len; + + /* Send response to sender IP on return-port via existing socket */ + struct sockaddr_in dest = { + .sin_family = AF_INET, + .sin_port = htons(return_port), + .sin_addr = sender->sin_addr, + }; + + int sent = sendto(server->mcast_sock, server->response_buf, total_len, 0, + (struct sockaddr *)&dest, sizeof(dest)); + if (sent < 0) { + ESP_LOGE(TAG, "Failed to send discovery response: errno %d", errno); + } else { + ESP_LOGI(TAG, "Sent discovery response (%d bytes) to %s:%d", sent, inet_ntoa(dest.sin_addr), + return_port); + } +} + +/* Parse a LSL:timedata query and respond with time correction data. + * Query format: "LSL:timedata\r\n \r\n" + * Response: " \r\n" */ +static void handle_timedata_query(lsl_udp_server_t *server, const char *buf, int len, + const struct sockaddr_in *sender) +{ + double t1 = clock_get_time(); /* receive time */ + + const char *line1_end = strstr(buf, "\r\n"); + if (!line1_end) { + ESP_LOGW(TAG, "Malformed timedata: no CRLF (%d bytes)", len); + return; + } + + const char *line2_start = line1_end + 2; + int wave_id = 0; + double t0 = 0.0; + if (sscanf(line2_start, "%d %lf", &wave_id, &t0) < 2) { + ESP_LOGW(TAG, "Malformed timedata query"); + return; + } + + double t2 = clock_get_time(); /* send time */ + + char response[128]; + int resp_len = + snprintf(response, sizeof(response), "%d %.10g %.10g %.10g\r\n", wave_id, t0, t1, t2); + + int sent = sendto(server->mcast_sock, response, resp_len, 0, (struct sockaddr *)sender, + sizeof(*sender)); + if (sent < 0) { + ESP_LOGE(TAG, "Failed to send time response: errno %d", errno); + } else { + ESP_LOGD(TAG, "Time response: wave=%d t0=%.6f t1=%.6f t2=%.6f", wave_id, t0, t1, t2); + } +} + +static void udp_server_task(void *arg) +{ + lsl_udp_server_t *server = (lsl_udp_server_t *)arg; + char *buf = malloc(UDP_BUF_SIZE); + if (!buf) { + ESP_LOGE(TAG, "Failed to allocate receive buffer"); + server->running = false; + xEventGroupSetBits(server->events, UDP_SERVER_STOPPED_BIT); + vTaskDelete(NULL); + return; + } + + ESP_LOGI(TAG, "UDP server task started on multicast %s:%d", LSL_ESP32_MULTICAST_ADDR, + LSL_ESP32_MULTICAST_PORT); + + int consecutive_errors = 0; + + while (server->running) { + struct sockaddr_in sender; + socklen_t sender_len = sizeof(sender); + + int len = recvfrom(server->mcast_sock, buf, UDP_BUF_SIZE - 1, 0, (struct sockaddr *)&sender, + &sender_len); + if (len < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) { + consecutive_errors = 0; + continue; /* timeout, check running flag */ + } + if (!server->running) { + break; + } + consecutive_errors++; + ESP_LOGE(TAG, "recvfrom error: errno %d (consecutive: %d)", errno, consecutive_errors); + if (consecutive_errors >= 10) { + ESP_LOGE(TAG, "Too many consecutive errors, stopping UDP server"); + server->running = false; + break; + } + vTaskDelay(pdMS_TO_TICKS(100)); /* back off */ + continue; + } + consecutive_errors = 0; + + buf[len] = '\0'; + + if (strncmp(buf, "LSL:shortinfo", 13) == 0) { + handle_shortinfo_query(server, buf, len, &sender); + } else if (strncmp(buf, "LSL:timedata", 12) == 0) { + handle_timedata_query(server, buf, len, &sender); + } else { + ESP_LOGD(TAG, "Unknown UDP message from %s (%d bytes)", inet_ntoa(sender.sin_addr), + len); + } + } + + /* Task owns socket cleanup */ + if (server->mcast_sock >= 0) { + close(server->mcast_sock); + server->mcast_sock = -1; + } + + free(buf); + ESP_LOGI(TAG, "UDP server task stopped"); + xEventGroupSetBits(server->events, UDP_SERVER_STOPPED_BIT); + vTaskDelete(NULL); +} + +lsl_esp32_err_t udp_server_start(lsl_udp_server_t *server, struct lsl_esp32_stream_info *info) +{ + if (!server || !info) { + return LSL_ESP32_ERR_INVALID_ARG; + } + + memset(server, 0, sizeof(*server)); + server->info = info; + server->mcast_sock = -1; + + server->events = xEventGroupCreate(); + if (!server->events) { + ESP_LOGE(TAG, "Failed to create event group"); + return LSL_ESP32_ERR_NO_MEMORY; + } + + /* Create UDP socket */ + server->mcast_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (server->mcast_sock < 0) { + ESP_LOGE(TAG, "Failed to create socket: errno %d", errno); + vEventGroupDelete(server->events); + return LSL_ESP32_ERR_NETWORK; + } + + /* Allow multiple LSL services on same port */ + int reuse = 1; + if (setsockopt(server->mcast_sock, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)) < 0) { + ESP_LOGW(TAG, "SO_REUSEADDR failed: errno %d (non-fatal)", errno); + } + + /* Bind to multicast port */ + struct sockaddr_in bind_addr = { + .sin_family = AF_INET, + .sin_port = htons(LSL_ESP32_MULTICAST_PORT), + .sin_addr.s_addr = htonl(INADDR_ANY), + }; + if (bind(server->mcast_sock, (struct sockaddr *)&bind_addr, sizeof(bind_addr)) < 0) { + ESP_LOGE(TAG, "Failed to bind to port %d: errno %d", LSL_ESP32_MULTICAST_PORT, errno); + close(server->mcast_sock); + server->mcast_sock = -1; + vEventGroupDelete(server->events); + return LSL_ESP32_ERR_NETWORK; + } + + /* Join multicast group */ + struct ip_mreq mreq = { + .imr_multiaddr.s_addr = inet_addr(LSL_ESP32_MULTICAST_ADDR), + .imr_interface.s_addr = htonl(INADDR_ANY), + }; + if (setsockopt(server->mcast_sock, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq)) < 0) { + ESP_LOGE(TAG, "Failed to join multicast group %s: errno %d", LSL_ESP32_MULTICAST_ADDR, + errno); + close(server->mcast_sock); + server->mcast_sock = -1; + vEventGroupDelete(server->events); + return LSL_ESP32_ERR_NETWORK; + } + + /* Set receive timeout (required for clean shutdown; task checks running flag on timeout) */ + struct timeval tv = {.tv_sec = 1, .tv_usec = 0}; + if (setsockopt(server->mcast_sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) < 0) { + ESP_LOGE(TAG, "SO_RCVTIMEO failed: errno %d (required for shutdown)", errno); + close(server->mcast_sock); + server->mcast_sock = -1; + vEventGroupDelete(server->events); + return LSL_ESP32_ERR_NETWORK; + } + + /* Start task */ + server->running = true; + BaseType_t ret = + xTaskCreatePinnedToCore(udp_server_task, "lsl_udp", UDP_TASK_STACK, (void *)server, + UDP_TASK_PRIO, &server->task_handle, 1 /* core 1 = app core */); + if (ret != pdPASS) { + ESP_LOGE(TAG, "Failed to create UDP server task"); + close(server->mcast_sock); + server->mcast_sock = -1; + server->running = false; + vEventGroupDelete(server->events); + return LSL_ESP32_ERR_NO_MEMORY; + } + + ESP_LOGI(TAG, "UDP discovery server started"); + return LSL_ESP32_OK; +} + +void udp_server_stop(lsl_udp_server_t *server) +{ + if (!server || !server->running) { + return; + } + + /* Signal task to stop; the 1s recv timeout will break the loop */ + server->running = false; + + /* Wait for task to exit (up to 2 seconds) */ + if (server->events) { + xEventGroupWaitBits(server->events, UDP_SERVER_STOPPED_BIT, pdFALSE, pdFALSE, + pdMS_TO_TICKS(2000)); + vEventGroupDelete(server->events); + server->events = NULL; + } + + server->task_handle = NULL; + ESP_LOGI(TAG, "UDP discovery server stopped"); +} diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_udp_server.h b/liblsl-ESP32/components/liblsl_esp32/src/lsl_udp_server.h new file mode 100644 index 0000000..59e7052 --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_udp_server.h @@ -0,0 +1,35 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#ifndef LSL_UDP_SERVER_H +#define LSL_UDP_SERVER_H + +#include "lsl_esp32_types.h" +#include "lsl_protocol.h" +#include "lsl_stream_info.h" +#include "freertos/FreeRTOS.h" +#include "freertos/event_groups.h" +#include "freertos/task.h" + +#define UDP_SERVER_STOPPED_BIT BIT0 + +typedef struct { + struct lsl_esp32_stream_info *info; + TaskHandle_t task_handle; + EventGroupHandle_t events; + int mcast_sock; + volatile bool running; + /* Pre-allocated response buffer (avoids malloc per query) */ + char response_buf[LSL_ESP32_SHORTINFO_MAX + 128]; +} lsl_udp_server_t; + +/* Start the UDP discovery responder and time service. + * Spawns a FreeRTOS task that listens on multicast for LSL queries. */ +lsl_esp32_err_t udp_server_start(lsl_udp_server_t *server, struct lsl_esp32_stream_info *info); + +/* Stop the UDP server and clean up resources. + * Blocks until the server task has exited (up to 2 seconds). */ +void udp_server_stop(lsl_udp_server_t *server); + +#endif /* LSL_UDP_SERVER_H */ diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_xml_parser.c b/liblsl-ESP32/components/liblsl_esp32/src/lsl_xml_parser.c new file mode 100644 index 0000000..0066e7e --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_xml_parser.c @@ -0,0 +1,198 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#include "lsl_xml_parser.h" +#include "esp_log.h" +#include +#include +#include + +static const char *TAG = "lsl_xml_parser"; + +int xml_extract_tag(const char *xml, const char *tag_name, char *out, size_t out_len) +{ + if (!xml || !tag_name || !out || out_len == 0) { + return -1; + } + + /* Build opening tag: "" */ + char open_tag[64]; + int open_len = snprintf(open_tag, sizeof(open_tag), "<%s>", tag_name); + if (open_len < 0 || (size_t)open_len >= sizeof(open_tag)) { + return -1; + } + + /* Build closing tag: "" */ + char close_tag[64]; + int close_len = snprintf(close_tag, sizeof(close_tag), "", tag_name); + if (close_len < 0 || (size_t)close_len >= sizeof(close_tag)) { + return -1; + } + + /* Find opening tag */ + const char *start = strstr(xml, open_tag); + if (!start) { + return -1; + } + start += open_len; + + /* Find closing tag */ + const char *end = strstr(start, close_tag); + if (!end) { + return -1; + } + + /* Copy content, warn on truncation */ + size_t content_len = (size_t)(end - start); + if (content_len >= out_len) { + ESP_LOGW(TAG, "Tag '%s' content truncated: %zu >= %zu", tag_name, content_len, out_len); + content_len = out_len - 1; + } + memcpy(out, start, content_len); + out[content_len] = '\0'; + + return (int)content_len; +} + +/* Parse channel format string to enum value */ +static lsl_esp32_channel_format_t parse_channel_format(const char *str) +{ + if (strcmp(str, "float32") == 0) { + return LSL_ESP32_FMT_FLOAT32; + } + if (strcmp(str, "double64") == 0) { + return LSL_ESP32_FMT_DOUBLE64; + } + if (strcmp(str, "int32") == 0) { + return LSL_ESP32_FMT_INT32; + } + if (strcmp(str, "int16") == 0) { + return LSL_ESP32_FMT_INT16; + } + if (strcmp(str, "int8") == 0) { + return LSL_ESP32_FMT_INT8; + } + return (lsl_esp32_channel_format_t)0; /* invalid */ +} + +int xml_parse_stream_info(const char *xml, size_t xml_len, struct lsl_esp32_stream_info *out) +{ + if (!xml || xml_len == 0 || !out) { + ESP_LOGE(TAG, "Invalid arguments to xml_parse_stream_info"); + return -1; + } + + /* Verify null-termination within declared length (strstr requires it) */ + if (strnlen(xml, xml_len + 1) > xml_len) { + ESP_LOGE(TAG, "XML buffer not null-terminated within declared length"); + return -1; + } + + /* Find root and restrict parsing to its content. + * This prevents matching tags inside that might have + * the same names as top-level fields (e.g., inside channel desc). */ + const char *info_start = strstr(xml, ""); + if (!info_start) { + ESP_LOGE(TAG, "Missing root element"); + return -1; + } + info_start += 6; /* skip "" */ + + /* Find the end of the top-level fields (before if present) */ + const char *desc_start = strstr(info_start, ""); + const char *info_end = strstr(info_start, ""); + if (!info_end) { + ESP_LOGE(TAG, "Missing closing element"); + return -1; + } + + /* Use desc boundary to limit search scope if desc contains nested elements */ + size_t search_len; + if (desc_start && desc_start < info_end) { + search_len = (size_t)(desc_start - info_start); + } else { + search_len = (size_t)(info_end - info_start); + } + + /* Create a bounded copy for safe parsing */ + char *bounded = malloc(search_len + 1); + if (!bounded) { + ESP_LOGE(TAG, "Failed to allocate parse buffer"); + return -1; + } + memcpy(bounded, info_start, search_len); + bounded[search_len] = '\0'; + + memset(out, 0, sizeof(*out)); + char buf[256]; + + /* Required fields */ + if (xml_extract_tag(bounded, "name", out->name, sizeof(out->name)) < 0) { + ESP_LOGE(TAG, "Missing element"); + free(bounded); + return -1; + } + + /* Optional string fields (empty string if missing) */ + xml_extract_tag(bounded, "type", out->type, sizeof(out->type)); + xml_extract_tag(bounded, "source_id", out->source_id, sizeof(out->source_id)); + xml_extract_tag(bounded, "uid", out->uid, sizeof(out->uid)); + xml_extract_tag(bounded, "session_id", out->session_id, sizeof(out->session_id)); + xml_extract_tag(bounded, "hostname", out->hostname, sizeof(out->hostname)); + xml_extract_tag(bounded, "v4address", out->v4addr, sizeof(out->v4addr)); + + /* Numeric fields */ + if (xml_extract_tag(bounded, "channel_count", buf, sizeof(buf)) >= 0) { + out->channel_count = atoi(buf); + } + + if (xml_extract_tag(bounded, "nominal_srate", buf, sizeof(buf)) >= 0) { + out->nominal_srate = atof(buf); + } + + if (xml_extract_tag(bounded, "channel_format", buf, sizeof(buf)) >= 0) { + out->channel_format = parse_channel_format(buf); + if (out->channel_format == 0) { + ESP_LOGE(TAG, "Unknown or unsupported channel format: '%s'", buf); + free(bounded); + return -1; + } + } + + if (xml_extract_tag(bounded, "v4data_port", buf, sizeof(buf)) >= 0) { + out->v4data_port = atoi(buf); + } + + if (xml_extract_tag(bounded, "v4service_port", buf, sizeof(buf)) >= 0) { + out->v4service_port = atoi(buf); + } + + if (xml_extract_tag(bounded, "created_at", buf, sizeof(buf)) >= 0) { + out->created_at = atof(buf); + } + + if (xml_extract_tag(bounded, "version", buf, sizeof(buf)) >= 0) { + /* Parse "1.10" -> 110 */ + double ver = atof(buf); + out->protocol_version = (int)(ver * 100.0 + 0.5); + } + + free(bounded); + + /* Validate minimum required fields */ + if (out->channel_count < 1) { + ESP_LOGE(TAG, "Invalid channel_count: %d", out->channel_count); + return -1; + } + if (out->channel_format == 0) { + ESP_LOGE(TAG, "Missing or invalid channel_format"); + return -1; + } + + ESP_LOGD(TAG, "Parsed stream: name=%s type=%s ch=%d fmt=%d uid=%s addr=%s:%d", out->name, + out->type, out->channel_count, out->channel_format, out->uid, out->v4addr, + out->v4data_port); + + return 0; +} diff --git a/liblsl-ESP32/components/liblsl_esp32/src/lsl_xml_parser.h b/liblsl-ESP32/components/liblsl_esp32/src/lsl_xml_parser.h new file mode 100644 index 0000000..a1831a9 --- /dev/null +++ b/liblsl-ESP32/components/liblsl_esp32/src/lsl_xml_parser.h @@ -0,0 +1,22 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#ifndef LSL_XML_PARSER_H +#define LSL_XML_PARSER_H + +#include "lsl_stream_info.h" +#include + +/* Parse LSL shortinfo/fullinfo XML into a stream_info struct. + * Handles the fixed schema emitted by liblsl outlets. + * xml must be null-terminated within xml_len bytes. + * Returns 0 on success, -1 on parse error. */ +int xml_parse_stream_info(const char *xml, size_t xml_len, struct lsl_esp32_stream_info *out); + +/* Extract the text content of an XML tag from a buffer. + * Searches for content and copies content to out. + * Returns length of content, or -1 if tag not found. */ +int xml_extract_tag(const char *xml, const char *tag_name, char *out, size_t out_len); + +#endif /* LSL_XML_PARSER_H */ diff --git a/liblsl-ESP32/docs/architecture.md b/liblsl-ESP32/docs/architecture.md new file mode 100644 index 0000000..db1f3ad --- /dev/null +++ b/liblsl-ESP32/docs/architecture.md @@ -0,0 +1,135 @@ +# Architecture + +## Overview + +liblsl-ESP32 is a clean-room C reimplementation of the LSL wire protocol for ESP32 microcontrollers. It is not a port of desktop liblsl; it reimplements the protocol from scratch using ESP-IDF native APIs. + +## Why Not Port Desktop liblsl? + +Desktop liblsl is ~50,000+ lines of C++ with deep dependencies: + +| Dependency | Purpose in Desktop liblsl | ESP32 Status | +|-----------|--------------------------|-------------| +| Boost.Asio | Async networking | Available (espressif/asio), but liblsl uses more than just Asio | +| Boost.Serialization | Protocol 1.00 archives | Not available on ESP32 | +| Boost threading | Thread management | Not needed (FreeRTOS) | +| pugixml | XML parsing | Could port, but overkill | +| C++ exceptions/RTTI | Error handling | Available but adds ~100KB binary overhead | +| STL containers | Data structures | Hidden heap allocation, fragmentation risk | + +The LSL wire protocol is simple: UDP multicast discovery, TCP streamfeed with test patterns, binary sample format. Reimplementing it in ~4000 lines of C gives us precise memory control and a smaller footprint than porting the C++ stack. + +## Protocol Layers + +``` +Application (lsl_esp32.h) + | + +-- Outlet (lsl_outlet.c) + | +-- UDP Discovery Server (lsl_udp_server.c) + | +-- TCP Data Server (lsl_tcp_server.c) + | +-- Ring Buffer (lsl_ring_buffer.c) + | + +-- Inlet (lsl_inlet.c) + | +-- Stream Resolver (lsl_resolver.c) + | +-- TCP Data Client (lsl_tcp_client.c) + | + +-- Security (lsl_security.c) + | +-- Key Manager (lsl_key_manager.c, NVS) + | +-- Encrypted Framing (lsl_tcp_common.c) + | + +-- Shared + +-- Stream Info + XML (lsl_stream_info.c, lsl_xml_parser.c) + +-- Sample Format (lsl_sample.c) + +-- Clock (lsl_clock.c) + +-- TCP Utilities (lsl_tcp_common.c) +``` + +## Threading Model + +All network tasks are pinned to core 1 (application core). Core 0 is reserved for the WiFi/protocol stack. + +| Task | Priority | Core | Stack | Purpose | +|------|----------|------|-------|---------| +| UDP server | 5 | 1 | 4KB | Discovery responses | +| TCP accept | 6 | 1 | 4KB | Accept connections | +| TCP feed (x3) | 7 | 1 | 8KB | Per-connection data streaming | +| Inlet receiver | 7 | 1 | 6KB | Sample reception | + +By default, `app_main` runs on core 0. All liblsl-esp32 network tasks are pinned to core 1. This means `push_sample_f()` (called from app_main on core 0) writes to the ring buffer, while the TCP feed task (on core 1) reads, optionally encrypts, and sends over WiFi. Push and pull operations are non-blocking ring buffer writes/reads. + +## Memory Architecture + +**Budget: ~200KB for liblsl-esp32, leaving 300KB+ for the user application.** + +| Component | Size | Notes | +|-----------|------|-------| +| Ring buffer | 2.6KB | 64 slots x 41 bytes (8ch float32) | +| TCP feed stack (x3) | 24KB | 8KB per connection | +| UDP server stack | 4KB | | +| Sample buffers | ~2KB | Stack-allocated per-task | +| Security session | ~1.1KB/conn | Session key (56B) + ciphertext buffer (~1KB) | +| Security config | ~100B | Keypair (outlet/inlet struct) | +| libsodium | ~40KB | Flash + minimal RAM | + +All hot-path allocations are pre-allocated. No `malloc` during streaming. + +## Security Architecture + +``` + ESP32 Outlet Desktop Inlet + ============ ============= + NVS: Ed25519 keypair lsl_api.cfg: same keypair + + TCP Headers: TCP Headers: + Security-Enabled: true --> Parse security headers + Security-Public-Key: b64 --> Verify key match + + Key Derivation: Key Derivation: + Ed25519 -> X25519 Ed25519 -> X25519 + DH shared secret DH shared secret + BLAKE2b(secret + "lsl-sess" BLAKE2b(secret + "lsl-sess" + + pk_smaller + pk_larger) + pk_smaller + pk_larger) + = identical session key = identical session key + + Test Patterns: plaintext --> Validate patterns (plaintext) + + Streaming Data: Streaming Data: + [4B len][8B nonce][ct+tag] --> Decrypt with session key +``` + +Key protocol details: +- Test patterns are always sent as plaintext (even when security is enabled) +- Encryption starts only for streaming data after test pattern validation +- Nonce starts at 1 (nonce 0 is reserved) +- Shared keypair model: all lab devices share the same Ed25519 keypair +- Unanimous enforcement: both sides must agree on security state + +## Wire Format + +### UDP Discovery (multicast 239.255.172.215:16571) +``` +Query: "LSL:shortinfo\r\n\r\n \r\n" +Reply: "\r\n" +``` + +### TCP Streamfeed (protocol 1.10) +``` +Request: "LSL:streamfeed/110 \r\n" + headers + "\r\n" +Response: "LSL/110 200 OK\r\n" + headers + "\r\n" + 2 test-pattern samples (plaintext) + streaming samples (encrypted if security enabled) +``` + +### Binary Sample Format +``` +[1 byte] tag: 0x01=deduced timestamp, 0x02=transmitted +[8 bytes] double timestamp (if tag=0x02) +[N bytes] channel data (little-endian) +``` + +### Encrypted Chunk Format +``` +[4 bytes BE] payload length (excludes this field) +[8 bytes LE] nonce (monotonically increasing, starts at 1) +[N bytes] ciphertext (ChaCha20-Poly1305, includes 16-byte auth tag) +``` diff --git a/liblsl-ESP32/docs/benchmarks.md b/liblsl-ESP32/docs/benchmarks.md new file mode 100644 index 0000000..0e9dcb2 --- /dev/null +++ b/liblsl-ESP32/docs/benchmarks.md @@ -0,0 +1,110 @@ +# Benchmarks + +Performance measurements for liblsl-ESP32, including encryption overhead analysis comparable to the [secureLSL benchmark methodology](https://github.com/sccn/secureLSL). + +## Test Environment + +| Component | Details | +|-----------|---------| +| MCU | ESP32-WROOM-32 (Xtensa LX6, dual-core, 240 MHz) | +| SRAM | 520 KB | +| WiFi | 802.11n, 2.4 GHz, channel 10 | +| RSSI | -36 to -38 dBm | +| Desktop | Mac (Apple Silicon), secureLSL v1.16.1-secure.1.0.0-alpha | +| FreeRTOS | Tick rate 1000 Hz | +| Test duration | 30s per config (60s for encryption overhead) | + +## Methodology + +### What We Measure + +| Metric | How | Clock-independent? | +|--------|-----|-------------------| +| Push timing | `esp_timer_get_time()` around `push_sample_f()` | Yes (local) | +| Throughput | Samples received / expected | Yes | +| Packet loss | (Expected - received) / expected | Yes | +| Jitter | Std dev of inter-sample arrival intervals | Yes | +| Heap usage | `esp_get_free_heap_size()` during streaming | Yes (local) | +| Encryption overhead | Secure vs insecure push timing delta | Yes (relative) | + +### What We Don't Measure + +**Absolute cross-machine latency** is not measured because ESP32 uses a monotonic clock (`lsl_esp32_local_clock()`, seconds since boot) while the desktop uses wall clock (`time.time()`). Without NTP synchronization or LSL time correction, absolute latency would be meaningless. WiFi jitter (~2ms) dominates any sub-millisecond crypto overhead. + +### Push Timing Interpretation + +`push_sample_f()` writes to a lock-free ring buffer. The actual encryption happens asynchronously in the TCP feed task on core 1. Therefore, push timing measures the ring buffer write cost, not encryption. This is the correct metric for application developers, as it represents the time their code spends in the LSL push call. + +## Results + +### 1. Encryption Overhead (8ch float32, 250 Hz, 60s) + +| Metric | Insecure | Encrypted | Delta | +|--------|----------|-----------|-------| +| Samples | 15,000 | 15,000 | 0% loss both | +| Push mean | 52.8 us | 33.1 us | No overhead | +| Push p95 | 67 us | 57 us | No overhead | +| Heap free | 113 KB | 111 KB | -2 KB | + +**Finding:** Encryption overhead is invisible to the application push path. ChaCha20-Poly1305 runs asynchronously on a separate core. The 2 KB heap difference is the security session state. + +### 2. Sampling Rate Sweep (8ch float32, 30s) + +| Rate | Insecure push (us) | Encrypted push (us) | Insecure p95 | Encrypted p95 | Loss | +|------|-------------------|---------------------|-------------|--------------|------| +| 250 Hz | 52.8 | 33.1 | 67 | 57 | 0% / 0% | +| 500 Hz | 65.1 | 70.3 | 255 | 310 | 0% / 0% | +| 1000 Hz | 68.1 | 48.3 | 319 | 97 | 0.02% / 0% | + +**Finding:** ESP32 sustains up to 1000 Hz with near-zero packet loss. The p95 increases at higher rates due to WiFi backpressure spikes, but the ring buffer absorbs them. The maximum reliable rate is 1000 Hz (limited by FreeRTOS 1ms tick resolution). Loss at 1000 Hz is within WiFi variance and not attributable to encryption. + +### 3. Channel Count Sweep (250 Hz, 30s) + +| Channels | Bytes/sample | Insecure push (us) | Encrypted push (us) | Insec. p95 | Enc. p95 | +|----------|-------------|-------------------|---------------------|-----------|---------| +| 4 | 16 | 49.4 | 28.3 | 70 | 58 | +| 8 | 32 | 52.8 | 33.1 | 67 | 57 | +| 16 | 64 | 15.6 | 28.9 | 52 | 58 | +| 32 | 128 | 20.0 | 37.8 | 54 | 74 | +| 64 | 256 | 22.3 | 40.0 | 63 | 83 | + +All configurations: 7,500/7,500 samples (0% loss). + +**Finding:** Push timing is dominated by ring buffer overhead, not payload size. Even 64-channel encrypted streaming achieves sub-100us push with zero loss at 250 Hz. Variability across channel counts reflects measurement noise and WiFi scheduling effects; the key takeaway is that all configurations achieve sub-100us push regardless of channel count. + +### 4. Resource Usage + +| Config | Heap free | Heap min | Notes | +|--------|-----------|----------|-------| +| 8ch insecure | 113 KB | 85 KB | Baseline | +| 8ch encrypted | 111 KB | 83 KB | +2 KB for security | +| 64ch insecure | ~110 KB | ~85 KB | Minimal increase | +| 64ch encrypted | ~108 KB | ~83 KB | | + +SRAM budget: ~200 KB used by liblsl-esp32, 300 KB+ free for user application code. + +## Comparison with Desktop secureLSL + +| Platform | Encryption Overhead | Max Rate Tested | Packet Loss | +|----------|-------------------|-----------------|-------------| +| Mac Mini M4 Pro (Ethernet) | <1% latency increase | 2000 Hz | 0% | +| Raspberry Pi 5 (Ethernet) | <1% latency increase | 1000 Hz | 0% | +| **ESP32 (WiFi)** | **0% (async on separate core)** | **1000 Hz** | **0.02%** | + +Mac Mini and Pi 5 results are from the [desktop secureLSL benchmark suite](https://github.com/sccn/secureLSL). The ESP32 achieves zero measurable encryption overhead because its dual-core architecture allows encryption to run on a separate core from the application. WiFi jitter (~2ms) dominates end-to-end timing, making any sub-millisecond crypto overhead invisible. + +## Running Benchmarks + +See [benchmarks/README.md](../benchmarks/README.md) for instructions on running the benchmark suite. + +```bash +# ESP32 firmware +cd benchmarks/throughput_bench +idf.py menuconfig # Configure channels, rate, security +idf.py build && idf.py -p PORT flash + +# Desktop collection +cd benchmarks/scripts +uv run python serial_monitor.py --port PORT -o results/esp32.json +uv run python esp32_benchmark_inlet.py --duration 60 -o results/desktop.json +``` diff --git a/liblsl-ESP32/docs/getting-started-macos.md b/liblsl-ESP32/docs/getting-started-macos.md new file mode 100644 index 0000000..bc08814 --- /dev/null +++ b/liblsl-ESP32/docs/getting-started-macos.md @@ -0,0 +1,296 @@ +# Getting Started on macOS + +This guide walks through setting up the liblsl-ESP32 development environment on macOS (Apple Silicon or Intel). + +## Prerequisites + +- macOS 13+ (Ventura or later recommended) +- [Homebrew](https://brew.sh) +- An ESP32-DevKitC board (v4 tested) connected via USB +- A WiFi network accessible by both the ESP32 and your Mac + +## 1. Install System Dependencies + +```bash +brew install cmake ninja python3 +brew install clang-format cppcheck typos-cli +``` + +Note: `dfu-util` is only needed for ESP32-S2/S3 boards with native USB. The +ESP32-DevKitC v4 uses UART flashing and does not require it. + +Verify installations: + +```bash +cmake --version # 3.16+ required +python3 --version # 3.9+ required +clang-format --version +cppcheck --version +typos --version +``` + +## 2. Install ESP-IDF + +ESP-IDF (Espressif IoT Development Framework) is the official SDK for ESP32. We use **v5.5.3**. + +```bash +mkdir -p ~/esp && cd ~/esp +git clone -b v5.5.3 --recursive https://github.com/espressif/esp-idf.git +cd esp-idf +./install.sh esp32 +``` + +This downloads the Xtensa GCC toolchain, Python dependencies, and build tools. It takes 10-20 minutes depending on your connection. + +### Source the environment + +You must source the ESP-IDF environment in every new terminal session: + +```bash +. ~/esp/esp-idf/export.sh +``` + +To make this convenient, add an alias to your shell profile (`~/.zshrc`): + +```bash +alias get_idf='. ~/esp/esp-idf/export.sh' +``` + +Then run `get_idf` whenever you start working on this project. + +### Verify ESP-IDF + +```bash +idf.py --version +``` + +Should print something like `ESP-IDF v5.5.3`. + +## 3. Connect and Identify the ESP32 Board + +Plug in your ESP32-DevKitC via USB. Identify the serial port: + +```bash +ls /dev/cu.usb* +``` + +Common results: + +| USB-UART Chip | Port Pattern | DevKit Version | +|---|---|---| +| CP2102 | `/dev/cu.usbserial-XXXX` | DevKitC v4 | +| CP2102N | `/dev/cu.usbserial-XXXX` | DevKitC v4 | +| CH340 | `/dev/cu.usbserial-XXXX` | Some clones | +| USB-JTAG | `/dev/cu.usbmodem-XXXX` | ESP32-S3/C3 built-in | + +If no `/dev/cu.usb*` devices appear: + +1. Try a different USB cable (some are charge-only, no data) +2. Install the CP2102 driver if needed: [Silicon Labs CP210x](https://www.silabs.com/developers/usb-to-uart-bridge-vcp-drivers) +3. Check System Settings > Privacy & Security for any blocked kernel extensions + +### Identify the chip + +You can confirm the board model: + +```bash +. ~/esp/esp-idf/export.sh +esptool.py --port /dev/cu.usbserial-0001 chip_id +``` + +Expected output includes `Chip is ESP32-D0WD-V3` or similar (older boards may +show `ESP32-D0WDQ6`). + +## 4. Build and Flash the Crypto Benchmark + +This verifies your full toolchain: ESP-IDF, build system, flash, and serial monitor. + +```bash +. ~/esp/esp-idf/export.sh +cd benchmarks/crypto_bench + +# Set target (first time only) +idf.py set-target esp32 + +# Build +idf.py build + +# Flash and monitor (replace port as needed) +idf.py -p /dev/cu.usbserial-0001 flash monitor +``` + +Press `Ctrl+]` to exit the serial monitor. + +If flashing fails: + +- Hold the **BOOT** button on the DevKit while flashing starts, then release +- Try a lower baud rate: `idf.py -p /dev/cu.usbserial-0001 -b 115200 flash` +- Ensure no other program (e.g., Arduino IDE serial monitor) is using the port + +## 5. Install Desktop LSL (for interop testing) + +Install pylsl to test ESP32 outlet/inlet against a desktop LSL peer: + +```bash +# Using UV (preferred) +uv pip install pylsl + +# Verify +python3 -c "import pylsl; print('pylsl', pylsl.__version__)" +``` + +Quick test to discover an ESP32 outlet (once one is running): + +```bash +python3 -c "import pylsl; print(pylsl.resolve_stream('name', 'ESP32Test', timeout=5))" +``` + +## 6. Configure Pre-commit Hooks + +The repo includes pre-commit hooks that run clang-format, cppcheck, and typos on staged C files: + +```bash +cd /path/to/liblsl-ESP32 +git config core.hooksPath .githooks +``` + +Test the hook: + +```bash +# Stage a file and commit to see hooks run +git add benchmarks/crypto_bench/main/bench_utils.c +git commit -m "test: verify pre-commit hooks" +# (cancel with Ctrl+C if you don't want to actually commit) +``` + +## 7. WiFi Configuration + +ESP32 projects that use WiFi need SSID and password configured. The +`examples/basic_outlet/` project uses Kconfig for this: + +```bash +cd examples/basic_outlet +. ~/esp/esp-idf/export.sh +idf.py menuconfig +``` + +Navigate to **Example Configuration** and set: + +- WiFi SSID +- WiFi Password + +These are stored in `sdkconfig` (gitignored) and persist across builds. + +Alternatively, edit `sdkconfig` directly: + +``` +CONFIG_ESP_WIFI_SSID="your_ssid" +CONFIG_ESP_WIFI_PASSWORD="your_password" +``` + +You can also store credentials in `.env` (gitignored) for reference. + +### WiFi Requirements + +- **2.4 GHz only**: ESP32 does not support 5 GHz or 6 GHz WiFi +- **WPA2-PSK**: recommended; WPA/WPA2 mixed mode also works +- **Same network**: your desktop and ESP32 must be on the same subnet + for multicast discovery to work. If your desktop is on Ethernet and + the ESP32 on WiFi, ensure your router forwards multicast between + wired and wireless clients (most do, some don't) +- **Channel**: standard 2.4 GHz channels (1-13) at 20 MHz bandwidth + work best; 40 MHz bandwidth on some channels may not be visible + +## 8. Testing LSL Discovery + +After flashing the basic_outlet example with WiFi configured: + +```bash +cd examples/basic_outlet +idf.py -p /dev/cu.usbserial-0001 flash monitor +``` + +Wait for "LSL Outlet Ready" in the serial output, then from your desktop: + +```bash +python3 -c "import pylsl; r = pylsl.resolve_byprop('name', 'ESP32Test', timeout=5); print(f'Found {len(r)} streams'); [print(f' {s.name()} {s.type()} {s.channel_count()}ch') for s in r]" +``` + +Expected output: + +``` +Found 1 streams + ESP32Test EEG 8ch +``` + +### Troubleshooting Discovery + +If no streams are found: + +1. **Check IP addresses**: both devices must be on the same subnet + (e.g., both `192.168.0.x`) +2. **Check WiFi band**: ESP32 serial should show "Connected. IP: ..." +3. **Try unicast**: send a UDP packet directly to the ESP32's IP:16571 + to verify the UDP server is running +4. **Router multicast**: some routers block multicast between WiFi + and Ethernet clients; connect both devices via WiFi if needed + +## Quick Reference + +| Task | Command | +|---|---| +| Source ESP-IDF | `. ~/esp/esp-idf/export.sh` | +| Build | `idf.py build` | +| Flash + monitor | `idf.py -p /dev/cu.usbserial-0001 flash monitor` | +| Monitor only | `idf.py -p /dev/cu.usbserial-0001 monitor` | +| Exit monitor | `Ctrl+]` | +| Set target | `idf.py set-target esp32` | +| Clean build | `idf.py fullclean` | +| Configure | `idf.py menuconfig` | +| Format code | `clang-format -i file.c` | +| Static analysis | `cppcheck --std=c11 --language=c file.c` | +| Spell check | `typos file.c` | +| Add component | `idf.py add-dependency "espressif/libsodium^1.0.20~4"` | + +## Troubleshooting + +### "Permission denied" on serial port + +```bash +# Check if the port is in use +lsof /dev/cu.usbserial-0001 + +# On macOS, your user typically has access; if not: +sudo chmod 666 /dev/cu.usbserial-0001 +``` + +### Build fails with "toolchain not found" + +You forgot to source the ESP-IDF environment: + +```bash +. ~/esp/esp-idf/export.sh +``` + +### Flash stuck at "Connecting..." + +1. Hold the **BOOT** button on the ESP32 board +2. While holding, press and release the **EN** (reset) button +3. Release BOOT after you see "Connecting" progress + +### "Fatal error: sodium.h: No such file or directory" + +The libsodium component needs to be fetched: + +```bash +cd benchmarks/crypto_bench +idf.py build # automatically fetches managed components +``` + +### Python/pylsl issues + +Use UV for Python package management: + +```bash +uv pip install pylsl +``` diff --git a/liblsl-ESP32/docs/security.md b/liblsl-ESP32/docs/security.md new file mode 100644 index 0000000..aa5eb1c --- /dev/null +++ b/liblsl-ESP32/docs/security.md @@ -0,0 +1,144 @@ +# Security Guide + +liblsl-ESP32 supports secureLSL encryption, providing end-to-end authenticated encryption between ESP32 devices and desktop LSL applications. + +## Overview + +When security is enabled, all streaming data is encrypted with ChaCha20-Poly1305 (the same algorithm used by desktop secureLSL). This provides: + +- **Confidentiality**: streaming data cannot be read by eavesdroppers +- **Integrity**: tampered packets are detected and rejected +- **Authentication**: only devices with the shared keypair can communicate +- **Replay prevention**: monotonically increasing nonces prevent replay attacks + +## Key Concepts + +### Shared Keypair Model + +All devices in a lab share the same Ed25519 keypair. Authorization is based on public key matching: if a connecting device presents the same public key, it is authorized to communicate. This is the same model used by desktop secureLSL. + +### Unanimous Enforcement + +Both sides must agree on security state. If the outlet has security enabled but the inlet does not (or vice versa), the connection is rejected with a 403 error. Mixed encrypted/unencrypted networks are not allowed. + +## Setup + +### Step 1: Generate or Import Keys + +**Option A: Generate on ESP32** +```c +#include "lsl_esp32.h" +#include "nvs_flash.h" + +// NVS must be initialized before key operations +nvs_flash_init(); + +// Generate new Ed25519 keypair (stored in NVS) +lsl_esp32_generate_keypair(); + +// Export public key for sharing with desktop +char pubkey[LSL_ESP32_KEY_BASE64_SIZE]; +lsl_esp32_export_pubkey(pubkey, sizeof(pubkey)); +printf("Public key: %s\n", pubkey); +``` + +**Option B: Import desktop keypair** +```c +// Import base64-encoded keypair (from desktop secureLSL config) +lsl_esp32_import_keypair( + "PqyFnq8EdB4kkp88KBHZ2DuSy9qbEspO5QSUqPnUvc0=", // public + "NdausXwoiZ7yPgh0UcncBc1LDAy58dNaD6d/guA8i8E+..." // private +); +``` + +**Option C: Configure via menuconfig** + +The secure examples (`examples/secure_outlet`, `examples/secure_inlet`) support key configuration via `idf.py menuconfig`: +``` +Example Configuration -> secureLSL public key (base64) +Example Configuration -> secureLSL private key (base64) +``` + +### Step 2: Configure Desktop + +Edit `~/.lsl_api/lsl_api.cfg` (or `~/lsl_api/lsl_api.cfg`): +```ini +[security] +enabled = true +private_key = NdausXwoiZ7yPgh0UcncBc1LDAy58dNaD6d/guA8i8E+rIWerwR0HiSSnzwoEdnYO5LL2psSyk7lBJSo+dS9zQ== +``` + +The desktop must use the secureLSL library (built with `-DLSL_SECURITY=ON`), not standard liblsl. + +### Step 3: Enable Security in Code + +```c +void app_main(void) { + nvs_flash_init(); + + // Enable security before creating outlets/inlets + lsl_esp32_err_t err = lsl_esp32_enable_security(); + if (err != LSL_ESP32_OK) { + ESP_LOGE(TAG, "Security setup failed: %d", err); + return; + } + + wifi_helper_init_sta(); + + // Create outlet as usual -- encryption is automatic + lsl_esp32_stream_info_t info = lsl_esp32_create_streaminfo(...); + lsl_esp32_outlet_t outlet = lsl_esp32_create_outlet(info, 0, 360); + + // Push samples -- encrypted transparently + lsl_esp32_push_sample_f(outlet, data, 0.0); +} +``` + +## Extracting Keys from Desktop Config + +The desktop's `lsl_api.cfg` contains a base64 private key (64 bytes: 32-byte seed + 32-byte public key). To extract the public key for ESP32: + +```python +import base64 +sk_b64 = "YOUR_PRIVATE_KEY_BASE64" +sk = base64.b64decode(sk_b64) +pk = sk[32:] # Public key is last 32 bytes +pk_b64 = base64.b64encode(pk).decode() +print(f"Public key: {pk_b64}") +``` + +## Algorithms + +| Operation | Algorithm | Library | +|-----------|-----------|---------| +| Device identity | Ed25519 | libsodium | +| Key exchange | X25519 (from Ed25519 conversion) | libsodium | +| Key derivation | BLAKE2b with "lsl-sess" domain separator | libsodium | +| Stream encryption | ChaCha20-Poly1305 IETF | libsodium | +| Key storage | ESP32 NVS (Non-Volatile Storage) | ESP-IDF | + +## Key Storage + +Keys are stored in NVS namespace `"lsl_security"`: + +| Field | Type | Size | +|-------|------|------| +| enabled | uint8 | 1 byte | +| public_key | blob | 32 bytes | +| private_key | blob | 64 bytes | + +Keys persist across reboots. To erase: `nvs_flash_erase()` (erases all NVS data). + +## Troubleshooting + +### "Security mismatch: server=enabled, client=disabled" +Both sides must have security in the same state. Either enable security on both or disable on both. + +### "Failed to connect to outlet" with 403 +The ESP32 or desktop rejected the connection due to a security mismatch or key mismatch. Check that both sides have the same keypair. + +### "Security enabled but keys not loadable" +No keypair is provisioned in NVS. Call `lsl_esp32_generate_keypair()` or `lsl_esp32_import_keypair()` first. + +### Desktop pylsl gets 403 from secure ESP32 +Standard pylsl (liblsl v1.17+) is not compiled with security support. Use the secureLSL library built with `-DLSL_SECURITY=ON`. diff --git a/liblsl-ESP32/examples/basic_inlet/CMakeLists.txt b/liblsl-ESP32/examples/basic_inlet/CMakeLists.txt new file mode 100644 index 0000000..8e5143d --- /dev/null +++ b/liblsl-ESP32/examples/basic_inlet/CMakeLists.txt @@ -0,0 +1,6 @@ +cmake_minimum_required(VERSION 3.16) + +set(EXTRA_COMPONENT_DIRS "../../components") + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(basic_inlet) diff --git a/liblsl-ESP32/examples/basic_inlet/main/CMakeLists.txt b/liblsl-ESP32/examples/basic_inlet/main/CMakeLists.txt new file mode 100644 index 0000000..dc4126a --- /dev/null +++ b/liblsl-ESP32/examples/basic_inlet/main/CMakeLists.txt @@ -0,0 +1,6 @@ +idf_component_register( + SRCS "basic_inlet_main.c" + "wifi_helper.c" + INCLUDE_DIRS "." + REQUIRES liblsl_esp32 esp_wifi esp_netif nvs_flash esp_event +) diff --git a/liblsl-ESP32/examples/basic_inlet/main/Kconfig.projbuild b/liblsl-ESP32/examples/basic_inlet/main/Kconfig.projbuild new file mode 100644 index 0000000..0470973 --- /dev/null +++ b/liblsl-ESP32/examples/basic_inlet/main/Kconfig.projbuild @@ -0,0 +1,27 @@ +menu "Example Configuration" + + config ESP_WIFI_SSID + string "WiFi SSID" + default "your_ssid" + help + SSID (network name) to connect to. + + config ESP_WIFI_PASSWORD + string "WiFi Password" + default "your_password" + help + WiFi password (WPA2). + + config ESP_MAXIMUM_RETRY + int "Maximum WiFi connection retries" + default 10 + help + Number of times to retry WiFi connection before giving up. + + config LSL_TARGET_STREAM + string "Target LSL stream name" + default "DesktopTest" + help + Name of the LSL stream to resolve and connect to. + +endmenu diff --git a/liblsl-ESP32/examples/basic_inlet/main/basic_inlet_main.c b/liblsl-ESP32/examples/basic_inlet/main/basic_inlet_main.c new file mode 100644 index 0000000..123ee2e --- /dev/null +++ b/liblsl-ESP32/examples/basic_inlet/main/basic_inlet_main.c @@ -0,0 +1,116 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +/* Basic LSL Inlet Example for ESP32 + * + * Demonstrates receiving LSL samples from a desktop outlet: + * 1. Connect to WiFi + * 2. Resolve a stream by name + * 3. Create an inlet (connects to outlet, validates test patterns) + * 4. Pull and display samples + * + * Desktop setup (run before flashing ESP32): + * python3 -c " + * import pylsl, time + * info = pylsl.StreamInfo('DesktopTest','EEG',8,250,'float32','desktop1') + * o = pylsl.StreamOutlet(info) + * print('Outlet started') + * while True: + * o.push_sample([float(i) for i in range(8)]) + * time.sleep(0.004) + * " + * + * Configure WiFi: idf.py menuconfig -> Example Configuration + * Configure stream name: idf.py menuconfig -> Example Configuration -> Target LSL stream name + */ + +#include "lsl_esp32.h" +#include "wifi_helper.h" +#include "esp_log.h" +#include "esp_system.h" +#include "nvs_flash.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +static const char *TAG = "basic_inlet"; + +void app_main(void) +{ + /* Initialize NVS (required for WiFi) */ + esp_err_t ret = nvs_flash_init(); + if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_ERROR_CHECK(nvs_flash_erase()); + ret = nvs_flash_init(); + } + ESP_ERROR_CHECK(ret); + + /* Connect to WiFi */ + ESP_LOGI(TAG, "Connecting to WiFi..."); + if (wifi_helper_init_sta() != ESP_OK) { + ESP_LOGE(TAG, "WiFi connection failed. Cannot start LSL inlet."); + return; + } + + /* Resolve a stream by name */ + ESP_LOGI(TAG, "Resolving stream '%s'...", CONFIG_LSL_TARGET_STREAM); + lsl_esp32_stream_info_t info = NULL; + int found = lsl_esp32_resolve_stream("name", CONFIG_LSL_TARGET_STREAM, 10.0, &info); + if (!found || !info) { + ESP_LOGE(TAG, "Stream '%s' not found within 10 seconds", CONFIG_LSL_TARGET_STREAM); + return; + } + + int ch_count = lsl_esp32_get_channel_count(info); + ESP_LOGI(TAG, "Found stream: %s (%s, %dch @ %.0fHz)", lsl_esp32_get_name(info), + lsl_esp32_get_type(info), ch_count, lsl_esp32_get_nominal_srate(info)); + + /* Create inlet (connects to outlet, validates test patterns) */ + ESP_LOGI(TAG, "Creating inlet..."); + lsl_esp32_inlet_t inlet = lsl_esp32_create_inlet(info); + if (!inlet) { + ESP_LOGE(TAG, "Failed to create inlet"); + lsl_esp32_destroy_streaminfo(info); + return; + } + + ESP_LOGI(TAG, ""); + ESP_LOGI(TAG, "=== LSL Inlet Ready ==="); + ESP_LOGI(TAG, "Receiving from: %s", CONFIG_LSL_TARGET_STREAM); + ESP_LOGI(TAG, ""); + + /* Pull and display samples */ + float sample[LSL_ESP32_MAX_CHANNELS]; + double timestamp; + uint32_t sample_count = 0; + uint32_t timeout_count = 0; + + while (1) { + lsl_esp32_err_t err = lsl_esp32_inlet_pull_sample_f( + inlet, sample, (int)(ch_count * sizeof(float)), ×tamp, 1.0); + + if (err == LSL_ESP32_OK) { + sample_count++; + + /* Print first sample and every 250th sample */ + if (sample_count == 1 || sample_count % 250 == 0) { + ESP_LOGI(TAG, "Sample %lu: ts=%.6f ch0=%.4f ch1=%.4f ch2=%.4f ch3=%.4f", + (unsigned long)sample_count, timestamp, sample[0], sample[1], sample[2], + sample[3]); + ESP_LOGI(TAG, " (heap=%lu)", (unsigned long)esp_get_free_heap_size()); + } + } else if (err == LSL_ESP32_ERR_TIMEOUT) { + timeout_count++; + if (timeout_count % 5 == 0) { + ESP_LOGW(TAG, "No samples received (timeouts=%lu, received=%lu)", + (unsigned long)timeout_count, (unsigned long)sample_count); + } + } else { + ESP_LOGE(TAG, "Pull error: %d", err); + break; + } + } + + lsl_esp32_destroy_inlet(inlet); + ESP_LOGI(TAG, "Inlet destroyed. Total samples: %lu", (unsigned long)sample_count); +} diff --git a/liblsl-ESP32/examples/basic_inlet/main/wifi_helper.c b/liblsl-ESP32/examples/basic_inlet/main/wifi_helper.c new file mode 100644 index 0000000..c5ab11a --- /dev/null +++ b/liblsl-ESP32/examples/basic_inlet/main/wifi_helper.c @@ -0,0 +1,128 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#include "wifi_helper.h" +#include "esp_log.h" +#include "esp_wifi.h" +#include "esp_event.h" +#include "esp_netif.h" +#include "freertos/FreeRTOS.h" +#include "freertos/event_groups.h" +#include + +static const char *TAG = "wifi_helper"; + +#define WIFI_CONNECTED_BIT BIT0 +#define WIFI_FAIL_BIT BIT1 +#define WIFI_CONNECT_TIMEOUT_MS 30000 + +static EventGroupHandle_t s_wifi_event_group; +static int s_retry_num = 0; +static esp_netif_t *s_sta_netif = NULL; + +static void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *data) +{ + if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) { + esp_err_t err = esp_wifi_connect(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_wifi_connect failed on STA_START: %s", esp_err_to_name(err)); + xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT); + } + } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { + wifi_event_sta_disconnected_t *disc = (wifi_event_sta_disconnected_t *)data; + ESP_LOGW(TAG, "Disconnected: reason=%d", disc->reason); + if (s_retry_num < CONFIG_ESP_MAXIMUM_RETRY) { + esp_err_t err = esp_wifi_connect(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_wifi_connect retry failed: %s", esp_err_to_name(err)); + xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT); + return; + } + s_retry_num++; + ESP_LOGI(TAG, "Retrying WiFi connection (%d/%d)", s_retry_num, + CONFIG_ESP_MAXIMUM_RETRY); + } else { + ESP_LOGE(TAG, "WiFi connection failed after %d retries", CONFIG_ESP_MAXIMUM_RETRY); + s_retry_num = 0; /* allow future reconnection attempts */ + xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT); + } + } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { + ip_event_got_ip_t *event = (ip_event_got_ip_t *)data; + ESP_LOGI(TAG, "Connected. IP: " IPSTR, IP2STR(&event->ip_info.ip)); + s_retry_num = 0; + xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT); + } +} + +esp_err_t wifi_helper_init_sta(void) +{ + s_wifi_event_group = xEventGroupCreate(); + if (!s_wifi_event_group) { + ESP_LOGE(TAG, "Failed to create WiFi event group"); + return ESP_ERR_NO_MEM; + } + + ESP_ERROR_CHECK(esp_netif_init()); + ESP_ERROR_CHECK(esp_event_loop_create_default()); + s_sta_netif = esp_netif_create_default_wifi_sta(); + if (!s_sta_netif) { + ESP_LOGE(TAG, "Failed to create default WiFi STA netif"); + vEventGroupDelete(s_wifi_event_group); + return ESP_ERR_NO_MEM; + } + + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + + ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, + &event_handler, NULL, NULL)); + ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, + &event_handler, NULL, NULL)); + + wifi_config_t wifi_config = { + .sta = + { + .ssid = CONFIG_ESP_WIFI_SSID, + .password = CONFIG_ESP_WIFI_PASSWORD, + }, + }; + + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); + ESP_ERROR_CHECK(esp_wifi_start()); + + ESP_LOGI(TAG, "Connecting to %s...", CONFIG_ESP_WIFI_SSID); + + /* Wait with timeout to avoid permanent hang */ + EventBits_t bits = + xEventGroupWaitBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, pdFALSE, + pdFALSE, pdMS_TO_TICKS(WIFI_CONNECT_TIMEOUT_MS)); + + if (bits & WIFI_CONNECTED_BIT) { + return ESP_OK; + } + if (bits & WIFI_FAIL_BIT) { + ESP_LOGE(TAG, "WiFi connection failed after retries"); + return ESP_FAIL; + } + + ESP_LOGE(TAG, "WiFi connection timed out (%d ms)", WIFI_CONNECT_TIMEOUT_MS); + return ESP_ERR_TIMEOUT; +} + +esp_err_t wifi_helper_get_ip_str(char *out, size_t out_len) +{ + if (!s_sta_netif || !out || out_len < 16) { + return ESP_ERR_INVALID_ARG; + } + + esp_netif_ip_info_t ip_info; + esp_err_t err = esp_netif_get_ip_info(s_sta_netif, &ip_info); + if (err != ESP_OK) { + return err; + } + + snprintf(out, out_len, IPSTR, IP2STR(&ip_info.ip)); + return ESP_OK; +} diff --git a/liblsl-ESP32/examples/basic_inlet/main/wifi_helper.h b/liblsl-ESP32/examples/basic_inlet/main/wifi_helper.h new file mode 100644 index 0000000..cf88092 --- /dev/null +++ b/liblsl-ESP32/examples/basic_inlet/main/wifi_helper.h @@ -0,0 +1,20 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#ifndef WIFI_HELPER_H +#define WIFI_HELPER_H + +#include "esp_err.h" + +/* Initialize WiFi in station mode and connect to the configured AP. + * Blocks until connected, max retries exhausted, or 30s timeout. + * Returns ESP_OK on success, ESP_FAIL on connection failure, + * ESP_ERR_TIMEOUT on timeout, ESP_ERR_NO_MEM on resource failure. */ +esp_err_t wifi_helper_init_sta(void); + +/* Get the local IPv4 address as a string (e.g., "192.168.1.50"). + * out must be at least 16 bytes. Returns ESP_OK on success. */ +esp_err_t wifi_helper_get_ip_str(char *out, size_t out_len); + +#endif /* WIFI_HELPER_H */ diff --git a/liblsl-ESP32/examples/basic_inlet/sdkconfig.defaults b/liblsl-ESP32/examples/basic_inlet/sdkconfig.defaults new file mode 100644 index 0000000..ad83d59 --- /dev/null +++ b/liblsl-ESP32/examples/basic_inlet/sdkconfig.defaults @@ -0,0 +1,19 @@ +# ESP32 target +CONFIG_IDF_TARGET="esp32" +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y + +# Stack size for main task (resolver + inlet need stack space) +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 + +# Logging +CONFIG_LOG_DEFAULT_LEVEL_INFO=y + +# WiFi +CONFIG_ESP_WIFI_SSID="your_ssid" +CONFIG_ESP_WIFI_PASSWORD="your_password" + +# IGMP for multicast support (required for LSL discovery) +CONFIG_LWIP_IGMP=y + +# 1000Hz tick rate for precise sample timing +CONFIG_FREERTOS_HZ=1000 diff --git a/liblsl-ESP32/examples/basic_outlet/CMakeLists.txt b/liblsl-ESP32/examples/basic_outlet/CMakeLists.txt new file mode 100644 index 0000000..5e43b8b --- /dev/null +++ b/liblsl-ESP32/examples/basic_outlet/CMakeLists.txt @@ -0,0 +1,6 @@ +cmake_minimum_required(VERSION 3.16) + +set(EXTRA_COMPONENT_DIRS "../../components") + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(basic_outlet) diff --git a/liblsl-ESP32/examples/basic_outlet/main/CMakeLists.txt b/liblsl-ESP32/examples/basic_outlet/main/CMakeLists.txt new file mode 100644 index 0000000..3cf6e7c --- /dev/null +++ b/liblsl-ESP32/examples/basic_outlet/main/CMakeLists.txt @@ -0,0 +1,6 @@ +idf_component_register( + SRCS "basic_outlet_main.c" + "wifi_helper.c" + INCLUDE_DIRS "." + REQUIRES liblsl_esp32 esp_wifi esp_netif nvs_flash esp_event +) diff --git a/liblsl-ESP32/examples/basic_outlet/main/Kconfig.projbuild b/liblsl-ESP32/examples/basic_outlet/main/Kconfig.projbuild new file mode 100644 index 0000000..e99d5f3 --- /dev/null +++ b/liblsl-ESP32/examples/basic_outlet/main/Kconfig.projbuild @@ -0,0 +1,21 @@ +menu "Example Configuration" + + config ESP_WIFI_SSID + string "WiFi SSID" + default "your_ssid" + help + SSID (network name) to connect to. + + config ESP_WIFI_PASSWORD + string "WiFi Password" + default "your_password" + help + WiFi password (WPA2). + + config ESP_MAXIMUM_RETRY + int "Maximum WiFi connection retries" + default 10 + help + Number of times to retry WiFi connection before giving up. + +endmenu diff --git a/liblsl-ESP32/examples/basic_outlet/main/basic_outlet_main.c b/liblsl-ESP32/examples/basic_outlet/main/basic_outlet_main.c new file mode 100644 index 0000000..cf057ad --- /dev/null +++ b/liblsl-ESP32/examples/basic_outlet/main/basic_outlet_main.c @@ -0,0 +1,101 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +/* Basic LSL Outlet Example for ESP32 + * + * Demonstrates the public liblsl_esp32 API: + * 1. Connect to WiFi + * 2. Create a stream info descriptor + * 3. Create an outlet (starts discovery + TCP servers automatically) + * 4. Push sine wave samples at 250 Hz + * + * Desktop test: + * python -c "import pylsl; i=pylsl.StreamInlet(pylsl.resolve_byprop( + * 'name','ESP32Test',timeout=5)[0]); print(i.pull_sample())" + * + * Configure WiFi: idf.py menuconfig -> Example Configuration + */ + +#include "lsl_esp32.h" +#include "wifi_helper.h" +#include "esp_log.h" +#include "esp_system.h" +#include "nvs_flash.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include + +static const char *TAG = "basic_outlet"; + +#define NUM_CHANNELS 8 +#define SAMPLE_RATE 250.0 + +void app_main(void) +{ + /* Initialize NVS (required for WiFi) */ + esp_err_t ret = nvs_flash_init(); + if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_ERROR_CHECK(nvs_flash_erase()); + ret = nvs_flash_init(); + } + ESP_ERROR_CHECK(ret); + + /* Connect to WiFi */ + ESP_LOGI(TAG, "Connecting to WiFi..."); + if (wifi_helper_init_sta() != ESP_OK) { + ESP_LOGE(TAG, "WiFi connection failed. Cannot start LSL outlet."); + return; + } + + /* Create stream info */ + lsl_esp32_stream_info_t info = lsl_esp32_create_streaminfo( + "ESP32Test", "EEG", NUM_CHANNELS, SAMPLE_RATE, LSL_ESP32_FMT_FLOAT32, "esp32_outlet_1"); + if (!info) { + ESP_LOGE(TAG, "Failed to create stream info"); + return; + } + + /* Create outlet (starts UDP discovery + TCP data server) */ + lsl_esp32_outlet_t outlet = lsl_esp32_create_outlet(info, 0, 360); + if (!outlet) { + ESP_LOGE(TAG, "Failed to create outlet"); + lsl_esp32_destroy_streaminfo(info); + return; + } + + ESP_LOGI(TAG, ""); + ESP_LOGI(TAG, "=== LSL Outlet Ready ==="); + ESP_LOGI(TAG, "Stream: ESP32Test (%dch float32 @ %.0fHz)", NUM_CHANNELS, SAMPLE_RATE); + ESP_LOGI(TAG, ""); + + /* Push sine wave samples at 250 Hz */ + float channels[NUM_CHANNELS]; + uint32_t sample_count = 0; + TickType_t last_wake = xTaskGetTickCount(); + + while (1) { + double timestamp = lsl_esp32_local_clock(); + + /* Generate sine wave data (different frequency per channel) */ + for (int ch = 0; ch < NUM_CHANNELS; ch++) { + channels[ch] = sinf((float)timestamp * 2.0f * 3.14159f * (float)(ch + 1)); + } + + /* Push sample through public API */ + lsl_esp32_err_t err = lsl_esp32_push_sample_f(outlet, channels, timestamp); + if (err != LSL_ESP32_OK) { + ESP_LOGW(TAG, "Push failed: %d", err); + } + sample_count++; + + /* Report status every 5 seconds */ + if (sample_count % (5 * (uint32_t)SAMPLE_RATE) == 0) { + ESP_LOGI(TAG, "Pushed %lu samples (heap=%lu, consumers=%d)", + (unsigned long)sample_count, (unsigned long)esp_get_free_heap_size(), + lsl_esp32_have_consumers(outlet)); + } + + vTaskDelayUntil(&last_wake, pdMS_TO_TICKS(4)); /* ~250 Hz at 1000Hz tick rate */ + } +} diff --git a/liblsl-ESP32/examples/basic_outlet/main/wifi_helper.c b/liblsl-ESP32/examples/basic_outlet/main/wifi_helper.c new file mode 100644 index 0000000..c5ab11a --- /dev/null +++ b/liblsl-ESP32/examples/basic_outlet/main/wifi_helper.c @@ -0,0 +1,128 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#include "wifi_helper.h" +#include "esp_log.h" +#include "esp_wifi.h" +#include "esp_event.h" +#include "esp_netif.h" +#include "freertos/FreeRTOS.h" +#include "freertos/event_groups.h" +#include + +static const char *TAG = "wifi_helper"; + +#define WIFI_CONNECTED_BIT BIT0 +#define WIFI_FAIL_BIT BIT1 +#define WIFI_CONNECT_TIMEOUT_MS 30000 + +static EventGroupHandle_t s_wifi_event_group; +static int s_retry_num = 0; +static esp_netif_t *s_sta_netif = NULL; + +static void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *data) +{ + if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) { + esp_err_t err = esp_wifi_connect(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_wifi_connect failed on STA_START: %s", esp_err_to_name(err)); + xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT); + } + } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { + wifi_event_sta_disconnected_t *disc = (wifi_event_sta_disconnected_t *)data; + ESP_LOGW(TAG, "Disconnected: reason=%d", disc->reason); + if (s_retry_num < CONFIG_ESP_MAXIMUM_RETRY) { + esp_err_t err = esp_wifi_connect(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_wifi_connect retry failed: %s", esp_err_to_name(err)); + xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT); + return; + } + s_retry_num++; + ESP_LOGI(TAG, "Retrying WiFi connection (%d/%d)", s_retry_num, + CONFIG_ESP_MAXIMUM_RETRY); + } else { + ESP_LOGE(TAG, "WiFi connection failed after %d retries", CONFIG_ESP_MAXIMUM_RETRY); + s_retry_num = 0; /* allow future reconnection attempts */ + xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT); + } + } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { + ip_event_got_ip_t *event = (ip_event_got_ip_t *)data; + ESP_LOGI(TAG, "Connected. IP: " IPSTR, IP2STR(&event->ip_info.ip)); + s_retry_num = 0; + xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT); + } +} + +esp_err_t wifi_helper_init_sta(void) +{ + s_wifi_event_group = xEventGroupCreate(); + if (!s_wifi_event_group) { + ESP_LOGE(TAG, "Failed to create WiFi event group"); + return ESP_ERR_NO_MEM; + } + + ESP_ERROR_CHECK(esp_netif_init()); + ESP_ERROR_CHECK(esp_event_loop_create_default()); + s_sta_netif = esp_netif_create_default_wifi_sta(); + if (!s_sta_netif) { + ESP_LOGE(TAG, "Failed to create default WiFi STA netif"); + vEventGroupDelete(s_wifi_event_group); + return ESP_ERR_NO_MEM; + } + + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + + ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, + &event_handler, NULL, NULL)); + ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, + &event_handler, NULL, NULL)); + + wifi_config_t wifi_config = { + .sta = + { + .ssid = CONFIG_ESP_WIFI_SSID, + .password = CONFIG_ESP_WIFI_PASSWORD, + }, + }; + + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); + ESP_ERROR_CHECK(esp_wifi_start()); + + ESP_LOGI(TAG, "Connecting to %s...", CONFIG_ESP_WIFI_SSID); + + /* Wait with timeout to avoid permanent hang */ + EventBits_t bits = + xEventGroupWaitBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, pdFALSE, + pdFALSE, pdMS_TO_TICKS(WIFI_CONNECT_TIMEOUT_MS)); + + if (bits & WIFI_CONNECTED_BIT) { + return ESP_OK; + } + if (bits & WIFI_FAIL_BIT) { + ESP_LOGE(TAG, "WiFi connection failed after retries"); + return ESP_FAIL; + } + + ESP_LOGE(TAG, "WiFi connection timed out (%d ms)", WIFI_CONNECT_TIMEOUT_MS); + return ESP_ERR_TIMEOUT; +} + +esp_err_t wifi_helper_get_ip_str(char *out, size_t out_len) +{ + if (!s_sta_netif || !out || out_len < 16) { + return ESP_ERR_INVALID_ARG; + } + + esp_netif_ip_info_t ip_info; + esp_err_t err = esp_netif_get_ip_info(s_sta_netif, &ip_info); + if (err != ESP_OK) { + return err; + } + + snprintf(out, out_len, IPSTR, IP2STR(&ip_info.ip)); + return ESP_OK; +} diff --git a/liblsl-ESP32/examples/basic_outlet/main/wifi_helper.h b/liblsl-ESP32/examples/basic_outlet/main/wifi_helper.h new file mode 100644 index 0000000..cf88092 --- /dev/null +++ b/liblsl-ESP32/examples/basic_outlet/main/wifi_helper.h @@ -0,0 +1,20 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#ifndef WIFI_HELPER_H +#define WIFI_HELPER_H + +#include "esp_err.h" + +/* Initialize WiFi in station mode and connect to the configured AP. + * Blocks until connected, max retries exhausted, or 30s timeout. + * Returns ESP_OK on success, ESP_FAIL on connection failure, + * ESP_ERR_TIMEOUT on timeout, ESP_ERR_NO_MEM on resource failure. */ +esp_err_t wifi_helper_init_sta(void); + +/* Get the local IPv4 address as a string (e.g., "192.168.1.50"). + * out must be at least 16 bytes. Returns ESP_OK on success. */ +esp_err_t wifi_helper_get_ip_str(char *out, size_t out_len); + +#endif /* WIFI_HELPER_H */ diff --git a/liblsl-ESP32/examples/basic_outlet/sdkconfig.defaults b/liblsl-ESP32/examples/basic_outlet/sdkconfig.defaults new file mode 100644 index 0000000..79b2fd2 --- /dev/null +++ b/liblsl-ESP32/examples/basic_outlet/sdkconfig.defaults @@ -0,0 +1,19 @@ +# ESP32 target +CONFIG_IDF_TARGET="esp32" +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y + +# Stack size for main task +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 + +# Logging +CONFIG_LOG_DEFAULT_LEVEL_INFO=y + +# WiFi +CONFIG_ESP_WIFI_SSID="your_ssid" +CONFIG_ESP_WIFI_PASSWORD="your_password" + +# IGMP for multicast support (required for LSL discovery) +CONFIG_LWIP_IGMP=y + +# 1000Hz tick rate for precise sample timing (250Hz needs 4ms resolution) +CONFIG_FREERTOS_HZ=1000 diff --git a/liblsl-ESP32/examples/secure_inlet/CMakeLists.txt b/liblsl-ESP32/examples/secure_inlet/CMakeLists.txt new file mode 100644 index 0000000..3ed85f3 --- /dev/null +++ b/liblsl-ESP32/examples/secure_inlet/CMakeLists.txt @@ -0,0 +1,6 @@ +cmake_minimum_required(VERSION 3.16) + +set(EXTRA_COMPONENT_DIRS "../../components") + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(secure_inlet) diff --git a/liblsl-ESP32/examples/secure_inlet/main/CMakeLists.txt b/liblsl-ESP32/examples/secure_inlet/main/CMakeLists.txt new file mode 100644 index 0000000..428b436 --- /dev/null +++ b/liblsl-ESP32/examples/secure_inlet/main/CMakeLists.txt @@ -0,0 +1,6 @@ +idf_component_register( + SRCS "secure_inlet_main.c" + "wifi_helper.c" + INCLUDE_DIRS "." + REQUIRES liblsl_esp32 esp_wifi esp_netif nvs_flash esp_event +) diff --git a/liblsl-ESP32/examples/secure_inlet/main/Kconfig.projbuild b/liblsl-ESP32/examples/secure_inlet/main/Kconfig.projbuild new file mode 100644 index 0000000..33f0636 --- /dev/null +++ b/liblsl-ESP32/examples/secure_inlet/main/Kconfig.projbuild @@ -0,0 +1,39 @@ +menu "Example Configuration" + + config ESP_WIFI_SSID + string "WiFi SSID" + default "your_ssid" + help + SSID (network name) to connect to. + + config ESP_WIFI_PASSWORD + string "WiFi Password" + default "your_password" + help + WiFi password (WPA2). + + config ESP_MAXIMUM_RETRY + int "Maximum WiFi connection retries" + default 10 + help + Number of times to retry WiFi connection before giving up. + + config LSL_SECURITY_PUBKEY + string "secureLSL public key (base64)" + default "" + help + Base64-encoded Ed25519 public key. Must match all devices in the lab. + + config LSL_SECURITY_PRIVKEY + string "secureLSL private key (base64)" + default "" + help + Base64-encoded Ed25519 private key (64 bytes). + + config LSL_TARGET_STREAM + string "Target stream name" + default "DesktopTest" + help + Name of the LSL stream to connect to. + +endmenu diff --git a/liblsl-ESP32/examples/secure_inlet/main/secure_inlet_main.c b/liblsl-ESP32/examples/secure_inlet/main/secure_inlet_main.c new file mode 100644 index 0000000..86f6cce --- /dev/null +++ b/liblsl-ESP32/examples/secure_inlet/main/secure_inlet_main.c @@ -0,0 +1,173 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +/* Secure LSL Inlet Example for ESP32 + * + * Demonstrates receiving encrypted LSL data with secureLSL: + * 1. Connect to WiFi + * 2. Import or generate Ed25519 keypair in NVS + * 3. Enable secureLSL encryption + * 4. Resolve a secure stream on the network + * 5. Create an encrypted inlet and receive samples + * + * Desktop test (requires secureLSL with matching keypair): + * ./cpp_secure_outlet --name DesktopTest --samples 5000 --channels 8 + * + * Key provisioning: + * Option A: Set keys via menuconfig -> Example Configuration + * Option B: Leave blank to auto-generate (then export to desktop) + * + * Configure: idf.py menuconfig -> Example Configuration + * + * Walkthrough: + * 1. Build: idf.py build + * 2. Flash: idf.py -p /dev/cu.usbserial-XXXX flash monitor + * 3. Start a secure outlet on the desktop with matching keypair + * 4. ESP32 resolves the stream, connects, and receives encrypted data + */ + +#include "lsl_esp32.h" +#include "wifi_helper.h" +#include "esp_log.h" +#include "esp_system.h" +#include "nvs_flash.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include + +static const char *TAG = "secure_inlet"; + +/* Provision keypair: import from config or generate new */ +static int provision_keys(void) +{ + if (lsl_esp32_has_keypair()) { + ESP_LOGI(TAG, "Keypair already provisioned in NVS"); + char pubkey[LSL_ESP32_KEY_BASE64_SIZE]; + if (lsl_esp32_export_pubkey(pubkey, sizeof(pubkey)) == LSL_ESP32_OK) { + ESP_LOGI(TAG, "Public key fingerprint: %.8s...", pubkey); + } + return 0; + } + + const char *pub = CONFIG_LSL_SECURITY_PUBKEY; + const char *priv = CONFIG_LSL_SECURITY_PRIVKEY; + + if (pub[0] != '\0' && priv[0] != '\0') { + ESP_LOGI(TAG, "Importing keypair from menuconfig..."); + if (lsl_esp32_import_keypair(pub, priv) == LSL_ESP32_OK) { + ESP_LOGI(TAG, "Keypair imported successfully"); + return 0; + } + ESP_LOGE(TAG, "Failed to import keypair from config"); + return -1; + } + + ESP_LOGI(TAG, "No keypair configured, generating new Ed25519 keypair..."); + if (lsl_esp32_generate_keypair() != LSL_ESP32_OK) { + ESP_LOGE(TAG, "Key generation failed"); + return -1; + } + + char pubkey[LSL_ESP32_KEY_BASE64_SIZE]; + if (lsl_esp32_export_pubkey(pubkey, sizeof(pubkey)) == LSL_ESP32_OK) { + ESP_LOGI(TAG, "Generated new keypair. Public key: %s", pubkey); + ESP_LOGW(TAG, "Import this key to all lab devices for encrypted communication"); + } + return 0; +} + +void app_main(void) +{ + esp_err_t ret = nvs_flash_init(); + if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_ERROR_CHECK(nvs_flash_erase()); + ret = nvs_flash_init(); + } + ESP_ERROR_CHECK(ret); + + if (provision_keys() != 0) { + ESP_LOGE(TAG, "Key provisioning failed"); + return; + } + + lsl_esp32_err_t err = lsl_esp32_enable_security(); + if (err != LSL_ESP32_OK) { + ESP_LOGE(TAG, "Failed to enable security: %d", err); + return; + } + + ESP_LOGI(TAG, "Connecting to WiFi..."); + if (wifi_helper_init_sta() != ESP_OK) { + ESP_LOGE(TAG, "WiFi connection failed"); + return; + } + + /* Resolve target stream */ + const char *target = CONFIG_LSL_TARGET_STREAM; + ESP_LOGI(TAG, "Resolving secure stream '%s'...", target); + + lsl_esp32_stream_info_t info = NULL; + int found = lsl_esp32_resolve_stream("name", target, 15.0, &info); + if (!found || !info) { + ESP_LOGE(TAG, "Stream '%s' not found within 15s", target); + return; + } + + ESP_LOGI(TAG, "Found: %s (%s, %dch @ %.0fHz)", lsl_esp32_get_name(info), + lsl_esp32_get_type(info), lsl_esp32_get_channel_count(info), + lsl_esp32_get_nominal_srate(info)); + + /* Create inlet (encryption is automatic when security is enabled) */ + lsl_esp32_inlet_t inlet = lsl_esp32_create_inlet(info); + if (!inlet) { + ESP_LOGE(TAG, "Failed to create inlet (handshake may have failed)"); + lsl_esp32_destroy_streaminfo(info); + return; + } + + ESP_LOGI(TAG, ""); + ESP_LOGI(TAG, "=== Secure LSL Inlet Ready ==="); + ESP_LOGI(TAG, "Receiving from: %s (ENCRYPTED)", target); + ESP_LOGI(TAG, ""); + + /* Receive samples */ + int nch = lsl_esp32_get_channel_count(info); + float *buf = malloc((size_t)nch * sizeof(float)); + if (!buf) { + ESP_LOGE(TAG, "Failed to allocate sample buffer"); + lsl_esp32_destroy_inlet(inlet); + return; + } + + uint32_t received = 0; + uint32_t timeouts = 0; + + while (1) { + double timestamp; + err = lsl_esp32_inlet_pull_sample_f(inlet, buf, nch * (int)sizeof(float), ×tamp, 5.0); + + if (err == LSL_ESP32_OK) { + received++; + if (received <= 3 || received % 250 == 0) { + ESP_LOGI(TAG, "Sample %lu: ts=%.6f ch0=%.4f ch1=%.4f (heap=%lu)", + (unsigned long)received, timestamp, buf[0], nch > 1 ? buf[1] : 0.0f, + (unsigned long)esp_get_free_heap_size()); + } + } else if (err == LSL_ESP32_ERR_TIMEOUT) { + timeouts++; + if (timeouts >= 6) { + ESP_LOGW(TAG, "No samples for 30s, outlet may have stopped"); + break; + } + ESP_LOGW(TAG, "Timeout (received=%lu so far)", (unsigned long)received); + } else { + ESP_LOGE(TAG, "Pull error: %d", err); + break; + } + } + + ESP_LOGI(TAG, "Total received: %lu samples", (unsigned long)received); + free(buf); + lsl_esp32_destroy_inlet(inlet); +} diff --git a/liblsl-ESP32/examples/secure_inlet/main/wifi_helper.c b/liblsl-ESP32/examples/secure_inlet/main/wifi_helper.c new file mode 100644 index 0000000..c5ab11a --- /dev/null +++ b/liblsl-ESP32/examples/secure_inlet/main/wifi_helper.c @@ -0,0 +1,128 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#include "wifi_helper.h" +#include "esp_log.h" +#include "esp_wifi.h" +#include "esp_event.h" +#include "esp_netif.h" +#include "freertos/FreeRTOS.h" +#include "freertos/event_groups.h" +#include + +static const char *TAG = "wifi_helper"; + +#define WIFI_CONNECTED_BIT BIT0 +#define WIFI_FAIL_BIT BIT1 +#define WIFI_CONNECT_TIMEOUT_MS 30000 + +static EventGroupHandle_t s_wifi_event_group; +static int s_retry_num = 0; +static esp_netif_t *s_sta_netif = NULL; + +static void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *data) +{ + if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) { + esp_err_t err = esp_wifi_connect(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_wifi_connect failed on STA_START: %s", esp_err_to_name(err)); + xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT); + } + } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { + wifi_event_sta_disconnected_t *disc = (wifi_event_sta_disconnected_t *)data; + ESP_LOGW(TAG, "Disconnected: reason=%d", disc->reason); + if (s_retry_num < CONFIG_ESP_MAXIMUM_RETRY) { + esp_err_t err = esp_wifi_connect(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_wifi_connect retry failed: %s", esp_err_to_name(err)); + xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT); + return; + } + s_retry_num++; + ESP_LOGI(TAG, "Retrying WiFi connection (%d/%d)", s_retry_num, + CONFIG_ESP_MAXIMUM_RETRY); + } else { + ESP_LOGE(TAG, "WiFi connection failed after %d retries", CONFIG_ESP_MAXIMUM_RETRY); + s_retry_num = 0; /* allow future reconnection attempts */ + xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT); + } + } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { + ip_event_got_ip_t *event = (ip_event_got_ip_t *)data; + ESP_LOGI(TAG, "Connected. IP: " IPSTR, IP2STR(&event->ip_info.ip)); + s_retry_num = 0; + xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT); + } +} + +esp_err_t wifi_helper_init_sta(void) +{ + s_wifi_event_group = xEventGroupCreate(); + if (!s_wifi_event_group) { + ESP_LOGE(TAG, "Failed to create WiFi event group"); + return ESP_ERR_NO_MEM; + } + + ESP_ERROR_CHECK(esp_netif_init()); + ESP_ERROR_CHECK(esp_event_loop_create_default()); + s_sta_netif = esp_netif_create_default_wifi_sta(); + if (!s_sta_netif) { + ESP_LOGE(TAG, "Failed to create default WiFi STA netif"); + vEventGroupDelete(s_wifi_event_group); + return ESP_ERR_NO_MEM; + } + + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + + ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, + &event_handler, NULL, NULL)); + ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, + &event_handler, NULL, NULL)); + + wifi_config_t wifi_config = { + .sta = + { + .ssid = CONFIG_ESP_WIFI_SSID, + .password = CONFIG_ESP_WIFI_PASSWORD, + }, + }; + + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); + ESP_ERROR_CHECK(esp_wifi_start()); + + ESP_LOGI(TAG, "Connecting to %s...", CONFIG_ESP_WIFI_SSID); + + /* Wait with timeout to avoid permanent hang */ + EventBits_t bits = + xEventGroupWaitBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, pdFALSE, + pdFALSE, pdMS_TO_TICKS(WIFI_CONNECT_TIMEOUT_MS)); + + if (bits & WIFI_CONNECTED_BIT) { + return ESP_OK; + } + if (bits & WIFI_FAIL_BIT) { + ESP_LOGE(TAG, "WiFi connection failed after retries"); + return ESP_FAIL; + } + + ESP_LOGE(TAG, "WiFi connection timed out (%d ms)", WIFI_CONNECT_TIMEOUT_MS); + return ESP_ERR_TIMEOUT; +} + +esp_err_t wifi_helper_get_ip_str(char *out, size_t out_len) +{ + if (!s_sta_netif || !out || out_len < 16) { + return ESP_ERR_INVALID_ARG; + } + + esp_netif_ip_info_t ip_info; + esp_err_t err = esp_netif_get_ip_info(s_sta_netif, &ip_info); + if (err != ESP_OK) { + return err; + } + + snprintf(out, out_len, IPSTR, IP2STR(&ip_info.ip)); + return ESP_OK; +} diff --git a/liblsl-ESP32/examples/secure_inlet/main/wifi_helper.h b/liblsl-ESP32/examples/secure_inlet/main/wifi_helper.h new file mode 100644 index 0000000..cf88092 --- /dev/null +++ b/liblsl-ESP32/examples/secure_inlet/main/wifi_helper.h @@ -0,0 +1,20 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#ifndef WIFI_HELPER_H +#define WIFI_HELPER_H + +#include "esp_err.h" + +/* Initialize WiFi in station mode and connect to the configured AP. + * Blocks until connected, max retries exhausted, or 30s timeout. + * Returns ESP_OK on success, ESP_FAIL on connection failure, + * ESP_ERR_TIMEOUT on timeout, ESP_ERR_NO_MEM on resource failure. */ +esp_err_t wifi_helper_init_sta(void); + +/* Get the local IPv4 address as a string (e.g., "192.168.1.50"). + * out must be at least 16 bytes. Returns ESP_OK on success. */ +esp_err_t wifi_helper_get_ip_str(char *out, size_t out_len); + +#endif /* WIFI_HELPER_H */ diff --git a/liblsl-ESP32/examples/secure_inlet/sdkconfig.defaults b/liblsl-ESP32/examples/secure_inlet/sdkconfig.defaults new file mode 100644 index 0000000..f643447 --- /dev/null +++ b/liblsl-ESP32/examples/secure_inlet/sdkconfig.defaults @@ -0,0 +1,19 @@ +# ESP32 target +CONFIG_IDF_TARGET="esp32" +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y + +# Stack size for main task (security needs more stack for key derivation) +CONFIG_ESP_MAIN_TASK_STACK_SIZE=10240 + +# Logging +CONFIG_LOG_DEFAULT_LEVEL_INFO=y + +# WiFi +CONFIG_ESP_WIFI_SSID="your_ssid" +CONFIG_ESP_WIFI_PASSWORD="your_password" + +# IGMP for multicast support (required for LSL discovery) +CONFIG_LWIP_IGMP=y + +# 1000Hz tick rate +CONFIG_FREERTOS_HZ=1000 diff --git a/liblsl-ESP32/examples/secure_outlet/CMakeLists.txt b/liblsl-ESP32/examples/secure_outlet/CMakeLists.txt new file mode 100644 index 0000000..de159a7 --- /dev/null +++ b/liblsl-ESP32/examples/secure_outlet/CMakeLists.txt @@ -0,0 +1,6 @@ +cmake_minimum_required(VERSION 3.16) + +set(EXTRA_COMPONENT_DIRS "../../components") + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(secure_outlet) diff --git a/liblsl-ESP32/examples/secure_outlet/main/CMakeLists.txt b/liblsl-ESP32/examples/secure_outlet/main/CMakeLists.txt new file mode 100644 index 0000000..91e41f3 --- /dev/null +++ b/liblsl-ESP32/examples/secure_outlet/main/CMakeLists.txt @@ -0,0 +1,6 @@ +idf_component_register( + SRCS "secure_outlet_main.c" + "wifi_helper.c" + INCLUDE_DIRS "." + REQUIRES liblsl_esp32 esp_wifi esp_netif nvs_flash esp_event +) diff --git a/liblsl-ESP32/examples/secure_outlet/main/Kconfig.projbuild b/liblsl-ESP32/examples/secure_outlet/main/Kconfig.projbuild new file mode 100644 index 0000000..7ab7ab2 --- /dev/null +++ b/liblsl-ESP32/examples/secure_outlet/main/Kconfig.projbuild @@ -0,0 +1,35 @@ +menu "Example Configuration" + + config ESP_WIFI_SSID + string "WiFi SSID" + default "your_ssid" + help + SSID (network name) to connect to. + + config ESP_WIFI_PASSWORD + string "WiFi Password" + default "your_password" + help + WiFi password (WPA2). + + config ESP_MAXIMUM_RETRY + int "Maximum WiFi connection retries" + default 10 + help + Number of times to retry WiFi connection before giving up. + + config LSL_SECURITY_PUBKEY + string "secureLSL public key (base64)" + default "" + help + Base64-encoded Ed25519 public key. Must match all devices in the lab. + Generate with lsl-keygen or export from desktop secureLSL config. + + config LSL_SECURITY_PRIVKEY + string "secureLSL private key (base64)" + default "" + help + Base64-encoded Ed25519 private key (64 bytes). Must match the public key. + WARNING: This is sensitive key material. + +endmenu diff --git a/liblsl-ESP32/examples/secure_outlet/main/secure_outlet_main.c b/liblsl-ESP32/examples/secure_outlet/main/secure_outlet_main.c new file mode 100644 index 0000000..dede1fc --- /dev/null +++ b/liblsl-ESP32/examples/secure_outlet/main/secure_outlet_main.c @@ -0,0 +1,168 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +/* Secure LSL Outlet Example for ESP32 + * + * Demonstrates encrypted LSL streaming with secureLSL: + * 1. Connect to WiFi + * 2. Import or generate Ed25519 keypair in NVS + * 3. Enable secureLSL encryption + * 4. Create an encrypted outlet + * 5. Push sine wave samples at 250 Hz (encrypted on the wire) + * + * Desktop test (requires secureLSL with matching keypair): + * ./cpp_secure_inlet --name ESP32Secure + * + * Key provisioning: + * Option A: Set keys via menuconfig -> Example Configuration + * Option B: Leave blank to auto-generate (export and import to desktop) + * + * Configure WiFi: idf.py menuconfig -> Example Configuration + * + * Walkthrough: + * 1. Build: idf.py build + * 2. Flash: idf.py -p /dev/cu.usbserial-XXXX flash monitor + * 3. On first boot with no keys configured, generates a new keypair + * and prints the public key fingerprint + * 4. To share keys with desktop, use key_provisioning example + * or set keys in menuconfig + */ + +#include "lsl_esp32.h" +#include "wifi_helper.h" +#include "esp_log.h" +#include "esp_system.h" +#include "nvs_flash.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include +#include + +static const char *TAG = "secure_outlet"; + +#define NUM_CHANNELS 8 +#define SAMPLE_RATE 250.0 + +/* Provision keypair: import from config or generate new */ +static int provision_keys(void) +{ + /* If keys already exist in NVS, use them */ + if (lsl_esp32_has_keypair()) { + ESP_LOGI(TAG, "Keypair already provisioned in NVS"); + char pubkey[LSL_ESP32_KEY_BASE64_SIZE]; + if (lsl_esp32_export_pubkey(pubkey, sizeof(pubkey)) == LSL_ESP32_OK) { + ESP_LOGI(TAG, "Public key fingerprint: %.8s...", pubkey); + } + return 0; + } + + /* Try importing from menuconfig */ + const char *pub = CONFIG_LSL_SECURITY_PUBKEY; + const char *priv = CONFIG_LSL_SECURITY_PRIVKEY; + + if (pub[0] != '\0' && priv[0] != '\0') { + ESP_LOGI(TAG, "Importing keypair from menuconfig..."); + if (lsl_esp32_import_keypair(pub, priv) == LSL_ESP32_OK) { + ESP_LOGI(TAG, "Keypair imported successfully"); + return 0; + } + ESP_LOGE(TAG, "Failed to import keypair from config"); + return -1; + } + + /* No keys configured; generate new keypair */ + ESP_LOGI(TAG, "No keypair configured, generating new Ed25519 keypair..."); + if (lsl_esp32_generate_keypair() != LSL_ESP32_OK) { + ESP_LOGE(TAG, "Key generation failed"); + return -1; + } + + char pubkey[LSL_ESP32_KEY_BASE64_SIZE]; + if (lsl_esp32_export_pubkey(pubkey, sizeof(pubkey)) == LSL_ESP32_OK) { + ESP_LOGI(TAG, "Generated new keypair. Public key: %s", pubkey); + ESP_LOGW(TAG, "Import this key to all lab devices for encrypted communication"); + } + return 0; +} + +void app_main(void) +{ + /* Initialize NVS (required for WiFi and key storage) */ + esp_err_t ret = nvs_flash_init(); + if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_ERROR_CHECK(nvs_flash_erase()); + ret = nvs_flash_init(); + } + ESP_ERROR_CHECK(ret); + + /* Provision keypair (sodium_init is called by enable_security) */ + if (provision_keys() != 0) { + ESP_LOGE(TAG, "Key provisioning failed, cannot start secure outlet"); + return; + } + + /* Enable secureLSL encryption */ + lsl_esp32_err_t err = lsl_esp32_enable_security(); + if (err != LSL_ESP32_OK) { + ESP_LOGE(TAG, "Failed to enable security: %d", err); + return; + } + + /* Connect to WiFi */ + ESP_LOGI(TAG, "Connecting to WiFi..."); + if (wifi_helper_init_sta() != ESP_OK) { + ESP_LOGE(TAG, "WiFi connection failed"); + return; + } + + /* Create stream info */ + lsl_esp32_stream_info_t info = + lsl_esp32_create_streaminfo("ESP32Secure", "EEG", NUM_CHANNELS, SAMPLE_RATE, + LSL_ESP32_FMT_FLOAT32, "esp32_secure_outlet_1"); + if (!info) { + ESP_LOGE(TAG, "Failed to create stream info"); + return; + } + + /* Create outlet (encryption is automatic when security is enabled) */ + lsl_esp32_outlet_t outlet = lsl_esp32_create_outlet(info, 0, 360); + if (!outlet) { + ESP_LOGE(TAG, "Failed to create outlet"); + lsl_esp32_destroy_streaminfo(info); + return; + } + + ESP_LOGI(TAG, ""); + ESP_LOGI(TAG, "=== Secure LSL Outlet Ready ==="); + ESP_LOGI(TAG, "Stream: ESP32Secure (%dch float32 @ %.0fHz, ENCRYPTED)", NUM_CHANNELS, + SAMPLE_RATE); + ESP_LOGI(TAG, ""); + + /* Push sine wave samples at 250 Hz */ + float channels[NUM_CHANNELS]; + uint32_t sample_count = 0; + TickType_t last_wake = xTaskGetTickCount(); + + while (1) { + double timestamp = lsl_esp32_local_clock(); + + for (int ch = 0; ch < NUM_CHANNELS; ch++) { + channels[ch] = sinf((float)timestamp * 2.0f * 3.14159f * (float)(ch + 1)); + } + + err = lsl_esp32_push_sample_f(outlet, channels, timestamp); + if (err != LSL_ESP32_OK) { + ESP_LOGW(TAG, "Push failed: %d", err); + } + sample_count++; + + if (sample_count % (5 * (uint32_t)SAMPLE_RATE) == 0) { + ESP_LOGI(TAG, "Pushed %lu samples (heap=%lu, consumers=%d)", + (unsigned long)sample_count, (unsigned long)esp_get_free_heap_size(), + lsl_esp32_have_consumers(outlet)); + } + + vTaskDelayUntil(&last_wake, pdMS_TO_TICKS(4)); + } +} diff --git a/liblsl-ESP32/examples/secure_outlet/main/wifi_helper.c b/liblsl-ESP32/examples/secure_outlet/main/wifi_helper.c new file mode 100644 index 0000000..c5ab11a --- /dev/null +++ b/liblsl-ESP32/examples/secure_outlet/main/wifi_helper.c @@ -0,0 +1,128 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#include "wifi_helper.h" +#include "esp_log.h" +#include "esp_wifi.h" +#include "esp_event.h" +#include "esp_netif.h" +#include "freertos/FreeRTOS.h" +#include "freertos/event_groups.h" +#include + +static const char *TAG = "wifi_helper"; + +#define WIFI_CONNECTED_BIT BIT0 +#define WIFI_FAIL_BIT BIT1 +#define WIFI_CONNECT_TIMEOUT_MS 30000 + +static EventGroupHandle_t s_wifi_event_group; +static int s_retry_num = 0; +static esp_netif_t *s_sta_netif = NULL; + +static void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *data) +{ + if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) { + esp_err_t err = esp_wifi_connect(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_wifi_connect failed on STA_START: %s", esp_err_to_name(err)); + xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT); + } + } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { + wifi_event_sta_disconnected_t *disc = (wifi_event_sta_disconnected_t *)data; + ESP_LOGW(TAG, "Disconnected: reason=%d", disc->reason); + if (s_retry_num < CONFIG_ESP_MAXIMUM_RETRY) { + esp_err_t err = esp_wifi_connect(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_wifi_connect retry failed: %s", esp_err_to_name(err)); + xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT); + return; + } + s_retry_num++; + ESP_LOGI(TAG, "Retrying WiFi connection (%d/%d)", s_retry_num, + CONFIG_ESP_MAXIMUM_RETRY); + } else { + ESP_LOGE(TAG, "WiFi connection failed after %d retries", CONFIG_ESP_MAXIMUM_RETRY); + s_retry_num = 0; /* allow future reconnection attempts */ + xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT); + } + } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { + ip_event_got_ip_t *event = (ip_event_got_ip_t *)data; + ESP_LOGI(TAG, "Connected. IP: " IPSTR, IP2STR(&event->ip_info.ip)); + s_retry_num = 0; + xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT); + } +} + +esp_err_t wifi_helper_init_sta(void) +{ + s_wifi_event_group = xEventGroupCreate(); + if (!s_wifi_event_group) { + ESP_LOGE(TAG, "Failed to create WiFi event group"); + return ESP_ERR_NO_MEM; + } + + ESP_ERROR_CHECK(esp_netif_init()); + ESP_ERROR_CHECK(esp_event_loop_create_default()); + s_sta_netif = esp_netif_create_default_wifi_sta(); + if (!s_sta_netif) { + ESP_LOGE(TAG, "Failed to create default WiFi STA netif"); + vEventGroupDelete(s_wifi_event_group); + return ESP_ERR_NO_MEM; + } + + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + + ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, + &event_handler, NULL, NULL)); + ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, + &event_handler, NULL, NULL)); + + wifi_config_t wifi_config = { + .sta = + { + .ssid = CONFIG_ESP_WIFI_SSID, + .password = CONFIG_ESP_WIFI_PASSWORD, + }, + }; + + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); + ESP_ERROR_CHECK(esp_wifi_start()); + + ESP_LOGI(TAG, "Connecting to %s...", CONFIG_ESP_WIFI_SSID); + + /* Wait with timeout to avoid permanent hang */ + EventBits_t bits = + xEventGroupWaitBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, pdFALSE, + pdFALSE, pdMS_TO_TICKS(WIFI_CONNECT_TIMEOUT_MS)); + + if (bits & WIFI_CONNECTED_BIT) { + return ESP_OK; + } + if (bits & WIFI_FAIL_BIT) { + ESP_LOGE(TAG, "WiFi connection failed after retries"); + return ESP_FAIL; + } + + ESP_LOGE(TAG, "WiFi connection timed out (%d ms)", WIFI_CONNECT_TIMEOUT_MS); + return ESP_ERR_TIMEOUT; +} + +esp_err_t wifi_helper_get_ip_str(char *out, size_t out_len) +{ + if (!s_sta_netif || !out || out_len < 16) { + return ESP_ERR_INVALID_ARG; + } + + esp_netif_ip_info_t ip_info; + esp_err_t err = esp_netif_get_ip_info(s_sta_netif, &ip_info); + if (err != ESP_OK) { + return err; + } + + snprintf(out, out_len, IPSTR, IP2STR(&ip_info.ip)); + return ESP_OK; +} diff --git a/liblsl-ESP32/examples/secure_outlet/main/wifi_helper.h b/liblsl-ESP32/examples/secure_outlet/main/wifi_helper.h new file mode 100644 index 0000000..cf88092 --- /dev/null +++ b/liblsl-ESP32/examples/secure_outlet/main/wifi_helper.h @@ -0,0 +1,20 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +#ifndef WIFI_HELPER_H +#define WIFI_HELPER_H + +#include "esp_err.h" + +/* Initialize WiFi in station mode and connect to the configured AP. + * Blocks until connected, max retries exhausted, or 30s timeout. + * Returns ESP_OK on success, ESP_FAIL on connection failure, + * ESP_ERR_TIMEOUT on timeout, ESP_ERR_NO_MEM on resource failure. */ +esp_err_t wifi_helper_init_sta(void); + +/* Get the local IPv4 address as a string (e.g., "192.168.1.50"). + * out must be at least 16 bytes. Returns ESP_OK on success. */ +esp_err_t wifi_helper_get_ip_str(char *out, size_t out_len); + +#endif /* WIFI_HELPER_H */ diff --git a/liblsl-ESP32/examples/secure_outlet/sdkconfig.defaults b/liblsl-ESP32/examples/secure_outlet/sdkconfig.defaults new file mode 100644 index 0000000..ecf2c2f --- /dev/null +++ b/liblsl-ESP32/examples/secure_outlet/sdkconfig.defaults @@ -0,0 +1,19 @@ +# ESP32 target +CONFIG_IDF_TARGET="esp32" +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y + +# Stack size for main task (security needs more stack for key derivation) +CONFIG_ESP_MAIN_TASK_STACK_SIZE=10240 + +# Logging +CONFIG_LOG_DEFAULT_LEVEL_INFO=y + +# WiFi +CONFIG_ESP_WIFI_SSID="your_ssid" +CONFIG_ESP_WIFI_PASSWORD="your_password" + +# IGMP for multicast support (required for LSL discovery) +CONFIG_LWIP_IGMP=y + +# 1000Hz tick rate for precise sample timing (250Hz needs 4ms resolution) +CONFIG_FREERTOS_HZ=1000 diff --git a/liblsl-ESP32/tests/build_test/CMakeLists.txt b/liblsl-ESP32/tests/build_test/CMakeLists.txt new file mode 100644 index 0000000..fe2ac4d --- /dev/null +++ b/liblsl-ESP32/tests/build_test/CMakeLists.txt @@ -0,0 +1,6 @@ +cmake_minimum_required(VERSION 3.16) + +set(EXTRA_COMPONENT_DIRS "../../components") + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(build_test) diff --git a/liblsl-ESP32/tests/build_test/main/CMakeLists.txt b/liblsl-ESP32/tests/build_test/main/CMakeLists.txt new file mode 100644 index 0000000..d7980f6 --- /dev/null +++ b/liblsl-ESP32/tests/build_test/main/CMakeLists.txt @@ -0,0 +1,6 @@ +idf_component_register( + SRCS "build_test_main.c" + INCLUDE_DIRS "." + REQUIRES liblsl_esp32 + PRIV_INCLUDE_DIRS "${CMAKE_CURRENT_LIST_DIR}/../../../components/liblsl_esp32/src" +) diff --git a/liblsl-ESP32/tests/build_test/main/build_test_main.c b/liblsl-ESP32/tests/build_test/main/build_test_main.c new file mode 100644 index 0000000..6eafb89 --- /dev/null +++ b/liblsl-ESP32/tests/build_test/main/build_test_main.c @@ -0,0 +1,146 @@ +// Copyright (C) 2026 The Regents of the University of California. All Rights Reserved. +// Author: Seyed Yahya Shirazi, SCCN, INC, UCSD +// See LICENSE in the repository root for terms. + +/* Build test: verifies the liblsl_esp32 component compiles and links. + * Also serves as a smoke test for core APIs: stream info, XML, and sample format. */ + +#include "lsl_esp32.h" +#include "lsl_protocol.h" +#include "lsl_stream_info.h" +#include "lsl_sample.h" +#include "lsl_xml_parser.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include + +static const char *TAG = "build_test"; + +void app_main(void) +{ + /* Brief delay so serial monitor can connect */ + ESP_LOGI(TAG, "Starting in 2 seconds..."); + vTaskDelay(pdMS_TO_TICKS(2000)); + + ESP_LOGI(TAG, "=== liblsl_esp32 build test ==="); + + /* Clock */ + double t = lsl_esp32_local_clock(); + ESP_LOGI(TAG, "[OK] Clock: %.6f s", t); + + /* Stream info */ + lsl_esp32_stream_info_t info = lsl_esp32_create_streaminfo( + "BuildTest", "Test", 8, 250.0, LSL_ESP32_FMT_FLOAT32, "build_test_src"); + + if (!info) { + ESP_LOGE(TAG, "[FAIL] Failed to create stream info"); + return; + } + ESP_LOGI(TAG, "[OK] Stream info created"); + + /* XML serialization */ + char xml_buf[LSL_ESP32_SHORTINFO_MAX]; + int xml_len = stream_info_to_shortinfo_xml(info, xml_buf, sizeof(xml_buf)); + if (xml_len > 0) { + ESP_LOGI(TAG, "[OK] shortinfo XML (%d bytes):", xml_len); + ESP_LOGI(TAG, "%s", xml_buf); + } else { + ESP_LOGE(TAG, "[FAIL] shortinfo XML serialization failed"); + } + + /* Query matching */ + int match1 = stream_info_match_query(info, "name='BuildTest'"); + int match2 = stream_info_match_query(info, "type='Test'"); + int match3 = stream_info_match_query(info, "name='Wrong'"); + int match4 = stream_info_match_query(info, ""); + int match5 = stream_info_match_query(info, "name='BuildTest' and type='Test'"); + int match6 = stream_info_match_query(info, "name='BuildTest' and type='Wrong'"); + int match7 = stream_info_match_query(info, "hostname='ESP32'"); + ESP_LOGI(TAG, "[%s] Query name='BuildTest': %d", match1 ? "OK" : "FAIL", match1); + ESP_LOGI(TAG, "[%s] Query type='Test': %d", match2 ? "OK" : "FAIL", match2); + ESP_LOGI(TAG, "[%s] Query name='Wrong': %d", !match3 ? "OK" : "FAIL", match3); + ESP_LOGI(TAG, "[%s] Query empty (match all): %d", match4 ? "OK" : "FAIL", match4); + ESP_LOGI(TAG, "[%s] Query AND (both match): %d", match5 ? "OK" : "FAIL", match5); + ESP_LOGI(TAG, "[%s] Query AND (type wrong): %d", !match6 ? "OK" : "FAIL", match6); + ESP_LOGI(TAG, "[%s] Query hostname (not name): %d", !match7 ? "OK" : "FAIL", match7); + + /* Test pattern generation */ + uint8_t sample_buf[256]; + int sample_len = + sample_generate_test_pattern(8, LSL_ESP32_FMT_FLOAT32, LSL_ESP32_TEST_OFFSET_1, + LSL_ESP32_TEST_TIMESTAMP, sample_buf, sizeof(sample_buf)); + + if (sample_len > 0) { + ESP_LOGI(TAG, "[OK] Test pattern 1 (%d bytes): tag=0x%02x", sample_len, sample_buf[0]); + /* Read channel values safely via memcpy (avoid strict-aliasing violation) */ + float ch[4]; + memcpy(ch, sample_buf + 1 + 8, sizeof(ch)); /* skip tag + timestamp */ + ESP_LOGI(TAG, " ch0=%.1f ch1=%.1f ch2=%.1f ch3=%.1f", ch[0], ch[1], ch[2], ch[3]); + } else { + ESP_LOGE(TAG, "[FAIL] Test pattern generation failed"); + } + + int sample_len2 = + sample_generate_test_pattern(8, LSL_ESP32_FMT_FLOAT32, LSL_ESP32_TEST_OFFSET_2, + LSL_ESP32_TEST_TIMESTAMP, sample_buf, sizeof(sample_buf)); + + if (sample_len2 > 0) { + ESP_LOGI(TAG, "[OK] Test pattern 2 (%d bytes): tag=0x%02x", sample_len2, sample_buf[0]); + float ch[4]; + memcpy(ch, sample_buf + 1 + 8, sizeof(ch)); + ESP_LOGI(TAG, " ch0=%.1f ch1=%.1f ch2=%.1f ch3=%.1f", ch[0], ch[1], ch[2], ch[3]); + } else { + ESP_LOGE(TAG, "[FAIL] Test pattern 2 generation failed"); + } + + /* XML roundtrip: serialize -> parse -> compare */ + ESP_LOGI(TAG, "--- XML roundtrip test ---"); + struct lsl_esp32_stream_info parsed_info; + int xml_parse_ok = xml_parse_stream_info(xml_buf, (size_t)xml_len, &parsed_info); + if (xml_parse_ok == 0) { + int name_ok = (strcmp(info->name, parsed_info.name) == 0); + int type_ok = (strcmp(info->type, parsed_info.type) == 0); + int ch_ok = (info->channel_count == parsed_info.channel_count); + int fmt_ok = (info->channel_format == parsed_info.channel_format); + int uid_ok = (strcmp(info->uid, parsed_info.uid) == 0); + ESP_LOGI(TAG, "[%s] XML roundtrip name: %s", name_ok ? "OK" : "FAIL", parsed_info.name); + ESP_LOGI(TAG, "[%s] XML roundtrip type: %s", type_ok ? "OK" : "FAIL", parsed_info.type); + ESP_LOGI(TAG, "[%s] XML roundtrip channels: %d", ch_ok ? "OK" : "FAIL", + parsed_info.channel_count); + ESP_LOGI(TAG, "[%s] XML roundtrip format: %d", fmt_ok ? "OK" : "FAIL", + parsed_info.channel_format); + ESP_LOGI(TAG, "[%s] XML roundtrip uid: %s", uid_ok ? "OK" : "FAIL", parsed_info.uid); + } else { + ESP_LOGE(TAG, "[FAIL] XML parse failed"); + } + + /* Sample deserialize roundtrip */ + ESP_LOGI(TAG, "--- Sample deserialize roundtrip ---"); + /* Reuse sample_buf from test pattern 1 (offset=4) */ + sample_len = + sample_generate_test_pattern(8, LSL_ESP32_FMT_FLOAT32, LSL_ESP32_TEST_OFFSET_1, + LSL_ESP32_TEST_TIMESTAMP, sample_buf, sizeof(sample_buf)); + if (sample_len > 0) { + float deser_channels[8]; + double deser_ts = 0.0; + int consumed = sample_deserialize(sample_buf, (size_t)sample_len, 8, LSL_ESP32_FMT_FLOAT32, + deser_channels, sizeof(deser_channels), &deser_ts); + if (consumed > 0) { + ESP_LOGI(TAG, "[OK] Deserialized %d bytes, ts=%.3f", consumed, deser_ts); + ESP_LOGI(TAG, " ch0=%.1f ch1=%.1f ch2=%.1f ch3=%.1f", deser_channels[0], + deser_channels[1], deser_channels[2], deser_channels[3]); + + /* Validate test pattern */ + int valid = + sample_validate_test_pattern(8, LSL_ESP32_FMT_FLOAT32, LSL_ESP32_TEST_OFFSET_1, + LSL_ESP32_TEST_TIMESTAMP, deser_channels, deser_ts); + ESP_LOGI(TAG, "[%s] Test pattern validation", valid == 0 ? "OK" : "FAIL"); + } else { + ESP_LOGE(TAG, "[FAIL] Sample deserialization failed"); + } + } + + lsl_esp32_destroy_streaminfo(info); + ESP_LOGI(TAG, "=== Build test PASSED ==="); +} diff --git a/liblsl-ESP32/tests/build_test/sdkconfig.defaults b/liblsl-ESP32/tests/build_test/sdkconfig.defaults new file mode 100644 index 0000000..cd5943e --- /dev/null +++ b/liblsl-ESP32/tests/build_test/sdkconfig.defaults @@ -0,0 +1,4 @@ +CONFIG_IDF_TARGET="esp32" +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y +CONFIG_ESP_MAIN_TASK_STACK_SIZE=4096 +CONFIG_LOG_DEFAULT_LEVEL_INFO=y diff --git a/liblsl/CMakeLists.txt b/liblsl/CMakeLists.txt index 323a076..2f3c042 100644 --- a/liblsl/CMakeLists.txt +++ b/liblsl/CMakeLists.txt @@ -19,7 +19,7 @@ set(LSL_ABI_VERSION 2) # Minor: New features (new API functions, new config options) # Patch: Bug fixes, security patches set(LSL_SECURITY_VERSION_MAJOR 1) -set(LSL_SECURITY_VERSION_MINOR 0) +set(LSL_SECURITY_VERSION_MINOR 1) set(LSL_SECURITY_VERSION_PATCH 0) set(LSL_SECURITY_STAGE "alpha") # "alpha", "beta", "rc.N", or "" for stable diff --git a/mkdocs.yml b/mkdocs.yml index e02eab8..9a3438e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -88,9 +88,9 @@ extra: # Current version info (update on release) slsl_version: base: "1.16.1" - security: "1.0.0" + security: "1.1.0" stage: "alpha" - full: "1.16.1-secure.1.0.0-alpha" + full: "1.16.1-secure.1.1.0-alpha" social: - icon: fontawesome/brands/github link: https://github.com/sccn/secureLSL @@ -128,6 +128,8 @@ nav: - stream_inlet: liblsl-cpp/classlsl_1_1stream__inlet.md - stream_outlet: liblsl-cpp/classlsl_1_1stream__outlet.md - lsl_cpp.h: liblsl-cpp/lsl__cpp_8h.md + - ESP32: + - Overview: esp32/overview.md - Integration: - LabRecorder: integration/labrecorder.md - SigVisualizer: integration/sigvisualizer.md