diff --git a/README.md b/README.md index d73302a..e304fcf 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ The CLI provides a lightweight alternative to the GUI: - `--cmd-port ` - Command port (default: 9877) - `--data-port ` - Data port (default: 9879) - `--amp-id ` - Amplifier ID (default: 0) -- `--sample-rate ` - Sample rate in Hz (default: 1000). Forces amplifier to this rate if already running at a different rate. Valid rates: 250, 500, 1000 (decimated) or 500, 1000, 2000, 4000, 8000 (native). +- `--sample-rate ` - Sample rate in Hz (default: 1000). Forces amplifier to this rate if already running at a different rate. Valid rates depend on the amplifier model — see [Sample Rate Modes](#sample-rate-modes). Unsupported combinations are rejected with an error before the amplifier is touched. - `--fast-recovery` - Use native rate mode (no FPGA anti-alias filter) for lower latency. See [Sample Rate Modes](#sample-rate-modes). - `--impedance` - Enable impedance testing mode - `--native-format` - Transmit raw int32 ADC counts instead of float microvolts @@ -92,15 +92,15 @@ If the amplifier is stopped or restarted externally while the CLI is streaming: ## Sample Rate Modes -The NA400/NA410 amplifiers support two operating modes that affect anti-aliasing and latency: +The NA400/NA410/NA500 amplifiers support two operating modes that affect anti-aliasing and latency: ### Decimated Mode (Default) Uses the FPGA's digital anti-aliasing filter to downsample from the ADC's native rate. This provides: - Better frequency response (~400 Hz bandwidth at 1000 Hz sample rate) -- Higher latency due to the filter group delay (36-111 ms depending on rate), which the app compensates for automatically — see [Timestamp Compensation](#timestamp-compensation) +- On the NA400, higher latency due to the filter group delay (36-111 ms depending on rate), which the app compensates for automatically — see [Timestamp Compensation](#timestamp-compensation) -Available decimated rates: 250, 500, 1000 Hz +Available decimated rates: 250, 500, 1000 Hz (NA400/NA410); 250, 500, 1000, 2000 Hz (NA500) ### Native Mode (Fast Recovery) @@ -109,20 +109,37 @@ Bypasses the FPGA filter and samples directly at the requested rate. This provid - Reduced bandwidth (~1/4 of sample rate, e.g., 250 Hz at 1000 Hz sample rate) - Optimized for EEG-TMS and real-time BCI applications -Available native rates: 500, 1000, 2000, 4000, 8000 Hz +Available native rates: 500, 1000, 2000, 4000, 8000 Hz (NA400); 250, 500, 1000, 2000, 4000, 8000, 16000 Hz (NA500) -Use `--fast-recovery` to enable native mode for rates that support both modes (500, 1000 Hz). +Use `--fast-recovery` (or a "Low Latency" dropdown entry) to select native mode for rates where the model offers both. + +The GUI dropdown shows the union of both models' rates; the client validates the selection against the amplifier's actual model right after it identifies itself and refuses unsupported combinations with a clear error. ### Filter Delay by Sample Rate -DIN→EEG group delay of the FPGA anti-alias filter (decimated mode only), measured with `scripts/delay_capture_sweep.py`: +DIN→EEG group delay of the FPGA anti-alias filter (decimated mode only). NA400 values measured with `scripts/delay_capture_sweep.py`: + +| Amp | Mode | Sample Rate | Filter delay (ms) | (samples) | +|-----|------|-------------|-------------------|-----------| +| NA400 | Decimated | 250 Hz | 111 | ~28 | +| NA400 | Decimated | 500 Hz | 61 | ~30 | +| NA400 | Decimated | 1000 Hz | 36 | 36 | +| NA400 | Native | 500-8000 Hz | 0 | 0 | +| NA500 | any | all rates | 0 | 0 | + +The NA500 rows are zero deliberately: Net Station 6 applies no anti-alias alignment shift for the NA500 (verified against Net Station 6.0.0.60960), so this app matches that behavior. If a real NA500 filter delay is ever measured, update `resources/sampling_rates.json`. -| Mode | Sample Rate | Filter delay (ms) | (samples) | -|------|-------------|-------------------|-----------| -| Decimated | 250 Hz | 111 | ~28 | -| Decimated | 500 Hz | 61 | ~30 | -| Decimated | 1000 Hz | 36 | 36 | -| Native | 500-8000 Hz | 0 | 0 | +## Net Amps 500 (GES 500) Support + +NA500 support was verified against real hardware (firmware 0.4.9). Differences from the NA400 that this app handles automatically: + +- **Scaling**: 0.044703 µV/bit (±375 mV over 24 bits, matching the GES 500 datasheet and Net Station 6). +- **Rates**: decimated up to 2000 Hz; native from 250 Hz up to 16000 Hz. No duplicate packets are sent at low rates (the NA400 duplicates samples below 1000 Hz). +- **Initialization**: the NA500 silently ignores commands sent while it is powering up (~10 s from cold), so the app waits for the power-on notification before configuring it. +- **Data connection handling**: the NA500's AmpServer stops serving data to new listeners once any listener disconnects (until the amp restarts), so the app maintains one persistent data connection per session and never issues `cmd_StopListeningToAmp`. +- **Single client**: while Net Station (or any other client) is linked to the NA500, this app's connections are accepted but not serviced — and vice versa. Unlink Net Station before using this app; concurrent monitoring is not possible on the NA500 (unlike the NA400). +- **Recovery**: the NA500 emits no `ntn_AmpStarted` notification, so external-restart recovery uses data-port polling instead. +- **Robustness**: the NA500's embedded AmpServer (fw 0.4.9) can crash or stop responding after abrupt client disconnects; commands now time out after 10 s instead of hanging, and the GUI performs linking on a background thread so the UI never freezes. A crashed AmpServer requires power-cycling the amplifier. ## Timestamp Compensation @@ -381,7 +398,9 @@ The amplifier's internal DIN ADC samples at a fixed 1 kHz rate, regardless of th The NA400/NA410 amplifiers have a 16-bit digital I/O port with active-low inputs (internal pull-ups). When no trigger device is connected, all lines are pulled high and the DIN stream reports `0` (idle). Grounding a line activates the corresponding bit. -The `cmd_SetDigitalInOutDirection` command can configure specific bits for output if needed (consult EGI documentation). +The NA500's DIN register idles at raw `0` (the NA400 idles at raw `0xFFFF`); its input polarity has not yet been verified with a working trigger source. Its BNC "sync in" connector expects TTL-level pulses (a line-level audio output is not sufficient to drive it). + +The `cmd_SetDigitalInOutDirection` command can configure specific bits for output if needed on the NA400 (consult EGI documentation). Do **not** send it to an NA500 — firmware 0.4.9 crashed when it was issued during testing. # Acknowledgements This application was written to behave near-identically to the BCI2000 AmpServer module that was originally created by EGI. @@ -466,4 +485,4 @@ After Net Station shuts down the amplifier (via "Shutdown" command), immediately ## Sample Rate Auto-Detection -The app automatically detects the sample rate when connecting to an already-running amplifier by measuring packet timing. This detection snaps to standard rates (250, 500, or 1000 Hz). If the amplifier is idle when connecting, the app uses the sample rate configured in the UI/config file. +The app automatically detects the sample rate when connecting to an already-running amplifier by measuring packet timing. This detection snaps to standard rates (250 Hz up to 16000 Hz). If the amplifier is idle when connecting, the app uses the sample rate configured in the UI/config file. diff --git a/mock/AmpServerAPI.json b/mock/AmpServerAPI.json index 28c53c2..bfba520 100644 --- a/mock/AmpServerAPI.json +++ b/mock/AmpServerAPI.json @@ -17,7 +17,7 @@ "NA300": { "id": 0, "name": "Net Amps 300", "scaling_factor": 0.0244140625 }, "NA400": { "id": 1, "name": "Net Amps 400", "scaling_factor": 0.000155220429 }, "NA410": { "id": 2, "name": "Net Amps 410", "scaling_factor": 0.00009636188 }, - "NA500": { "id": 3, "name": "Net Amps 500", "scaling_factor": 1.0 }, + "NA500": { "id": 3, "name": "Net Amps 500", "scaling_factor": 0.04470348358154297 }, "GTEN200": { "id": 1, "name": "NA GTEN 200", "scaling_factor": 0.000155220429 } }, "net_codes": { diff --git a/mock/include/AmpServerProtocol.h b/mock/include/AmpServerProtocol.h index 94531e0..608ed80 100644 --- a/mock/include/AmpServerProtocol.h +++ b/mock/include/AmpServerProtocol.h @@ -145,6 +145,9 @@ inline float getScalingFactor(AmplifierType type) { return 0.000155220429f; case AmplifierType::NA410: return 0.00009636188f; + case AmplifierType::NA500: + // 0.75 V range / 2^24 codes in uV (Net Station 6 NA500ScaleFactor) + return 750000.0f / 16777216.0f; default: return 1.0f; } diff --git a/mock/src/CommandHandler.cpp b/mock/src/CommandHandler.cpp index 3d36158..ed66d53 100644 --- a/mock/src/CommandHandler.cpp +++ b/mock/src/CommandHandler.cpp @@ -378,8 +378,17 @@ std::string CommandHandler::cmdGetAmpDetails(int64_t ampId, int16_t channel, con << "(amp_type " << amplifierTypeName(state.amplifierType) << ") " << "(legacy_board " << (state.legacyBoard ? "true" : "false") << ") " << "(packet_format " << static_cast(state.packetFormat) << ") " - << "(system_version " << state.firmwareVersion << ") " - << "(number_of_channels " << state.channelCount << "))"; + << "(system_version " << state.firmwareVersion << ") "; + if (state.amplifierType == AmplifierType::NA500) { + // Extra tokens the real NA500 (fw 0.4.9) reports + oss << "(fpga_version 0.6.1.f0) " + << "(pcb_version 4.1.0.0) "; + } + oss << "(number_of_channels " << state.channelCount << ")"; + if (state.amplifierType == AmplifierType::NA500) { + oss << " (feature_flags 1)"; + } + oss << ")"; return responseWithData(oss.str()); } diff --git a/mock/src/MockAmplifier.cpp b/mock/src/MockAmplifier.cpp index d0db2a3..94d9dc7 100644 --- a/mock/src/MockAmplifier.cpp +++ b/mock/src/MockAmplifier.cpp @@ -117,8 +117,15 @@ bool MockAmplifier::setNativeRate(int rate) { // Real amplifier validates native rate // NA400: 500, 1000, 2000, 4000, 8000 // NA410: 20000 only + // NA500: 250, 500, 1000, 2000, 4000, 8000, 16000 (verified on hardware; + // its AmpServer answers "complete" regardless, but only these stream) if (state_.amplifierType == AmplifierType::NA410) { if (rate != 20000) return false; + } else if (state_.amplifierType == AmplifierType::NA500) { + if (rate != 250 && rate != 500 && rate != 1000 && rate != 2000 && + rate != 4000 && rate != 8000 && rate != 16000) { + return false; + } } else { if (rate != 500 && rate != 1000 && rate != 2000 && rate != 4000 && rate != 8000) { @@ -133,7 +140,7 @@ bool MockAmplifier::setNativeRate(int rate) { bool MockAmplifier::setDecimatedRate(int rate) { std::lock_guard lock(mutex_); // Real amplifier accepts any rate value without validation - // Valid rates: 250, 500, 1000 + // Valid rates: 250, 500, 1000 (NA400); NA500 additionally supports 2000 state_.decimatedRate = rate; state_.decimated = true; // decimated mode: FPGA filter introduces EEG/physio skew return true; diff --git a/resources/amplifier_scaling.json b/resources/amplifier_scaling.json index 13edb24..83f13ea 100644 --- a/resources/amplifier_scaling.json +++ b/resources/amplifier_scaling.json @@ -14,6 +14,10 @@ "microvolt_scale": 0.00009636188, "description": "Net Amps 410 series" }, + "NA500": { + "microvolt_scale": 0.04470348358154297, + "description": "Net Amps 500 series — 0.75 V total input range / 2^24 codes, in µV. Extracted 2026-08-31 from Net Station 6.0.0.60960 libEgiDataSource.dll PhysicalAmp::NA500ScaleFactor() (computes rangeVolts 0.75 / 2^24 * 1e6; a Software\\Magstim\\AmpScaleFactor registry value can override it in Net Station)." + }, "TI": { "microvolt_scale": 0.02384185791016, "description": "TI-based amplifier" diff --git a/resources/sampling_rates.json b/resources/sampling_rates.json index fe0f278..60d9340 100644 --- a/resources/sampling_rates.json +++ b/resources/sampling_rates.json @@ -57,6 +57,52 @@ "8000": 0 } }, + "NA500": { + "_note": "Rates verified against hardware on 2026-08-31 (serial 0007, system_version 0.4.9, fpga_version 0.6.1.f0). Unlike the NA400, the NA500 sends no duplicate packets at sub-1000 Hz decimated rates, and its native mode extends down to 250 Hz and up to 16000 Hz.", + "_delay_note": "All shifts 0 = no timestamp compensation in decimated mode. This matches Net Station 6.0.0.60960: libEgiDataSource.dll PhysicalAmp::calculateShift() applies anti-alias shifts only for NA400 (112/66/36 ms eeg, 80/28/3 ms pns) and NA410 (76/34/13, 64/24/3) and leaves the NA500 at zero (verified by decompilation 2026-08-31). If a real DIN->EEG delay is ever measured for NA500 decimated mode (scripts/delay_capture_sweep.py), update these values.", + "available_rates": [250, 500, 1000, 2000], + "fast_recovery_rates": [250, 500, 1000, 2000, 4000, 8000, 16000], + "source_rate": 1000, + "eeg_shift_msec": { + "250": 0, + "500": 0, + "1000": 0, + "2000": 0, + "250_fast_recovery": 0, + "500_fast_recovery": 0, + "1000_fast_recovery": 0, + "2000_fast_recovery": 0, + "4000": 0, + "8000": 0, + "16000": 0 + }, + "pns_shift_msec": { + "250": 0, + "500": 0, + "1000": 0, + "2000": 0, + "250_fast_recovery": 0, + "500_fast_recovery": 0, + "1000_fast_recovery": 0, + "2000_fast_recovery": 0, + "4000": 0, + "8000": 0, + "16000": 0 + }, + "pns_diff_msec": { + "250": 0, + "500": 0, + "1000": 0, + "2000": 0, + "250_fast_recovery": 0, + "500_fast_recovery": 0, + "1000_fast_recovery": 0, + "2000_fast_recovery": 0, + "4000": 0, + "8000": 0, + "16000": 0 + } + }, "NA410": { "available_rates": [250, 500, 1000], "fast_recovery_rates": [1000], diff --git a/src/core/include/egiamp/AmpServerConfig.h b/src/core/include/egiamp/AmpServerConfig.h index 85c15dd..b32ed01 100644 --- a/src/core/include/egiamp/AmpServerConfig.h +++ b/src/core/include/egiamp/AmpServerConfig.h @@ -42,8 +42,9 @@ struct AmpServerConfig { /// Suffix for LSL source ID to distinguish native/decimated mode and timestamp alignment. /// Ensures LSL consumers won't auto-reconnect across incompatible configurations. - std::string modeSuffix() const { - const bool native = sampleRate > 1000 || (fastRecovery && sampleRate >= 500); + /// The caller decides `native` via usesNativeRate(), which needs the amp model + /// (rate/mode capabilities differ between NA400 and NA500). + std::string modeSuffix(bool native) const { // Decimated streams are always alignment-corrected, so "_decimated" // already implies aligned — no separate suffix needed. return native ? "_native" : "_decimated"; diff --git a/src/core/include/egiamp/AmpServerProtocol.h b/src/core/include/egiamp/AmpServerProtocol.h index c26f0fa..2f3bdb1 100644 --- a/src/core/include/egiamp/AmpServerProtocol.h +++ b/src/core/include/egiamp/AmpServerProtocol.h @@ -2,6 +2,7 @@ #define EGIAMP_AMPSERVERPROTOCOL_H #include +#include namespace egiamp { @@ -136,11 +137,66 @@ inline float getScalingFactor(AmplifierType type) { return 0.00015522042f; case AmplifierType::NA410: return 0.00009636188f; + case AmplifierType::NA500: + // 0.75 V total input range over 2^24 codes (24-bit), in microvolts. + // Extracted from Net Station 6.0.0.60960's + // libEgiDataSource.dll PhysicalAmp::NA500ScaleFactor(), which + // computes exactly rangeVolts(0.75) / 2^24 * 1e6. + return 750000.0f / 16777216.0f; // 0.044703484 uV per count default: return 1.0f; } } +// Rate/mode capabilities per model, verified against hardware where possible. +// NA400 (measured): decimated 250/500/1000; native 500/1000/2000/4000/8000. +// NA500 (measured, fw 0.4.9): decimated 250/500/1000/2000; native +// 250/500/1000/2000/4000/8000/16000. Unlike the NA400, the NA500 never sends +// duplicate packets at sub-1000 Hz decimated rates. +inline int maxDecimatedRate(AmplifierType type) { + return type == AmplifierType::NA500 ? 2000 : 1000; +} + +inline int minNativeRate(AmplifierType type) { + return type == AmplifierType::NA500 ? 250 : 500; +} + +// Whether the given configuration runs the amp in native mode (no FPGA +// anti-alias filter) as opposed to decimated mode. Rates above the model's +// decimated maximum are necessarily native; otherwise fastRecovery selects +// native where the model supports it. +inline bool usesNativeRate(AmplifierType type, int sampleRate, bool fastRecovery) { + return sampleRate > maxDecimatedRate(type) || + (fastRecovery && sampleRate >= minNativeRate(type)); +} + +// Whether the model supports the (rate, fastRecovery) combination, in the +// mode usesNativeRate() would select for it. Unknown models are permissive. +inline bool supportsSampleRate(AmplifierType type, int sampleRate, bool fastRecovery) { + auto contains = [sampleRate](std::initializer_list rates) { + for (int r : rates) if (r == sampleRate) return true; + return false; + }; + const bool native = usesNativeRate(type, sampleRate, fastRecovery); + switch (type) { + case AmplifierType::NA300: + // Decimated only; no native mode exists. + return !native && contains({125, 250, 500, 1000}); + case AmplifierType::NA400: + return native ? contains({500, 1000, 2000, 4000, 8000}) + : contains({250, 500, 1000}); + case AmplifierType::NA410: + return native ? contains({20000}) + : contains({250, 500, 1000}); + case AmplifierType::NA500: + // Verified on hardware (fw 0.4.9) + return native ? contains({250, 500, 1000, 2000, 4000, 8000, 16000}) + : contains({250, 500, 1000, 2000}); + default: + return true; + } +} + // Physio16 scaling factors (from EGI documentation) // Channels 1-8 use negative scaling, channels 9-16 use positive scaling constexpr float PHYSIO_SCALING_1_8 = -0.00111758708f; // PIB channels 1-8 diff --git a/src/core/include/egiamp/EGIAmpClient.h b/src/core/include/egiamp/EGIAmpClient.h index 5aaebe4..400bbfb 100644 --- a/src/core/include/egiamp/EGIAmpClient.h +++ b/src/core/include/egiamp/EGIAmpClient.h @@ -85,6 +85,8 @@ class EGIAmpClient { void emitSensor(NetCode code) const; bool queryAmplifierDetails(); + bool waitForAmpPowerOn(int timeoutSeconds); + void ensureListening(); bool isAmplifierStreaming(); int detectSampleRate(); // Returns detected sample rate, or 0 if detection failed bool initAmplifier(); @@ -132,6 +134,10 @@ class EGIAmpClient { int detectedSampleRate_{0}; // Sample rate detected from running amp NetCode detectedNetCode_{NetCode::Unknown}; // Sensor net code detected from running amp int physioConnectionStatus_{0}; // 0=none, 1=port1(16ch), 2=port2(16ch), 3=both(32ch) + // Whether cmd_ListenToAmp has been sent on the current data connection. + // Used by the NA500 code paths, which keep one persistent listening + // connection for the whole session (see ensureListening()). + bool dataListening_{false}; std::unique_ptr readerThread_; std::unique_ptr notificationThread_; diff --git a/src/core/src/AmpServerConfig.cpp b/src/core/src/AmpServerConfig.cpp index 70f8e95..3fa30e4 100644 --- a/src/core/src/AmpServerConfig.cpp +++ b/src/core/src/AmpServerConfig.cpp @@ -7,8 +7,15 @@ namespace egiamp { std::string AmpServerConfig::streamName() const { if (serverAddress == "localhost" || serverAddress.compare(0, 4, "127.") == 0) return "EGINetAmp_mock"; - if (serverAddress.size() >= 3 && serverAddress.compare(serverAddress.size() - 3, 3, ".52") == 0) - return "EGINetAmp_52"; + // Use the last octet of the address so amps at non-default addresses + // (e.g. .62) get distinct stream names. .51/.52 keep their historic names. + const auto lastDot = serverAddress.rfind('.'); + if (lastDot != std::string::npos && lastDot + 1 < serverAddress.size()) { + const std::string octet = serverAddress.substr(lastDot + 1); + if (!octet.empty() && octet.find_first_not_of("0123456789") == std::string::npos) { + return "EGINetAmp_" + octet; + } + } return "EGINetAmp_51"; } diff --git a/src/core/src/AmpServerConnection.cpp b/src/core/src/AmpServerConnection.cpp index ad119c7..39d7f05 100644 --- a/src/core/src/AmpServerConnection.cpp +++ b/src/core/src/AmpServerConnection.cpp @@ -1,5 +1,7 @@ #include "egiamp/AmpServerConnection.h" +#include + namespace egiamp { AmpServerConnection::AmpServerConnection() = default; @@ -66,16 +68,56 @@ bool AmpServerConnection::isConnected() const { std::string AmpServerConnection::sendCommand(const std::string& command, int ampId, int channel, const std::string& value) { char response[4096]; - commandStream_ << "(sendCommand " << command << ' ' << ampId << ' ' - << channel << ' ' << value << ")\n" << std::flush; - commandStream_.getline(response, sizeof(response)); + // Build the whole command first and write it in a single insertion: the + // NA500's AmpServer parses each TCP segment as a complete message, so a + // command split across segments (which multiple << insertions can cause) + // desyncs its request/response handling. + const std::string message = "(sendCommand " + command + ' ' + std::to_string(ampId) + + ' ' + std::to_string(channel) + ' ' + value + ")\n"; + // Bound the response wait: a half-dead AmpServer (observed on the NA500 + // after firmware crashes) accepts connections but never responds, and an + // unbounded getline would block the caller forever. + try { + commandStream_.expires_after(std::chrono::seconds(10)); + commandStream_ << message << std::flush; + commandStream_.getline(response, sizeof(response)); + commandStream_.expires_after(std::chrono::hours(8760)); + } catch (...) { + commandStream_.clear(); + commandStream_.expires_after(std::chrono::hours(8760)); + throw std::runtime_error("No response from AmpServer for " + command + + " within 10 s (server unresponsive?)"); + } + + // Resynchronize: the NA500's AmpServer occasionally delivers a stale line + // (e.g. left over from a previous client session) ahead of the actual + // response, which would desync every subsequent request/response pair. + // Skip lines that are not command responses, bounded so a misbehaving + // server cannot stall us for long. + for (int skipped = 0; + skipped < 5 && std::string(response).find("sendCommand_return") == std::string::npos; + ++skipped) { + try { + commandStream_.expires_after(std::chrono::seconds(2)); + commandStream_.getline(response, sizeof(response)); + commandStream_.expires_after(std::chrono::hours(8760)); + } catch (...) { + // Timed out waiting for a proper response line — give up on the + // resync and return what we have; callers treat it as a soft error. + commandStream_.clear(); + commandStream_.expires_after(std::chrono::hours(8760)); + break; + } + } return std::string(response); } void AmpServerConnection::sendDatastreamCommand(const std::string& command, int ampId, int channel, const std::string& value) { - dataStream_ << "(sendCommand " << command << ' ' << ampId << ' ' - << channel << ' ' << value << ")\n" << std::flush; + // Single insertion for the same fragmentation reason as sendCommand(). + const std::string message = "(sendCommand " + command + ' ' + std::to_string(ampId) + + ' ' + std::to_string(channel) + ' ' + value + ")\n"; + dataStream_ << message << std::flush; } } // namespace egiamp diff --git a/src/core/src/EGIAmpClient.cpp b/src/core/src/EGIAmpClient.cpp index 1976dc0..3473650 100644 --- a/src/core/src/EGIAmpClient.cpp +++ b/src/core/src/EGIAmpClient.cpp @@ -48,6 +48,8 @@ bool EGIAmpClient::connect() { return false; } + dataListening_ = false; // Fresh data connection, not yet listening + // Create notification stream — lives as long as the connection std::string streamName = config_.streamName(); notificationStreamer_.createNotificationOutlet( @@ -119,8 +121,21 @@ bool EGIAmpClient::cmd_ImpedanceAcquisitionState() { bool EGIAmpClient::queryAmplifierDetails() { try { - std::string response = connection_.sendCommand( - "cmd_GetAmpDetails", config_.amplifierId, 0, "0"); + std::string response; + for (int attempt = 0; attempt < 3; attempt++) { + response = connection_.sendCommand( + "cmd_GetAmpDetails", config_.amplifierId, 0, "0"); + if (response.find("amp_details") != std::string::npos) { + break; + } + // Occasionally (observed on the NA500) the response comes back + // without the amp_details payload; retry rather than proceeding + // with an Unknown amplifier type, which would cascade into wrong + // packet-format and initialization decisions. + emitStatus("Amplifier details incomplete (attempt " + + std::to_string(attempt + 1) + "/3): " + response + "\n"); + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + } emitStatus("__________________________\n Amplifier Details\n__________________________\n"); @@ -144,6 +159,8 @@ bool EGIAmpClient::queryAmplifierDetails() { details_.amplifierType = AmplifierType::NA300; } else if (value.find("NA400") != std::string::npos) { details_.amplifierType = AmplifierType::NA400; + } else if (value.find("NA500") != std::string::npos) { + details_.amplifierType = AmplifierType::NA500; } } else if (key.find("legacy_board") != std::string::npos) { @@ -169,6 +186,10 @@ bool EGIAmpClient::queryAmplifierDetails() { emitStatus("__________________________\n__________________________\n"); details_.scalingFactor = getScalingFactor(details_.amplifierType); + if (details_.scalingFactor == 1.0f) { + emitStatus("Warning: no calibrated microvolt scaling factor for this " + "amplifier model; float output will be raw ADC counts.\n"); + } return true; } catch (...) { @@ -176,9 +197,57 @@ bool EGIAmpClient::queryAmplifierDetails() { } } +void EGIAmpClient::ensureListening() { + // Send cmd_ListenToAmp at most once per data connection. The NA500 keeps + // one persistent listening connection for the whole session because its + // AmpServer wedges data delivery once a listener is dropped (no new + // listener receives data until the amp is restarted) and + // cmd_StopListeningToAmp wedges the client session outright. + if (!dataListening_) { + connection_.sendDatastreamCommand("cmd_ListenToAmp", config_.amplifierId, 0, "0"); + dataListening_ = true; + } +} + bool EGIAmpClient::isAmplifierStreaming() { // Try to receive data with a short timeout to detect if amp is already running. - // After detection, we reconnect the data stream to reset the buffer position. + if (details_.amplifierType == AmplifierType::NA500) { + // NA500: probe on the persistent listening connection and leave it + // open — never StopListening or reconnect (see ensureListening()). + // The consumed packet is negligible; the reader tolerates starting + // mid-stream. + try { + ensureListening(); + auto& stream = connection_.dataStream(); + stream.clear(); + connection_.setDataStreamTimeout(std::chrono::seconds(2)); + + AmpDataPacketHeader header{}; + stream.read(reinterpret_cast(&header), sizeof(header)); + if (!stream.good()) { + stream.clear(); + return false; + } + // Consume the payload so the stream stays packet-aligned + const uint64_t length = big_to_native(header.length); + if (length == 0 || length > (10u << 20)) { + // Implausible length — treat as a corrupt/misaligned stream + stream.clear(); + return false; + } + std::vector payload(length); + stream.read(payload.data(), static_cast(length)); + const bool ok = stream.good(); + stream.clear(); + return ok; + } catch (...) { + try { connection_.dataStream().clear(); } catch (...) {} + return false; + } + } + + // NA400 and others: probe, then reconnect the data stream to reset the + // buffer position. try { connection_.sendDatastreamCommand("cmd_ListenToAmp", config_.amplifierId, 0, "0"); @@ -195,6 +264,7 @@ bool EGIAmpClient::isAmplifierStreaming() { // Stop listening and reconnect the data stream to clear any buffered data connection_.sendDatastreamCommand("cmd_StopListeningToAmp", config_.amplifierId, 0, "0"); connection_.reconnectDataStream(); + dataListening_ = false; return wasStreaming; } catch (...) { @@ -202,6 +272,7 @@ bool EGIAmpClient::isAmplifierStreaming() { try { connection_.sendDatastreamCommand("cmd_StopListeningToAmp", config_.amplifierId, 0, "0"); connection_.reconnectDataStream(); + dataListening_ = false; } catch (...) {} return false; } @@ -209,11 +280,19 @@ bool EGIAmpClient::isAmplifierStreaming() { int EGIAmpClient::detectSampleRate() { // Read packets and calculate sample rate from unique packetCounter values. - // At lower sample rates, duplicate packets are sent (same packetCounter), - // so we count unique samples, not total packets received. + // At lower sample rates the NA400 sends duplicate packets (same + // packetCounter), so we count unique samples, not total packets received. // Also detects the sensor net code from the first packet. + // NA500: measures on the persistent listening connection and leaves it + // open (see ensureListening()); others listen/stop/reconnect around the + // measurement. + const bool persistent = details_.amplifierType == AmplifierType::NA500; try { - connection_.sendDatastreamCommand("cmd_ListenToAmp", config_.amplifierId, 0, "0"); + if (persistent) { + ensureListening(); + } else { + connection_.sendDatastreamCommand("cmd_ListenToAmp", config_.amplifierId, 0, "0"); + } auto& stream = connection_.dataStream(); stream.clear(); @@ -224,9 +303,18 @@ int EGIAmpClient::detectSampleRate() { uint64_t lastPacketCounter = 0; int uniqueSampleCount = 0; bool netCodeCaptured = false; - constexpr int packetsToRead = 1000; // Read ~1 second of packets + // Cap by packet count AND measured duration: the NA400 delivers ~1000 + // packets/s at every rate (duplicates below 1000 Hz), but the NA500 + // sends no duplicates, so at 250 Hz 1000 chunked packets would take + // ~30 s. ~1.2 s of amp-timestamped data is plenty for rate detection. + constexpr int packetsToRead = 1000; + constexpr uint64_t maxSpanUs = 1200000; for (int i = 0; i < packetsToRead && stream.good(); i++) { + if (firstTimestamp != 0 && lastTimestamp > firstTimestamp && + lastTimestamp - firstTimestamp >= maxSpanUs) { + break; + } AmpDataPacketHeader header{}; stream.read(reinterpret_cast(&header), sizeof(header)); header.ampID = big_to_native(header.ampID); @@ -247,6 +335,14 @@ int EGIAmpClient::detectSampleRate() { if (packet.packetCounter != lastPacketCounter) { if (firstTimestamp == 0) { firstTimestamp = packet.timeStamp; + } else if (packet.timeStamp > lastTimestamp + 1000000 || + packet.timeStamp < lastTimestamp) { + // Timestamp discontinuity: stale pre-restart samples + // buffered on the connection followed by fresh data + // (common on the NA500's persistent connection). + // Restart the measurement from here. + firstTimestamp = packet.timeStamp; + uniqueSampleCount = 0; } lastTimestamp = packet.timeStamp; lastPacketCounter = packet.packetCounter; @@ -255,8 +351,13 @@ int EGIAmpClient::detectSampleRate() { } } - connection_.sendDatastreamCommand("cmd_StopListeningToAmp", config_.amplifierId, 0, "0"); - connection_.reconnectDataStream(); + if (!persistent) { + connection_.sendDatastreamCommand("cmd_StopListeningToAmp", config_.amplifierId, 0, "0"); + connection_.reconnectDataStream(); + dataListening_ = false; + } else { + stream.clear(); + } if (uniqueSampleCount > 1 && lastTimestamp > firstTimestamp) { // timeStamp is in microseconds @@ -264,24 +365,40 @@ int EGIAmpClient::detectSampleRate() { const double durationSec = durationUs / 1000000.0; const double rate = (uniqueSampleCount - 1) / durationSec; - // Round to nearest standard rate (250, 500, 1000) + // Snap to the nearest standard rate. Includes the high native + // rates so an externally-configured NA400 (up to 8000) or NA500 + // (up to 16000) is detected correctly. if (rate < 375) return 250; if (rate < 750) return 500; - return 1000; + if (rate < 1500) return 1000; + if (rate < 3000) return 2000; + if (rate < 6000) return 4000; + if (rate < 12000) return 8000; + return 16000; } return 0; // Detection failed } catch (...) { try { - connection_.sendDatastreamCommand("cmd_StopListeningToAmp", config_.amplifierId, 0, "0"); - connection_.reconnectDataStream(); + if (!persistent) { + connection_.sendDatastreamCommand("cmd_StopListeningToAmp", config_.amplifierId, 0, "0"); + connection_.reconnectDataStream(); + dataListening_ = false; + } else { + connection_.dataStream().clear(); + } } catch (...) {} return 0; } } bool EGIAmpClient::queryAmpState() { - // Query if amp is running and detect its sample rate and sensor + // Query if amp is running and detect its sample rate and sensor. + // Amplifier details first: the probes below behave differently per model + // (the NA500 needs the persistent-listener variants). + if (details_.amplifierType == AmplifierType::Unknown) { + queryAmplifierDetails(); + } ampWasRunning_ = isAmplifierStreaming(); if (ampWasRunning_) { @@ -296,6 +413,32 @@ bool EGIAmpClient::queryAmpState() { return ampWasRunning_; } +bool EGIAmpClient::waitForAmpPowerOn(const int timeoutSeconds) { + // Read the notification stream until ntn_AmpPowerOn arrives (requires an + // active cmd_ReceiveNotifications subscription). Safe to call from the + // main thread during startup (notification thread not yet running) and + // from the notification thread during proactive recovery (same thread + // that normally reads this stream). + try { + auto& notifStream = connection_.notificationStream(); + const auto deadline = std::chrono::steady_clock::now() + + std::chrono::seconds(timeoutSeconds); + while (std::chrono::steady_clock::now() < deadline && !stopFlag_) { + notifStream.clear(); + connection_.setNotificationStreamTimeout(std::chrono::milliseconds(500)); + char notifBuffer[4096]; + notifStream.getline(notifBuffer, sizeof(notifBuffer)); + if (!notifStream.good()) { + continue; // Timeout, retry + } + if (std::string(notifBuffer).find("ntn_AmpPowerOn") != std::string::npos) { + return true; + } + } + } catch (...) {} + return false; +} + bool EGIAmpClient::initAmplifier() { emitStatus("Initializing Amplifier...\n"); @@ -306,17 +449,34 @@ bool EGIAmpClient::initAmplifier() { connection_.sendCommand("cmd_Stop", ampId, 0, "0"); connection_.sendCommand("cmd_SetPower", ampId, 0, "0"); - // Set sample rate - // Native rates: 500, 1000, 2000, 4000, 8000 (no FPGA anti-alias filter, lower latency) - // Decimated rates: 250, 500, 1000 (FPGA anti-alias filter, higher latency) - const bool useNative = config_.sampleRate > 1000 || - (config_.fastRecovery && config_.sampleRate >= 500); + // The NA500 silently drops configuration and start commands issued while + // it is still powering up (which can take ~10 s from cold), so power it on + // first and wait for ntn_AmpPowerOn before configuring. The NA400 path is + // left untouched: it tolerates the immediate sequence, and waiting here + // would add an unverified dependency on its notification timing. + if (details_.amplifierType == AmplifierType::NA500) { + connection_.sendCommand("cmd_ReceiveNotifications", ampId, 0, "0"); + connection_.sendCommand("cmd_SetPower", ampId, 0, "1"); + emitStatus("Waiting for amplifier power-on...\n"); + if (waitForAmpPowerOn(30)) { + emitStatus("Amplifier powered on.\n"); + } else { + emitStatus("Warning: no power-on confirmation within 30 s, continuing.\n"); + } + } + + // Set sample rate. Native rates have no FPGA anti-alias filter (lower + // latency); decimated rates do. The rate ranges for each mode depend on + // the amplifier model — see usesNativeRate() in AmpServerProtocol.h. + const bool useNative = usesNativeRate(details_.amplifierType, + config_.sampleRate, config_.fastRecovery); + + if (config_.fastRecovery && !useNative) { + emitStatus("Warning: Fast recovery not available at " + + std::to_string(config_.sampleRate) + " Hz, using decimated mode.\n"); + } if (useNative) { - if (config_.fastRecovery && config_.sampleRate < 500) { - emitStatus("Warning: Fast recovery not available at " + - std::to_string(config_.sampleRate) + " Hz, using decimated mode.\n"); - } connection_.sendCommand("cmd_SetNativeRate", ampId, 0, sampleRate); emitStatus("Using native rate (fast recovery, no FPGA filter).\n"); } else { @@ -324,8 +484,10 @@ bool EGIAmpClient::initAmplifier() { emitStatus("Using decimated rate (FPGA anti-alias filter enabled).\n"); } - // Power on - connection_.sendCommand("cmd_SetPower", ampId, 0, "1"); + // Power on (NA500 was already powered on above, before configuration) + if (details_.amplifierType != AmplifierType::NA500) { + connection_.sendCommand("cmd_SetPower", ampId, 0, "1"); + } // Configure acquisition state (impedance or default) if (config_.impedance) { @@ -361,8 +523,13 @@ void EGIAmpClient::haltAmplifier() { impedanceModeActive_ = false; try { - connection_.sendDatastreamCommand("cmd_StopListeningToAmp", - config_.amplifierId, 0, "0"); + // NA500: cmd_StopListeningToAmp wedges data delivery for the whole + // client session (and possibly beyond) — never send it; disconnecting + // the socket is sufficient cleanup. + if (details_.amplifierType != AmplifierType::NA500) { + connection_.sendDatastreamCommand("cmd_StopListeningToAmp", + config_.amplifierId, 0, "0"); + } if (weInitializedAmp_) { // Only stop/power off if we initialized the amp ourselves connection_.sendCommand("cmd_Stop", config_.amplifierId, 0, "0"); @@ -389,11 +556,31 @@ bool EGIAmpClient::startStreaming() { stopFlag_ = false; + // Everything below talks to the AmpServer; sendCommand() throws when the + // server stops responding (seen on the NA500 after firmware crashes). + // Convert that into a clean failure instead of letting the exception + // escape into the caller (e.g. a Qt slot). + try { + if (!queryAmplifierDetails()) { emitError("Failed to get amplifier details"); return false; } + // Reject unsupported rate/mode combinations before touching the amp. + // The GUI offers the union of all models' rates; which are valid is only + // knowable now that the amplifier has identified itself. + if (!supportsSampleRate(details_.amplifierType, config_.sampleRate, + config_.fastRecovery)) { + const bool native = usesNativeRate(details_.amplifierType, + config_.sampleRate, config_.fastRecovery); + emitError(std::string(amplifierTypeName(details_.amplifierType)) + + " does not support " + std::to_string(config_.sampleRate) + + " Hz in " + (native ? "native (low latency)" : "decimated") + + " mode. Please select a different sample rate."); + return false; + } + // Check if amplifier is already streaming data if (bool ampRunning = isAmplifierStreaming()) { @@ -407,10 +594,12 @@ bool EGIAmpClient::startStreaming() { // Rate mismatch - must reinitialize needsReinit = true; } else if (!config_.fastRecovery && - (config_.sampleRate == 500 || config_.sampleRate == 1000)) { - // Rate matches, but 500/1000 Hz can be either native or decimated - // and the user configured decimated. Reinit to guarantee decimated - // mode so the alignment we apply is correct. + config_.sampleRate >= minNativeRate(details_.amplifierType) && + config_.sampleRate <= maxDecimatedRate(details_.amplifierType)) { + // Rate matches, but at rates where this amp supports both modes + // (NA400: 500/1000; NA500: 250-2000) the stream doesn't reveal + // native vs decimated, and the user configured decimated. Reinit + // to guarantee decimated mode so the alignment we apply is correct. needsReinit = true; emitStatus("Reinitializing to ensure decimated mode for alignment.\n"); } @@ -428,8 +617,8 @@ bool EGIAmpClient::startStreaming() { // Stop, change rate, restart (no power cycle) connection_.sendCommand("cmd_Stop", config_.amplifierId, 0, "0"); const std::string rateStr = std::to_string(config_.sampleRate); - const bool useNative = config_.sampleRate > 1000 || - (config_.fastRecovery && config_.sampleRate >= 500); + const bool useNative = usesNativeRate(details_.amplifierType, + config_.sampleRate, config_.fastRecovery); if (useNative) { connection_.sendCommand("cmd_SetNativeRate", config_.amplifierId, 0, rateStr); emitStatus("Using native rate (fast recovery, no FPGA filter).\n"); @@ -460,12 +649,15 @@ bool EGIAmpClient::startStreaming() { emitStatus("Using detected sample rate: " + std::to_string(detectedRate) + " Hz\n"); } - // At 1000 Hz, native and decimated modes are indistinguishable in the - // data stream. Warn the user but trust their configuration selection. - if (config_.sampleRate == 1000) { + // At rates where the amp supports both modes (NA400: 500/1000; + // NA500: 250-2000), native and decimated are indistinguishable in + // the data stream. Warn the user but trust their configuration. + if (config_.sampleRate >= minNativeRate(details_.amplifierType) && + config_.sampleRate <= maxDecimatedRate(details_.amplifierType)) { const bool weUseNative = config_.fastRecovery; emitStatus("Warning: Cannot verify whether the amplifier is running in native or " - "decimated mode at 1000 Hz. Assuming " + + "decimated mode at " + std::to_string(config_.sampleRate) + + " Hz. Assuming " + std::string(weUseNative ? "native" : "decimated") + " mode as configured.\n"); } @@ -476,9 +668,9 @@ bool EGIAmpClient::startStreaming() { weInitializedAmp_ = true; } - // Start listening for data - connection_.sendDatastreamCommand("cmd_ListenToAmp", - config_.amplifierId, 0, "0"); + // Start listening for data (no-op on the NA500 if the persistent + // connection is already listening from the probes above) + ensureListening(); if (details_.channelCount != 0) { emitChannelCount(details_.channelCount); @@ -553,6 +745,14 @@ bool EGIAmpClient::startStreaming() { }); return true; + + } catch (const std::exception& ex) { + emitError(std::string("Failed to start streaming: ") + ex.what()); + return false; + } catch (...) { + emitError("Failed to start streaming: unknown error"); + return false; + } } void EGIAmpClient::stopStreaming() { @@ -687,6 +887,7 @@ void EGIAmpClient::processNotifications() { auto& stream = connection_.notificationStream(); bool ampStopped = false; bool pendingRecovery = false; // Set when stop+poweroff sequence detected + int streamLostPollTicks = 0; // NA500 passive-recovery poll counter while (!stopFlag_) { // If stop+poweroff was detected and reader has noticed stream loss, @@ -698,6 +899,28 @@ void EGIAmpClient::processNotifications() { continue; } + // The NA500 emits no ntn_AmpStarted, so a restart by an external app + // (without a power cycle) produces no notification we can react to. + // While the stream is lost, poll the data port every few seconds and + // run passive recovery once data is flowing again. (The reader thread + // is blocked on recoveryCv_ whenever streamLost_ is set, so touching + // the data stream here is safe.) + if (streamLost_ && !pendingRecovery && + details_.amplifierType == AmplifierType::NA500) { + if (++streamLostPollTicks >= 5) { + streamLostPollTicks = 0; + if (isAmplifierStreaming()) { + emitStatus("Data detected on amp restart (no notification); " + "attempting passive recovery.\n"); + ampStopped = false; + attemptRecovery(false); + continue; + } + } + } else { + streamLostPollTicks = 0; + } + stream.clear(); // Reset stream state after any prior timeout connection_.setNotificationStreamTimeout(std::chrono::seconds(1)); char response[4096]; @@ -871,7 +1094,10 @@ void EGIAmpClient::readPacketFormat2() { std::string streamName = config_.streamName(); streamer_.createOutlet(streamName, eegChannelCount, physioChannelCount, 0, config_.sampleRate, config_.serverAddress, details_, - config_.nativeFormat, config_.modeSuffix()); + config_.nativeFormat, + config_.modeSuffix(usesNativeRate( + details_.amplifierType, config_.sampleRate, + config_.fastRecovery))); // Create separate DIN event stream (irregular rate, no filter delay) dinStreamer_.createDINOutlet(streamName + "_DIN", config_.serverAddress); @@ -937,14 +1163,15 @@ void EGIAmpClient::readPacketFormat2() { double durationSec = static_cast(durationUs) / 1000000.0; double measuredRate = (rateCheckSampleCount - 1) / durationSec; - // Snap to standard rates (250, 500, 1000, 2000, 4000, 8000) + // Snap to standard rates (250 ... 16000) int newRate; if (measuredRate < 375) newRate = 250; else if (measuredRate < 750) newRate = 500; else if (measuredRate < 1500) newRate = 1000; else if (measuredRate < 3000) newRate = 2000; else if (measuredRate < 6000) newRate = 4000; - else newRate = 8000; + else if (measuredRate < 12000) newRate = 8000; + else newRate = 16000; if (newRate != config_.sampleRate) { emitStatus("Sample rate changed from " + @@ -960,7 +1187,10 @@ void EGIAmpClient::readPacketFormat2() { (physioConnectionStatus_ > 0) ? 16 : 0; streamer_.createOutlet(streamName, nChannels + 1, physioChCount, 0, config_.sampleRate, config_.serverAddress, details_, - config_.nativeFormat, config_.modeSuffix()); + config_.nativeFormat, + config_.modeSuffix(usesNativeRate( + details_.amplifierType, config_.sampleRate, + config_.fastRecovery))); dinStreamer_.createDINOutlet(streamName + "_DIN", config_.serverAddress); // Apply timestamp offset + physio delay for the new rate @@ -1168,10 +1398,18 @@ bool EGIAmpClient::attemptRecovery(const bool reinitialize) { // At this point, streamLost_ is true and reader thread is blocked on CV. // Safe to touch the data stream. - // Reconnect data stream - if (!connection_.reconnectDataStream()) { - emitError("Failed to reconnect data stream during recovery."); - return false; + // Reconnect data stream. NA500: keep the persistent listening + // connection instead — data resumes on the same socket after an amp + // restart (verified on hardware), whereas a reconnected listener would + // never receive data. + if (details_.amplifierType != AmplifierType::NA500) { + if (!connection_.reconnectDataStream()) { + emitError("Failed to reconnect data stream during recovery."); + return false; + } + dataListening_ = false; + } else { + connection_.dataStream().clear(); } bool needsNewOutlet = false; @@ -1194,21 +1432,30 @@ bool EGIAmpClient::attemptRecovery(const bool reinitialize) { // Even when the rate matches, the mode (native vs decimated) might differ. // Close the outlet when we can't confirm the mode is the same. if (!needsNewOutlet && streamer_.hasOutlet()) { - if (config_.sampleRate == 1000) { - // At 1000 Hz, there is no way to distinguish native from decimated - // in the data stream. Trust user's configured mode selection. + const bool modeAmbiguous = + config_.sampleRate >= minNativeRate(details_.amplifierType) && + config_.sampleRate <= maxDecimatedRate(details_.amplifierType); + if (modeAmbiguous && config_.fastRecovery && config_.sampleRate != 1000) { + // External apps (Net Station) use decimated mode at these + // rates, but we were configured native — assume mismatch. + needsNewOutlet = true; + emitStatus("Recreating outlet: our native " + + std::to_string(config_.sampleRate) + + " Hz vs external decimated " + + std::to_string(config_.sampleRate) + " Hz.\n"); + } else if (modeAmbiguous) { + // No way to distinguish native from decimated in the data + // stream at this rate. Trust user's configured mode selection. + // (1000 Hz native is offered by Net Station as "Fast Recovery", + // so at 1000 Hz we trust the config in either mode.) emitStatus("Warning: Cannot verify whether the amplifier is running in native or " - "decimated mode at 1000 Hz. Assuming " + + "decimated mode at " + std::to_string(config_.sampleRate) + + " Hz. Assuming " + std::string(config_.fastRecovery ? "native" : "decimated") + " mode as configured.\n"); - } else if (config_.sampleRate == 500 && config_.fastRecovery) { - // Net Station always uses decimated 500 Hz, but we use native. - needsNewOutlet = true; - emitStatus("Recreating outlet: our native 500 Hz vs external decimated 500 Hz.\n"); } - // 250 Hz: only decimated mode exists — always matches - // 500 Hz decimated: Net Station also uses decimated — matches - // 2000+ Hz: only native mode exists — always matches + // Rates only one mode supports (NA400: 250, 2000+; NA500: 4000+) + // always match. } weInitializedAmp_ = false; @@ -1243,8 +1490,9 @@ bool EGIAmpClient::attemptRecovery(const bool reinitialize) { emitStatus("Reusing existing LSL outlet (settings unchanged).\n"); } - // Start listening for data on the new stream - connection_.sendDatastreamCommand("cmd_ListenToAmp", config_.amplifierId, 0, "0"); + // Start listening for data on the new stream (no-op on the NA500, + // whose persistent connection is still registered as a listener) + ensureListening(); // Signal recovery to reader thread streamLost_ = false; diff --git a/src/gui/EGIAmpWindow.cpp b/src/gui/EGIAmpWindow.cpp index dffb7a2..5233278 100644 --- a/src/gui/EGIAmpWindow.cpp +++ b/src/gui/EGIAmpWindow.cpp @@ -59,6 +59,10 @@ EGIAmpWindow::EGIAmpWindow(QWidget* parent, const std::string& configFile) Qt::QueuedConnection); connect(this, &EGIAmpWindow::disableUI, this, &EGIAmpWindow::lockUI, Qt::QueuedConnection); + connect(this, &EGIAmpWindow::linkFinished, this, &EGIAmpWindow::onLinkFinished, + Qt::QueuedConnection); + connect(this, &EGIAmpWindow::ampDetectedRunning, this, &EGIAmpWindow::onAmpDetectedRunning, + Qt::QueuedConnection); // Field enable/disable connections connect(this, &EGIAmpWindow::fieldsEnabled, ui->sampleRateComboBox, &QComboBox::setEnabled); @@ -87,6 +91,7 @@ EGIAmpWindow::EGIAmpWindow(QWidget* parent, const std::string& configFile) } EGIAmpWindow::~EGIAmpWindow() { + joinLinkThread(); if (client_->isStreaming()) { client_->stopStreaming(); } @@ -160,9 +165,17 @@ void EGIAmpWindow::closeEvent(QCloseEvent* ev) { } } +void EGIAmpWindow::joinLinkThread() { + if (linkThread_ && linkThread_->joinable()) { + linkThread_->join(); + } + linkThread_.reset(); +} + void EGIAmpWindow::linkAmpserver() { if (client_->isStreaming()) { // Unlink + joinLinkThread(); client_->stopStreaming(); client_->disconnect(); ui->statusBar->clearMessage(); @@ -177,51 +190,75 @@ void EGIAmpWindow::linkAmpserver() { auto config = getConfigFromUI(); client_->setConfig(config); - emit appendStatusMessage("Connecting to AmpServer..."); - if (!client_->connect()) { - ui->linkButton->setEnabled(true); - emit error("Could not connect to AmpServer. Please check network settings " - "and ensure AmpServer is running."); - return; - } + // Run the whole connect/query/start sequence off the UI thread: it + // blocks on the network for seconds (NA500 cold start: 15+ s waiting + // for power-on) and must not freeze the UI even if the AmpServer is + // unresponsive. All UI updates flow back through queued signals. + joinLinkThread(); + linkThread_ = std::make_unique([this]() { + emit appendStatusMessage("Connecting to AmpServer..."); + if (!client_->connect()) { + emit linkFinished(false, + "Could not connect to AmpServer. Please check network settings " + "and ensure AmpServer is running."); + return; + } - // Query amplifier state to detect if it's already running - emit appendStatusMessage("Querying amplifier state..."); - client_->queryAmpState(); - if (client_->ampWasRunning()) { - int detectedRate = client_->detectedSampleRate(); - if (detectedRate > 0) { - // Can't detect native vs decimated from the stream, default to decimated - // for ambiguous rates (500, 1000) - ui->sampleRateComboBox->setCurrentIndex( - findSampleRateIndex(detectedRate, /*native=*/false)); - emit appendStatusMessage(QString("Amplifier already running at %1 Hz").arg(detectedRate)); - } else { - emit appendStatusMessage("Amplifier already running (sample rate detection failed, using configured rate)"); + // Query amplifier state to detect if it's already running + emit appendStatusMessage("Querying amplifier state..."); + client_->queryAmpState(); + if (client_->ampWasRunning()) { + int detectedRate = client_->detectedSampleRate(); + if (detectedRate > 0) { + emit appendStatusMessage( + QString("Amplifier already running at %1 Hz").arg(detectedRate)); + } else { + emit appendStatusMessage( + "Amplifier already running (sample rate detection failed, " + "using configured rate)"); + } + emit ampDetectedRunning(detectedRate); + emit sensorLayoutUpdated( + QString::fromUtf8(egiamp::netCodeName(client_->detectedNetCode()))); } - // Update sensor layout from detected net code - egiamp::NetCode netCode = client_->detectedNetCode(); - emit sensorLayoutUpdated(QString::fromUtf8(egiamp::netCodeName(netCode))); - - // Show persistent warning in status bar for ambiguous rates where we - // cannot detect whether the FPGA anti-alias filter is active - if (detectedRate == 500 || detectedRate == 1000) { - ui->statusBar->setStyleSheet("QStatusBar { color: #b35900; }"); - ui->statusBar->showMessage( - QString("Warning: Amp started externally at %1 Hz — " - "filter mode unknown, timestamp alignment may be inaccurate") - .arg(detectedRate)); + + if (!client_->startStreaming()) { + client_->disconnect(); + emit linkFinished(false, "Failed to start streaming"); + return; } - } - if (!client_->startStreaming()) { - ui->linkButton->setEnabled(true); - emit error("Failed to start streaming"); - client_->disconnect(); - return; - } + emit linkFinished(true, QString()); + }); + } +} - emit disableUI(); +void EGIAmpWindow::onLinkFinished(bool ok, QString errorMessage) { + joinLinkThread(); + if (ok) { + lockUI(); + } else { + ui->linkButton->setEnabled(true); + displayError(errorMessage); + } +} + +void EGIAmpWindow::onAmpDetectedRunning(int detectedRate) { + if (detectedRate > 0) { + // Can't detect native vs decimated from the stream, default to decimated + // for ambiguous rates + ui->sampleRateComboBox->setCurrentIndex( + findSampleRateIndex(detectedRate, /*native=*/false)); + } + + // Show persistent warning in status bar for ambiguous rates where we + // cannot detect whether the FPGA anti-alias filter is active + if (detectedRate == 500 || detectedRate == 1000) { + ui->statusBar->setStyleSheet("QStatusBar { color: #b35900; }"); + ui->statusBar->showMessage( + QString("Warning: Amp started externally at %1 Hz — " + "filter mode unknown, timestamp alignment may be inaccurate") + .arg(detectedRate)); } } @@ -313,17 +350,26 @@ void EGIAmpWindow::toggleImpedanceMode(bool enabled) { } void EGIAmpWindow::populateSampleRateCombo() { - // Each item stores {sampleRate, isNative} as user data + // Each item stores {sampleRate, isNative} as user data. + // Union of NA400 and NA500 capabilities; the client validates the + // selection against the actual amplifier model at link time + // (supportsSampleRate in AmpServerProtocol.h) and refuses with a clear + // error if unsupported. Unqualified labels select decimated mode where + // the model offers it (NA500 at 2000 Hz; NA400 runs 2000 Hz native as + // that is its only option). struct RateEntry { const char* label; int rate; bool native; }; static constexpr RateEntry entries[] = { - {"250 Hz", 250, false}, - {"500 Hz", 500, false}, - {"500 Hz \xe2\x80\x94 Low Latency", 500, true}, - {"1000 Hz", 1000, false}, - {"1000 Hz \xe2\x80\x94 Low Latency", 1000, true}, - {"2000 Hz", 2000, true}, - {"4000 Hz", 4000, true}, - {"8000 Hz", 8000, true}, + {"250 Hz", 250, false}, + {"250 Hz \xe2\x80\x94 Low Latency (NA500 only)", 250, true}, + {"500 Hz", 500, false}, + {"500 Hz \xe2\x80\x94 Low Latency", 500, true}, + {"1000 Hz", 1000, false}, + {"1000 Hz \xe2\x80\x94 Low Latency", 1000, true}, + {"2000 Hz", 2000, false}, + {"2000 Hz \xe2\x80\x94 Low Latency", 2000, true}, + {"4000 Hz", 4000, true}, + {"8000 Hz", 8000, true}, + {"16000 Hz (NA500 only)", 16000, true}, }; ui->sampleRateComboBox->clear(); @@ -332,7 +378,18 @@ void EGIAmpWindow::populateSampleRateCombo() { QString::fromUtf8(e.label), QVariantList{e.rate, e.native}); } - ui->sampleRateComboBox->setCurrentIndex(3); // "1000 Hz" default + ui->sampleRateComboBox->setCurrentIndex(defaultSampleRateIndex()); +} + +int EGIAmpWindow::defaultSampleRateIndex() const { + // "1000 Hz" (decimated) is the default + for (int i = 0; i < ui->sampleRateComboBox->count(); ++i) { + QVariantList data = ui->sampleRateComboBox->itemData(i).toList(); + if (data[0].toInt() == 1000 && !data[1].toBool()) { + return i; + } + } + return 0; } int EGIAmpWindow::findSampleRateIndex(int rate, bool native) const { @@ -342,7 +399,7 @@ int EGIAmpWindow::findSampleRateIndex(int rate, bool native) const { return i; } } - // Rates ≥ 2000 are always native, so a non-native lookup just means + // A non-native lookup for a rate that only has native entries just means // we couldn't detect the mode — find the rate regardless of mode flag. for (int i = 0; i < ui->sampleRateComboBox->count(); ++i) { QVariantList data = ui->sampleRateComboBox->itemData(i).toList(); @@ -350,5 +407,5 @@ int EGIAmpWindow::findSampleRateIndex(int rate, bool native) const { return i; } } - return 3; // fallback to "1000 Hz" + return defaultSampleRateIndex(); // fallback to "1000 Hz" } diff --git a/src/gui/EGIAmpWindow.h b/src/gui/EGIAmpWindow.h index fb3c33d..3776957 100644 --- a/src/gui/EGIAmpWindow.h +++ b/src/gui/EGIAmpWindow.h @@ -7,6 +7,7 @@ #include #include #include +#include namespace Ui { class EGIAmpWindow; @@ -29,6 +30,8 @@ private slots: void displayError(QString description); void unlockUI(); void lockUI(); + void onLinkFinished(bool ok, QString errorMessage); + void onAmpDetectedRunning(int detectedRate); signals: void appendStatusMessage(QString message); @@ -39,6 +42,9 @@ private slots: void error(QString description); void enableUI(); void disableUI(); + // Emitted from the link worker thread (see linkAmpserver) + void linkFinished(bool ok, QString errorMessage); + void ampDetectedRunning(int detectedRate); protected: void closeEvent(QCloseEvent* ev) override; @@ -49,10 +55,17 @@ private slots: void updateUIFromConfig(); egiamp::AmpServerConfig getConfigFromUI() const; void populateSampleRateCombo(); + int defaultSampleRateIndex() const; int findSampleRateIndex(int rate, bool native) const; + void joinLinkThread(); + Ui::EGIAmpWindow* ui; std::unique_ptr client_; + // Runs the connect/query/start sequence off the UI thread — an NA500 cold + // start legitimately takes 15+ s (power-on wait), and a hung AmpServer + // must never freeze the UI. Progress arrives via queued signals. + std::unique_ptr linkThread_; }; #endif // EGIAMPWINDOW_H