From 2c3690a0ccf0c61aad8d23c148add51be105611d Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Sun, 12 Jul 2026 22:03:10 -0400 Subject: [PATCH 1/6] feat(dca): reassign console DCA membership on cue fire --- src/core/PlaybackEngine.cpp | 123 +++++++++++- src/core/PlaybackEngine.h | 6 + src/protocol/LoopbackProtocol.cpp | 46 +++++ src/protocol/LoopbackProtocol.h | 8 + src/protocol/MixerProtocol.cpp | 13 ++ src/protocol/MixerProtocol.h | 16 ++ src/protocol/behringer/WingProtocol.cpp | 50 +++++ src/protocol/behringer/WingProtocol.h | 9 + src/protocol/behringer/X32Protocol.cpp | 50 +++++ src/protocol/behringer/X32Protocol.h | 9 + tests/CMakeLists.txt | 27 +++ tests/test_playback_dca_assign.cpp | 237 ++++++++++++++++++++++++ tests/test_show_control.cpp | 31 +++- 13 files changed, 614 insertions(+), 11 deletions(-) create mode 100644 tests/test_playback_dca_assign.cpp diff --git a/src/core/PlaybackEngine.cpp b/src/core/PlaybackEngine.cpp index 4538bbe..0c9fe4d 100644 --- a/src/core/PlaybackEngine.cpp +++ b/src/core/PlaybackEngine.cpp @@ -9,6 +9,7 @@ #include "protocol/MixerProtocol.h" #include #include +#include #include #include @@ -62,7 +63,12 @@ void PlaybackEngine::onCueListReset() { stop(); } -void PlaybackEngine::setMixer(MixerProtocol* mixer) { m_mixer = mixer; } +void PlaybackEngine::setMixer(MixerProtocol* mixer) { + m_mixer = mixer; + m_appliedChDcaMasks.clear(); + m_appliedBusDcaMasks.clear(); + m_engineOwnedDcas.clear(); +} void PlaybackEngine::setDCAMapping(DCAMapping* mapping) { m_dcaMapping = mapping; } @@ -317,7 +323,7 @@ void PlaybackEngine::executeCueInternal(const Cue& cue) { // recall console snippets (partial scene recalls) recallSnippets(cue); - // apply DCA overrides (mute & label only) + applyDCAAssignments(cue, targetDCAs); applyDCAOverrides(cue, targetDCAs); setState(PlaybackState::Running); @@ -363,14 +369,13 @@ void PlaybackEngine::applyDCAOverrides(const Cue& cue, const QSet& targetDC if (override.mute.has_value()) { const bool muting = override.mute.value(); - QString path = QString("/dca/%1/mute").arg(dca); - m_mixer->sendParameter(path, muting ? 1 : 0); + m_mixer->setDcaMute(dca, muting); // optional console behaviors, only on the muting edge if (muting && m_dimDcaFaders) - m_mixer->sendParameter(QString("/dca/%1/fader").arg(dca), 0.0); + m_mixer->setDcaFader(dca, 0.0); if (muting && m_muteDcaUnassign) - m_mixer->sendParameter(QString("/dca/%1/assign").arg(dca), 0); + clearDcaFromMembers(dca); } if (override.label.has_value()) { @@ -379,10 +384,112 @@ void PlaybackEngine::applyDCAOverrides(const Cue& cue, const QSet& targetDC if (label.length() > caps.maxDCANameLength) { label = label.left(caps.maxDCANameLength); } - QString path = QString("/dca/%1/config/name").arg(dca); - m_mixer->sendParameter(path, label); + m_mixer->setDcaName(dca, label); + } + } +} + +void PlaybackEngine::applyDCAAssignments(const Cue& cue, const QSet& targetDCAs) { + if (!m_mixer || !m_mixer->isConnected()) + return; + + const bool hasMapping = cue.hasCustomDCAMapping() || m_dcaMapping; + if (!hasMapping) + return; + + const MixerCapabilities& caps = m_mixer->capabilities(); + + QSet mappedDcas; + for (int d = 1; d <= caps.dcaCount; ++d) { + if (!getChannelsForDCA(cue, d).isEmpty() || !getBusesForDCA(cue, d).isEmpty()) + mappedDcas.insert(d); + } + + QSet controlled; + quint32 controlledBits = 0; + for (int d : targetDCAs) { + if (d < 1 || d > caps.dcaCount || m_inactiveDcas.contains(d)) + continue; + if (mappedDcas.contains(d) || m_engineOwnedDcas.contains(d)) { + controlled.insert(d); + controlledBits |= (1u << (d - 1)); + } + } + if (controlled.isEmpty()) + return; + + const int channelCount = std::min(caps.inputChannels, 32); + const int busCount = std::min(caps.mixBuses, 16); + + QHash chAssign; + QHash busAssign; + for (int d : controlled) { + const quint32 bit = (1u << (d - 1)); + for (int ch : getChannelsForDCA(cue, d)) { + if (ch >= 1 && ch <= channelCount) + chAssign[ch] |= bit; } + for (int bus : getBusesForDCA(cue, d)) { + if (bus >= 1 && bus <= busCount) + busAssign[bus] |= bit; + } + } + + for (int ch = 1; ch <= channelCount; ++ch) + writeDcaMask(ch, chAssign.value(ch, 0u), controlledBits, /*isBus=*/false); + for (int bus = 1; bus <= busCount; ++bus) + writeDcaMask(bus, busAssign.value(bus, 0u), controlledBits, /*isBus=*/true); + + m_engineOwnedDcas.unite(controlled); +} + +void PlaybackEngine::writeDcaMask(int entity, quint32 assignedBits, quint32 controlledBits, + bool isBus) { + if (!m_mixer) + return; + + QHash& shadow = isBus ? m_appliedBusDcaMasks : m_appliedChDcaMasks; + + quint32 base = 0; + bool baseKnown = false; + const std::optional live = + isBus ? m_mixer->readBusDcaMask(entity) : m_mixer->readChannelDcaMask(entity); + if (live.has_value()) { + base = live.value(); + baseKnown = true; + } else if (shadow.contains(entity)) { + base = shadow.value(entity); + baseKnown = true; } + if (!baseKnown) + return; + + const quint32 newMask = (base & ~controlledBits) | assignedBits; + shadow[entity] = newMask; + if (newMask == base) + return; + + if (isBus) + m_mixer->setBusDcaMask(entity, newMask); + else + m_mixer->setChannelDcaMask(entity, newMask); +} + +void PlaybackEngine::clearDcaFromMembers(int dca) { + if (!m_mixer || !m_mixer->isConnected() || dca < 1) + return; + const MixerCapabilities& caps = m_mixer->capabilities(); + if (dca > caps.dcaCount || m_inactiveDcas.contains(dca)) + return; + + const quint32 bit = (1u << (dca - 1)); + const int channelCount = std::min(caps.inputChannels, 32); + const int busCount = std::min(caps.mixBuses, 16); + for (int ch = 1; ch <= channelCount; ++ch) + writeDcaMask(ch, 0u, bit, /*isBus=*/false); + for (int bus = 1; bus <= busCount; ++bus) + writeDcaMask(bus, 0u, bit, /*isBus=*/true); + m_engineOwnedDcas.insert(dca); } void PlaybackEngine::expandChannelProfiles(const Cue& cue) { diff --git a/src/core/PlaybackEngine.h b/src/core/PlaybackEngine.h index f5a43c0..40e8ec5 100644 --- a/src/core/PlaybackEngine.h +++ b/src/core/PlaybackEngine.h @@ -147,6 +147,9 @@ class PlaybackEngine : public QObject { // first index after 'from' whose cue is not skipped, or -1 at end of list [[nodiscard]] int nextEnabledIndex(int from) const; void executeCueInternal(const Cue& cue); + void applyDCAAssignments(const Cue& cue, const QSet& targetDCAs); + void writeDcaMask(int entity, quint32 assignedBits, quint32 controlledBits, bool isBus); + void clearDcaFromMembers(int dca); void applyDCAOverrides(const Cue& cue, const QSet& targetDCAs); void executeMacroCue(const Cue& cue); void executeGoToCue(const Cue& cue); @@ -179,6 +182,9 @@ class PlaybackEngine : public QObject { FadeEngine m_fadeEngine; QMap m_appliedChannelLevels; // last-applied fader per channel (fade source) + QHash m_appliedChDcaMasks; + QHash m_appliedBusDcaMasks; + QSet m_engineOwnedDcas; QList> m_channelGangs; // ganged input-channel pairs QHash m_gangPartners; // channel -> ganged partner (both directions) bool m_checkMode = false; // soundcheck: GO holds on current cue diff --git a/src/protocol/LoopbackProtocol.cpp b/src/protocol/LoopbackProtocol.cpp index 2bd859b..ed01543 100644 --- a/src/protocol/LoopbackProtocol.cpp +++ b/src/protocol/LoopbackProtocol.cpp @@ -222,4 +222,50 @@ void LoopbackProtocol::setChannelDynamics(int channel, bool on, double threshold .arg(makeupDb)); } +namespace { +QString loopChannelDca(int channel) { + return QString("/ch/%1/grp/dca").arg(channel, 2, 10, QChar('0')); +} +QString loopBusDca(int bus) { return QString("/bus/%1/grp/dca").arg(bus, 2, 10, QChar('0')); } +} // namespace + +void LoopbackProtocol::setDcaMute(int dca, bool muted) { + m_parameterState[QString("/dca/%1/mute").arg(dca)] = muted ? 1 : 0; + m_recordedCalls.append(QString("dcamute:dca=%1:muted=%2").arg(dca).arg(muted ? 1 : 0)); +} + +void LoopbackProtocol::setDcaFader(int dca, double level) { + m_parameterState[QString("/dca/%1/fader").arg(dca)] = level; + m_recordedCalls.append(QString("dcafader:dca=%1:level=%2").arg(dca).arg(level)); +} + +void LoopbackProtocol::setDcaName(int dca, const QString& name) { + m_parameterState[QString("/dca/%1/config/name").arg(dca)] = name; + m_recordedCalls.append(QString("dcaname:dca=%1:name=%2").arg(dca).arg(name)); +} + +void LoopbackProtocol::setChannelDcaMask(int channel, quint32 mask) { + m_parameterState[loopChannelDca(channel)] = static_cast(mask); + m_recordedCalls.append(QString("dcamask:ch=%1:mask=%2").arg(channel).arg(mask)); +} + +void LoopbackProtocol::setBusDcaMask(int bus, quint32 mask) { + m_parameterState[loopBusDca(bus)] = static_cast(mask); + m_recordedCalls.append(QString("dcamask:bus=%1:mask=%2").arg(bus).arg(mask)); +} + +std::optional LoopbackProtocol::readChannelDcaMask(int channel) { + const QString path = loopChannelDca(channel); + if (!m_parameterState.contains(path)) + return std::nullopt; + return static_cast(m_parameterState.value(path).toInt()); +} + +std::optional LoopbackProtocol::readBusDcaMask(int bus) { + const QString path = loopBusDca(bus); + if (!m_parameterState.contains(path)) + return std::nullopt; + return static_cast(m_parameterState.value(path).toInt()); +} + } // namespace OpenMix diff --git a/src/protocol/LoopbackProtocol.h b/src/protocol/LoopbackProtocol.h index e5d869b..c4719db 100644 --- a/src/protocol/LoopbackProtocol.h +++ b/src/protocol/LoopbackProtocol.h @@ -53,6 +53,14 @@ class LoopbackProtocol : public MixerProtocol { void setChannelDynamics(int channel, bool on, double thresholdDb, double ratio, double attackMs, double releaseMs, double makeupDb) override; + void setDcaMute(int dca, bool muted) override; + void setDcaFader(int dca, double level) override; + void setDcaName(int dca, const QString& name) override; + void setChannelDcaMask(int channel, quint32 mask) override; + void setBusDcaMask(int bus, quint32 mask) override; + [[nodiscard]] std::optional readChannelDcaMask(int channel) override; + [[nodiscard]] std::optional readBusDcaMask(int bus) override; + [[nodiscard]] QStringList recordedCalls() const { return m_recordedCalls; } void clearRecordedCalls() { m_recordedCalls.clear(); } diff --git a/src/protocol/MixerProtocol.cpp b/src/protocol/MixerProtocol.cpp index 8c631c5..e4af8f3 100644 --- a/src/protocol/MixerProtocol.cpp +++ b/src/protocol/MixerProtocol.cpp @@ -20,4 +20,17 @@ void MixerProtocol::setChannelDynamics(int, bool, double, double, double, double void MixerProtocol::setChannelName(int, const QString&) {} void MixerProtocol::setChannelColor(int, int) {} +void MixerProtocol::setDcaMute(int dca, bool muted) { + sendParameter(QStringLiteral("/dca/%1/mute").arg(dca), muted ? 1 : 0); +} +void MixerProtocol::setDcaFader(int dca, double level) { + sendParameter(QStringLiteral("/dca/%1/fader").arg(dca), level); +} +void MixerProtocol::setDcaName(int dca, const QString& name) { + sendParameter(QStringLiteral("/dca/%1/config/name").arg(dca), name); +} + +void MixerProtocol::setChannelDcaMask(int, quint32) {} +void MixerProtocol::setBusDcaMask(int, quint32) {} + } // namespace OpenMix diff --git a/src/protocol/MixerProtocol.h b/src/protocol/MixerProtocol.h index 84711a9..7e8f307 100644 --- a/src/protocol/MixerProtocol.h +++ b/src/protocol/MixerProtocol.h @@ -64,6 +64,22 @@ class MixerProtocol : public QObject { virtual void setChannelName(int channel, const QString& name); virtual void setChannelColor(int channel, int color); + // Defaults send generic /dca paths the A&H MIDI driver parses into NRPN; OSC + // drivers override (e.g. X32 mute is /dca/N/on, not /dca/N/mute). + virtual void setDcaMute(int dca, bool muted); + virtual void setDcaFader(int dca, double level); + virtual void setDcaName(int dca, const QString& name); + + // DCA-group membership bitmask, bit d-1 = member of DCA d. Default no-op / nullopt. + virtual void setChannelDcaMask(int channel, quint32 mask); + virtual void setBusDcaMask(int bus, quint32 mask); + [[nodiscard]] virtual std::optional readChannelDcaMask(int /*channel*/) { + return std::nullopt; + } + [[nodiscard]] virtual std::optional readBusDcaMask(int /*bus*/) { + return std::nullopt; + } + // read back a channel's current fader (normalized 0..1) from the driver's // parameter cache, or nullopt if unsupported / not yet known. Used to capture // live console levels into a cue. channel is 1-based. diff --git a/src/protocol/behringer/WingProtocol.cpp b/src/protocol/behringer/WingProtocol.cpp index 6a6a339..39ca5dc 100644 --- a/src/protocol/behringer/WingProtocol.cpp +++ b/src/protocol/behringer/WingProtocol.cpp @@ -46,6 +46,7 @@ void WingProtocol::initializeSnapshotParams() { QString chPrefix = QString("/ch/%1").arg(i); m_snapshotParams.append(chPrefix + "/fader"); m_snapshotParams.append(chPrefix + "/mute"); + m_snapshotParams.append(chPrefix + "/grp/dca"); // EQ params if (m_capabilities.supportsChannelEQ) { @@ -75,6 +76,7 @@ void WingProtocol::initializeSnapshotParams() { QString busPrefix = QString("/bus/%1").arg(i); m_snapshotParams.append(busPrefix + "/fader"); m_snapshotParams.append(busPrefix + "/mute"); + m_snapshotParams.append(busPrefix + "/grp/dca"); // bus EQ params if (m_capabilities.supportsChannelEQ) { @@ -232,6 +234,8 @@ void WingProtocol::recallSnippet(int snippetNumber) { namespace { QString wingChannel(int channel) { return QString("/ch/%1").arg(channel); } +QString wingBus(int bus) { return QString("/bus/%1").arg(bus); } + // WING faders carry real-world dB; map a normalized 0..1 level onto the exact // X32/WING fader law (piecewise-linear in dB, per the Maillot/WING references): // 0.0000-0.0625 -> -inf..-60, 0.0625-0.25 -> -60..-30, @@ -312,6 +316,41 @@ void WingProtocol::setChannelColor(int channel, int color) { sendParameter(wingChannel(channel) + "/col", color); } +void WingProtocol::setDcaMute(int dca, bool muted) { + // WING /dca/N/mute: 1 = muted (opposite of X32's /dca/N/on) + sendParameter(QString("/dca/%1/mute").arg(dca), muted ? 1 : 0); +} + +void WingProtocol::setDcaFader(int dca, double level) { + sendParameter(QString("/dca/%1/fdr").arg(dca), wingFaderDb(level)); +} + +void WingProtocol::setDcaName(int dca, const QString& name) { + sendParameter(QString("/dca/%1/name").arg(dca), name); +} + +void WingProtocol::setChannelDcaMask(int channel, quint32 mask) { + sendParameter(wingChannel(channel) + "/grp/dca", static_cast(mask)); +} + +void WingProtocol::setBusDcaMask(int bus, quint32 mask) { + sendParameter(wingBus(bus) + "/grp/dca", static_cast(mask)); +} + +std::optional WingProtocol::readChannelDcaMask(int channel) { + const QVariant value = getParameter(wingChannel(channel) + "/grp/dca"); + if (!value.isValid()) + return std::nullopt; + return static_cast(value.toInt()); +} + +std::optional WingProtocol::readBusDcaMask(int bus) { + const QVariant value = getParameter(wingBus(bus) + "/grp/dca"); + if (!value.isValid()) + return std::nullopt; + return static_cast(value.toInt()); +} + void WingProtocol::refresh() { if (m_connectionState != ConnectionState::Connected) return; @@ -477,10 +516,21 @@ void WingProtocol::handleInfoResponse([[maybe_unused]] const QVariant& value) { // subscribe to updates m_transport.send("/$xremote"); + requestDcaMembership(); + emit connected(); } } +void WingProtocol::requestDcaMembership() { + if (m_connectionState != ConnectionState::Connected) + return; + for (int i = 1; i <= m_capabilities.inputChannels && i <= 48; ++i) + requestParameter(wingChannel(i) + "/grp/dca"); + for (int i = 1; i <= m_capabilities.mixBuses && i <= 16; ++i) + requestParameter(wingBus(i) + "/grp/dca"); +} + void WingProtocol::startReconnection() { m_keepAliveTimer.stop(); m_connectionTimer.stop(); diff --git a/src/protocol/behringer/WingProtocol.h b/src/protocol/behringer/WingProtocol.h index e549d3b..e86d015 100644 --- a/src/protocol/behringer/WingProtocol.h +++ b/src/protocol/behringer/WingProtocol.h @@ -70,6 +70,14 @@ class WingProtocol : public MixerProtocol { void setChannelName(int channel, const QString& name) override; void setChannelColor(int channel, int color) override; + void setDcaMute(int dca, bool muted) override; + void setDcaFader(int dca, double level) override; + void setDcaName(int dca, const QString& name) override; + void setChannelDcaMask(int channel, quint32 mask) override; + void setBusDcaMask(int bus, quint32 mask) override; + [[nodiscard]] std::optional readChannelDcaMask(int channel) override; + [[nodiscard]] std::optional readBusDcaMask(int bus) override; + // keep-alive void refresh() override; @@ -92,6 +100,7 @@ class WingProtocol : public MixerProtocol { private: void initializeSnapshotParams(); + void requestDcaMembership(); void setStatus(const QString& status); void setConnectionState(ConnectionState state); void handleInfoResponse(const QVariant& value); diff --git a/src/protocol/behringer/X32Protocol.cpp b/src/protocol/behringer/X32Protocol.cpp index bcbbc4a..b5c2c98 100644 --- a/src/protocol/behringer/X32Protocol.cpp +++ b/src/protocol/behringer/X32Protocol.cpp @@ -20,6 +20,8 @@ constexpr int MAX_X32_MIX_BUSES = 16; // exact lookup tables can refine the constants without changing call sites. QString x32Channel(int channel) { return QString("/ch/%1").arg(channel, 2, 10, QChar('0')); } +QString x32Bus(int bus) { return QString("/bus/%1").arg(bus, 2, 10, QChar('0')); } + float clampUnit(double v) { return static_cast(std::clamp(v, 0.0, 1.0)); } float linNorm(double v, double lo, double hi) { return clampUnit((v - lo) / (hi - lo)); } @@ -89,6 +91,7 @@ void X32Protocol::rebuildSnapshotParams() { QString chPrefix = QString("/ch/%1").arg(i, 2, 10, QChar('0')); m_snapshotParams.append(chPrefix + "/mix/fader"); m_snapshotParams.append(chPrefix + "/mix/on"); + m_snapshotParams.append(chPrefix + "/grp/dca"); // EQ parameters if (m_capabilities.supportsChannelEQ) { @@ -119,6 +122,7 @@ void X32Protocol::rebuildSnapshotParams() { QString busPrefix = QString("/bus/%1").arg(i, 2, 10, QChar('0')); m_snapshotParams.append(busPrefix + "/mix/fader"); m_snapshotParams.append(busPrefix + "/mix/on"); + m_snapshotParams.append(busPrefix + "/grp/dca"); // bus EQ parameters if (m_capabilities.supportsChannelEQ) { @@ -334,6 +338,42 @@ void X32Protocol::setChannelColor(int channel, int color) { sendParameter(x32Channel(channel) + "/config/color", color); } +void X32Protocol::setDcaMute(int dca, bool muted) { + // X32 has no /dca/N/mute; use /dca/N/on (1 = unmuted, 0 = muted) + sendParameter(QString("/dca/%1/on").arg(dca), muted ? 0 : 1); +} + +void X32Protocol::setDcaFader(int dca, double level) { + sendParameter(QString("/dca/%1/fader").arg(dca), clampUnit(level)); +} + +void X32Protocol::setDcaName(int dca, const QString& name) { + sendParameter(QString("/dca/%1/config/name").arg(dca), name); +} + +void X32Protocol::setChannelDcaMask(int channel, quint32 mask) { + // /ch/NN/grp/dca: int bitmask, bit d-1 = member of DCA d + sendParameter(x32Channel(channel) + "/grp/dca", static_cast(mask)); +} + +void X32Protocol::setBusDcaMask(int bus, quint32 mask) { + sendParameter(x32Bus(bus) + "/grp/dca", static_cast(mask)); +} + +std::optional X32Protocol::readChannelDcaMask(int channel) { + const QVariant value = getParameter(x32Channel(channel) + "/grp/dca"); + if (!value.isValid()) + return std::nullopt; + return static_cast(value.toInt()); +} + +std::optional X32Protocol::readBusDcaMask(int bus) { + const QVariant value = getParameter(x32Bus(bus) + "/grp/dca"); + if (!value.isValid()) + return std::nullopt; + return static_cast(value.toInt()); +} + void X32Protocol::refresh() { if (m_connectionState != ConnectionState::Connected) return; @@ -549,10 +589,20 @@ void X32Protocol::handleXinfoResponse([[maybe_unused]] const QVariant& value) { m_transport.send("/xremote"); // subscribe to input-channel meters (bank 1); renewed on keep-alive m_transport.send(QString("/meters"), QString("/meters/1")); + requestDcaMembership(); emit connected(); } } +void X32Protocol::requestDcaMembership() { + if (m_connectionState != ConnectionState::Connected) + return; + for (int i = 1; i <= m_capabilities.inputChannels && i <= MAX_X32_INPUT_CHANNELS; ++i) + requestParameter(x32Channel(i) + "/grp/dca"); + for (int i = 1; i <= m_capabilities.mixBuses && i <= MAX_X32_MIX_BUSES; ++i) + requestParameter(x32Bus(i) + "/grp/dca"); +} + void X32Protocol::startReconnection() { m_keepAliveTimer.stop(); m_connectionTimer.stop(); diff --git a/src/protocol/behringer/X32Protocol.h b/src/protocol/behringer/X32Protocol.h index d388746..46433e2 100644 --- a/src/protocol/behringer/X32Protocol.h +++ b/src/protocol/behringer/X32Protocol.h @@ -69,6 +69,14 @@ class X32Protocol : public MixerProtocol { void setChannelName(int channel, const QString& name) override; void setChannelColor(int channel, int color) override; + void setDcaMute(int dca, bool muted) override; + void setDcaFader(int dca, double level) override; + void setDcaName(int dca, const QString& name) override; + void setChannelDcaMask(int channel, quint32 mask) override; + void setBusDcaMask(int bus, quint32 mask) override; + [[nodiscard]] std::optional readChannelDcaMask(int channel) override; + [[nodiscard]] std::optional readBusDcaMask(int bus) override; + // keep-alive void refresh() override; void requestConsoleNames(int count) override; @@ -102,6 +110,7 @@ class X32Protocol : public MixerProtocol { private: void initializeSnapshotParams(); void rebuildSnapshotParams(); + void requestDcaMembership(); void setStatus(const QString& status); void setConnectionState(ConnectionState state); void handleXinfoResponse(const QVariant& value); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 60c4717..2415b1b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -105,6 +105,33 @@ target_include_directories(test_playback_profiles PRIVATE ) add_test(NAME PlaybackProfilesTest COMMAND test_playback_profiles) +add_executable(test_playback_dca_assign + test_playback_dca_assign.cpp + ${CMAKE_SOURCE_DIR}/src/core/PlaybackEngine.cpp + ${CMAKE_SOURCE_DIR}/src/core/Cue.cpp + ${CMAKE_SOURCE_DIR}/src/core/CueList.cpp + ${CMAKE_SOURCE_DIR}/src/core/DCAMapping.cpp + ${CMAKE_SOURCE_DIR}/src/core/CueValidator.cpp + ${CMAKE_SOURCE_DIR}/src/core/PlaybackGuard.cpp + ${CMAKE_SOURCE_DIR}/src/core/PlaybackLogger.cpp + ${CMAKE_SOURCE_DIR}/src/core/ActorProfile.cpp + ${CMAKE_SOURCE_DIR}/src/core/Actor.cpp + ${CMAKE_SOURCE_DIR}/src/core/ActorProfileLibrary.cpp + ${CMAKE_SOURCE_DIR}/src/core/FadeEngine.cpp + ${CMAKE_SOURCE_DIR}/src/core/Position.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/LoopbackProtocol.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/MixerProtocol.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/MixerCapabilities.cpp +) +target_link_libraries(test_playback_dca_assign PRIVATE + Qt6::Core + Qt6::Test +) +target_include_directories(test_playback_dca_assign PRIVATE + ${CMAKE_SOURCE_DIR}/src +) +add_test(NAME PlaybackDcaAssignTest COMMAND test_playback_dca_assign) + add_executable(test_fade_engine test_fade_engine.cpp ${CMAKE_SOURCE_DIR}/src/core/FadeEngine.cpp diff --git a/tests/test_playback_dca_assign.cpp b/tests/test_playback_dca_assign.cpp new file mode 100644 index 0000000..c502c92 --- /dev/null +++ b/tests/test_playback_dca_assign.cpp @@ -0,0 +1,237 @@ +#include "core/Cue.h" +#include "core/CueList.h" +#include "core/DCAMapping.h" +#include "core/PlaybackEngine.h" +#include "protocol/LoopbackProtocol.h" +#include "protocol/MixerCapabilities.h" +#include "protocol/MixerProtocol.h" +#include + +using namespace OpenMix; + +namespace { +constexpr quint32 dcaBit(int d) { return 1u << (d - 1); } + +LoopbackProtocol* makeConnectedLoopback(QObject* parent) { + auto* mixer = new LoopbackProtocol(MixerCapabilities::forConsole(ConsoleType::X32), parent); + [[maybe_unused]] const bool ok = mixer->connect("loopback", 0); + Q_ASSERT(ok); + return mixer; +} + +// overrides no DCA setters, so it exercises MixerProtocol's generic-path defaults +class RecordingProtocol : public MixerProtocol { + public: + [[nodiscard]] QString protocolName() const override { return "recording"; } + [[nodiscard]] QString protocolDescription() const override { return "records sends"; } + [[nodiscard]] bool connect(const QString&, int) override { return true; } + void disconnect() override {} + [[nodiscard]] bool isConnected() const override { return true; } + [[nodiscard]] QString connectionStatus() const override { return "connected"; } + [[nodiscard]] ConnectionState connectionState() const override { + return ConnectionState::Connected; + } + void sendParameter(const QString& path, const QVariant& value) override { + sends.append(qMakePair(path, value)); + } + [[nodiscard]] QVariant getParameter(const QString&) override { return {}; } + void requestParameter(const QString&) override {} + void requestParameterAsync(const QString&, ParameterCallback) override {} + void recallSnapshot(const Cue&) override {} + void recallScene(int) override {} + void refresh() override {} + [[nodiscard]] int latencyMs() const override { return 0; } + + QList> sends; +}; +} // namespace + +class TestPlaybackDcaAssign : public QObject { + Q_OBJECT + + private: + static quint32 chMask(LoopbackProtocol* m, int ch) { + auto v = m->readChannelDcaMask(ch); + return v.value_or(0u); + } + static quint32 busMask(LoopbackProtocol* m, int bus) { + auto v = m->readBusDcaMask(bus); + return v.value_or(0u); + } + + private slots: + void showMapping_addsRemovesPreserves() { + DCAMapping mapping; + mapping.assignChannelToDCA(1, 3); // (channel, dca): ch1 -> DCA3 + mapping.assignChannelToDCA(2, 3); + mapping.assignBusToDCA(1, 3); + + CueList cues; + Cue cue(1.0, "A"); + cue.setType(CueType::Snapshot); + cues.addCue(cue); + + auto* mixer = makeConnectedLoopback(this); + PlaybackEngine engine; + engine.setCueList(&cues); + engine.setDCAMapping(&mapping); + engine.setMixer(mixer); + + // loopback seeds ch1/ch2 in DCA1, ch9 in DCA3, bus5 in DCA3 + QCOMPARE(chMask(mixer, 1), dcaBit(1)); + QCOMPARE(chMask(mixer, 9), dcaBit(3)); + QCOMPARE(busMask(mixer, 5), dcaBit(3)); + + engine.executeCue(0); + + QCOMPARE(chMask(mixer, 1), dcaBit(1) | dcaBit(3)); + QCOMPARE(chMask(mixer, 2), dcaBit(1) | dcaBit(3)); + QCOMPARE(chMask(mixer, 9), 0u); + QCOMPARE(busMask(mixer, 1), dcaBit(1) | dcaBit(3)); + QCOMPARE(busMask(mixer, 5), 0u); + } + + void customMapping_overridesThenShowRestores() { + DCAMapping mapping; + mapping.assignChannelToDCA(5, 1); // show: DCA1 -> ch5 + + CueList cues; + Cue custom(1.0, "custom"); + custom.setType(CueType::Snapshot); + custom.setDCAChannelMapping({{1, {6}}}); // cue: DCA1 -> ch6 + cues.addCue(custom); + Cue plain(2.0, "plain"); + plain.setType(CueType::Snapshot); + cues.addCue(plain); + + auto* mixer = makeConnectedLoopback(this); + PlaybackEngine engine; + engine.setCueList(&cues); + engine.setDCAMapping(&mapping); + engine.setMixer(mixer); + + engine.executeCue(0); // custom cue + QVERIFY((chMask(mixer, 6) & dcaBit(1)) != 0); + QCOMPARE(chMask(mixer, 5) & dcaBit(1), 0u); + + engine.executeCue(1); // plain cue restores show mapping + QVERIFY((chMask(mixer, 5) & dcaBit(1)) != 0); + QCOMPARE(chMask(mixer, 6) & dcaBit(1), 0u); + } + + void inactiveDca_membershipPreserved() { + DCAMapping mapping; + mapping.assignChannelToDCA(7, 2); // DCA2 (marked inactive below) -> ch7 + mapping.assignChannelToDCA(8, 3); // DCA3 -> ch8 + + CueList cues; + Cue cue(1.0, "A"); + cue.setType(CueType::Snapshot); + cues.addCue(cue); + + auto* mixer = makeConnectedLoopback(this); + PlaybackEngine engine; + engine.setCueList(&cues); + engine.setDCAMapping(&mapping); + engine.setInactiveDcas({2}); + engine.setMixer(mixer); + + QCOMPARE(chMask(mixer, 7), dcaBit(2)); + QCOMPARE(chMask(mixer, 8), dcaBit(2)); + + engine.executeCue(0); + + QCOMPARE(chMask(mixer, 7), dcaBit(2)); + QCOMPARE(chMask(mixer, 8), dcaBit(2) | dcaBit(3)); + } + + void targetedSubset_leavesOtherDcasAlone() { + DCAMapping mapping; + mapping.assignChannelToDCA(3, 1); // DCA1 -> ch3 + mapping.assignChannelToDCA(3, 4); // DCA4 -> ch3 + + CueList cues; + Cue cue(1.0, "A"); + cue.setType(CueType::Snapshot); + cue.setTargetedDCAs({4}); + cues.addCue(cue); + + auto* mixer = makeConnectedLoopback(this); + PlaybackEngine engine; + engine.setCueList(&cues); + engine.setDCAMapping(&mapping); + engine.setMixer(mixer); + + QCOMPARE(chMask(mixer, 3), dcaBit(1)); + + engine.executeCue(0); + + QCOMPARE(chMask(mixer, 3), dcaBit(1) | dcaBit(4)); + } + + void muteUnassign_clearsMembers() { + DCAMapping mapping; + mapping.assignChannelToDCA(8, 5); // DCA5 -> ch8 + + CueList cues; + Cue cue(1.0, "A"); + cue.setType(CueType::Snapshot); + DCAOverride ov; + ov.mute = true; + cue.setDCAOverride(5, ov); + cues.addCue(cue); + + auto* mixer = makeConnectedLoopback(this); + PlaybackEngine engine; + engine.setCueList(&cues); + engine.setDCAMapping(&mapping); + engine.setMuteDcaUnassign(true); + engine.setMixer(mixer); + mixer->clearRecordedCalls(); + + engine.executeCue(0); + + QCOMPARE(chMask(mixer, 8) & dcaBit(5), 0u); + QVERIFY((chMask(mixer, 8) & dcaBit(2)) != 0); + QVERIFY(mixer->recordedCalls().contains("dcamute:dca=5:muted=1")); + } + + void noMapping_writesNothing() { + CueList cues; + Cue cue(1.0, "A"); + cue.setType(CueType::Snapshot); + cues.addCue(cue); + + auto* mixer = makeConnectedLoopback(this); + PlaybackEngine engine; + engine.setCueList(&cues); + engine.setMixer(mixer); + mixer->clearRecordedCalls(); + + engine.executeCue(0); + + for (const QString& call : mixer->recordedCalls()) + QVERIFY2(!call.startsWith("dcamask:"), qPrintable(call)); + } + + void baseDefaults_useGenericPaths() { + RecordingProtocol proto; + proto.setDcaMute(3, true); + proto.setDcaFader(2, 0.5); + proto.setDcaName(1, "Band"); + proto.setChannelDcaMask(5, 7); // default no-op + + QCOMPARE(proto.sends.size(), 3); + QCOMPARE(proto.sends[0].first, QString("/dca/3/mute")); + QCOMPARE(proto.sends[0].second.toInt(), 1); + QCOMPARE(proto.sends[1].first, QString("/dca/2/fader")); + QCOMPARE(proto.sends[2].first, QString("/dca/1/config/name")); + QCOMPARE(proto.sends[2].second.toString(), QString("Band")); + + QVERIFY(!proto.readChannelDcaMask(5).has_value()); + QVERIFY(!proto.readBusDcaMask(5).has_value()); + } +}; + +QTEST_MAIN(TestPlaybackDcaAssign) +#include "test_playback_dca_assign.moc" diff --git a/tests/test_show_control.cpp b/tests/test_show_control.cpp index 3a5e4c4..8e90a7a 100644 --- a/tests/test_show_control.cpp +++ b/tests/test_show_control.cpp @@ -3,8 +3,10 @@ #include "core/PlaybackEngine.h" #include "core/Show.h" #include "protocol/MixerProtocol.h" +#include #include #include +#include using namespace OpenMix; @@ -52,10 +54,31 @@ class RecordingMixer : public MixerProtocol { calls << QString("mute:ch=%1:%2").arg(channel).arg(muted ? 1 : 0); } + void setChannelDcaMask(int channel, quint32 mask) override { + chMasks[channel] = mask; + calls << QString("dcamask:ch=%1:mask=%2").arg(channel).arg(mask); + } + void setBusDcaMask(int bus, quint32 mask) override { + busMasks[bus] = mask; + calls << QString("dcamask:bus=%1:mask=%2").arg(bus).arg(mask); + } + std::optional readChannelDcaMask(int channel) override { + if (chMasks.contains(channel)) + return chMasks.value(channel); + return std::nullopt; + } + std::optional readBusDcaMask(int bus) override { + if (busMasks.contains(bus)) + return busMasks.value(bus); + return std::nullopt; + } + void refresh() override {} int latencyMs() const override { return 0; } QStringList calls; + QHash chMasks; + QHash busMasks; private: bool m_connected = false; @@ -261,7 +284,7 @@ class TestShowControl : public QObject { // --- DCA console-behavior toggles on fire --- - void fire_muteDcaUnassign_sendsUnassign() { + void fire_muteDcaUnassign_clearsMembership() { CueList cues; Cue cue(1.0, "A"); cue.setType(CueType::Snapshot); @@ -272,6 +295,7 @@ class TestShowControl : public QObject { cues.addCue(cue); RecordingMixer* mixer = makeConnectedMixer(this); + mixer->chMasks[5] = 0x2; // ch5 rides DCA2 (bit 1) PlaybackEngine engine; engine.setCueList(&cues); engine.setMixer(mixer); @@ -279,7 +303,7 @@ class TestShowControl : public QObject { engine.executeCue(0); QVERIFY2(mixer->calls.contains("send:/dca/2/mute=1"), qPrintable(mixer->calls.join(" | "))); - QVERIFY(mixer->calls.contains("send:/dca/2/assign=0")); + QVERIFY(mixer->calls.contains("dcamask:ch=5:mask=0")); } void fire_dimDcaFaders_sendsFaderDim() { @@ -313,6 +337,7 @@ class TestShowControl : public QObject { cues.addCue(cue); RecordingMixer* mixer = makeConnectedMixer(this); + mixer->chMasks[5] = 0x2; // ch5 rides DCA2 PlaybackEngine engine; engine.setCueList(&cues); engine.setMixer(mixer); @@ -320,7 +345,7 @@ class TestShowControl : public QObject { // only the plain mute is sent; no unassign/dim side effects QVERIFY(mixer->calls.contains("send:/dca/2/mute=1")); - QVERIFY(!mixer->calls.contains("send:/dca/2/assign=0")); + QVERIFY(!mixer->calls.contains("dcamask:ch=5:mask=0")); QVERIFY(!mixer->calls.contains("send:/dca/2/fader=0")); } From 0863c4f2570831f444fd4e04db949562a14d5020 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Mon, 13 Jul 2026 21:18:41 -0400 Subject: [PATCH 2/6] feat(protocol): add Allen & Heath Qu-16/24/32 console support Qu shares the SQ MIDI-over-TCP control path (same A&H 'Soft' family, port 51325); all Qu models run the same 32-channel DSP and differ only in fader count. Adds ConsoleType::Qu16/24/32, capabilities, QuProtocol (reusing the SQ MIDI base), ProtocolFactory wiring, protocolId lookups, and allSupported so the console picker lists them. Verified against the official A&H SQ MIDI Protocol. --- CMakeLists.txt | 2 ++ src/protocol/MixerCapabilities.cpp | 47 +++++++++++++++++++++++++- src/protocol/MixerCapabilities.h | 3 ++ src/protocol/ProtocolFactory.cpp | 9 +++++ src/protocol/allenheath/QuProtocol.cpp | 28 +++++++++++++++ src/protocol/allenheath/QuProtocol.h | 25 ++++++++++++++ tests/test_mixer_capabilities.cpp | 25 ++++++++++++++ 7 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 src/protocol/allenheath/QuProtocol.cpp create mode 100644 src/protocol/allenheath/QuProtocol.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 478dd24..f4c7c62 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -178,6 +178,7 @@ set(SOURCES src/protocol/allenheath/AllenHeathMidiProtocol.cpp src/protocol/allenheath/SQProtocol.cpp + src/protocol/allenheath/QuProtocol.cpp src/protocol/allenheath/GLDProtocol.cpp src/protocol/allenheath/AllenHeathTcpProtocol.cpp src/protocol/allenheath/AvantisProtocol.cpp @@ -299,6 +300,7 @@ set(HEADERS src/protocol/allenheath/AllenHeathMidiProtocol.h src/protocol/allenheath/SQProtocol.h + src/protocol/allenheath/QuProtocol.h src/protocol/allenheath/GLDProtocol.h src/protocol/allenheath/AllenHeathTcpProtocol.h src/protocol/allenheath/AvantisProtocol.h diff --git a/src/protocol/MixerCapabilities.cpp b/src/protocol/MixerCapabilities.cpp index d463f7f..5754e52 100644 --- a/src/protocol/MixerCapabilities.cpp +++ b/src/protocol/MixerCapabilities.cpp @@ -128,6 +128,39 @@ MixerCapabilities MixerCapabilities::forConsole(ConsoleType type) { caps.supportsEffectSends = true; break; + // Qu series shares the SQ MIDI-over-TCP control path (same "Soft" control + // family on port 51325). All Qu models run the same 32-channel DSP; only the + // physical fader count differs, so capabilities are identical across + // Qu-16/24/32 apart from the display name. + case ConsoleType::Qu16: + case ConsoleType::Qu24: + case ConsoleType::Qu32: + caps.manufacturer = Manufacturer::AllenHeath; + caps.protocol = ProtocolType::MidiTcp; + caps.defaultPort = 51325; + caps.dcaCount = 8; + caps.inputChannels = 32; + caps.mixBuses = 12; + caps.matrixOutputs = 0; + caps.scenes = 300; + caps.maxDCANameLength = 6; + caps.eqBandsPerChannel = 4; + caps.supportsChannelEQ = true; + caps.eqBandTypes = {"LShv", "PEQ", "PEQ", "HShv"}; + caps.effectSendBuses = 4; + caps.supportsEffectSends = true; + if (type == ConsoleType::Qu16) { + caps.displayName = "Allen & Heath Qu-16"; + caps.protocolId = "qu16"; + } else if (type == ConsoleType::Qu24) { + caps.displayName = "Allen & Heath Qu-24"; + caps.protocolId = "qu24"; + } else { + caps.displayName = "Allen & Heath Qu-32"; + caps.protocolId = "qu32"; + } + break; + case ConsoleType::GLD80: caps.manufacturer = Manufacturer::AllenHeath; caps.protocol = ProtocolType::MidiTcp; @@ -507,6 +540,12 @@ MixerCapabilities MixerCapabilities::forProtocolId(const QString& protocolId) { return forConsole(ConsoleType::SQ6); if (id == "sq7" || id == "sq-7" || id == "sq") return forConsole(ConsoleType::SQ7); + if (id == "qu16" || id == "qu-16" || id == "qu") + return forConsole(ConsoleType::Qu16); + if (id == "qu24" || id == "qu-24") + return forConsole(ConsoleType::Qu24); + if (id == "qu32" || id == "qu-32") + return forConsole(ConsoleType::Qu32); // Allen & Heath GLD if (id == "gld80" || id == "gld-80") @@ -574,6 +613,9 @@ QVector MixerCapabilities::allSupported() { all.append(forConsole(ConsoleType::SQ5)); all.append(forConsole(ConsoleType::SQ6)); all.append(forConsole(ConsoleType::SQ7)); + all.append(forConsole(ConsoleType::Qu16)); + all.append(forConsole(ConsoleType::Qu24)); + all.append(forConsole(ConsoleType::Qu32)); all.append(forConsole(ConsoleType::GLD80)); all.append(forConsole(ConsoleType::GLD112)); @@ -620,10 +662,13 @@ bool MixerCapabilities::isSupported() const { case ConsoleType::M32: return true; - // Allen & Heath SQ/GLD + // Allen & Heath SQ/Qu/GLD case ConsoleType::SQ5: case ConsoleType::SQ6: case ConsoleType::SQ7: + case ConsoleType::Qu16: + case ConsoleType::Qu24: + case ConsoleType::Qu32: case ConsoleType::GLD80: case ConsoleType::GLD112: return true; diff --git a/src/protocol/MixerCapabilities.h b/src/protocol/MixerCapabilities.h index b9de159..a7b7f4e 100644 --- a/src/protocol/MixerCapabilities.h +++ b/src/protocol/MixerCapabilities.h @@ -14,6 +14,9 @@ enum class ConsoleType { SQ5, // SQ-5 SQ6, // SQ-6 SQ7, // SQ-7 + Qu16, // Qu-16 (discovery family byte 8) + Qu24, // Qu-24 (discovery family byte 9) + Qu32, // Qu-32 (discovery family byte 10) GLD80, // GLD-80 GLD112, // GLD-112 Avantis, // Avantis / Avantis Solo diff --git a/src/protocol/ProtocolFactory.cpp b/src/protocol/ProtocolFactory.cpp index 70e4237..8291de8 100644 --- a/src/protocol/ProtocolFactory.cpp +++ b/src/protocol/ProtocolFactory.cpp @@ -6,6 +6,7 @@ #include "allenheath/AvantisProtocol.h" #include "allenheath/DLiveProtocol.h" #include "allenheath/GLDProtocol.h" +#include "allenheath/QuProtocol.h" #include "allenheath/SQProtocol.h" #include "behringer/WingProtocol.h" #include "behringer/X32Protocol.h" @@ -41,6 +42,11 @@ MixerProtocol* ProtocolFactory::create(const MixerCapabilities& caps, QObject* p case ConsoleType::SQ7: return new SQProtocol(caps, parent); + case ConsoleType::Qu16: + case ConsoleType::Qu24: + case ConsoleType::Qu32: + return new QuProtocol(caps, parent); + case ConsoleType::GLD80: case ConsoleType::GLD112: return new GLDProtocol(caps, parent); @@ -95,6 +101,9 @@ bool ProtocolFactory::isImplemented(ConsoleType type) { case ConsoleType::SQ5: case ConsoleType::SQ6: case ConsoleType::SQ7: + case ConsoleType::Qu16: + case ConsoleType::Qu24: + case ConsoleType::Qu32: case ConsoleType::GLD80: case ConsoleType::GLD112: case ConsoleType::Avantis: diff --git a/src/protocol/allenheath/QuProtocol.cpp b/src/protocol/allenheath/QuProtocol.cpp new file mode 100644 index 0000000..98e1a9d --- /dev/null +++ b/src/protocol/allenheath/QuProtocol.cpp @@ -0,0 +1,28 @@ +#include "QuProtocol.h" + +namespace OpenMix { + +QuProtocol::QuProtocol(const MixerCapabilities& caps, QObject* parent) + : AllenHeathMidiProtocol(caps, parent) { + initializeSnapshotParams(); +} + +void QuProtocol::initializeSnapshotParams() { + m_snapshotParams.clear(); + + for (int i = 1; i <= m_capabilities.dcaCount && i <= 8; ++i) { + m_snapshotParams.append(dcaFaderPath(i)); + m_snapshotParams.append(dcaMutePath(i)); + } + + // input-channel faders, so a cue restores channel levels (not just DCAs) + for (int i = 1; i <= m_capabilities.inputChannels; ++i) { + m_snapshotParams.append(QString("/ch/%1/fader").arg(i)); + } +} + +QString QuProtocol::dcaFaderPath(int dca) const { return QString("/dca/%1/fader").arg(dca); } + +QString QuProtocol::dcaMutePath(int dca) const { return QString("/dca/%1/mute").arg(dca); } + +} // namespace OpenMix diff --git a/src/protocol/allenheath/QuProtocol.h b/src/protocol/allenheath/QuProtocol.h new file mode 100644 index 0000000..fdd9739 --- /dev/null +++ b/src/protocol/allenheath/QuProtocol.h @@ -0,0 +1,25 @@ +#pragma once + +#include "AllenHeathMidiProtocol.h" + +namespace OpenMix { + +// Allen & Heath Qu series (Qu-16, Qu-24, Qu-32) protocol. +// Qu shares the SQ MIDI-over-TCP control path (port 51325); all Qu models run +// the same 32-channel DSP, differing only in physical fader count. +// 8 DCAs, 32 input channels, 12 mix buses. +class QuProtocol : public AllenHeathMidiProtocol { + Q_OBJECT + + public: + explicit QuProtocol(const MixerCapabilities& caps, QObject* parent = nullptr); + + QString protocolDescription() const override { return "Allen & Heath Qu MIDI/TCP Protocol"; } + + protected: + void initializeSnapshotParams() override; + QString dcaFaderPath(int dca) const override; + QString dcaMutePath(int dca) const override; +}; + +} // namespace OpenMix diff --git a/tests/test_mixer_capabilities.cpp b/tests/test_mixer_capabilities.cpp index 0c45a3b..b7dab31 100644 --- a/tests/test_mixer_capabilities.cpp +++ b/tests/test_mixer_capabilities.cpp @@ -23,6 +23,31 @@ class TestMixerCapabilities : public QObject { QCOMPARE(MixerCapabilities::forProtocolId("sd12").dcaCount, 12); } + void quSeries_capabilities() { + // Qu shares the SQ MIDI-over-TCP path but runs a 32-channel DSP + struct Case { + const char* id; + ConsoleType type; + const char* name; + }; + const Case cases[] = {{"qu16", ConsoleType::Qu16, "Allen & Heath Qu-16"}, + {"qu24", ConsoleType::Qu24, "Allen & Heath Qu-24"}, + {"qu32", ConsoleType::Qu32, "Allen & Heath Qu-32"}}; + for (const Case& c : cases) { + const MixerCapabilities caps = MixerCapabilities::forProtocolId(c.id); + QCOMPARE(caps.type, c.type); + QCOMPARE(caps.displayName, QString(c.name)); + QCOMPARE(caps.manufacturer, Manufacturer::AllenHeath); + QCOMPARE(caps.protocol, ProtocolType::MidiTcp); + QCOMPARE(caps.defaultPort, 51325); + QCOMPARE(caps.inputChannels, 32); + QCOMPARE(caps.dcaCount, 8); + QCOMPARE(caps.mixBuses, 12); + } + // bare "qu" resolves to the base Qu-16 + QCOMPARE(MixerCapabilities::forProtocolId("qu").type, ConsoleType::Qu16); + } + void unknownId_fallsBackToDefaults() { const MixerCapabilities caps = MixerCapabilities::forProtocolId("bogus"); QCOMPARE(caps.dcaCount, 8); From 04cab03fa8de2a74b46dea20e1486251f5fb6b77 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Mon, 13 Jul 2026 21:20:10 -0400 Subject: [PATCH 3/6] fix(discovery): rework console auto-discovery to match hardware Recovered the real discovery wire contracts from the reference firmware and corrected every probe: - WING: raw 'WING?' to UDP 2222 (was an OSC probe the console ignores); parse the 'WING,,,,,' reply. - X32/M32: parse all OSC /xinfo args so M32 is no longer misdetected as X32. - Yamaha: replace the non-functional OSC/8000 probe with the real YSDP datagram on UDP 54330 (control stays SCP/TCP 49280). - Allen & Heath SQ/dLive/Avantis: two-stage discovery - broadcast the per-family 'Find' strings to UDP 51320, then an async TCP identify (SQ 51326 binary, dLive/Avantis 51321 SysEx) to resolve the model. Adds a raw-datagram + TCP-identify probe mechanism to ConsoleDiscoveryService (AnyIPv4 bind, per-interface broadcast, unicast probeHost fallback), a testable processDatagram seam, and a full discovery test suite. --- CMakeLists.txt | 6 +- src/app/Application.cpp | 64 +-- .../discovery/ConsoleDiscoveryService.cpp | 237 +++++++-- .../discovery/ConsoleDiscoveryService.h | 26 +- src/protocol/discovery/OscProbeStrategy.h | 75 ++- .../probes/AllenHeathProbeStrategy.cpp | 215 ++++++++ .../probes/AllenHeathProbeStrategy.h | 59 +++ .../probes/BehringerWingProbeStrategy.cpp | 62 +-- .../probes/BehringerWingProbeStrategy.h | 23 +- .../probes/BehringerX32ProbeStrategy.cpp | 16 +- .../probes/BehringerX32ProbeStrategy.h | 2 +- .../probes/YamahaOscProbeStrategy.cpp | 82 --- .../discovery/probes/YamahaOscProbeStrategy.h | 26 - .../probes/YamahaYsdpProbeStrategy.cpp | 147 ++++++ .../probes/YamahaYsdpProbeStrategy.h | 42 ++ tests/CMakeLists.txt | 14 + tests/test_console_discovery.cpp | 482 ++++++++++++++++++ 17 files changed, 1356 insertions(+), 222 deletions(-) create mode 100644 src/protocol/discovery/probes/AllenHeathProbeStrategy.cpp create mode 100644 src/protocol/discovery/probes/AllenHeathProbeStrategy.h delete mode 100644 src/protocol/discovery/probes/YamahaOscProbeStrategy.cpp delete mode 100644 src/protocol/discovery/probes/YamahaOscProbeStrategy.h create mode 100644 src/protocol/discovery/probes/YamahaYsdpProbeStrategy.cpp create mode 100644 src/protocol/discovery/probes/YamahaYsdpProbeStrategy.h create mode 100644 tests/test_console_discovery.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f4c7c62..a507605 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -196,7 +196,8 @@ set(SOURCES src/protocol/discovery/ConsoleDiscoveryService.cpp src/protocol/discovery/probes/BehringerX32ProbeStrategy.cpp src/protocol/discovery/probes/BehringerWingProbeStrategy.cpp - src/protocol/discovery/probes/YamahaOscProbeStrategy.cpp + src/protocol/discovery/probes/YamahaYsdpProbeStrategy.cpp + src/protocol/discovery/probes/AllenHeathProbeStrategy.cpp src/ui/ConsoleDiscoveryWidget.cpp @@ -319,7 +320,8 @@ set(HEADERS src/protocol/discovery/ConsoleDiscoveryService.h src/protocol/discovery/probes/BehringerX32ProbeStrategy.h src/protocol/discovery/probes/BehringerWingProbeStrategy.h - src/protocol/discovery/probes/YamahaOscProbeStrategy.h + src/protocol/discovery/probes/YamahaYsdpProbeStrategy.h + src/protocol/discovery/probes/AllenHeathProbeStrategy.h src/ui/ConsoleDiscoveryWidget.h diff --git a/src/app/Application.cpp b/src/app/Application.cpp index d1ed595..efc773a 100644 --- a/src/app/Application.cpp +++ b/src/app/Application.cpp @@ -1,28 +1,27 @@ #include "Application.h" -#include "OscRemoteServer.h" #include "CuePlayerClient.h" -#include "ScsClient.h" +#include "OscRemoteServer.h" #include "QLabClient.h" #include "ReaperClient.h" -#include "ui/MainWindow.h" +#include "ScsClient.h" #include "core/AppLogger.h" #include "core/ChannelMonitor.h" #include "core/ConnectionLogBridge.h" #include "core/Cue.h" #include "core/CueList.h" -#include "core/CueZero.h" -#include "core/Position.h" -#include "core/TimecodeTrigger.h" #include "core/CueValidator.h" +#include "core/CueZero.h" #include "core/DCAMapping.h" #include "core/DryRunEngine.h" #include "core/PlaybackEngine.h" #include "core/PlaybackGuard.h" #include "core/PlaybackLogger.h" +#include "core/Position.h" #include "core/ScribbleController.h" #include "core/ShortcutManager.h" #include "core/Show.h" #include "core/SpareBackup.h" +#include "core/TimecodeTrigger.h" #include "io/AutosaveManager.h" #include "io/CrashRecovery.h" #include "midi/MidiInputManager.h" @@ -30,9 +29,11 @@ #include "protocol/ProtocolFactory.h" #include "protocol/discovery/ConsoleDiscoveryService.h" #include "protocol/discovery/DiscoveredConsole.h" +#include "protocol/discovery/probes/AllenHeathProbeStrategy.h" #include "protocol/discovery/probes/BehringerWingProbeStrategy.h" #include "protocol/discovery/probes/BehringerX32ProbeStrategy.h" -#include "protocol/discovery/probes/YamahaOscProbeStrategy.h" +#include "protocol/discovery/probes/YamahaYsdpProbeStrategy.h" +#include "ui/MainWindow.h" #include #include #include @@ -65,7 +66,8 @@ Application::Application(QObject* parent) : QObject(parent) { m_discoveryService = new ConsoleDiscoveryService(this); m_discoveryService->registerStrategy(std::make_shared()); m_discoveryService->registerStrategy(std::make_shared()); - m_discoveryService->registerStrategy(std::make_shared()); + m_discoveryService->registerStrategy(std::make_shared()); + m_discoveryService->registerStrategy(std::make_shared()); // inbound OSC remote control (QLab / stage-manager) m_oscRemoteServer = new OscRemoteServer(this); @@ -209,13 +211,13 @@ void Application::initialize() { }); m_oscRemoteServer->loadFromSettings(); if (m_oscRemoteServer->start(m_oscRemoteServer->port())) { - m_appLogger->log(LogLevel::Info, LogSource::System, - QString("OSC remote control listening on port %1") - .arg(m_oscRemoteServer->port())); + m_appLogger->log( + LogLevel::Info, LogSource::System, + QString("OSC remote control listening on port %1").arg(m_oscRemoteServer->port())); } else { - m_appLogger->log(LogLevel::Warning, LogSource::System, - QString("OSC remote control could not bind port %1") - .arg(m_oscRemoteServer->port())); + m_appLogger->log( + LogLevel::Warning, LogSource::System, + QString("OSC remote control could not bind port %1").arg(m_oscRemoteServer->port())); } // outbound QLab/DAW remote: fire a linked QLab cue when a cue executes @@ -302,14 +304,13 @@ void Application::initialize() { // the spare channel. connect(m_playbackEngine, &PlaybackEngine::cueExecuted, m_show->spareBackup(), &SpareBackup::onCueFired); - connect(m_show->spareBackup(), &SpareBackup::stateChanged, this, - [this](SpareBackup::State state) { - if (state != SpareBackup::State::Active) - return; - SpareBackup* spare = m_show->spareBackup(); - m_playbackEngine->applyBackupSwitch(spare->allocatedChannel(), - spare->spareChannel()); - }); + connect( + m_show->spareBackup(), &SpareBackup::stateChanged, this, [this](SpareBackup::State state) { + if (state != SpareBackup::State::Active) + return; + SpareBackup* spare = m_show->spareBackup(); + m_playbackEngine->applyBackupSwitch(spare->allocatedChannel(), spare->spareChannel()); + }); } void Application::setupMixerConnection(const QString& type, const QString& host, int port) { @@ -385,8 +386,7 @@ void Application::connectToDiscoveredConsole(const DiscoveredConsole& console) { if (!m_mixer) return; - setupMixerConnection(console.toCapabilities().protocolId, - console.address.toString(), + setupMixerConnection(console.toCapabilities().protocolId, console.address.toString(), console.port); } @@ -429,21 +429,25 @@ void Application::startupScan() { if (!savedHost.isEmpty()) { auto conn = std::make_shared(); - *conn = connect(m_discoveryService, &ConsoleDiscoveryService::consoleDiscovered, - this, [this, savedHost, conn](const DiscoveredConsole& console) { + *conn = connect(m_discoveryService, &ConsoleDiscoveryService::consoleDiscovered, this, + [this, savedHost, conn](const DiscoveredConsole& console) { if (m_mixer == nullptr && console.address.toString() == savedHost) { QObject::disconnect(*conn); connectToDiscoveredConsole(console); } }); - connect(m_discoveryService, &ConsoleDiscoveryService::scanFinished, - this, [conn]() { - QObject::disconnect(*conn); - }, Qt::SingleShotConnection); + connect( + m_discoveryService, &ConsoleDiscoveryService::scanFinished, this, + [conn]() { QObject::disconnect(*conn); }, Qt::SingleShotConnection); } m_discoveryService->startScan(3000); + + // broadcast-hostile networks: also probe the last-used console directly + if (!savedHost.isEmpty()) { + m_discoveryService->probeHost(QHostAddress(savedHost)); + } } void Application::setRecordFadersActive(bool active) { diff --git a/src/protocol/discovery/ConsoleDiscoveryService.cpp b/src/protocol/discovery/ConsoleDiscoveryService.cpp index 88d1faf..cab9065 100644 --- a/src/protocol/discovery/ConsoleDiscoveryService.cpp +++ b/src/protocol/discovery/ConsoleDiscoveryService.cpp @@ -1,7 +1,9 @@ #include "ConsoleDiscoveryService.h" #include #include +#include #include +#include namespace OpenMix { @@ -26,7 +28,8 @@ void ConsoleDiscoveryService::startScan(int timeoutMs) { m_discovered.clear(); m_scanning = true; - if (!m_socket.bind(QHostAddress::Any, 0)) { + // IPv4-only socket: dual-stack sockets have platform quirks with IPv4 broadcast + if (!m_socket.bind(QHostAddress::AnyIPv4, 0)) { m_scanning = false; emit scanError("Failed to bind UDP socket for discovery"); return; @@ -41,44 +44,106 @@ void ConsoleDiscoveryService::startScan(int timeoutMs) { void ConsoleDiscoveryService::stopScan() { m_scanTimer.stop(); + cancelIdentifyProbes(); m_socket.close(); m_scanning = false; } +void ConsoleDiscoveryService::cancelIdentifyProbes() { + for (QTcpSocket* sock : m_identifySockets) { + if (sock) { + sock->abort(); + sock->deleteLater(); + } + } + m_identifySockets.clear(); + m_identifyProbed.clear(); +} + void ConsoleDiscoveryService::sendProbes() { - QHostAddress broadcastAddr(QHostAddress::Broadcast); + // per-interface subnet broadcasts, using each interface's own address for + // probes that embed the sender IP + QHostAddress firstLocal; + for (const QNetworkInterface& iface : QNetworkInterface::allInterfaces()) { + if (!(iface.flags() & QNetworkInterface::IsUp) || + !(iface.flags() & QNetworkInterface::IsRunning) || + (iface.flags() & QNetworkInterface::IsLoopBack)) { + continue; + } + + for (const QNetworkAddressEntry& entry : iface.addressEntries()) { + if (entry.ip().protocol() != QAbstractSocket::IPv4Protocol) { + continue; + } + + if (firstLocal.isNull()) { + firstLocal = entry.ip(); + } + QHostAddress subnetBroadcast = entry.broadcast(); + if (!subnetBroadcast.isNull()) { + sendProbesTo(subnetBroadcast, entry.ip()); + } + } + } + + // global broadcast as a catch-all + sendProbesTo(QHostAddress(QHostAddress::Broadcast), firstLocal); +} + +void ConsoleDiscoveryService::sendProbesTo(const QHostAddress& target, + const QHostAddress& localAddress) { for (const auto& strategy : m_strategies) { - QByteArray probeMsg = buildOscMessage(strategy->probeCommand()); - int port = strategy->probePort(); + const int port = strategy->probePort(); + const QList rawPayloads = strategy->rawProbes(localAddress); + if (rawPayloads.isEmpty()) { + m_socket.writeDatagram(buildOscMessage(strategy->probeCommand()), target, port); + continue; + } + for (const QByteArray& payload : rawPayloads) { + m_socket.writeDatagram(payload, target, port); + } + } +} - // send to broadcast address - m_socket.writeDatagram(probeMsg, broadcastAddr, port); +void ConsoleDiscoveryService::probeHost(const QHostAddress& host) { + if (!m_scanning || host.isNull()) { + return; + } - // also send to all local subnet broadcast addresses - for (const QNetworkInterface& iface : QNetworkInterface::allInterfaces()) { - if (!(iface.flags() & QNetworkInterface::IsUp) || - !(iface.flags() & QNetworkInterface::IsRunning) || - (iface.flags() & QNetworkInterface::IsLoopBack)) { + // pick the local interface address on the target's subnet for probes that + // embed the sender IP; fall back to the first usable interface + QHostAddress localAddress; + QHostAddress firstLocal; + for (const QNetworkInterface& iface : QNetworkInterface::allInterfaces()) { + if (!(iface.flags() & QNetworkInterface::IsUp) || + !(iface.flags() & QNetworkInterface::IsRunning) || + (iface.flags() & QNetworkInterface::IsLoopBack)) { + continue; + } + for (const QNetworkAddressEntry& entry : iface.addressEntries()) { + if (entry.ip().protocol() != QAbstractSocket::IPv4Protocol) { continue; } - - for (const QNetworkAddressEntry& entry : iface.addressEntries()) { - if (entry.ip().protocol() == QAbstractSocket::IPv4Protocol) { - QHostAddress subnetBroadcast = entry.broadcast(); - if (!subnetBroadcast.isNull() && subnetBroadcast != broadcastAddr) { - m_socket.writeDatagram(probeMsg, subnetBroadcast, port); - } - } + if (firstLocal.isNull()) { + firstLocal = entry.ip(); + } + if (host.isInSubnet(entry.ip(), entry.prefixLength())) { + localAddress = entry.ip(); } } } + if (localAddress.isNull()) { + localAddress = firstLocal; + } + + sendProbesTo(host, localAddress); } void ConsoleDiscoveryService::onReadyRead() { while (m_socket.hasPendingDatagrams()) { QNetworkDatagram datagram = m_socket.receiveDatagram(); - parseOscMessage(datagram.data(), datagram.senderAddress(), datagram.senderPort()); + processDatagram(datagram.data(), datagram.senderAddress(), datagram.senderPort()); } } @@ -87,6 +152,103 @@ void ConsoleDiscoveryService::onScanTimeout() { emit scanFinished(); } +void ConsoleDiscoveryService::processDatagram(const QByteArray& data, const QHostAddress& sender, + int senderPort) { + if (data.isEmpty()) { + return; + } + + // raw (non-OSC) replies first: WING, YSDP, etc. + for (const auto& strategy : m_strategies) { + if (strategy->canParseRawResponse(data)) { + addConsole(strategy->parseRawResponse(data, sender, senderPort)); + return; + } + } + + // sender-port-matched replies with follow-up TCP handshakes (Allen & Heath). + // A device may speak either identify protocol, so every descriptor is tried. + bool launched = false; + for (const auto& strategy : m_strategies) { + if (!strategy->matchesReplyPort(senderPort)) { + continue; + } + for (const TcpIdentify& id : strategy->tcpIdentifies()) { + if (id.isValid()) { + launchTcpIdentify(strategy, sender, id); + launched = true; + } + } + } + if (launched) { + return; + } + + if (data.at(0) == '/') { + parseOscMessage(data, sender, senderPort); + } +} + +void ConsoleDiscoveryService::launchTcpIdentify(const OscProbeStrategyPtr& strategy, + const QHostAddress& host, const TcpIdentify& id) { + const QString key = QString("%1:%2").arg(host.toString()).arg(id.port); + if (m_identifyProbed.contains(key)) { + return; // already probing or probed this host during the current scan + } + m_identifyProbed.insert(key); + + auto* sock = new QTcpSocket(this); + m_identifySockets.append(sock); + + auto buffer = std::make_shared(); + auto done = std::make_shared(false); + + const int identifyPort = id.port; + auto finish = [this, sock, buffer, done, strategy, host, identifyPort]() { + if (*done) { + return; + } + *done = true; + + DiscoveredConsole console = strategy->parseIdentifyResponse(*buffer, host, identifyPort); + addConsole(console); + + m_identifySockets.removeAll(sock); + sock->abort(); + sock->deleteLater(); + }; + + connect(sock, &QTcpSocket::connected, sock, [sock, id]() { sock->write(id.request); }); + connect(sock, &QTcpSocket::readyRead, sock, [sock, buffer, id, finish]() { + buffer->append(sock->readAll()); + if (buffer->size() >= id.minResponseBytes) { + finish(); + } + }); + connect(sock, &QTcpSocket::errorOccurred, sock, + [finish](QAbstractSocket::SocketError) { finish(); }); + + // bound the wait: a silent or non-A&H host must not stall the scan + QTimer::singleShot(id.timeoutMs, sock, finish); + + sock->connectToHost(host, id.port); +} + +void ConsoleDiscoveryService::addConsole(const DiscoveredConsole& console) { + if (!console.isValid()) { + return; + } + + for (const auto& existing : m_discovered) { + if (existing == console) { + return; + } + } + + m_discovered.append(console); + emit consoleDiscovered(console); +} + void ConsoleDiscoveryService::parseOscMessage(const QByteArray& data, const QHostAddress& sender, int senderPort) { if (data.size() < 4) { @@ -102,7 +264,7 @@ void ConsoleDiscoveryService::parseOscMessage(const QByteArray& data, const QHos // skip to type tag (4-byte aligned) int typeStart = ((pathEnd + 4) / 4) * 4; - QVariant value; + QVariantList args; if (typeStart < data.size() && data.at(typeStart) == ',') { int typeEnd = data.indexOf('\0', typeStart); @@ -110,8 +272,15 @@ void ConsoleDiscoveryService::parseOscMessage(const QByteArray& data, const QHos QString types = QString::fromUtf8(data.mid(typeStart + 1, typeEnd - typeStart - 1)); int argOffset = ((typeEnd + 4) / 4) * 4; - if (!types.isEmpty() && argOffset <= data.size()) { - value = parseOscArgument(data, argOffset, types.at(0).toLatin1()); + for (const QChar& type : types) { + if (argOffset > data.size()) { + break; + } + QVariant value = parseOscArgument(data, argOffset, type.toLatin1()); + if (!value.isValid()) { + break; + } + args.append(value); } } } @@ -119,23 +288,7 @@ void ConsoleDiscoveryService::parseOscMessage(const QByteArray& data, const QHos // try each strategy to parse the response for (const auto& strategy : m_strategies) { if (strategy->canParseResponse(path)) { - DiscoveredConsole console = strategy->parseResponse(path, value, sender, senderPort); - - if (console.isValid()) { - // check if already discovered - bool alreadyFound = false; - for (const auto& existing : m_discovered) { - if (existing == console) { - alreadyFound = true; - break; - } - } - - if (!alreadyFound) { - m_discovered.append(console); - emit consoleDiscovered(console); - } - } + addConsole(strategy->parseResponse(path, args, sender, senderPort)); break; } } @@ -169,7 +322,7 @@ QVariant ConsoleDiscoveryService::parseOscArgument(const QByteArray& data, int& } case 's': { int strEnd = data.indexOf('\0', offset); - if (strEnd > offset) { + if (strEnd >= offset) { QString str = QString::fromUtf8(data.mid(offset, strEnd - offset)); offset = ((strEnd + 4) / 4) * 4; return str; @@ -183,7 +336,7 @@ QVariant ConsoleDiscoveryService::parseOscArgument(const QByteArray& data, int& (static_cast(data[offset + 2]) << 8) | static_cast(data[offset + 3]); offset += 4; - if (offset + size <= data.size()) { + if (size >= 0 && offset + size <= data.size()) { QByteArray blob = data.mid(offset, size); offset += ((size + 3) / 4) * 4; return blob; diff --git a/src/protocol/discovery/ConsoleDiscoveryService.h b/src/protocol/discovery/ConsoleDiscoveryService.h index 7fc7ed4..fa3404f 100644 --- a/src/protocol/discovery/ConsoleDiscoveryService.h +++ b/src/protocol/discovery/ConsoleDiscoveryService.h @@ -4,10 +4,12 @@ #include "OscProbeStrategy.h" #include #include +#include #include #include #include -#include + +class QTcpSocket; namespace OpenMix { @@ -23,9 +25,18 @@ class ConsoleDiscoveryService : public QObject { void startScan(int timeoutMs = 3000); void stopScan(); + // unicast all probes to a known host (fallback for networks that drop broadcasts); + // only effective while a scan is running + void probeHost(const QHostAddress& host); + bool isScanning() const { return m_scanning; } const QVector& discoveredConsoles() const { return m_discovered; } + // handles one inbound discovery datagram (exposed for tests) + void processDatagram(const QByteArray& data, const QHostAddress& sender, int senderPort); + + static QByteArray buildOscMessage(const QString& path); + signals: void scanStarted(); void scanFinished(); @@ -38,9 +49,16 @@ class ConsoleDiscoveryService : public QObject { private: void sendProbes(); + void sendProbesTo(const QHostAddress& target, const QHostAddress& localAddress); + void addConsole(const DiscoveredConsole& console); void parseOscMessage(const QByteArray& data, const QHostAddress& sender, int senderPort); QVariant parseOscArgument(const QByteArray& data, int& offset, char type); - QByteArray buildOscMessage(const QString& path); + + // launches one async TCP handshake that identifies an Allen & Heath console + // after it answers a UDP "Find"; result is emitted via consoleDiscovered + void launchTcpIdentify(const OscProbeStrategyPtr& strategy, const QHostAddress& host, + const TcpIdentify& identify); + void cancelIdentifyProbes(); QVector m_strategies; QVector m_discovered; @@ -48,6 +66,10 @@ class ConsoleDiscoveryService : public QObject { QUdpSocket m_socket; QTimer m_scanTimer; bool m_scanning = false; + + // per-scan bookkeeping for the TCP identify stage + QSet m_identifyProbed; // "ip:port" already probed, avoids duplicates + QVector m_identifySockets; }; } // namespace OpenMix diff --git a/src/protocol/discovery/OscProbeStrategy.h b/src/protocol/discovery/OscProbeStrategy.h index 58ad229..a03574c 100644 --- a/src/protocol/discovery/OscProbeStrategy.h +++ b/src/protocol/discovery/OscProbeStrategy.h @@ -2,13 +2,28 @@ #include "../MixerCapabilities.h" #include "DiscoveredConsole.h" +#include #include +#include #include #include +#include #include namespace OpenMix { +// Describes a follow-up TCP query used to identify a console after a UDP probe +// reply. Some consoles (Allen & Heath) only announce presence over UDP and +// require a short binary TCP handshake to report their exact model. +struct TcpIdentify { + int port = 0; // 0 = no TCP identify step + QByteArray request; // bytes to send once connected + int minResponseBytes = 0; // wait until at least this many bytes arrive + int timeoutMs = 300; // give up if the console is silent this long + + bool isValid() const { return port > 0; } +}; + class OscProbeStrategy { public: virtual ~OscProbeStrategy() = default; @@ -17,11 +32,69 @@ class OscProbeStrategy { virtual QString probeCommand() const = 0; + // raw (non-OSC) probe payload; non-empty means the service sends these + // bytes verbatim instead of OSC-encoding probeCommand(). localAddress is + // the sending interface's address, for protocols that embed it (YSDP). + virtual QByteArray rawProbe(const QHostAddress& localAddress) const { + Q_UNUSED(localAddress); + return {}; + } + + // some protocols broadcast several distinct probe payloads on one port + // (Allen & Heath sends a "Find" string per console family). Defaults to the + // single rawProbe() payload when non-empty. + virtual QList rawProbes(const QHostAddress& localAddress) const { + QByteArray single = rawProbe(localAddress); + if (single.isEmpty()) { + return {}; + } + return {single}; + } + virtual bool canParseResponse(const QString& path) const = 0; - virtual DiscoveredConsole parseResponse(const QString& path, const QVariant& value, + virtual DiscoveredConsole parseResponse(const QString& path, const QVariantList& args, const QHostAddress& sender, int senderPort) = 0; + // raw (non-OSC) response handling for consoles that reply with plain datagrams + virtual bool canParseRawResponse(const QByteArray& data) const { + Q_UNUSED(data); + return false; + } + + virtual DiscoveredConsole parseRawResponse(const QByteArray& data, const QHostAddress& sender, + int senderPort) { + Q_UNUSED(data); + Q_UNUSED(sender); + Q_UNUSED(senderPort); + return {}; + } + + // reply matched by sender port rather than content (Allen & Heath answers a + // "Find" from UDP 51320 with no useful payload; the model comes from a + // follow-up TCP handshake). Strategies that return true for a sender port + // should also provide tcpIdentifies() descriptors and parseIdentifyResponse(). + virtual bool matchesReplyPort(int senderPort) const { + Q_UNUSED(senderPort); + return false; + } + + // one or more follow-up TCP handshakes to try after a port-matched reply. + // Allen & Heath uses two distinct identify protocols on different ports + // (SQ on 51326, the ACE families on 51321); each is attempted and the one + // the device answers wins. + virtual QList tcpIdentifies() const { return {}; } + + // parse the response from the handshake sent to identifyPort + virtual DiscoveredConsole parseIdentifyResponse(const QByteArray& response, + const QHostAddress& sender, + int identifyPort) const { + Q_UNUSED(response); + Q_UNUSED(sender); + Q_UNUSED(identifyPort); + return {}; + } + virtual Manufacturer manufacturer() const = 0; virtual QString strategyName() const = 0; diff --git a/src/protocol/discovery/probes/AllenHeathProbeStrategy.cpp b/src/protocol/discovery/probes/AllenHeathProbeStrategy.cpp new file mode 100644 index 0000000..d550b63 --- /dev/null +++ b/src/protocol/discovery/probes/AllenHeathProbeStrategy.cpp @@ -0,0 +1,215 @@ +#include "AllenHeathProbeStrategy.h" + +namespace OpenMix { + +namespace { + +// builds an ACE SysEx property request: F0 00 01 00 00 00 01 00 04 00 +// 00 F7, where counts the ASCII bytes plus the trailing NUL +QByteArray aceRequest(const QByteArray& property) { + QByteArray req = QByteArray::fromHex("f0000100000001000400"); + req.append(static_cast(property.size() + 1)); // + NUL + req.append(property); + req.append('\0'); + req.append('\xf7'); + return req; +} + +// extracts the ASCII identity string from an ACE reply frame: +// require F0 00 prefix, strip the 11-byte header, drop the trailing F7 +QString aceIdentityString(const QByteArray& response) { + if (response.size() < 13 || static_cast(response.at(0)) != 0xf0 || + static_cast(response.at(1)) != 0x00) { + return {}; + } + QByteArray body = response.mid(11); + if (!body.isEmpty() && static_cast(body.back()) == 0xf7) { + body.chop(1); + } + return QString::fromLatin1(body); +} + +// version = substring after " V", up to " - "; revision = after " - Rev. " +QString aceFirmware(const QString& identity) { + QString firmware; + const int vIdx = identity.indexOf(" V"); + if (vIdx >= 0) { + int end = identity.indexOf(" - ", vIdx); + if (end < 0) { + end = identity.size(); + } + firmware = identity.mid(vIdx + 2, end - (vIdx + 2)).trimmed(); + } + const int rIdx = identity.indexOf(" - Rev. "); + if (rIdx >= 0) { + const QString rev = identity.mid(rIdx + 8).trimmed(); + firmware = firmware.isEmpty() ? QString("Rev. %1").arg(rev) + : QString("%1 (Rev. %2)").arg(firmware, rev); + } + return firmware; +} + +// maps a dLive "TLDDMStagebox" identity token to a short model code; compact +// (DMC) variants must be checked before the plain DM ones +QString dliveModelCode(const QString& identity) { + struct Token { + const char* token; + const char* code; + }; + static const Token tokens[] = { + {"TLDDMC32Stagebox", "CDM32"}, {"TLDDMC48Stagebox", "CDM48"}, {"TLDDMC64Stagebox", "CDM64"}, + {"TLDDM0Stagebox", "DM0"}, {"TLDDM32Stagebox", "DM32"}, {"TLDDM48Stagebox", "DM48"}, + {"TLDDM64Stagebox", "DM64"}, + }; + for (const Token& t : tokens) { + if (identity.startsWith(QLatin1String(t.token))) { + return QString::fromLatin1(t.code); + } + } + return {}; +} + +} // namespace + +QList AllenHeathProbeStrategy::rawProbes(const QHostAddress& localAddress) const { + Q_UNUSED(localAddress); + // one broadcast per console family, as sent by the reference implementation + return {QByteArrayLiteral("SQ Find"), QByteArrayLiteral("Qu-567 Find"), + QByteArrayLiteral("GLD Find"), QByteArrayLiteral("Bridge Find"), + QByteArrayLiteral("TLD Find")}; +} + +DiscoveredConsole AllenHeathProbeStrategy::parseResponse( + [[maybe_unused]] const QString& path, [[maybe_unused]] const QVariantList& args, + [[maybe_unused]] const QHostAddress& sender, [[maybe_unused]] int senderPort) { + // A&H discovery does not use OSC + return {}; +} + +QList AllenHeathProbeStrategy::tcpIdentifies() const { + TcpIdentify sq; + sq.port = SQ_IDENTIFY_PORT; // 51326 + sq.request = QByteArray::fromHex("7f0100000000"); + sq.minResponseBytes = 12; // 6-byte header + family + 3 version octets + 2 revision + sq.timeoutMs = 300; + + TcpIdentify ace; + ace.port = ACE_IDENTIFY_PORT; // 51321 + ace.request = aceRequest(QByteArrayLiteral("DR Box Identification")); + ace.minResponseBytes = 13; // F0 + 10 header + at least one payload byte + F7 + ace.timeoutMs = 300; + + return {sq, ace}; +} + +DiscoveredConsole AllenHeathProbeStrategy::parseIdentifyResponse(const QByteArray& response, + const QHostAddress& sender, + int identifyPort) const { + if (identifyPort == SQ_IDENTIFY_PORT) { + return parseSqIdentify(response, sender); + } + if (identifyPort == ACE_IDENTIFY_PORT) { + return parseAceIdentify(response, sender); + } + return {}; +} + +DiscoveredConsole AllenHeathProbeStrategy::parseSqIdentify(const QByteArray& response, + const QHostAddress& sender) const { + DiscoveredConsole console; + + // response frame: [7F 02 xx xx xx xx][family][vMaj][vMin][vPatch][rev:2 LE] + if (response.size() < 12 || static_cast(response.at(0)) != 0x7f || + static_cast(response.at(1)) != 0x02) { + return console; + } + + const int family = static_cast(response.at(6)); + const ConsoleType type = consoleForSqFamily(family); + if (type == ConsoleType::Unknown) { + return console; // e.g. Qu, which OpenMix does not model + } + + const int major = static_cast(response.at(7)); + const int minor = static_cast(response.at(8)); + const int patch = static_cast(response.at(9)); + + MixerCapabilities caps = MixerCapabilities::forConsole(type); + console.address = sender; + console.type = type; + console.port = caps.defaultPort; // MIDI-over-TCP control port (51325) + console.manufacturer = caps.manufacturer; + console.displayName = caps.displayName; + console.modelName = caps.displayName; + console.firmwareVersion = QString("%1.%2.%3").arg(major).arg(minor).arg(patch); + + return console; +} + +ConsoleType AllenHeathProbeStrategy::consoleForSqFamily(int family) const { + switch (family) { + case 1: + return ConsoleType::SQ5; + case 2: + return ConsoleType::SQ6; + case 3: + return ConsoleType::SQ7; + // families 8/9/10 are the reference's Qu-5/6/7 = Qu-16/24/32 by fader count + case 8: + return ConsoleType::Qu16; + case 9: + return ConsoleType::Qu24; + case 10: + return ConsoleType::Qu32; + default: + return ConsoleType::Unknown; + } +} + +DiscoveredConsole AllenHeathProbeStrategy::parseAceIdentify(const QByteArray& response, + const QHostAddress& sender) const { + DiscoveredConsole console; + + const QString identity = aceIdentityString(response); + if (identity.isEmpty()) { + return console; + } + + ConsoleType type = ConsoleType::Unknown; + QString modelName; + + if (identity.startsWith(QLatin1String("TLD"))) { + // dLive mixrack; the size variant is display-only (OpenMix has one dLive type) + type = ConsoleType::DLive; + const QString code = dliveModelCode(identity); + modelName = code.isEmpty() ? QStringLiteral("dLive") : QString("dLive %1").arg(code); + } else if (identity.startsWith(QLatin1String("Bridge"))) { + type = ConsoleType::Avantis; + modelName = QStringLiteral("Avantis"); + } else if (identity.startsWith(QLatin1String("Avantis Solo"))) { + type = ConsoleType::Avantis; + modelName = QStringLiteral("Avantis Solo"); + } + // GLD is intentionally not identified here. Unlike dLive/Avantis it does not + // answer this ACE handshake: GLD identification is a stateful MIDI-over-TCP + // (51325) "box-walk" that resolves the exact GLD-80/112 model only via a + // follow-up query keyed on a runtime box id, which a single-shot probe + // cannot perform. GLD stays a connect-time selection. + + if (type == ConsoleType::Unknown) { + return console; + } + + MixerCapabilities caps = MixerCapabilities::forConsole(type); + console.address = sender; + console.type = type; + console.port = caps.defaultPort; // ACE control port (51321) + console.manufacturer = caps.manufacturer; + console.modelName = modelName; + console.displayName = modelName; + console.firmwareVersion = aceFirmware(identity); + + return console; +} + +} // namespace OpenMix diff --git a/src/protocol/discovery/probes/AllenHeathProbeStrategy.h b/src/protocol/discovery/probes/AllenHeathProbeStrategy.h new file mode 100644 index 0000000..1797889 --- /dev/null +++ b/src/protocol/discovery/probes/AllenHeathProbeStrategy.h @@ -0,0 +1,59 @@ +#pragma once + +#include "../OscProbeStrategy.h" + +namespace OpenMix { + +// Allen & Heath discovery is two-stage. Stage 1: broadcast a per-family ASCII +// "Find" string to UDP 51320; A&H devices answer from source port 51320. +// Stage 2: a follow-up TCP handshake reports the exact model. A&H uses two +// distinct identify protocols: +// * SQ family - TCP 51326, binary 7F 01 / 7F 02 frame with a model byte. +// * ACE family - TCP 51321, SysEx "DR Box Identification" request whose reply +// string identifies dLive ("TLD..."), Avantis ("Bridge"/"Avantis Solo"), +// or GLD ("GLD..."). +// Both are attempted; the one the device answers wins. Control ports differ +// (SQ/GLD MIDI-over-TCP 51325, Avantis/dLive ACE-TCP 51321). +class AllenHeathProbeStrategy : public OscProbeStrategy { + public: + int probePort() const override { return 51320; } + + QString probeCommand() const override { return QString(); } + + QList rawProbes(const QHostAddress& localAddress) const override; + + bool canParseResponse(const QString& path) const override { + Q_UNUSED(path); + return false; + } + + DiscoveredConsole parseResponse(const QString& path, const QVariantList& args, + const QHostAddress& sender, int senderPort) override; + + // A&H announces presence from source port 51320 with no useful UDP payload + bool matchesReplyPort(int senderPort) const override { return senderPort == 51320; } + + QList tcpIdentifies() const override; + + DiscoveredConsole parseIdentifyResponse(const QByteArray& response, const QHostAddress& sender, + int identifyPort) const override; + + Manufacturer manufacturer() const override { return Manufacturer::AllenHeath; } + + QString strategyName() const override { return "Allen & Heath"; } + + // identify ports (public so tests can target them directly) + static constexpr int SQ_IDENTIFY_PORT = 51326; + static constexpr int ACE_IDENTIFY_PORT = 51321; + + private: + // SQ (TCP 51326): 7F 02 frame, model byte -> SQ-5/6/7 + DiscoveredConsole parseSqIdentify(const QByteArray& response, const QHostAddress& sender) const; + ConsoleType consoleForSqFamily(int family) const; + + // ACE (TCP 51321): SysEx reply string -> dLive / Avantis / GLD + DiscoveredConsole parseAceIdentify(const QByteArray& response, + const QHostAddress& sender) const; +}; + +} // namespace OpenMix diff --git a/src/protocol/discovery/probes/BehringerWingProbeStrategy.cpp b/src/protocol/discovery/probes/BehringerWingProbeStrategy.cpp index cd83b61..8cf26cd 100644 --- a/src/protocol/discovery/probes/BehringerWingProbeStrategy.cpp +++ b/src/protocol/discovery/probes/BehringerWingProbeStrategy.cpp @@ -2,45 +2,47 @@ namespace OpenMix { -DiscoveredConsole BehringerWingProbeStrategy::parseResponse([[maybe_unused]] const QString& path, - const QVariant& value, - const QHostAddress& sender, - [[maybe_unused]] int senderPort) { +DiscoveredConsole BehringerWingProbeStrategy::parseResponse( + [[maybe_unused]] const QString& path, [[maybe_unused]] const QVariantList& args, + [[maybe_unused]] const QHostAddress& sender, [[maybe_unused]] int senderPort) { + // WING discovery is raw-datagram only + return {}; +} +DiscoveredConsole BehringerWingProbeStrategy::parseRawResponse(const QByteArray& data, + const QHostAddress& sender, + [[maybe_unused]] int senderPort) { DiscoveredConsole console; + + // "WING,,,,," + const QList fields = data.split(','); + if (fields.size() < 6) { + return console; + } + console.address = sender; - console.port = 2223; - - QString modelString = value.toString(); - console.modelName = modelString; - console.type = identifyModel(modelString); - - if (console.type != ConsoleType::Unknown) { - MixerCapabilities caps = MixerCapabilities::forConsole(console.type); - console.manufacturer = caps.manufacturer; - console.displayName = caps.displayName; - } else { - // if we got a response on port 2223 to /$info, it's likely a WING - console.type = ConsoleType::Wing; - console.manufacturer = Manufacturer::Behringer; - console.displayName = "Behringer WING"; + console.port = 2223; // OSC control port + console.modelName = QString::fromUtf8(fields[3]).trimmed(); + console.firmwareVersion = QString::fromUtf8(fields[5]).trimmed(); + console.type = identifyModel(console.modelName); + + MixerCapabilities caps = MixerCapabilities::forConsole(console.type); + console.manufacturer = caps.manufacturer; + console.displayName = caps.displayName; + + const QString consoleName = QString::fromUtf8(fields[2]).trimmed(); + if (!consoleName.isEmpty()) { + console.displayName = QString("%1 (%2)").arg(caps.displayName, consoleName); } return console; } ConsoleType BehringerWingProbeStrategy::identifyModel(const QString& modelString) const { - QString model = modelString.toLower(); - - if (model.contains("wing compact") || model.contains("wingcompact")) { - return ConsoleType::Wing; // WING Compact uses same protocol - } - - if (model.contains("wing")) { - return ConsoleType::Wing; - } - - return ConsoleType::Unknown; + // known tokens: wing-fullsize, wing-compact, wing-rack; all variants speak + // the same protocol, and a well-formed "WING," reply is always a WING + Q_UNUSED(modelString); + return ConsoleType::Wing; } } // namespace OpenMix diff --git a/src/protocol/discovery/probes/BehringerWingProbeStrategy.h b/src/protocol/discovery/probes/BehringerWingProbeStrategy.h index e1b32c3..7b6eea9 100644 --- a/src/protocol/discovery/probes/BehringerWingProbeStrategy.h +++ b/src/protocol/discovery/probes/BehringerWingProbeStrategy.h @@ -4,19 +4,34 @@ namespace OpenMix { +// WING discovery: raw ASCII "WING?" broadcast to UDP 2222; the console replies +// with "WING,,,,,". Control stays OSC on 2223. class BehringerWingProbeStrategy : public OscProbeStrategy { public: - int probePort() const override { return 2223; } + int probePort() const override { return 2222; } - QString probeCommand() const override { return "/$info"; } + QString probeCommand() const override { return QString(); } + + QByteArray rawProbe(const QHostAddress& localAddress) const override { + Q_UNUSED(localAddress); + return QByteArrayLiteral("WING?"); + } bool canParseResponse(const QString& path) const override { - return path == "/$info" || path.startsWith("/$"); + Q_UNUSED(path); + return false; } - DiscoveredConsole parseResponse(const QString& path, const QVariant& value, + DiscoveredConsole parseResponse(const QString& path, const QVariantList& args, const QHostAddress& sender, int senderPort) override; + bool canParseRawResponse(const QByteArray& data) const override { + return data.startsWith("WING,"); + } + + DiscoveredConsole parseRawResponse(const QByteArray& data, const QHostAddress& sender, + int senderPort) override; + Manufacturer manufacturer() const override { return Manufacturer::Behringer; } QString strategyName() const override { return "Behringer WING"; } diff --git a/src/protocol/discovery/probes/BehringerX32ProbeStrategy.cpp b/src/protocol/discovery/probes/BehringerX32ProbeStrategy.cpp index ba91365..f4efb86 100644 --- a/src/protocol/discovery/probes/BehringerX32ProbeStrategy.cpp +++ b/src/protocol/discovery/probes/BehringerX32ProbeStrategy.cpp @@ -3,7 +3,7 @@ namespace OpenMix { DiscoveredConsole BehringerX32ProbeStrategy::parseResponse([[maybe_unused]] const QString& path, - const QVariant& value, + const QVariantList& args, const QHostAddress& sender, [[maybe_unused]] int senderPort) { @@ -11,13 +11,25 @@ DiscoveredConsole BehringerX32ProbeStrategy::parseResponse([[maybe_unused]] cons console.address = sender; console.port = 10023; - QString modelString = value.toString(); + // /xinfo reply: [ip, name, model, firmware] + QString modelString = args.value(2).toString(); + if (modelString.isEmpty()) { + // unexpected arg layout: fall back to the first arg that names a model + for (const QVariant& arg : args) { + const QString candidate = arg.toString().toLower(); + if (candidate.contains("x32") || candidate.contains("m32")) { + modelString = arg.toString(); + break; + } + } + } if (modelString.isEmpty()) { return console; } console.modelName = modelString; + console.firmwareVersion = args.value(3).toString(); console.type = identifyModel(modelString); if (console.type != ConsoleType::Unknown) { diff --git a/src/protocol/discovery/probes/BehringerX32ProbeStrategy.h b/src/protocol/discovery/probes/BehringerX32ProbeStrategy.h index 4a0061c..69b3397 100644 --- a/src/protocol/discovery/probes/BehringerX32ProbeStrategy.h +++ b/src/protocol/discovery/probes/BehringerX32ProbeStrategy.h @@ -12,7 +12,7 @@ class BehringerX32ProbeStrategy : public OscProbeStrategy { bool canParseResponse(const QString& path) const override { return path == "/xinfo"; } - DiscoveredConsole parseResponse(const QString& path, const QVariant& value, + DiscoveredConsole parseResponse(const QString& path, const QVariantList& args, const QHostAddress& sender, int senderPort) override; Manufacturer manufacturer() const override { return Manufacturer::Behringer; } diff --git a/src/protocol/discovery/probes/YamahaOscProbeStrategy.cpp b/src/protocol/discovery/probes/YamahaOscProbeStrategy.cpp deleted file mode 100644 index 0841a35..0000000 --- a/src/protocol/discovery/probes/YamahaOscProbeStrategy.cpp +++ /dev/null @@ -1,82 +0,0 @@ -#include "YamahaOscProbeStrategy.h" - -namespace OpenMix { - -DiscoveredConsole YamahaOscProbeStrategy::parseResponse([[maybe_unused]] const QString& path, - const QVariant& value, - const QHostAddress& sender, - [[maybe_unused]] int senderPort) { - - DiscoveredConsole console; - console.address = sender; - console.port = 8000; // Yamaha OSC receive port - console.manufacturer = Manufacturer::Yamaha; - - QString modelString = value.toString(); - console.modelName = modelString; - console.type = identifyModel(modelString); - - if (console.type != ConsoleType::Unknown) { - MixerCapabilities caps = MixerCapabilities::forConsole(console.type); - console.displayName = caps.displayName; - } else if (!modelString.isEmpty()) { - // unknown Yamaha model, display as generic - console.displayName = QString("Yamaha %1").arg(modelString); - } - - return console; -} - -ConsoleType YamahaOscProbeStrategy::identifyModel(const QString& modelString) const { - QString model = modelString.toLower(); - - // DM7 series - if (model.contains("dm7")) { - return ConsoleType::DM7; - } - - // CL series - if (model.contains("cl5")) { - return ConsoleType::CL5; - } - if (model.contains("cl3")) { - return ConsoleType::CL3; - } - if (model.contains("cl1")) { - return ConsoleType::CL1; - } - - // QL series - if (model.contains("ql5")) { - return ConsoleType::QL5; - } - if (model.contains("ql1")) { - return ConsoleType::QL1; - } - - // TF series - if (model.contains("tf5")) { - return ConsoleType::TF5; - } - if (model.contains("tf3")) { - return ConsoleType::TF3; - } - if (model.contains("tf1")) { - return ConsoleType::TF1; - } - - // generic matches - if (model.contains("tf")) { - return ConsoleType::TF5; - } - if (model.contains("ql")) { - return ConsoleType::QL5; - } - if (model.contains("cl")) { - return ConsoleType::CL5; - } - - return ConsoleType::Unknown; -} - -} // namespace OpenMix diff --git a/src/protocol/discovery/probes/YamahaOscProbeStrategy.h b/src/protocol/discovery/probes/YamahaOscProbeStrategy.h deleted file mode 100644 index 5419c59..0000000 --- a/src/protocol/discovery/probes/YamahaOscProbeStrategy.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include "../OscProbeStrategy.h" - -namespace OpenMix { - -class YamahaOscProbeStrategy : public OscProbeStrategy { - public: - int probePort() const override { return 8000; } - - QString probeCommand() const override { return "/sys/model"; } - - bool canParseResponse(const QString& path) const override { return path == "/sys/model"; } - - DiscoveredConsole parseResponse(const QString& path, const QVariant& value, - const QHostAddress& sender, int senderPort) override; - - Manufacturer manufacturer() const override { return Manufacturer::Yamaha; } - - QString strategyName() const override { return "Yamaha TF/QL/CL/DM7"; } - - private: - ConsoleType identifyModel(const QString& modelString) const; -}; - -} // namespace OpenMix diff --git a/src/protocol/discovery/probes/YamahaYsdpProbeStrategy.cpp b/src/protocol/discovery/probes/YamahaYsdpProbeStrategy.cpp new file mode 100644 index 0000000..0d090ab --- /dev/null +++ b/src/protocol/discovery/probes/YamahaYsdpProbeStrategy.cpp @@ -0,0 +1,147 @@ +#include "YamahaYsdpProbeStrategy.h" + +namespace OpenMix { + +namespace { +// reads one length-prefixed field ([len byte][len bytes]) and advances pos; +// returns empty on truncation +QByteArray readLengthPrefixedField(const QByteArray& data, int& pos) { + if (pos >= data.size()) { + return {}; + } + const int len = static_cast(data.at(pos)); + ++pos; + if (len == 0 || pos + len > data.size()) { + return {}; + } + QByteArray field = data.mid(pos, len); + pos += len; + return field; +} +} // namespace + +QByteArray YamahaYsdpProbeStrategy::rawProbe(const QHostAddress& localAddress) const { + QByteArray probe; + probe.append("YSDP", 4); + probe.append('\0'); + probe.append('#'); + probe.append('\0'); + probe.append('\x04'); + + // sender IPv4, big-endian (the console addresses its reply using this) + const quint32 ip = localAddress.toIPv4Address(); + probe.append(static_cast((ip >> 24) & 0xFF)); + probe.append(static_cast((ip >> 16) & 0xFF)); + probe.append(static_cast((ip >> 8) & 0xFF)); + probe.append(static_cast(ip & 0xFF)); + + probe.append(18, '\0'); + probe.append('\x08'); + probe.append("_ypa-scp", 8); + probe.append(2, '\0'); + + return probe; +} + +DiscoveredConsole YamahaYsdpProbeStrategy::parseResponse( + [[maybe_unused]] const QString& path, [[maybe_unused]] const QVariantList& args, + [[maybe_unused]] const QHostAddress& sender, [[maybe_unused]] int senderPort) { + // YSDP discovery is raw-datagram only + return {}; +} + +DiscoveredConsole YamahaYsdpProbeStrategy::parseRawResponse(const QByteArray& data, + const QHostAddress& sender, + [[maybe_unused]] int senderPort) { + DiscoveredConsole console; + + const int serviceIdx = data.indexOf("_ypa-scp"); + if (serviceIdx < 0) { + return console; + } + + // fields start 10 bytes past the service name: host, model, version + int pos = serviceIdx + 10; + + const QByteArray hostField = readLengthPrefixedField(data, pos); + if (hostField.size() <= 4) { + return console; + } + + const QByteArray modelField = readLengthPrefixedField(data, pos); + if (modelField.size() <= 1) { + return console; + } + + const QByteArray versionField = readLengthPrefixedField(data, pos); + + const QString modelString = QString::fromLatin1(modelField).trimmed(); + + console.address = sender; + console.modelName = modelString; + console.firmwareVersion = QString::fromLatin1(versionField).trimmed(); + console.manufacturer = Manufacturer::Yamaha; + console.type = identifyModel(modelString); + + if (console.type != ConsoleType::Unknown) { + MixerCapabilities caps = MixerCapabilities::forConsole(console.type); + console.displayName = caps.displayName; + console.port = caps.defaultPort; // SCP over TCP 49280 + } + + return console; +} + +ConsoleType YamahaYsdpProbeStrategy::identifyModel(const QString& modelString) const { + QString model = modelString.toLower(); + + // DM7 series (incl. DM7C / DM7 Compact) + if (model.contains("dm7")) { + return ConsoleType::DM7; + } + + // CL series + if (model.contains("cl5")) { + return ConsoleType::CL5; + } + if (model.contains("cl3")) { + return ConsoleType::CL3; + } + if (model.contains("cl1")) { + return ConsoleType::CL1; + } + + // QL series + if (model.contains("ql5")) { + return ConsoleType::QL5; + } + if (model.contains("ql1")) { + return ConsoleType::QL1; + } + + // TF series + if (model.contains("tf5")) { + return ConsoleType::TF5; + } + if (model.contains("tf3")) { + return ConsoleType::TF3; + } + if (model.contains("tf1")) { + return ConsoleType::TF1; + } + + // generic matches + if (model.contains("tf")) { + return ConsoleType::TF5; + } + if (model.contains("ql")) { + return ConsoleType::QL5; + } + if (model.contains("cl")) { + return ConsoleType::CL5; + } + + return ConsoleType::Unknown; +} + +} // namespace OpenMix diff --git a/src/protocol/discovery/probes/YamahaYsdpProbeStrategy.h b/src/protocol/discovery/probes/YamahaYsdpProbeStrategy.h new file mode 100644 index 0000000..6c0cea1 --- /dev/null +++ b/src/protocol/discovery/probes/YamahaYsdpProbeStrategy.h @@ -0,0 +1,42 @@ +#pragma once + +#include "../OscProbeStrategy.h" + +namespace OpenMix { + +// Yamaha discovery (YSDP): raw datagram broadcast to UDP 54330 carrying the +// sender's IPv4 and the "_ypa-scp" service name. Consoles reply with a +// datagram containing "_ypa-scp" followed by length-prefixed fields +// (host, model, version). Control is SCP over TCP 49280. +class YamahaYsdpProbeStrategy : public OscProbeStrategy { + public: + int probePort() const override { return 54330; } + + QString probeCommand() const override { return QString(); } + + QByteArray rawProbe(const QHostAddress& localAddress) const override; + + bool canParseResponse(const QString& path) const override { + Q_UNUSED(path); + return false; + } + + DiscoveredConsole parseResponse(const QString& path, const QVariantList& args, + const QHostAddress& sender, int senderPort) override; + + bool canParseRawResponse(const QByteArray& data) const override { + return data.contains("_ypa-scp"); + } + + DiscoveredConsole parseRawResponse(const QByteArray& data, const QHostAddress& sender, + int senderPort) override; + + Manufacturer manufacturer() const override { return Manufacturer::Yamaha; } + + QString strategyName() const override { return "Yamaha TF/QL/CL/DM7"; } + + private: + ConsoleType identifyModel(const QString& modelString) const; +}; + +} // namespace OpenMix diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2415b1b..fee3f2f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -644,3 +644,17 @@ add_executable(test_bubble_button target_link_libraries(test_bubble_button PRIVATE Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Test) target_include_directories(test_bubble_button PRIVATE ${CMAKE_SOURCE_DIR}/src) add_test(NAME BubbleButtonTest COMMAND test_bubble_button) + +add_executable(test_console_discovery + test_console_discovery.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/discovery/ConsoleDiscoveryService.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/discovery/DiscoveredConsole.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/discovery/probes/BehringerX32ProbeStrategy.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/discovery/probes/BehringerWingProbeStrategy.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/discovery/probes/YamahaYsdpProbeStrategy.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/discovery/probes/AllenHeathProbeStrategy.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/MixerCapabilities.cpp +) +target_link_libraries(test_console_discovery PRIVATE Qt6::Core Qt6::Network Qt6::Test) +target_include_directories(test_console_discovery PRIVATE ${CMAKE_SOURCE_DIR}/src) +add_test(NAME ConsoleDiscoveryTest COMMAND test_console_discovery) diff --git a/tests/test_console_discovery.cpp b/tests/test_console_discovery.cpp new file mode 100644 index 0000000..dc37263 --- /dev/null +++ b/tests/test_console_discovery.cpp @@ -0,0 +1,482 @@ +#include "protocol/discovery/ConsoleDiscoveryService.h" +#include "protocol/discovery/probes/AllenHeathProbeStrategy.h" +#include "protocol/discovery/probes/BehringerWingProbeStrategy.h" +#include "protocol/discovery/probes/BehringerX32ProbeStrategy.h" +#include "protocol/discovery/probes/YamahaYsdpProbeStrategy.h" +#include +#include +#include + +using namespace OpenMix; + +namespace { + +// builds an OSC message with string arguments, mirroring console replies +QByteArray buildOscReply(const QByteArray& path, const QList& stringArgs) { + auto pad = [](QByteArray bytes) { + bytes.append('\0'); + while (bytes.size() % 4 != 0) { + bytes.append('\0'); + } + return bytes; + }; + + QByteArray msg = pad(path); + + QByteArray typeTag = ","; + for (int i = 0; i < stringArgs.size(); ++i) { + typeTag.append('s'); + } + msg.append(pad(typeTag)); + + for (const QByteArray& arg : stringArgs) { + msg.append(pad(arg)); + } + + return msg; +} + +// builds a YSDP discovery reply: service name then length-prefixed fields +QByteArray buildYsdpReply(const QByteArray& host, const QByteArray& model, + const QByteArray& version) { + QByteArray reply; + reply.append(4, '\x00'); // arbitrary header bytes before the service name + reply.append("_ypa-scp", 8); + reply.append(2, '\x00'); // 2 bytes between service name and fields + + auto appendField = [&reply](const QByteArray& field) { + reply.append(static_cast(field.size())); + reply.append(field); + }; + appendField(host); + appendField(model); + appendField(version); + + return reply; +} + +DiscoveredConsole parseWing(const QByteArray& reply) { + BehringerWingProbeStrategy strategy; + return strategy.parseRawResponse(reply, QHostAddress("192.168.1.70"), 2222); +} + +// builds an Allen & Heath TCP identify response frame: +// [7F 02 00 00 00 00][family][vMaj][vMin][vPatch][rev lo][rev hi] +QByteArray buildAhIdentify(quint8 family, quint8 maj, quint8 min, quint8 patch, + quint16 revision = 0) { + QByteArray frame = QByteArray::fromHex("7f0200000000"); + frame.append(static_cast(family)); + frame.append(static_cast(maj)); + frame.append(static_cast(min)); + frame.append(static_cast(patch)); + frame.append(static_cast(revision & 0xFF)); + frame.append(static_cast((revision >> 8) & 0xFF)); + return frame; +} + +DiscoveredConsole parseAh(const QByteArray& frame) { + AllenHeathProbeStrategy strategy; + return strategy.parseIdentifyResponse(frame, QHostAddress("192.168.1.80"), + AllenHeathProbeStrategy::SQ_IDENTIFY_PORT); +} + +// builds an ACE SysEx identify reply: [F0 00 + 9 header bytes][identity ASCII][F7] +QByteArray buildAceReply(const QByteArray& identity) { + QByteArray frame = QByteArray::fromHex("f0000100000001000400"); // 10 bytes + frame.append('\x00'); // 11th header byte + frame.append(identity); + frame.append('\xf7'); + return frame; +} + +DiscoveredConsole parseAce(const QByteArray& identity) { + AllenHeathProbeStrategy strategy; + return strategy.parseIdentifyResponse(buildAceReply(identity), QHostAddress("192.168.1.81"), + AllenHeathProbeStrategy::ACE_IDENTIFY_PORT); +} + +} // namespace + +class TestConsoleDiscovery : public QObject { + Q_OBJECT + + private slots: + void wingReply_fullsize() { + const auto console = + parseWing("WING,192.168.1.70,FOH Wing,wing-fullsize,ABC1234567,3.1.2-50"); + QVERIFY(console.isValid()); + QCOMPARE(console.type, ConsoleType::Wing); + QCOMPARE(console.port, 2223); + QCOMPARE(console.modelName, QString("wing-fullsize")); + QCOMPARE(console.firmwareVersion, QString("3.1.2-50")); + QCOMPARE(console.displayName, QString("Behringer WING (FOH Wing)")); + QCOMPARE(console.manufacturer, Manufacturer::Behringer); + } + + void wingReply_compactAndRack() { + QCOMPARE(parseWing("WING,10.0.0.5,,wing-compact,X,1.0").type, ConsoleType::Wing); + QCOMPARE(parseWing("WING,10.0.0.6,,wing-rack,X,1.0").type, ConsoleType::Wing); + } + + void wingReply_emptyName_usesPlainDisplayName() { + const auto console = parseWing("WING,10.0.0.5,,wing-compact,X,1.0"); + QCOMPARE(console.displayName, QString("Behringer WING")); + } + + void wingReply_tooFewFields_isInvalid() { + QVERIFY(!parseWing("WING,192.168.1.70,Name").isValid()); + QVERIFY(!parseWing("WING,").isValid()); + } + + void wingProbe_isRawOnPort2222() { + BehringerWingProbeStrategy strategy; + QCOMPARE(strategy.probePort(), 2222); + QCOMPARE(strategy.rawProbe(QHostAddress("192.168.1.2")), QByteArray("WING?")); + QVERIFY(strategy.canParseRawResponse("WING,anything")); + QVERIFY(!strategy.canParseRawResponse("/xinfo")); + } + + void x32Reply_identifiesModelFromThirdArg() { + BehringerX32ProbeStrategy strategy; + const QVariantList args{"192.168.1.30", "MyDesk", "X32 RACK", "4.06"}; + const auto console = + strategy.parseResponse("/xinfo", args, QHostAddress("192.168.1.30"), 10023); + QVERIFY(console.isValid()); + QCOMPARE(console.type, ConsoleType::X32); + QCOMPARE(console.modelName, QString("X32 RACK")); + QCOMPARE(console.firmwareVersion, QString("4.06")); + QCOMPARE(console.port, 10023); + } + + void x32Reply_detectsM32() { + BehringerX32ProbeStrategy strategy; + const QVariantList args{"192.168.1.31", "Monitors", "M32C", "4.11"}; + const auto console = + strategy.parseResponse("/xinfo", args, QHostAddress("192.168.1.31"), 10023); + QCOMPARE(console.type, ConsoleType::M32); + } + + void x32Reply_missingArgs_isInvalid() { + BehringerX32ProbeStrategy strategy; + const auto console = + strategy.parseResponse("/xinfo", {}, QHostAddress("192.168.1.31"), 10023); + QVERIFY(!console.isValid()); + } + + void yamahaReply_mapsModels() { + YamahaYsdpProbeStrategy strategy; + const QHostAddress sender("192.168.1.40"); + + struct Case { + QByteArray model; + ConsoleType type; + }; + const QList cases{{"Yamaha TF5", ConsoleType::TF5}, + {"Yamaha QL5", ConsoleType::QL5}, + {"Yamaha CL5", ConsoleType::CL5}, + {"Yamaha DM7", ConsoleType::DM7}, + {"DM7C", ConsoleType::DM7}}; + + for (const Case& c : cases) { + const auto console = strategy.parseRawResponse( + buildYsdpReply("console-host", c.model, "V5.10"), sender, 54330); + QVERIFY2(console.isValid(), c.model.constData()); + QCOMPARE(console.type, c.type); + QCOMPARE(console.port, 49280); + QCOMPARE(console.firmwareVersion, QString("V5.10")); + } + } + + void yamahaReply_shortHostField_isInvalid() { + YamahaYsdpProbeStrategy strategy; + const auto console = strategy.parseRawResponse(buildYsdpReply("ab", "Yamaha TF5", "V5.10"), + QHostAddress("192.168.1.40"), 54330); + QVERIFY(!console.isValid()); + } + + void yamahaReply_unknownModel_isInvalid() { + YamahaYsdpProbeStrategy strategy; + const auto console = strategy.parseRawResponse( + buildYsdpReply("console-host", "Mystery", "V1.0"), QHostAddress("192.168.1.40"), 54330); + QVERIFY(!console.isValid()); + } + + void yamahaProbe_embedsLocalAddress() { + YamahaYsdpProbeStrategy strategy; + const QByteArray probe = strategy.rawProbe(QHostAddress("192.168.1.2")); + QCOMPARE(probe.size(), 41); + QVERIFY(probe.startsWith("YSDP")); + QCOMPARE(static_cast(probe.at(8)), quint8(192)); + QCOMPARE(static_cast(probe.at(9)), quint8(168)); + QCOMPARE(static_cast(probe.at(10)), quint8(1)); + QCOMPARE(static_cast(probe.at(11)), quint8(2)); + QVERIFY(probe.contains("_ypa-scp")); + } + + void service_routesRawWingReply() { + ConsoleDiscoveryService service; + service.registerStrategy(std::make_shared()); + service.registerStrategy(std::make_shared()); + service.registerStrategy(std::make_shared()); + + int discovered = 0; + DiscoveredConsole last; + connect(&service, &ConsoleDiscoveryService::consoleDiscovered, + [&](const DiscoveredConsole& console) { + ++discovered; + last = console; + }); + + const QByteArray reply = "WING,192.168.1.70,FOH,wing-fullsize,SN1,3.1"; + service.processDatagram(reply, QHostAddress("192.168.1.70"), 2222); + QCOMPARE(discovered, 1); + QCOMPARE(last.type, ConsoleType::Wing); + + // duplicate reply must not re-emit + service.processDatagram(reply, QHostAddress("192.168.1.70"), 2222); + QCOMPARE(discovered, 1); + QCOMPARE(service.discoveredConsoles().size(), 1); + } + + void service_routesOscXinfoReply() { + ConsoleDiscoveryService service; + service.registerStrategy(std::make_shared()); + service.registerStrategy(std::make_shared()); + + int discovered = 0; + DiscoveredConsole last; + connect(&service, &ConsoleDiscoveryService::consoleDiscovered, + [&](const DiscoveredConsole& console) { + ++discovered; + last = console; + }); + + const QByteArray reply = + buildOscReply("/xinfo", {"192.168.1.30", "MyDesk", "M32 LIVE", "4.11"}); + service.processDatagram(reply, QHostAddress("192.168.1.30"), 10023); + QCOMPARE(discovered, 1); + QCOMPARE(last.type, ConsoleType::M32); + QCOMPARE(last.modelName, QString("M32 LIVE")); + } + + void service_ignoresJunkDatagrams() { + ConsoleDiscoveryService service; + service.registerStrategy(std::make_shared()); + service.registerStrategy(std::make_shared()); + + int discovered = 0; + connect(&service, &ConsoleDiscoveryService::consoleDiscovered, + [&](const DiscoveredConsole&) { ++discovered; }); + + service.processDatagram(QByteArray(), QHostAddress("10.0.0.1"), 1234); + service.processDatagram("garbage", QHostAddress("10.0.0.1"), 1234); + service.processDatagram("/unknown/path", QHostAddress("10.0.0.1"), 1234); + service.processDatagram("WING,short", QHostAddress("10.0.0.1"), 2222); + QCOMPARE(discovered, 0); + } + + void ahProbe_sendsFivePerFamilyFinds() { + AllenHeathProbeStrategy strategy; + QCOMPARE(strategy.probePort(), 51320); + const QList finds = strategy.rawProbes(QHostAddress("192.168.1.2")); + QCOMPARE(finds.size(), 5); + QVERIFY(finds.contains(QByteArray("SQ Find"))); + QVERIFY(finds.contains(QByteArray("Qu-567 Find"))); + QVERIFY(finds.contains(QByteArray("GLD Find"))); + QVERIFY(finds.contains(QByteArray("Bridge Find"))); + QVERIFY(finds.contains(QByteArray("TLD Find"))); + } + + void ahIdentify_descriptors() { + AllenHeathProbeStrategy strategy; + const QList ids = strategy.tcpIdentifies(); + QCOMPARE(ids.size(), 2); + + // SQ binary identify on 51326 + const TcpIdentify& sq = ids.at(0); + QVERIFY(sq.isValid()); + QCOMPARE(sq.port, 51326); + QCOMPARE(sq.request, QByteArray::fromHex("7f0100000000")); + + // ACE SysEx "DR Box Identification" on 51321 + const TcpIdentify& ace = ids.at(1); + QVERIFY(ace.isValid()); + QCOMPARE(ace.port, 51321); + QVERIFY(ace.request.startsWith(QByteArray::fromHex("f0000100000001000400"))); + QVERIFY(ace.request.contains("DR Box Identification")); + QCOMPARE(static_cast(ace.request.back()), quint8(0xf7)); + + QVERIFY(strategy.matchesReplyPort(51320)); + QVERIFY(!strategy.matchesReplyPort(2222)); + } + + void ahIdentify_mapsSqFamilies() { + struct Case { + quint8 family; + ConsoleType type; + }; + const QList cases{ + {1, ConsoleType::SQ5}, {2, ConsoleType::SQ6}, {3, ConsoleType::SQ7}}; + for (const Case& c : cases) { + const auto console = parseAh(buildAhIdentify(c.family, 1, 6, 2, 40)); + QVERIFY2(console.isValid(), QByteArray::number(c.family).constData()); + QCOMPARE(console.type, c.type); + QCOMPARE(console.port, 51325); // MIDI-over-TCP control port + QCOMPARE(console.firmwareVersion, QString("1.6.2")); + QCOMPARE(console.manufacturer, Manufacturer::AllenHeath); + } + } + + void ahIdentify_mapsQuFamilies() { + struct Case { + quint8 family; + ConsoleType type; + }; + const QList cases{ + {8, ConsoleType::Qu16}, {9, ConsoleType::Qu24}, {10, ConsoleType::Qu32}}; + for (const Case& c : cases) { + const auto console = parseAh(buildAhIdentify(c.family, 1, 9, 4, 0)); + QVERIFY2(console.isValid(), QByteArray::number(c.family).constData()); + QCOMPARE(console.type, c.type); + QCOMPARE(console.port, 51325); // MIDI-over-TCP control port, same as SQ + QCOMPARE(console.firmwareVersion, QString("1.9.4")); + } + } + + void ahIdentify_rejectsMalformedFrames() { + QVERIFY(!parseAh(QByteArray()).isValid()); + QVERIFY(!parseAh(QByteArray::fromHex("7f0200000000")).isValid()); // no payload + QVERIFY(!parseAh(QByteArray::fromHex("7f0100000000010602ABCD")).isValid()); // wrong tag + QByteArray unknownFamily = buildAhIdentify(99, 1, 0, 0); + QVERIFY(!parseAh(unknownFamily).isValid()); + } + + void aceIdentify_dliveModels() { + struct Case { + QByteArray identity; + QString model; + }; + const QList cases{{"TLDDM0Stagebox V1.90 - Rev. 100", "dLive DM0"}, + {"TLDDM32Stagebox V1.90 - Rev. 100", "dLive DM32"}, + {"TLDDM64Stagebox V1.90 - Rev. 100", "dLive DM64"}, + {"TLDDMC32Stagebox V1.90 - Rev. 100", "dLive CDM32"}, + {"TLDDMC64Stagebox V1.90 - Rev. 100", "dLive CDM64"}}; + for (const Case& c : cases) { + const auto console = parseAce(c.identity); + QVERIFY2(console.isValid(), c.identity.constData()); + QCOMPARE(console.type, ConsoleType::DLive); + QCOMPARE(console.modelName, c.model); + QCOMPARE(console.port, 51321); // ACE control port + QVERIFY(console.firmwareVersion.contains("1.90")); + QVERIFY(console.firmwareVersion.contains("Rev. 100")); + } + } + + void aceIdentify_avantis() { + const auto bridge = parseAce("Bridge Avantis - Rev. 96500"); + QVERIFY(bridge.isValid()); + QCOMPARE(bridge.type, ConsoleType::Avantis); + QCOMPARE(bridge.modelName, QString("Avantis")); + QCOMPARE(bridge.port, 51321); + + const auto solo = parseAce("Avantis Solo - Rev. 96500"); + QVERIFY(solo.isValid()); + QCOMPARE(solo.type, ConsoleType::Avantis); + QCOMPARE(solo.modelName, QString("Avantis Solo")); + } + + void aceIdentify_rejectsUnknownAndMalformed() { + QVERIFY(!parseAce("GLD80").isValid()); // GLD not identified via ACE + QVERIFY(!parseAce("Something Else").isValid()); + // frame too short / wrong prefix + AllenHeathProbeStrategy strategy; + QVERIFY(!strategy + .parseIdentifyResponse(QByteArray::fromHex("f000"), + QHostAddress("192.168.1.81"), + AllenHeathProbeStrategy::ACE_IDENTIFY_PORT) + .isValid()); + } + + void service_twoStageAllenHeathIdentify() { + // stand up a fake SQ responder on the A&H identify port and drive the + // full UDP-reply -> TCP-handshake path through real sockets + QTcpServer server; + QVERIFY2(server.listen(QHostAddress::LocalHost, 51326), qPrintable(server.errorString())); + + QByteArray requestSeen; + connect(&server, &QTcpServer::newConnection, [&server, &requestSeen]() { + QTcpSocket* conn = server.nextPendingConnection(); + connect(conn, &QTcpSocket::readyRead, [conn, &requestSeen]() { + requestSeen = conn->readAll(); + conn->write(buildAhIdentify(3, 1, 6, 2, 40)); // SQ-7, fw 1.6.2 + conn->flush(); + }); + }); + + ConsoleDiscoveryService service; + service.registerStrategy(std::make_shared()); + service.startScan(2000); + + DiscoveredConsole found; + int count = 0; + connect(&service, &ConsoleDiscoveryService::consoleDiscovered, + [&](const DiscoveredConsole& c) { + found = c; + ++count; + }); + + // simulate the console's UDP "Find" reply from source port 51320 + service.processDatagram(QByteArray("Ok"), QHostAddress::LocalHost, 51320); + + QTRY_COMPARE_WITH_TIMEOUT(count, 1, 3000); + QCOMPARE(found.type, ConsoleType::SQ7); + QCOMPARE(found.port, 51325); + QCOMPARE(requestSeen, QByteArray::fromHex("7f0100000000")); + + // a second identical reply must not spawn a duplicate probe/console + service.processDatagram(QByteArray("Ok"), QHostAddress::LocalHost, 51320); + QTest::qWait(300); + QCOMPARE(count, 1); + } + + void service_twoStageAceIdentify() { + // fake dLive responder on the ACE identify port; drives the UDP-reply -> + // TCP "DR Box Identification" -> reply-string path over real sockets + QTcpServer server; + QVERIFY2(server.listen(QHostAddress::LocalHost, AllenHeathProbeStrategy::ACE_IDENTIFY_PORT), + qPrintable(server.errorString())); + + QByteArray requestSeen; + connect(&server, &QTcpServer::newConnection, [&server, &requestSeen]() { + QTcpSocket* conn = server.nextPendingConnection(); + connect(conn, &QTcpSocket::readyRead, [conn, &requestSeen]() { + requestSeen = conn->readAll(); + conn->write(buildAceReply("TLDDM32Stagebox V1.90 - Rev. 100")); + conn->flush(); + }); + }); + + ConsoleDiscoveryService service; + service.registerStrategy(std::make_shared()); + service.startScan(2000); + + DiscoveredConsole found; + int count = 0; + connect(&service, &ConsoleDiscoveryService::consoleDiscovered, + [&](const DiscoveredConsole& c) { + found = c; + ++count; + }); + + service.processDatagram(QByteArray("Ok"), QHostAddress::LocalHost, 51320); + + QTRY_COMPARE_WITH_TIMEOUT(count, 1, 3000); + QCOMPARE(found.type, ConsoleType::DLive); + QCOMPARE(found.modelName, QString("dLive DM32")); + QCOMPARE(found.port, 51321); + QVERIFY(requestSeen.contains("DR Box Identification")); + } +}; + +QTEST_MAIN(TestConsoleDiscovery) +#include "test_console_discovery.moc" From d72646534c8d4a57eca0eb72042eecdbcb063a21 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Mon, 13 Jul 2026 21:20:25 -0400 Subject: [PATCH 4/6] fix(allenheath): correct SQ/Qu MIDI scene recall and DCA level param Verified against the official A&H SQ MIDI Protocol (Firmware V2.0.3): - Scene recall was emitting a GLD-style SysEx; the SQ/Qu/GLD MIDI interface uses Bank Select (CC0) + Program Change (1-based scene, banks of 128). e.g. scene 156 -> B0 00 01 C0 1B. - DCA fader NRPN LSB base was 0x20 (invented); the Level table gives 0x67 (DCA1=0x67..DCA8=0x6E). Input level (MSB 0x40), input/DCA mutes, and the 4-CC NRPN framing were already correct and are now covered by byte-exact tests. The fader value is a 14-bit position; the console applies its own NRPN Fader Law (Linear/Audio Taper), so the linear encoding is the documented behaviour. --- .../allenheath/AllenHeathMidiProtocol.cpp | 38 ++++++++++-------- .../allenheath/AllenHeathMidiProtocol.h | 22 ++++++----- tests/test_allenheath_midi.cpp | 39 +++++++++++++++++++ 3 files changed, 73 insertions(+), 26 deletions(-) diff --git a/src/protocol/allenheath/AllenHeathMidiProtocol.cpp b/src/protocol/allenheath/AllenHeathMidiProtocol.cpp index 6e18168..11851a0 100644 --- a/src/protocol/allenheath/AllenHeathMidiProtocol.cpp +++ b/src/protocol/allenheath/AllenHeathMidiProtocol.cpp @@ -148,10 +148,9 @@ void AllenHeathMidiProtocol::recallScene(int sceneNumber) { if (m_connectionState != ConnectionState::Connected) return; - // Allen & Heath SysEx scene recall - // F0 00 00 1A 50 10 01 00 [scene] F7 - QByteArray msg = buildSysExSceneRecall(sceneNumber); - m_transport.send(msg); + QByteArray msg = buildSceneRecall(sceneNumber); + if (!msg.isEmpty()) + m_transport.send(msg); } void AllenHeathMidiProtocol::refresh() { @@ -187,20 +186,25 @@ QByteArray AllenHeathMidiProtocol::buildNRPNMessage(int channel, int nrpnMsb, in return msg; } -// SysEx builders -QByteArray AllenHeathMidiProtocol::buildSysExSceneRecall(int sceneNumber) { +// Allen & Heath scene recall over MIDI is a Bank Select (CC0) followed by a +// Program Change, per the A&H MIDI Protocol. sceneNumber is the 1-based scene as +// shown on the console; MIDI values are offset by -1, and scenes split into +// banks of 128 (1-128 -> bank 0, 129-256 -> bank 1, 257-300 -> bank 2). +QByteArray AllenHeathMidiProtocol::buildSceneRecall(int sceneNumber) { + const int index = sceneNumber - 1; + if (index < 0) + return {}; // "None" / unset + + const int bank = index / 128; + const int program = index % 128; + const int channel = 0; // MIDI channel 1 + QByteArray msg; - // Allen & Heath SysEx header - msg.append(static_cast(0xF0)); // SysEx start - msg.append(static_cast(0x00)); // manufacturer ID byte 1 - msg.append(static_cast(0x00)); // manufacturer ID byte 2 - msg.append(static_cast(0x1A)); // manufacturer ID byte 3 (Allen & Heath) - msg.append(static_cast(0x50)); // device ID (SQ/GLD family) - msg.append(static_cast(0x10)); // command: scene recall - msg.append(static_cast(0x01)); // sub-command - msg.append(static_cast(0x00)); // reserved - msg.append(static_cast(sceneNumber & 0x7F)); // scene number (0-indexed) - msg.append(static_cast(0xF7)); // SysEx end + msg.append(static_cast(0xB0 | channel)); // Control Change + msg.append(static_cast(0x00)); // CC 0 = Bank Select MSB + msg.append(static_cast(bank & 0x7F)); + msg.append(static_cast(0xC0 | channel)); // Program Change + msg.append(static_cast(program & 0x7F)); return msg; } diff --git a/src/protocol/allenheath/AllenHeathMidiProtocol.h b/src/protocol/allenheath/AllenHeathMidiProtocol.h index 0377d0e..6733be7 100644 --- a/src/protocol/allenheath/AllenHeathMidiProtocol.h +++ b/src/protocol/allenheath/AllenHeathMidiProtocol.h @@ -26,8 +26,8 @@ struct NRPNState { } }; -// base class for Allen & Heath MIDI-over-TCP protocols (SQ, GLD) -// uses MIDI messages (including SysEx & NRPN) wrapped in TCP +// base class for Allen & Heath MIDI-over-TCP protocols (SQ, Qu, GLD) +// uses MIDI messages (NRPN levels/mutes, Bank+Program scene recall) over TCP class AllenHeathMidiProtocol : public MixerProtocol { Q_OBJECT @@ -44,7 +44,9 @@ class AllenHeathMidiProtocol : public MixerProtocol { // connection management [[nodiscard]] bool connect(const QString& host, int port) override; void disconnect() override; - [[nodiscard]] bool isConnected() const override { return m_connectionState == ConnectionState::Connected; } + [[nodiscard]] bool isConnected() const override { + return m_connectionState == ConnectionState::Connected; + } [[nodiscard]] QString connectionStatus() const override { return m_statusMessage; } [[nodiscard]] ConnectionState connectionState() const override { return m_connectionState; } @@ -70,9 +72,11 @@ class AllenHeathMidiProtocol : public MixerProtocol { [[nodiscard]] const MixerCapabilities& capabilities() const override { return m_capabilities; } // Input-channel fader level + mute via NRPN. Parameter numbers verified - // against the A&H SQ MIDI Protocol Issue 5 reference tables (Inputs-to-LR - // level p22, mutes p21, DCA level/mute p24/p21). These are the SQ numbers; - // GLD uses a different map (documented limitation of the shared base). + // against the A&H SQ MIDI Protocol (Firmware V2.0.3) reference tables: + // Inputs-to-LR level MSB 0x40, input mute MSB 0x00, DCA level MSB 0x4F / + // LSB 0x67+, DCA mute MSB 0x02 (all LSB = 0-based item index). Levels are a + // 14-bit position; the console applies its own NRPN Fader Law (Linear/Audio + // Taper). Qu shares this map; GLD is close but should be checked per its doc. void setChannelFader(int channel, double level) override; void setChannelMute(int channel, bool muted) override; @@ -80,13 +84,13 @@ class AllenHeathMidiProtocol : public MixerProtocol { // SQ NRPN parameter MSB; the LSB is the 0-based item index unless noted. static constexpr int CH_LEVEL_TO_LR_MSB = 0x40; // input N -> LR level (LSB = N-1) static constexpr int CH_MUTE_MSB = 0x00; // input N mute (LSB = N-1) - static constexpr int DCA_LEVEL_MSB = 0x4F; // DCA N level (LSB = 0x20 + N-1) - static constexpr int DCA_LEVEL_LSB_BASE = 0x20; // + static constexpr int DCA_LEVEL_MSB = 0x4F; // DCA N level (LSB = 0x67 + N-1) + static constexpr int DCA_LEVEL_LSB_BASE = 0x67; // per A&H SQ MIDI Protocol, Level table static constexpr int DCA_MUTE_MSB = 0x02; // DCA N mute (LSB = N-1) // MIDI message builders used by subclasses QByteArray buildNRPNMessage(int channel, int nrpnMsb, int nrpnLsb, int valueMsb, int valueLsb); - QByteArray buildSysExSceneRecall(int sceneNumber); + QByteArray buildSceneRecall(int sceneNumber); QByteArray buildControlChange(int channel, int cc, int value); // parse incoming MIDI data diff --git a/tests/test_allenheath_midi.cpp b/tests/test_allenheath_midi.cpp index 77e2d94..7c24084 100644 --- a/tests/test_allenheath_midi.cpp +++ b/tests/test_allenheath_midi.cpp @@ -10,6 +10,10 @@ class SqProbe : public AllenHeathMidiProtocol { public: using AllenHeathMidiProtocol::AllenHeathMidiProtocol; using AllenHeathMidiProtocol::buildNRPNMessage; + using AllenHeathMidiProtocol::buildSceneRecall; + static constexpr int dcaLevelMsb() { return DCA_LEVEL_MSB; } + static constexpr int dcaLevelLsbBase() { return DCA_LEVEL_LSB_BASE; } + static constexpr int dcaMuteMsb() { return DCA_MUTE_MSB; } protected: void initializeSnapshotParams() override {} @@ -50,6 +54,41 @@ class TestAllenHeathMidi : public QObject { QCOMPARE(p->buildNRPNMessage(0, 0x40, 0x04, 0x40, 0x00).left(6), QByteArray::fromHex("B06340B06204")); } + + void dcaFader_usesVerifiedLevelParam() { + // A&H SQ MIDI Protocol Level table: DCA N level = MSB 0x4F, LSB 0x67 + N-1 + QCOMPARE(SqProbe::dcaLevelMsb(), 0x4F); + QCOMPARE(SqProbe::dcaLevelLsbBase(), 0x67); + // DCA1 fader at unity: MSB 0x4F, LSB 0x67 + QCOMPARE( + p->buildNRPNMessage(0, SqProbe::dcaLevelMsb(), SqProbe::dcaLevelLsbBase(), 0x7F, 0x7F) + .left(6), + QByteArray::fromHex("B0634FB06267")); + // DCA8 -> LSB 0x6E + QCOMPARE( + p->buildNRPNMessage(0, SqProbe::dcaLevelMsb(), SqProbe::dcaLevelLsbBase() + 7, 0, 0) + .left(6), + QByteArray::fromHex("B0634FB0626E")); + } + + void dcaMute_usesVerifiedParam() { + // DCA N mute = MSB 0x02, LSB N-1 + QCOMPARE(SqProbe::dcaMuteMsb(), 0x02); + } + + void sceneRecall_matchesOfficialBankProgram() { + // A&H SQ MIDI Protocol scene recall = Bank Select (CC0) + Program Change, + // scene is 1-based, MIDI offset -1, banks of 128. Doc examples (Ch1): + QCOMPARE(p->buildSceneRecall(7), QByteArray::fromHex("B00000C006")); // Scene 7 + QCOMPARE(p->buildSceneRecall(120), QByteArray::fromHex("B00000C077")); // Scene 120 + QCOMPARE(p->buildSceneRecall(156), QByteArray::fromHex("B00001C01B")); // Scene 156 + QCOMPARE(p->buildSceneRecall(264), QByteArray::fromHex("B00002C007")); // Scene 264 + } + + void sceneRecall_noneIsEmpty() { + QVERIFY(p->buildSceneRecall(0).isEmpty()); // 0 -> index -1 -> unset + QVERIFY(p->buildSceneRecall(-1).isEmpty()); // explicit None + } }; QTEST_MAIN(TestAllenHeathMidi) From f2b08a80fa502ea65d79e76133f573ccbe52d874 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Mon, 13 Jul 2026 21:21:55 -0400 Subject: [PATCH 5/6] feat(allenheath): implement the real ACE property protocol (Avantis/dLive/GLD) Replaces the invented [len][type][float] stub with the actual A&H property protocol recovered byte-exact from the reference firmware: - F0..F7 length-prefixed framing; subscribe-by-name handshake that learns each object's 2-byte handle from the reply; SET frames F0 00 00 00 00 00 F7. - Channel level/mute, DCA mute, scene recall with per-console opcodes (Avantis 0x25/0x26, dLive 0x30/0x31), firmware-gated variants (+6/+8), and dLive bank/spill addressing (op 0x12). - Exact per-0.1 dB lookup tables (not a formula), distinct per family. - GLD moved onto its real property protocol (op 0x16, own dB table) with a secondary MIDI socket for scene-recall MMC, matching the reference two-socket design; capabilities updated to ACE-TCP 51321. - DCA fader dropped (receive-only in the reference). Firmware wired from the discovered console. Byte-exact tests for every frame and both dB tables. --- src/app/Application.cpp | 12 + src/protocol/MixerCapabilities.cpp | 8 +- .../allenheath/AllenHeathTcpProtocol.cpp | 538 +++++++++++------- .../allenheath/AllenHeathTcpProtocol.h | 83 ++- src/protocol/allenheath/AvantisProtocol.cpp | 7 +- src/protocol/allenheath/AvantisProtocol.h | 7 + src/protocol/allenheath/DLiveProtocol.cpp | 7 +- src/protocol/allenheath/DLiveProtocol.h | 15 + src/protocol/allenheath/GLDProtocol.cpp | 55 +- src/protocol/allenheath/GLDProtocol.h | 34 +- tests/CMakeLists.txt | 3 + tests/test_allenheath_parsing.cpp | 212 ++++--- 12 files changed, 674 insertions(+), 307 deletions(-) diff --git a/src/app/Application.cpp b/src/app/Application.cpp index efc773a..db4b13c 100644 --- a/src/app/Application.cpp +++ b/src/app/Application.cpp @@ -27,6 +27,7 @@ #include "midi/MidiInputManager.h" #include "protocol/MixerProtocol.h" #include "protocol/ProtocolFactory.h" +#include "protocol/allenheath/AllenHeathTcpProtocol.h" #include "protocol/discovery/ConsoleDiscoveryService.h" #include "protocol/discovery/DiscoveredConsole.h" #include "protocol/discovery/probes/AllenHeathProbeStrategy.h" @@ -386,6 +387,17 @@ void Application::connectToDiscoveredConsole(const DiscoveredConsole& console) { if (!m_mixer) return; + // pass the discovered firmware to A&H ACE protocols so their firmware-gated + // opcodes match the console exactly + if (auto* ace = dynamic_cast(m_mixer)) { + ace->setFirmwareVersion(console.firmwareVersion); + const int revIdx = console.firmwareVersion.indexOf("Rev. "); + if (revIdx >= 0) { + ace->setFirmwareRevision( + console.firmwareVersion.mid(revIdx + 5).remove(')').trimmed().toInt()); + } + } + setupMixerConnection(console.toCapabilities().protocolId, console.address.toString(), console.port); } diff --git a/src/protocol/MixerCapabilities.cpp b/src/protocol/MixerCapabilities.cpp index 5754e52..b0ea754 100644 --- a/src/protocol/MixerCapabilities.cpp +++ b/src/protocol/MixerCapabilities.cpp @@ -163,10 +163,10 @@ MixerCapabilities MixerCapabilities::forConsole(ConsoleType type) { case ConsoleType::GLD80: caps.manufacturer = Manufacturer::AllenHeath; - caps.protocol = ProtocolType::MidiTcp; + caps.protocol = ProtocolType::BinaryTcp; caps.displayName = "Allen & Heath GLD-80"; caps.protocolId = "gld80"; - caps.defaultPort = 51325; + caps.defaultPort = 51321; caps.dcaCount = 8; caps.inputChannels = 48; caps.mixBuses = 20; @@ -183,10 +183,10 @@ MixerCapabilities MixerCapabilities::forConsole(ConsoleType type) { case ConsoleType::GLD112: caps.manufacturer = Manufacturer::AllenHeath; - caps.protocol = ProtocolType::MidiTcp; + caps.protocol = ProtocolType::BinaryTcp; caps.displayName = "Allen & Heath GLD-112"; caps.protocolId = "gld112"; - caps.defaultPort = 51325; + caps.defaultPort = 51321; caps.dcaCount = 8; caps.inputChannels = 48; caps.mixBuses = 30; diff --git a/src/protocol/allenheath/AllenHeathTcpProtocol.cpp b/src/protocol/allenheath/AllenHeathTcpProtocol.cpp index 0d266a5..4cca7a7 100644 --- a/src/protocol/allenheath/AllenHeathTcpProtocol.cpp +++ b/src/protocol/allenheath/AllenHeathTcpProtocol.cpp @@ -1,17 +1,20 @@ #include "AllenHeathTcpProtocol.h" #include "../../core/Cue.h" #include -#include +#include namespace OpenMix { -// ACE protocol message types -namespace AceMessage { -constexpr quint8 TYPE_KEEPALIVE = 0x01; -constexpr quint8 TYPE_PARAMETER = 0x02; -constexpr quint8 TYPE_SCENE = 0x03; -constexpr quint8 TYPE_DCA = 0x10; -} // namespace AceMessage +// object names whose handles the control path needs (learned during handshake) +const QByteArray AllenHeathTcpProtocol::kInputMixer = QByteArrayLiteral("Input Mixer"); +const QByteArray AllenHeathTcpProtocol::kDcaLevelsAndMutes = + QByteArrayLiteral("DCA Levels and Mutes"); +const QByteArray AllenHeathTcpProtocol::kSceneManager = QByteArrayLiteral("StageBox Scene Manager"); + +namespace { +constexpr char ACE_F0 = '\xf0'; +constexpr char ACE_F7 = '\xf7'; +} // namespace AllenHeathTcpProtocol::AllenHeathTcpProtocol(const MixerCapabilities& caps, QObject* parent) : MixerProtocol(parent), m_capabilities(caps), m_transport(this) { @@ -37,6 +40,219 @@ AllenHeathTcpProtocol::AllenHeathTcpProtocol(const MixerCapabilities& caps, QObj AllenHeathTcpProtocol::~AllenHeathTcpProtocol() { disconnect(); } +// --- value + frame builders (byte layouts recovered from AvantisDriver/DLiveDriver) --- + +QByteArray AllenHeathTcpProtocol::encodeDbTables(double dB, const quint8 posLo[10], + const quint8 negLo[10]) { + // Exact reproduction of the reference encoders: the high byte is + // (intDB + 0x80); the low byte is a per-0.1 dB lookup (distinct tables for + // positive vs negative tenths). Negative values with a nonzero tenth floor + // the integer part. Usable range -95..+10 dB; <= -95 -> -inf (0x0000). + if (dB <= -95.0) { + return QByteArray::fromHex("0000"); // -inf + } + if (dB >= 10.0) { + QByteArray b; + b.append(static_cast(0x8A)); // +10 dB + b.append(static_cast(0x00)); + return b; + } + + const int tenths = static_cast(std::lround(dB * 10.0)); + const int fracDigit = std::abs(tenths % 10); + + int intPart = tenths / 10; // truncates toward zero + quint8 lo; + if (tenths >= 0) { + lo = posLo[fracDigit]; + } else { + if (fracDigit != 0) { + intPart -= 1; // floor for negative non-integer dB + } + lo = negLo[fracDigit]; + } + + QByteArray b; + b.append(static_cast((intPart + 0x80) & 0xFF)); + b.append(static_cast(lo)); + return b; +} + +QByteArray AllenHeathTcpProtocol::encodeDb(double dB) const { + // Avantis/dLive tables (reference FUN_00415330) + static const quint8 kPosLo[10] = {0x00, 0x1C, 0x36, 0x4F, 0x69, 0x82, 0x9C, 0xB6, 0xCF, 0xE9}; + static const quint8 kNegLo[10] = {0x00, 0xD9, 0xBF, 0xA6, 0x8C, 0x73, 0x59, 0x40, 0x26, 0x0C}; + return encodeDbTables(dB, kPosLo, kNegLo); +} + +QList AllenHeathTcpProtocol::subscribeObjects() const { + // control-relevant subset of the ACE handshake (identity gate + the three + // objects whose handles the control path addresses). The full reference + // handshake also subscribes name/colour/metering objects used only for RX. + return {QByteArrayLiteral("DR Box Identification"), QByteArrayLiteral("Surface Identification"), + kInputMixer, kDcaLevelsAndMutes, kSceneManager}; +} + +double AllenHeathTcpProtocol::dbFromLevel(double level) { + // OpenMix stores fader positions normalized 0..1; the console wants dB. Map + // with 0 dB at 0.75 travel and +10 dB at the top, -inf at the bottom. + level = std::clamp(level, 0.0, 1.0); + if (level <= 0.0) { + return -96.0; // below the encoder's -95 floor -> 0x0000 + } + if (level >= 1.0) { + return 10.0; + } + if (level < 0.75) { + return -60.0 + (level / 0.75) * 60.0; // -60 .. 0 dB + } + return (level - 0.75) / 0.25 * 10.0; // 0 .. +10 dB +} + +QByteArray AllenHeathTcpProtocol::buildSubscribe(const QByteArray& objectName) { + // F0 00 01 00 00 00 01 00 04 00 00 F7 + QByteArray f = QByteArray::fromHex("f0000100000001000400"); + f.append(static_cast(objectName.size() + 1)); + f.append(objectName); + f.append('\0'); + f.append(ACE_F7); + return f; +} + +QByteArray AllenHeathTcpProtocol::buildChannelLevel(const QByteArray& handle, int channel, + double dB) const { + if (handle.isEmpty() || channel < 1) { + return {}; + } + // F0 00 00 00 00 00 02 F7 + QByteArray f; + f.append(ACE_F0); + f.append('\0'); + f.append('\0'); + f.append(handle); + f.append('\0'); + f.append('\0'); + f.append(static_cast(channelLevelOp())); + f.append(static_cast((channel - 1) & 0xFF)); + f.append('\0'); + f.append('\x02'); + f.append(encodeDb(dB)); + f.append(ACE_F7); + return f; +} + +QByteArray AllenHeathTcpProtocol::buildChannelMute(const QByteArray& handle, int channel, + bool on) const { + if (handle.isEmpty() || channel < 1) { + return {}; + } + // F0 00 00 00 00 <(ch-1)+plane> 00 01 F7 + QByteArray f; + f.append(ACE_F0); + f.append('\0'); + f.append('\0'); + f.append(handle); + f.append('\0'); + f.append('\0'); + f.append(static_cast(channelMuteOp())); + f.append(static_cast(((channel - 1) + channelMutePlane()) & 0xFF)); + f.append('\0'); + f.append('\x01'); + f.append(static_cast(on ? 1 : 0)); + f.append(ACE_F7); + return f; +} + +QByteArray AllenHeathTcpProtocol::buildDcaMute(const QByteArray& handle, int dca, bool on) const { + if (handle.isEmpty() || dca < 1) { + return {}; + } + // F0 00 00 00 00 10 <(dca-1)+plane> 00 01 F7 + QByteArray f; + f.append(ACE_F0); + f.append('\0'); + f.append('\0'); + f.append(handle); + f.append('\0'); + f.append('\0'); + f.append('\x10'); + f.append(static_cast(((dca - 1) + dcaMutePlane()) & 0xFF)); + f.append('\0'); + f.append('\x01'); + f.append(static_cast(on ? 1 : 0)); + f.append(ACE_F7); + return f; +} + +QByteArray AllenHeathTcpProtocol::buildChannelLevelSpill(const QByteArray& bankHandle, int channel, + double dB) const { + if (bankHandle.isEmpty() || channel < 1) { + return {}; + } + // F0 00 00 00 00 12 <((ch-1)&7)+0x80> 00 02 F7 + QByteArray f; + f.append(ACE_F0); + f.append('\0'); + f.append('\0'); + f.append(bankHandle); + f.append('\0'); + f.append('\0'); + f.append('\x12'); + f.append(static_cast((((channel - 1) & 0x07) + 0x80) & 0xFF)); + f.append('\0'); + f.append('\x02'); + f.append(encodeDb(dB)); + f.append(ACE_F7); + return f; +} + +QByteArray AllenHeathTcpProtocol::buildChannelMuteSpill(const QByteArray& bankHandle, int channel, + bool on) const { + if (bankHandle.isEmpty() || channel < 1) { + return {}; + } + // F0 00 00 00 00 12 <((ch-1)&7)+0x98> 00 01 F7 + QByteArray f; + f.append(ACE_F0); + f.append('\0'); + f.append('\0'); + f.append(bankHandle); + f.append('\0'); + f.append('\0'); + f.append('\x12'); + f.append(static_cast((((channel - 1) & 0x07) + 0x98) & 0xFF)); + f.append('\0'); + f.append('\x01'); + f.append(static_cast(on ? 1 : 0)); + f.append(ACE_F7); + return f; +} + +QByteArray AllenHeathTcpProtocol::buildSceneRecall(const QByteArray& handle, int sceneNumber) { + if (handle.isEmpty() || sceneNumber < 1) { + return {}; + } + // F0 00 00 00 00 10 00 00 02 <(scene-1):2 BE> F7 + const int s = sceneNumber - 1; + QByteArray f; + f.append(ACE_F0); + f.append('\0'); + f.append('\0'); + f.append(handle); + f.append('\0'); + f.append('\0'); + f.append('\x10'); + f.append('\0'); + f.append('\0'); + f.append('\x02'); + f.append(static_cast((s >> 8) & 0xFF)); + f.append(static_cast(s & 0xFF)); + f.append(ACE_F7); + return f; +} + +// --- connection --- + bool AllenHeathTcpProtocol::connect(const QString& host, int port) { if (m_connectionState == ConnectionState::Connected || m_connectionState == ConnectionState::Connecting) { @@ -54,10 +270,19 @@ bool AllenHeathTcpProtocol::connect(const QString& host, int port) { void AllenHeathTcpProtocol::disconnect() { m_keepAliveTimer.stop(); + + // ACE teardown: E0 00 03 "BYE" E7 + if (m_transport.isConnected()) { + m_transport.send(QByteArray::fromHex("e00003425945e7")); + } m_transport.disconnect(); m_parameterCache.clear(); m_receiveBuffer.clear(); + m_handles.clear(); + m_subscribeQueue.clear(); + m_subscribeIndex = 0; + m_handshakeComplete = false; m_latencyMs = 0; setConnectionState(ConnectionState::Disconnected); @@ -65,26 +290,58 @@ void AllenHeathTcpProtocol::disconnect() { emit disconnected(); } +void AllenHeathTcpProtocol::startHandshake() { + // session seed, then subscribe to the objects whose handles we need to + // address. The console replies to each with a 14-byte frame carrying the + // object's 2-byte handle at offset 11. + m_transport.send(QByteArray::fromHex("e000040203")); + + m_handles.clear(); + m_subscribeQueue = subscribeObjects(); + m_subscribeIndex = 0; + m_handshakeComplete = false; + sendNextSubscribe(); +} + +void AllenHeathTcpProtocol::sendNextSubscribe() { + if (m_subscribeIndex >= m_subscribeQueue.size()) { + // all handles learned: ready to control + m_handshakeComplete = true; + setConnectionState(ConnectionState::Connected); + setStatus(QString("Connected to %1:%2").arg(m_host).arg(m_port)); + m_keepAliveTimer.start(KEEPALIVE_INTERVAL); + emit connected(); + return; + } + m_transport.send(buildSubscribe(m_subscribeQueue.at(m_subscribeIndex))); +} + +// --- parameter operations --- + void AllenHeathTcpProtocol::sendParameter(const QString& path, const QVariant& value) { - if (m_connectionState != ConnectionState::Connected) + if (m_connectionState != ConnectionState::Connected) { return; + } - if (path.startsWith("/dca/")) { - QStringList parts = path.split('/'); - if (parts.size() >= 4) { - bool ok; - int dca = parts[2].toInt(&ok); - if (!ok || dca < 1) - return; - QString param = parts[3]; + const QStringList parts = path.split('/', Qt::SkipEmptyParts); + if (parts.size() >= 3) { + bool ok = false; + const int index = parts[1].toInt(&ok); + const QString param = parts[2]; + if (ok && parts[0] == "ch") { if (param == "fader") { - QByteArray msg = buildDCAFaderMessage(dca, value.toFloat()); - m_transport.send(msg); + m_transport.send(buildChannelLevel(handleFor(kInputMixer), index, + dbFromLevel(value.toDouble()))); } else if (param == "mute") { - QByteArray msg = buildDCAMuteMessage(dca, value.toBool()); - m_transport.send(msg); + m_transport.send(buildChannelMute(handleFor(kInputMixer), index, value.toBool())); + } + } else if (ok && parts[0] == "dca") { + if (param == "mute") { + m_transport.send( + buildDcaMute(handleFor(kDcaLevelsAndMutes), index, value.toBool())); } + // DCA fader is not transmitted on ACE (receive-only plane) } } @@ -95,218 +352,104 @@ QVariant AllenHeathTcpProtocol::getParameter(const QString& path) { return m_parameterCache.value(path); } -void AllenHeathTcpProtocol::requestParameter(const QString& path) { - // binary protocol, params pushed by console - Q_UNUSED(path); -} +void AllenHeathTcpProtocol::requestParameter(const QString& path) { Q_UNUSED(path); } void AllenHeathTcpProtocol::requestParameterAsync(const QString& path, ParameterCallback callback) { + if (!callback) { + return; + } if (m_parameterCache.contains(path)) { - if (callback) { - callback(path, m_parameterCache[path], true); - } + callback(path, m_parameterCache[path], true); } else { - if (callback) { - callback(path, QVariant(), false); - } + callback(path, QVariant(), false); } } void AllenHeathTcpProtocol::recallSnapshot(const Cue& cue) { - if (m_connectionState != ConnectionState::Connected) + if (m_connectionState != ConnectionState::Connected) { return; - - QJsonObject params = cue.parameters(); + } + const QJsonObject params = cue.parameters(); for (auto it = params.begin(); it != params.end(); ++it) { sendParameter(it.key(), it.value().toVariant()); } } void AllenHeathTcpProtocol::recallScene(int sceneNumber) { - if (m_connectionState != ConnectionState::Connected) + if (m_connectionState != ConnectionState::Connected) { return; - - QByteArray msg = buildSceneRecallMessage(sceneNumber); - m_transport.send(msg); -} - -void AllenHeathTcpProtocol::refresh() { - // binary protocol doesn't support general refresh -} - -QByteArray AllenHeathTcpProtocol::buildDCAFaderMessage(int dca, float level) { - if (dca < 1) - return {}; - - QByteArray msg; - - // ACE Protocol frame: [Length(2)] [Type(1)] [Payload...] - // DCA fader: Type 0x10, Payload: [DCA index(1)] [Level(4, float BE)] - - quint16 length = 6; // 1 byte type + 1 byte DCA + 4 bytes float - msg.append(static_cast((length >> 8) & 0xFF)); - msg.append(static_cast(length & 0xFF)); - msg.append(static_cast(AceMessage::TYPE_DCA)); - msg.append(static_cast(dca - 1)); // 0-indexed - - // float in big-endian - float clamped = qBound(0.0f, level, 1.0f); - quint32 i; - std::memcpy(&i, &clamped, 4); - quint32 be = qToBigEndian(i); - msg.append(reinterpret_cast(&be), 4); - - return msg; + } + m_transport.send(buildSceneRecall(handleFor(kSceneManager), sceneNumber)); } -QByteArray AllenHeathTcpProtocol::buildDCAMuteMessage(int dca, bool muted) { - if (dca < 1) - return {}; - - QByteArray msg; - - // DCA mute: Type 0x10, Payload: [DCA index(1)] [0x80 flag for mute] [Mute state(1)] - quint16 length = 4; - msg.append(static_cast((length >> 8) & 0xFF)); - msg.append(static_cast(length & 0xFF)); - msg.append(static_cast(AceMessage::TYPE_DCA)); - msg.append(static_cast((dca - 1) | 0x80)); // 0x80 indicates mute operation - msg.append(static_cast(muted ? 1 : 0)); - - return msg; +void AllenHeathTcpProtocol::setChannelFader(int channel, double level) { + sendParameter(QString("/ch/%1/fader").arg(channel), level); } -QByteArray AllenHeathTcpProtocol::buildSceneRecallMessage(int sceneNumber) { - QByteArray msg; - - // scene recall: Type 0x03, Payload: [Scene number(2, BE)] - quint16 length = 3; - msg.append(static_cast((length >> 8) & 0xFF)); - msg.append(static_cast(length & 0xFF)); - msg.append(static_cast(AceMessage::TYPE_SCENE)); - msg.append(static_cast((sceneNumber >> 8) & 0xFF)); - msg.append(static_cast(sceneNumber & 0xFF)); - - return msg; +void AllenHeathTcpProtocol::setChannelMute(int channel, bool muted) { + sendParameter(QString("/ch/%1/mute").arg(channel), muted); } -QByteArray AllenHeathTcpProtocol::buildKeepAliveMessage() { - QByteArray msg; - - quint16 length = 1; - msg.append(static_cast((length >> 8) & 0xFF)); - msg.append(static_cast(length & 0xFF)); - msg.append(static_cast(AceMessage::TYPE_KEEPALIVE)); +void AllenHeathTcpProtocol::refresh() {} - return msg; -} +// --- receive --- -void AllenHeathTcpProtocol::parseProtocolData(const QByteArray& data) { +void AllenHeathTcpProtocol::onDataReceived(const QByteArray& data) { m_receiveBuffer.append(data); - while (m_receiveBuffer.size() >= 3) { - quint16 length = (static_cast(m_receiveBuffer[0]) << 8) | - static_cast(m_receiveBuffer[1]); - - if (m_receiveBuffer.size() < length + 2) - break; - - QByteArray frame = m_receiveBuffer.mid(2, length); - m_receiveBuffer.remove(0, length + 2); - - if (frame.isEmpty()) - continue; - - quint8 type = static_cast(frame[0]); - - switch (type) { - case AceMessage::TYPE_KEEPALIVE: - // could be used to measure round-trip time if we track send time - break; - - case AceMessage::TYPE_PARAMETER: - if (frame.size() >= 3) { - int pathLen = static_cast(frame[1]); - if (frame.size() >= 2 + pathLen + 2) { - QString path = QString::fromUtf8(frame.mid(2, pathLen)); - quint8 valueType = static_cast(frame[2 + pathLen]); - - QVariant value; - int valueOffset = 3 + pathLen; - - if (valueType == 0x01 && frame.size() >= valueOffset + 4) { - // float value - quint32 be; - std::memcpy(&be, frame.constData() + valueOffset, 4); - quint32 hostBits = qFromBigEndian(be); - float f; - std::memcpy(&f, &hostBits, 4); - value = f; - } else if (valueType == 0x02 && frame.size() >= valueOffset + 4) { - quint32 be; - memcpy(&be, frame.constData() + valueOffset, 4); - value = static_cast(qFromBigEndian(be)); - } else if (valueType == 0x03 && frame.size() >= valueOffset + 1) { - value = frame[valueOffset] != 0; - } - - if (value.isValid()) { - m_parameterCache[path] = value; - emit parameterChanged(path, value); - } - } - } - break; + // property frames are length-prefixed: an F0 frame's total length is the + // 16-bit big-endian payload length at bytes [9..10] plus 0x0C of header/tail. + // E0 control frames are E7-terminated. Skip anything else (padding). + while (!m_receiveBuffer.isEmpty()) { + const quint8 lead = static_cast(m_receiveBuffer.at(0)); - case AceMessage::TYPE_SCENE: - if (frame.size() >= 3) { - int sceneNumber = - (static_cast(frame[1]) << 8) | static_cast(frame[2]); - emit sceneChanged(sceneNumber); + if (lead == 0xF0) { + if (m_receiveBuffer.size() < 11) { + break; // need the length field } - break; - - case AceMessage::TYPE_DCA: - if (frame.size() >= 2) { - int dcaIndex = static_cast(frame[1]); - bool isMute = (dcaIndex & 0x80) != 0; - dcaIndex &= 0x7F; - - if (isMute && frame.size() >= 3) { - bool muted = frame[2] != 0; - QString path = QString("/dca/%1/mute").arg(dcaIndex + 1); - m_parameterCache[path] = muted; - emit parameterChanged(path, muted); - } else if (frame.size() >= 6) { - quint32 be; - std::memcpy(&be, frame.constData() + 2, 4); - quint32 hostBits = qFromBigEndian(be); - float f; - std::memcpy(&f, &hostBits, 4); - QString path = QString("/dca/%1/fader").arg(dcaIndex + 1); - m_parameterCache[path] = f; - emit parameterChanged(path, f); - } + const int payloadLen = (static_cast(m_receiveBuffer.at(9)) << 8) | + static_cast(m_receiveBuffer.at(10)); + const int frameLen = payloadLen + 0x0C; + if (m_receiveBuffer.size() < frameLen) { + break; // wait for the rest } - break; - - default: - break; + handleAceFrame(m_receiveBuffer.left(frameLen)); + m_receiveBuffer.remove(0, frameLen); + } else if (lead == 0xE0) { + const int end = m_receiveBuffer.indexOf('\xe7', 1); + if (end < 0) { + break; + } + m_receiveBuffer.remove(0, end + 1); // console control frame; no action + } else { + m_receiveBuffer.remove(0, 1); // padding / resync } } } -void AllenHeathTcpProtocol::onTransportConnected() { - setConnectionState(ConnectionState::Connected); - setStatus(QString("Connected to %1:%2").arg(m_host).arg(m_port)); - - m_keepAliveTimer.start(KEEPALIVE_INTERVAL); +void AllenHeathTcpProtocol::handleAceFrame(const QByteArray& frame) { + // A subscribe reply is exactly 14 bytes with the reply marker 00 01 at [1..2] + // (console->app; app->console SET frames use 00 00) and carries the learned + // object handle at [11..12]. Correlation is by the order we sent subscribes. + const bool isHandleReply = frame.size() == 14 && static_cast(frame.at(1)) == 0x00 && + static_cast(frame.at(2)) == 0x01; + + if (!m_handshakeComplete && m_subscribeIndex < m_subscribeQueue.size() && isHandleReply) { + m_handles.insert(m_subscribeQueue.at(m_subscribeIndex), frame.mid(11, 2)); + ++m_subscribeIndex; + sendNextSubscribe(); + return; + } - emit connected(); + // other frames are parameter/metering feedback (not needed for the outbound + // control path) } +void AllenHeathTcpProtocol::onTransportConnected() { startHandshake(); } + void AllenHeathTcpProtocol::onTransportDisconnected() { m_keepAliveTimer.stop(); + m_handshakeComplete = false; setConnectionState(ConnectionState::Disconnected); setStatus("Disconnected"); emit disconnected(); @@ -318,16 +461,15 @@ void AllenHeathTcpProtocol::onTransportError(const QString& error) { } void AllenHeathTcpProtocol::onTransportConnectionLost() { + m_handshakeComplete = false; setConnectionState(ConnectionState::Reconnecting); setStatus("Connection lost, reconnecting..."); emit connectionLost(); } -void AllenHeathTcpProtocol::onDataReceived(const QByteArray& data) { parseProtocolData(data); } - void AllenHeathTcpProtocol::onKeepAliveTimeout() { if (m_connectionState == ConnectionState::Connected) { - m_transport.send(buildKeepAliveMessage()); + m_transport.send(QByteArray::fromHex("e000040203")); } } diff --git a/src/protocol/allenheath/AllenHeathTcpProtocol.h b/src/protocol/allenheath/AllenHeathTcpProtocol.h index af5a466..6e99689 100644 --- a/src/protocol/allenheath/AllenHeathTcpProtocol.h +++ b/src/protocol/allenheath/AllenHeathTcpProtocol.h @@ -4,14 +4,18 @@ #include "../MixerProtocol.h" #include "../transport/TcpTransport.h" #include +#include #include #include namespace OpenMix { -// base class for Allen & Heath binary TCP protocols (Avantis, dLive) -// use a proprietary binary protocol (ACE Protocol) -// port 51321 +// base class for Allen & Heath "ACE" binary TCP protocols (Avantis, dLive), port +// 51321. This is a property protocol: frames are F0 .. F7, addresses are 2-byte +// handles the console hands back during a subscribe-by-name handshake, and level +// values are a 2-byte dB encoding. Control covers channel level/mute, DCA mute, +// and scene recall (the DCA *fader* plane is receive-only, as in the reference). +// Byte layouts recovered from the reference AvantisDriver/DLiveDriver. class AllenHeathTcpProtocol : public MixerProtocol { Q_OBJECT @@ -22,13 +26,15 @@ class AllenHeathTcpProtocol : public MixerProtocol { // protocol identification [[nodiscard]] QString protocolName() const override { return m_capabilities.displayName; } [[nodiscard]] QString protocolDescription() const override { - return m_capabilities.displayName + " TCP Protocol"; + return m_capabilities.displayName + " ACE Protocol"; } // connection management [[nodiscard]] bool connect(const QString& host, int port) override; void disconnect() override; - [[nodiscard]] bool isConnected() const override { return m_connectionState == ConnectionState::Connected; } + [[nodiscard]] bool isConnected() const override { + return m_connectionState == ConnectionState::Connected; + } [[nodiscard]] QString connectionStatus() const override { return m_statusMessage; } [[nodiscard]] ConnectionState connectionState() const override { return m_connectionState; } @@ -44,6 +50,10 @@ class AllenHeathTcpProtocol : public MixerProtocol { // scene recall void recallScene(int sceneNumber) override; + // semantic setters + void setChannelFader(int channel, double level) override; + void setChannelMute(int channel, bool muted) override; + // keep-alive void refresh() override; @@ -53,24 +63,62 @@ class AllenHeathTcpProtocol : public MixerProtocol { // capabilities [[nodiscard]] const MixerCapabilities& capabilities() const override { return m_capabilities; } + // some ACE opcodes are firmware-gated (Avantis extends the op above a build + // threshold; dLive extends for non-1.x software). The app sets these from the + // discovered console's version so the exact opcode variant is emitted. + void setFirmwareRevision(int rev) { m_firmwareRev = rev; } + void setFirmwareVersion(const QString& version) { m_firmwareVersion = version; } + protected: - // binary message builders - QByteArray buildDCAFaderMessage(int dca, float level); - QByteArray buildDCAMuteMessage(int dca, bool muted); - QByteArray buildSceneRecallMessage(int sceneNumber); - QByteArray buildKeepAliveMessage(); + // per-console opcodes / plane offsets (differ between Avantis and dLive, and + // are firmware-gated). Defaults are the base (older-firmware) opcodes. + virtual quint8 channelLevelOp() const = 0; // Avantis 0x25/0x2B, dLive 0x30/0x38 + virtual quint8 channelMuteOp() const = 0; // Avantis 0x26/0x2C, dLive 0x31/0x39 + virtual int channelMutePlane() const = 0; // added to (ch-1): Avantis 0x20, dLive 0x80 + virtual int dcaMutePlane() const = 0; // added to (dca-1): Avantis 0x18, dLive 0x20 - // parse incoming binary data - virtual void parseProtocolData(const QByteArray& data); + int m_firmwareRev = -1; // build number from " - Rev. N"; -1 = unknown + QString m_firmwareVersion; // full version string (for the dLive 1.x test) - // subclass-specific initialization virtual void initializeSnapshotParams() = 0; + // the ordered object names subscribed on connect (control-relevant subset); + // the console returns a 2-byte handle per name. Overridable for GLD. + virtual QList subscribeObjects() const; + + // dB -> 2-byte encoding. Avantis/dLive use one lookup table; GLD uses a + // sibling table (overridden). Shared logic in encodeDbTables(). + virtual QByteArray encodeDb(double dB) const; + static QByteArray encodeDbTables(double dB, const quint8 posLo[10], const quint8 negLo[10]); + + // frame builders (protected for tests). Handle is the 2-byte object handle. + static QByteArray buildSubscribe(const QByteArray& objectName); + QByteArray buildChannelLevel(const QByteArray& handle, int channel, double dB) const; + QByteArray buildChannelMute(const QByteArray& handle, int channel, bool on) const; + QByteArray buildDcaMute(const QByteArray& handle, int dca, bool on) const; + static QByteArray buildSceneRecall(const QByteArray& handle, int sceneNumber); + + // dLive bank/spill addressing (op 0x12): channel index is bank-relative + // ((ch-1)&7)+0x80 for level, +0x98 for mute, against a per-8-channel handle + QByteArray buildChannelLevelSpill(const QByteArray& bankHandle, int channel, double dB) const; + QByteArray buildChannelMuteSpill(const QByteArray& bankHandle, int channel, bool on) const; + + // maps a normalized 0..1 fader position to dB for the encoder + static double dbFromLevel(double level); + + // object names whose handles the control path needs + static const QByteArray kInputMixer; + static const QByteArray kDcaLevelsAndMutes; + static const QByteArray kSceneManager; + MixerCapabilities m_capabilities; TcpTransport m_transport; QMap m_parameterCache; QStringList m_snapshotParams; + // learned object handles (object name -> 2-byte handle) + QMap m_handles; + private slots: void onTransportConnected(); void onTransportDisconnected(); @@ -83,6 +131,10 @@ class AllenHeathTcpProtocol : public MixerProtocol { private: void setStatus(const QString& status); void setConnectionState(ConnectionState state); + void startHandshake(); + void sendNextSubscribe(); + void handleAceFrame(const QByteArray& frame); + QByteArray handleFor(const QByteArray& objectName) const { return m_handles.value(objectName); } QString m_host; int m_port; @@ -94,6 +146,11 @@ class AllenHeathTcpProtocol : public MixerProtocol { int m_latencyMs = 0; QByteArray m_receiveBuffer; + + // subscribe handshake progress + QList m_subscribeQueue; + int m_subscribeIndex = 0; + bool m_handshakeComplete = false; }; } // namespace OpenMix diff --git a/src/protocol/allenheath/AvantisProtocol.cpp b/src/protocol/allenheath/AvantisProtocol.cpp index 68dfc32..e0563fa 100644 --- a/src/protocol/allenheath/AvantisProtocol.cpp +++ b/src/protocol/allenheath/AvantisProtocol.cpp @@ -10,9 +10,12 @@ AvantisProtocol::AvantisProtocol(const MixerCapabilities& caps, QObject* parent) void AvantisProtocol::initializeSnapshotParams() { m_snapshotParams.clear(); - // Avantis has 16 DCAs + // ACE controls channel level/mute and DCA mute (DCA fader is receive-only) + for (int i = 1; i <= m_capabilities.inputChannels; ++i) { + m_snapshotParams.append(QString("/ch/%1/fader").arg(i)); + m_snapshotParams.append(QString("/ch/%1/mute").arg(i)); + } for (int i = 1; i <= m_capabilities.dcaCount && i <= 16; ++i) { - m_snapshotParams.append(QString("/dca/%1/fader").arg(i)); m_snapshotParams.append(QString("/dca/%1/mute").arg(i)); } } diff --git a/src/protocol/allenheath/AvantisProtocol.h b/src/protocol/allenheath/AvantisProtocol.h index 93a6543..581b9a0 100644 --- a/src/protocol/allenheath/AvantisProtocol.h +++ b/src/protocol/allenheath/AvantisProtocol.h @@ -17,6 +17,13 @@ class AvantisProtocol : public AllenHeathTcpProtocol { protected: void initializeSnapshotParams() override; + + // ACE opcodes / plane offsets recovered from AvantisDriver. The channel op + // extends by 6 (0x25->0x2B, 0x26->0x2C) on firmware build > 96884. + quint8 channelLevelOp() const override { return m_firmwareRev > 96884 ? 0x2B : 0x25; } + quint8 channelMuteOp() const override { return m_firmwareRev > 96884 ? 0x2C : 0x26; } + int channelMutePlane() const override { return 0x20; } + int dcaMutePlane() const override { return 0x18; } }; } // namespace OpenMix diff --git a/src/protocol/allenheath/DLiveProtocol.cpp b/src/protocol/allenheath/DLiveProtocol.cpp index 46ce7ba..ce6f447 100644 --- a/src/protocol/allenheath/DLiveProtocol.cpp +++ b/src/protocol/allenheath/DLiveProtocol.cpp @@ -10,9 +10,12 @@ DLiveProtocol::DLiveProtocol(const MixerCapabilities& caps, QObject* parent) void DLiveProtocol::initializeSnapshotParams() { m_snapshotParams.clear(); - // dLive has 16 DCAs + // ACE controls channel level/mute and DCA mute (DCA fader is receive-only) + for (int i = 1; i <= m_capabilities.inputChannels; ++i) { + m_snapshotParams.append(QString("/ch/%1/fader").arg(i)); + m_snapshotParams.append(QString("/ch/%1/mute").arg(i)); + } for (int i = 1; i <= m_capabilities.dcaCount && i <= 16; ++i) { - m_snapshotParams.append(QString("/dca/%1/fader").arg(i)); m_snapshotParams.append(QString("/dca/%1/mute").arg(i)); } } diff --git a/src/protocol/allenheath/DLiveProtocol.h b/src/protocol/allenheath/DLiveProtocol.h index 9ae5666..573c297 100644 --- a/src/protocol/allenheath/DLiveProtocol.h +++ b/src/protocol/allenheath/DLiveProtocol.h @@ -17,6 +17,21 @@ class DLiveProtocol : public AllenHeathTcpProtocol { protected: void initializeSnapshotParams() override; + + // ACE opcodes / plane offsets recovered from DLiveDriver (primary, non-spill + // path). The channel op extends by 8 (0x30->0x38, 0x31->0x39) on non-1.x + // software. Bank/spill addressing (op 0x12) applies on build > 90984 when the + // console reports per-bank handles instead of a global Input Mixer handle + // (see buildChannelLevelSpill); OpenMix uses the global handle when present. + quint8 channelLevelOp() const override { return dliveExtended() ? 0x38 : 0x30; } + quint8 channelMuteOp() const override { return dliveExtended() ? 0x39 : 0x31; } + int channelMutePlane() const override { return 0x80; } + int dcaMutePlane() const override { return 0x20; } + + private: + bool dliveExtended() const { + return !m_firmwareVersion.isEmpty() && !m_firmwareVersion.startsWith("1."); + } }; } // namespace OpenMix diff --git a/src/protocol/allenheath/GLDProtocol.cpp b/src/protocol/allenheath/GLDProtocol.cpp index 0b5ec89..a81b15c 100644 --- a/src/protocol/allenheath/GLDProtocol.cpp +++ b/src/protocol/allenheath/GLDProtocol.cpp @@ -3,22 +3,61 @@ namespace OpenMix { GLDProtocol::GLDProtocol(const MixerCapabilities& caps, QObject* parent) - : AllenHeathMidiProtocol(caps, parent) { + : AllenHeathTcpProtocol(caps, parent), m_midiTransport(this) { initializeSnapshotParams(); } +bool GLDProtocol::connect(const QString& host, int port) { + // secondary MIDI socket for scene recall (best-effort; property socket is the + // primary control channel) + (void)m_midiTransport.connect(host, GLD_MIDI_PORT); + return AllenHeathTcpProtocol::connect(host, port); +} + +void GLDProtocol::disconnect() { + m_midiTransport.disconnect(); + AllenHeathTcpProtocol::disconnect(); +} + +void GLDProtocol::recallScene(int sceneNumber) { + if (sceneNumber < 1) { + return; + } + // A&H MMC scene recall over the MIDI socket (51325): + // F0 00 00 1A 50 10 01 00 04 00 F7 (scene is 0-based on the wire) + QByteArray msg = QByteArray::fromHex("f000001a50100100"); + msg.append(static_cast((sceneNumber - 1) & 0x7F)); + msg.append('\x04'); + msg.append('\0'); + msg.append('\xf7'); + m_midiTransport.send(msg); +} + +QList GLDProtocol::subscribeObjects() const { + // GLD's identity object is "iGL Surface Identification"; it addresses channels + // and DCAs via the Input Mixer / DCA Levels and Mutes handles. + return {QByteArrayLiteral("DR Box Identification"), + QByteArrayLiteral("iGL Surface Identification"), kInputMixer, kDcaLevelsAndMutes}; +} + +QByteArray GLDProtocol::encodeDb(double dB) const { + // GLD dB tables (reference FUN_00415780; distinct from the Avantis/dLive twin) + static const quint8 kPosLo[10] = {0x00, 0x1B, 0x35, 0x4E, 0x68, 0x81, 0x9B, 0xB5, 0xCE, 0xE8}; + static const quint8 kNegLo[10] = {0x00, 0xE6, 0xCC, 0xB3, 0x99, 0x7F, 0x66, 0x4C, 0x33, 0x19}; + return encodeDbTables(dB, kPosLo, kNegLo); +} + void GLDProtocol::initializeSnapshotParams() { m_snapshotParams.clear(); - // GLD has 8 DCAs + // GLD control covers channel level/mute and DCA mute + for (int i = 1; i <= m_capabilities.inputChannels; ++i) { + m_snapshotParams.append(QString("/ch/%1/fader").arg(i)); + m_snapshotParams.append(QString("/ch/%1/mute").arg(i)); + } for (int i = 1; i <= m_capabilities.dcaCount && i <= 8; ++i) { - m_snapshotParams.append(dcaFaderPath(i)); - m_snapshotParams.append(dcaMutePath(i)); + m_snapshotParams.append(QString("/dca/%1/mute").arg(i)); } } -QString GLDProtocol::dcaFaderPath(int dca) const { return QString("/dca/%1/fader").arg(dca); } - -QString GLDProtocol::dcaMutePath(int dca) const { return QString("/dca/%1/mute").arg(dca); } - } // namespace OpenMix diff --git a/src/protocol/allenheath/GLDProtocol.h b/src/protocol/allenheath/GLDProtocol.h index 02a98aa..fda5370 100644 --- a/src/protocol/allenheath/GLDProtocol.h +++ b/src/protocol/allenheath/GLDProtocol.h @@ -1,24 +1,42 @@ #pragma once -#include "AllenHeathMidiProtocol.h" +#include "../transport/TcpTransport.h" +#include "AllenHeathTcpProtocol.h" namespace OpenMix { -// Allen & Heath GLD series (GLD-80, GLD-112) protocol -// uses MIDI over TCP on port 51325 -// 8 DCAs, up to 48 input channels, 20-30 mix buses -class GLDProtocol : public AllenHeathMidiProtocol { +// Allen & Heath GLD series (GLD-80, GLD-112). +// GLD is driven over the A&H "property protocol" (the same family as +// Avantis/dLive) on TCP 51321, with its own opcodes and dB table. Scene recall +// by number is issued as an A&H MMC SysEx over a secondary MIDI socket (51325), +// matching the reference GLDDriver's two-socket design. +class GLDProtocol : public AllenHeathTcpProtocol { Q_OBJECT public: explicit GLDProtocol(const MixerCapabilities& caps, QObject* parent = nullptr); - QString protocolDescription() const override { return "Allen & Heath GLD MIDI/TCP Protocol"; } + QString protocolDescription() const override { return "Allen & Heath GLD ACE Protocol"; } + + [[nodiscard]] bool connect(const QString& host, int port) override; + void disconnect() override; + void recallScene(int sceneNumber) override; protected: void initializeSnapshotParams() override; - QString dcaFaderPath(int dca) const override; - QString dcaMutePath(int dca) const override; + QList subscribeObjects() const override; + QByteArray encodeDb(double dB) const override; + + // GLD property opcodes recovered from GLDDriver: channel level and mute share + // op 0x16 (mute lives at index plane 0x90); DCA mute is op 0x10 plane 0x10. + quint8 channelLevelOp() const override { return 0x16; } + quint8 channelMuteOp() const override { return 0x16; } + int channelMutePlane() const override { return 0x90; } + int dcaMutePlane() const override { return 0x10; } + + private: + TcpTransport m_midiTransport; // secondary MIDI socket for scene MMC + static constexpr int GLD_MIDI_PORT = 51325; }; } // namespace OpenMix diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index fee3f2f..0b744df 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -324,6 +324,9 @@ add_test(NAME DiGiCoProtocolTest COMMAND test_digico_protocol) add_executable(test_allenheath_parsing test_allenheath_parsing.cpp ${CMAKE_SOURCE_DIR}/src/protocol/allenheath/AllenHeathTcpProtocol.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/allenheath/AvantisProtocol.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/allenheath/DLiveProtocol.cpp + ${CMAKE_SOURCE_DIR}/src/protocol/allenheath/GLDProtocol.cpp ${CMAKE_SOURCE_DIR}/src/protocol/transport/TcpTransport.cpp ${CMAKE_SOURCE_DIR}/src/protocol/MixerProtocol.cpp ${CMAKE_SOURCE_DIR}/src/protocol/MixerCapabilities.cpp diff --git a/tests/test_allenheath_parsing.cpp b/tests/test_allenheath_parsing.cpp index f877716..a7efc35 100644 --- a/tests/test_allenheath_parsing.cpp +++ b/tests/test_allenheath_parsing.cpp @@ -1,101 +1,169 @@ -#include "protocol/allenheath/AllenHeathTcpProtocol.h" -#include -#include +#include "protocol/MixerCapabilities.h" +#include "protocol/allenheath/AvantisProtocol.h" +#include "protocol/allenheath/DLiveProtocol.h" +#include "protocol/allenheath/GLDProtocol.h" #include -#include using namespace OpenMix; -class ParseableAllenHeathProtocol : public AllenHeathTcpProtocol { - Q_OBJECT +namespace { +// exposes the protected ACE frame builders for byte-exact checks +class AvantisProbe : public AvantisProtocol { public: - explicit ParseableAllenHeathProtocol(QObject* parent = nullptr) - : AllenHeathTcpProtocol(MixerCapabilities{}, parent) {} + AvantisProbe() : AvantisProtocol(MixerCapabilities::forConsole(ConsoleType::Avantis)) {} + using AllenHeathTcpProtocol::buildChannelLevel; + using AllenHeathTcpProtocol::buildChannelMute; + using AllenHeathTcpProtocol::buildDcaMute; + using AllenHeathTcpProtocol::buildSceneRecall; + using AllenHeathTcpProtocol::buildSubscribe; + using AllenHeathTcpProtocol::encodeDb; +}; - void parseData(const QByteArray& data) { parseProtocolData(data); } +class DLiveProbe : public DLiveProtocol { + public: + DLiveProbe() : DLiveProtocol(MixerCapabilities::forConsole(ConsoleType::DLive)) {} + using AllenHeathTcpProtocol::buildChannelLevel; + using AllenHeathTcpProtocol::buildChannelLevelSpill; + using AllenHeathTcpProtocol::buildChannelMute; + using AllenHeathTcpProtocol::buildChannelMuteSpill; + using AllenHeathTcpProtocol::buildDcaMute; +}; - protected: - void initializeSnapshotParams() override {} +class GLDProbe : public GLDProtocol { + public: + GLDProbe() : GLDProtocol(MixerCapabilities::forConsole(ConsoleType::GLD80)) {} + using AllenHeathTcpProtocol::buildChannelLevel; + using AllenHeathTcpProtocol::buildChannelMute; + using AllenHeathTcpProtocol::buildDcaMute; + using AllenHeathTcpProtocol::encodeDb; }; +const QByteArray H = QByteArray::fromHex("abcd"); // mock learned handle +} // namespace + +// Byte layouts recovered from the reference AvantisDriver/DLiveDriver (ACE +// property protocol). Handles are learned at runtime; here we inject a known +// handle (AB CD) and assert the exact outgoing frame bytes. class TestAllenHeathParsing : public QObject { Q_OBJECT - private: - static QByteArray makeParameterFloatFrame(const QString& path, float value) { - QByteArray pathBytes = path.toUtf8(); - QByteArray frame; - frame.append(static_cast(0x02)); // TYPE_PARAMETER - frame.append(static_cast(pathBytes.size())); - frame.append(pathBytes); - frame.append(static_cast(0x01)); // float indicator - - quint32 bits; - std::memcpy(&bits, &value, 4); - quint32 be = qToBigEndian(bits); - frame.append(reinterpret_cast(&be), 4); - - QByteArray msg; - quint16 len = static_cast(frame.size()); - msg.append(static_cast((len >> 8) & 0xFF)); - msg.append(static_cast(len & 0xFF)); - msg.append(frame); - return msg; + private slots: + void encodeDb_matchesReferenceTables() { + // exact per-0.1 dB lookup tables (reference FUN_00415330), not a formula + AvantisProbe p; + QCOMPARE(p.encodeDb(0.0), QByteArray::fromHex("8000")); + QCOMPARE(p.encodeDb(10.0), QByteArray::fromHex("8a00")); + QCOMPARE(p.encodeDb(-10.0), QByteArray::fromHex("7600")); + QCOMPARE(p.encodeDb(3.0), QByteArray::fromHex("8300")); + QCOMPARE(p.encodeDb(-96.0), QByteArray::fromHex("0000")); // -inf + QCOMPARE(p.encodeDb(5.3), QByteArray::fromHex("854f")); + QCOMPARE(p.encodeDb(-5.3), QByteArray::fromHex("7aa6")); // floor -6, .3 neg + QCOMPARE(p.encodeDb(-0.5), QByteArray::fromHex("7f73")); + QCOMPARE(p.encodeDb(-6.0), QByteArray::fromHex("7a00")); } - static QByteArray makeDcaFaderFrame(int dcaIndex0, float level) { - QByteArray frame; - frame.append(static_cast(0x10)); // TYPE_DCA - frame.append(static_cast(dcaIndex0)); - - quint32 bits; - std::memcpy(&bits, &level, 4); - quint32 be = qToBigEndian(bits); - frame.append(reinterpret_cast(&be), 4); - - QByteArray msg; - quint16 len = static_cast(frame.size()); - msg.append(static_cast((len >> 8) & 0xFF)); - msg.append(static_cast(len & 0xFF)); - msg.append(frame); - return msg; + void avantis_firmwareGatedOp() { + AvantisProbe p; + p.setFirmwareRevision(96500); // <= 96884 -> base op 0x25 + QCOMPARE(p.buildChannelLevel(H, 1, 0.0), + QByteArray::fromHex("f00000abcd0000250000028000f7")); + p.setFirmwareRevision(97000); // > 96884 -> extended op 0x2B + QCOMPARE(p.buildChannelLevel(H, 1, 0.0), + QByteArray::fromHex("f00000abcd00002b0000028000f7")); } - private slots: - void typeParameter_floatRoundTrip() { - ParseableAllenHeathProtocol proto; - QSignalSpy spy(&proto, &ParseableAllenHeathProtocol::parameterChanged); + void dlive_extendedOpForNon1x() { + DLiveProbe p; + p.setFirmwareVersion("1.90"); // 1.x -> base op 0x30 + QCOMPARE(p.buildChannelLevel(H, 1, 0.0), + QByteArray::fromHex("f00000abcd0000300000028000f7")); + p.setFirmwareVersion("2.00"); // non-1.x -> extended op 0x38 + QCOMPARE(p.buildChannelLevel(H, 1, 0.0), + QByteArray::fromHex("f00000abcd0000380000028000f7")); + } - proto.parseData(makeParameterFloatFrame("/ch/01/mix/fader", 0.75f)); + void dlive_spillFrames() { + DLiveProbe p; + // op 0x12, level idx ((ch-1)&7)+0x80, mute idx +0x98, bank handle + QCOMPARE(p.buildChannelLevelSpill(H, 1, 0.0), + QByteArray::fromHex("f00000abcd0000128000028000f7")); + QCOMPARE(p.buildChannelMuteSpill(H, 1, true), + QByteArray::fromHex("f00000abcd00001298000101f7")); + } - QCOMPARE(spy.count(), 1); - QCOMPARE(spy.at(0).at(0).toString(), QString("/ch/01/mix/fader")); - QVERIFY(qAbs(spy.at(0).at(1).toFloat() - 0.75f) < 1e-6f); + void subscribe_matchesReferenceBlob() { + // "Input Mixer" subscribe is a verbatim reference blob + QCOMPARE(AvantisProbe::buildSubscribe("Input Mixer"), + QByteArray::fromHex("f00001000000010004000c496e707574204d6978657200f7")); } - void dcaFader_floatRoundTrip() { - ParseableAllenHeathProtocol proto; - QSignalSpy spy(&proto, &ParseableAllenHeathProtocol::parameterChanged); + void avantis_channelLevel() { + AvantisProbe p; + // F0 00 00 00 00 25 (ch-1) 00 02 F7 ; ch1 @ 0 dB + QCOMPARE(p.buildChannelLevel(H, 1, 0.0), + QByteArray::fromHex("f00000abcd0000250000028000f7")); + // ch2 @ +10 dB -> idx 01, value 8A00 + QCOMPARE(p.buildChannelLevel(H, 2, 10.0), + QByteArray::fromHex("f00000abcd0000250100028a00f7")); + } - proto.parseData(makeDcaFaderFrame(0, 0.5f)); // DCA 1 (0-indexed: 0) + void avantis_channelMute() { + AvantisProbe p; + // op 0x26, idx = (ch-1)+0x20, disc 01 ; ch1 mute on / off + QCOMPARE(p.buildChannelMute(H, 1, true), QByteArray::fromHex("f00000abcd00002620000101f7")); + QCOMPARE(p.buildChannelMute(H, 1, false), + QByteArray::fromHex("f00000abcd00002620000100f7")); + } - QCOMPARE(spy.count(), 1); - QCOMPARE(spy.at(0).at(0).toString(), QString("/dca/1/fader")); - QVERIFY(qAbs(spy.at(0).at(1).toFloat() - 0.5f) < 1e-6f); + void avantis_dcaMute() { + AvantisProbe p; + // op 0x10, idx = (dca-1)+0x18 ; dca1 on, dca2 -> idx 0x19 + QCOMPARE(p.buildDcaMute(H, 1, true), QByteArray::fromHex("f00000abcd00001018000101f7")); + QCOMPARE(p.buildDcaMute(H, 2, true), QByteArray::fromHex("f00000abcd00001019000101f7")); } - void typeParameter_multipleFrames_parsedInOrder() { - ParseableAllenHeathProtocol proto; - QSignalSpy spy(&proto, &ParseableAllenHeathProtocol::parameterChanged); + void sceneRecall_matchesReference() { + AvantisProbe p; + // F0 00 00 00 00 10 00 00 02 <(scene-1) BE> F7 ; scene 1 -> 0000 + QCOMPARE(p.buildSceneRecall(H, 1), QByteArray::fromHex("f00000abcd0000100000020000f7")); + // scene 256 -> (255) = 00FF + QCOMPARE(p.buildSceneRecall(H, 256), QByteArray::fromHex("f00000abcd00001000000200fff7")); + } - QByteArray combined; - combined.append(makeParameterFloatFrame("/dca/1/fader", 0.25f)); - combined.append(makeParameterFloatFrame("/dca/2/fader", 0.50f)); - proto.parseData(combined); + void dlive_opcodesDifferFromAvantis() { + DLiveProbe p; + // dLive channel level op 0x30, mute op 0x31 / plane 0x80, DCA plane 0x20 + QCOMPARE(p.buildChannelLevel(H, 1, 0.0), + QByteArray::fromHex("f00000abcd0000300000028000f7")); + QCOMPARE(p.buildChannelMute(H, 1, true), + QByteArray::fromHex("f00000abcd00003180000101f7")); // idx (0)+0x80 + QCOMPARE(p.buildDcaMute(H, 1, true), + QByteArray::fromHex("f00000abcd00001020000101f7")); // idx (0)+0x20 + } + + void emptyHandle_producesNoFrame() { + AvantisProbe p; + QVERIFY(p.buildChannelLevel(QByteArray(), 1, 0.0).isEmpty()); + QVERIFY(p.buildDcaMute(QByteArray(), 1, true).isEmpty()); + } + + void gld_propertyFrames() { + GLDProbe p; + // GLD level + mute share op 0x16 (mute plane 0x90); DCA mute op 0x10 plane 0x10 + QCOMPARE(p.buildChannelLevel(H, 1, 0.0), + QByteArray::fromHex("f00000abcd0000160000028000f7")); + QCOMPARE(p.buildChannelMute(H, 1, true), QByteArray::fromHex("f00000abcd00001690000101f7")); + QCOMPARE(p.buildDcaMute(H, 1, true), QByteArray::fromHex("f00000abcd00001010000101f7")); + } - QCOMPARE(spy.count(), 2); - QVERIFY(qAbs(spy.at(0).at(1).toFloat() - 0.25f) < 1e-6f); - QVERIFY(qAbs(spy.at(1).at(1).toFloat() - 0.50f) < 1e-6f); + void gld_dbTableDiffersFromAce() { + GLDProbe g; + AvantisProbe a; + // GLD uses reference FUN_00415780: positive .3 = 0x4E (ACE 0x4F) + QCOMPARE(g.encodeDb(5.3), QByteArray::fromHex("854e")); + QCOMPARE(a.encodeDb(5.3), QByteArray::fromHex("854f")); + // GLD negative .3 = 0xB3 + QCOMPARE(g.encodeDb(-5.3), QByteArray::fromHex("7ab3")); } }; From 3cc924af1ff3e7cad6723feec39563a06a6ae269 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Tue, 14 Jul 2026 16:21:02 -0400 Subject: [PATCH 6/6] chore: bump version to 1.0.6 --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index a507605..ca22552 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.16) project(OpenMix - VERSION 1.0.5 + VERSION 1.0.6 DESCRIPTION "Stage mixing software for live theatre that lets engineers program, automate, and run cues seamlessly across 30+ digital mixing consoles" LANGUAGES CXX )