From a725bc5dfc8f75d2ab9c75bff69be3408fbf5436 Mon Sep 17 00:00:00 2001 From: Richard Thompson Date: Mon, 22 Jan 2024 11:07:00 +0000 Subject: [PATCH 01/13] Add optional NSIS signing header include Devs can insert their own installer signing logic and certificate(s) --- install/win/install.nsi | 7 +++++++ install/win64/install.nsi | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/install/win/install.nsi b/install/win/install.nsi index 96c64b71..b5d23522 100644 --- a/install/win/install.nsi +++ b/install/win/install.nsi @@ -55,6 +55,13 @@ ShowUninstDetails show InstallDir "$PROGRAMFILES\${PRODUCT_NAME}" InstallDirRegKey ${INSTDIR_REG_ROOT} "${INSTDIR_REG_KEY}" "InstallDir" +; Create this file if you wish to sign the installer and uninstaller +; Example content (NSIS 3.08): +; !finalize 'C:\sign.bat "%1" "${PRODUCT_NAME} Installer" http://example.com' = 0 +; !uninstfinalize 'C:\sign.bat "%1" "${PRODUCT_NAME} Installer" http://example.com' = 0 + +!include /NONFATAL ..\signing\sign_installer.nsh + !insertmacro INTERACTIVE_UNINSTALL !insertmacro MUI_PAGE_WELCOME diff --git a/install/win64/install.nsi b/install/win64/install.nsi index a230bb52..ba89b9cb 100644 --- a/install/win64/install.nsi +++ b/install/win64/install.nsi @@ -58,6 +58,13 @@ ShowUninstDetails show InstallDir "$PROGRAMFILES64\${PRODUCT_NAME}" InstallDirRegKey ${INSTDIR_REG_ROOT} "${INSTDIR_REG_KEY}" "InstallDir" +; Create this file if you wish to sign the installer and uninstaller +; Example content (NSIS 3.08): +; !finalize 'C:\sign.bat "%1" "${PRODUCT_NAME} Installer" http://example.com' = 0 +; !uninstfinalize 'C:\sign.bat "%1" "${PRODUCT_NAME} Installer" http://example.com' = 0 + +!include /NONFATAL ..\signing\sign_installer.nsh + !insertmacro INTERACTIVE_UNINSTALL !insertmacro MUI_PAGE_WELCOME From 30779a2608d2228febedf5d3994330b7df2ab441 Mon Sep 17 00:00:00 2001 From: Richard Thompson Date: Mon, 22 Jan 2024 12:47:01 +0000 Subject: [PATCH 02/13] Fix Windows firewall rules and uninstaller --- install/win/install.nsi | 50 ++++++++++++++------------------- install/win64/install.nsi | 59 +++++++++++++++++---------------------- 2 files changed, 47 insertions(+), 62 deletions(-) diff --git a/install/win/install.nsi b/install/win/install.nsi index b5d23522..a78319b9 100644 --- a/install/win/install.nsi +++ b/install/win/install.nsi @@ -15,6 +15,7 @@ ; limitations under the License. SetCompressor /SOLID lzma +RequestExecutionLevel admin !define PRODUCT_NAME "sACNView" !define PRODUCT_PUBLISHER "Tom Steer" @@ -66,23 +67,6 @@ InstallDirRegKey ${INSTDIR_REG_ROOT} "${INSTDIR_REG_KEY}" "InstallDir" !insertmacro MUI_PAGE_WELCOME -; Check for Admin rights -Section CheckAdmin - DetailPrint "Checking Admin Rights" - System::Call "kernel32::GetModuleHandle(t 'shell32.dll') i .s" - System::Call "kernel32::GetProcAddress(i s, i 680) i .r0" - System::Call "::$0() i .r0" - - IntCmp $0 0 isNotAdmin isNotAdmin isAdmin -isNotAdmin: - DetailPrint "Missing Administrator Rights !!!" - messageBox MB_OK "You do not have Administrator rights on this computer.$\r$\r\ -Please log in as an administrator to install sACNView." - quit -isAdmin: - DetailPrint "Administrator Rights granted" -SectionEnd - !insertmacro MUI_PAGE_DIRECTORY !insertmacro MUI_PAGE_INSTFILES !define MUI_FINISHPAGE_RUN $INSTDIR\${PRODUCT_NAME}.exe @@ -128,7 +112,13 @@ Section "Main Application" sec01 ;Same as create shortcut you need to use ${UNINST_EXE} instead of anything else. WriteRegStr ${INSTDIR_REG_ROOT} "${INSTDIR_REG_KEY}" "UninstallString" "${UNINST_EXE}" - SimpleFC::AddApplication "sACNView" "$INSTDIR\sACNView.exe" 0 2 "" 1 + DetailPrint "Adding Firewall Exception" + ; rule_name, description, protocol, direction, + ; status, profiles, action, application, + ; service_name, icmp_types_and_codes, group, local_ports, remote_ports, local_address, remote_address + SimpleFC::AdvAddRule ${PRODUCT_NAME} "${PRODUCT_NAME} UDP Multicast Receive" "17" \ + "1" "1" "2147483647" "1" "$INSTDIR\sACNView.exe" \ + "" "" "@$INSTDIR\sACNView.exe,-10000" "" "" "" "" Pop $0 IntCmp $0 0 fw_ok @@ -165,21 +155,23 @@ FunctionEnd ####################################################################################### Section UnInstall + ;uninstall from path, must be repeated for every install logged path individual + !insertmacro UNINSTALL.LOG_UNINSTALL "$INSTDIR" - ;begin uninstall, especially for MUI could be added in UN.onInit function instead - ;!insertmacro UNINSTALL.LOG_BEGIN_UNINSTALL - - ;uninstall from path, must be repeated for every install logged path individual - !insertmacro UNINSTALL.LOG_UNINSTALL "$INSTDIR" + ;end uninstall, after uninstall from all logged paths has been performed + !insertmacro UNINSTALL.LOG_END_UNINSTALL - ;end uninstall, after uninstall from all logged paths has been performed - !insertmacro UNINSTALL.LOG_END_UNINSTALL + ; Remove firewall exception + SimpleFC::AdvRemoveRule ${PRODUCT_NAME} - Delete "$SMPROGRAMS\${PRODUCT_NAME}\${PRODUCT_NAME}.lnk" - Delete "$SMPROGRAMS\${PRODUCT_NAME}\Uninstall.lnk" - RmDir "$SMPROGRAMS\${PRODUCT_NAME}" + DeleteRegValue ${INSTDIR_REG_ROOT} "${INSTDIR_REG_KEY}" "InstallDir" + DeleteRegValue ${INSTDIR_REG_ROOT} "${INSTDIR_REG_KEY}" "DisplayIcon" + DeleteRegValue ${INSTDIR_REG_ROOT} "${INSTDIR_REG_KEY}" "DisplayName" + DeleteRegValue ${INSTDIR_REG_ROOT} "${INSTDIR_REG_KEY}" "DisplayVersion" + DeleteRegValue ${INSTDIR_REG_ROOT} "${INSTDIR_REG_KEY}" "Publisher" + DeleteRegValue ${INSTDIR_REG_ROOT} "${INSTDIR_REG_KEY}" "UninstallString" - DeleteRegKey /ifempty ${INSTDIR_REG_ROOT} "${INSTDIR_REG_KEY}" + DeleteRegKey /ifempty ${INSTDIR_REG_ROOT} "${INSTDIR_REG_KEY}" SectionEnd diff --git a/install/win64/install.nsi b/install/win64/install.nsi index ba89b9cb..f6c4cac1 100644 --- a/install/win64/install.nsi +++ b/install/win64/install.nsi @@ -17,6 +17,7 @@ Unicode true SetCompressor /SOLID lzma +RequestExecutionLevel admin !define PRODUCT_NAME "sACNView64" !define PRODUCT_PUBLISHER "Tom Steer" @@ -69,23 +70,6 @@ InstallDirRegKey ${INSTDIR_REG_ROOT} "${INSTDIR_REG_KEY}" "InstallDir" !insertmacro MUI_PAGE_WELCOME -; Check for Admin rights -Section CheckAdmin - DetailPrint "Checking Admin Rights" - System::Call "kernel32::GetModuleHandle(t 'shell32.dll') i .s" - System::Call "kernel32::GetProcAddress(i s, i 680) i .r0" - System::Call "::$0() i .r0" - - IntCmp $0 0 isNotAdmin isNotAdmin isAdmin -isNotAdmin: - DetailPrint "Missing Administrator Rights !!!" - messageBox MB_OK "You do not have Administrator rights on this computer.$\r$\r\ -Please log in as an administrator to install sACNView." - quit -isAdmin: - DetailPrint "Administrator Rights granted" -SectionEnd - !insertmacro MUI_PAGE_DIRECTORY !insertmacro MUI_PAGE_INSTFILES !define MUI_FINISHPAGE_RUN $INSTDIR\sACNView.exe @@ -132,8 +116,13 @@ Section "Main Application" sec01 ;Same as create shortcut you need to use ${UNINST_EXE} instead of anything else. WriteRegStr ${INSTDIR_REG_ROOT} "${INSTDIR_REG_KEY}" "UninstallString" "${UNINST_EXE}" - ; Add firewall exception - SimpleFC::AddApplication ${PRODUCT_NAME} "$INSTDIR\sACNView.exe" 0 2 "" 1 + DetailPrint "Adding Firewall Exception" + ; rule_name, description, protocol, direction, + ; status, profiles, action, application, + ; service_name, icmp_types_and_codes, group, local_ports, remote_ports, local_address, remote_address + SimpleFC::AdvAddRule ${PRODUCT_NAME} "${PRODUCT_NAME} UDP Multicast Receive" "17" \ + "1" "1" "2147483647" "1" "$INSTDIR\sACNView.exe" \ + "" "" "@$INSTDIR\sACNView.exe,-10000" "" "" "" "" Pop $0 IntCmp $0 0 fw_ok @@ -176,22 +165,26 @@ Section UnInstall ; Use the 64bit registry SetRegView 64 - ;begin uninstall, especially for MUI could be added in UN.onInit function instead - ;!insertmacro UNINSTALL.LOG_BEGIN_UNINSTALL + ;uninstall from path, must be repeated for every install logged path individual + !insertmacro UNINSTALL.LOG_UNINSTALL "$INSTDIR" + + ;end uninstall, after uninstall from all logged paths has been performed + !insertmacro UNINSTALL.LOG_END_UNINSTALL - ;uninstall from path, must be repeated for every install logged path individual - !insertmacro UNINSTALL.LOG_UNINSTALL "$INSTDIR" + Delete "$SMPROGRAMS\${PRODUCT_NAME}\${PRODUCT_NAME}.lnk" + RmDir "$SMPROGRAMS\${PRODUCT_NAME}" - ;end uninstall, after uninstall from all logged paths has been performed - !insertmacro UNINSTALL.LOG_END_UNINSTALL + ; Remove firewall exception + SimpleFC::AdvRemoveRule ${PRODUCT_NAME} - Delete "$SMPROGRAMS\${PRODUCT_NAME}\${PRODUCT_NAME}.lnk" - RmDir "$SMPROGRAMS\${PRODUCT_NAME}" - - ; Remove firewall exception - SimpleFC::RemoveApplication "$INSTDIR\sACNView.exe" + DeleteRegValue ${INSTDIR_REG_ROOT} "${INSTDIR_REG_KEY}" "InstallDir" + DeleteRegValue ${INSTDIR_REG_ROOT} "${INSTDIR_REG_KEY}" "DisplayIcon" + DeleteRegValue ${INSTDIR_REG_ROOT} "${INSTDIR_REG_KEY}" "DisplayName" + DeleteRegValue ${INSTDIR_REG_ROOT} "${INSTDIR_REG_KEY}" "DisplayVersion" + DeleteRegValue ${INSTDIR_REG_ROOT} "${INSTDIR_REG_KEY}" "Publisher" + DeleteRegValue ${INSTDIR_REG_ROOT} "${INSTDIR_REG_KEY}" "UninstallString" - DeleteRegKey /ifempty ${INSTDIR_REG_ROOT} "${INSTDIR_REG_KEY}" + DeleteRegKey /ifempty ${INSTDIR_REG_ROOT} "${INSTDIR_REG_KEY}" SectionEnd @@ -200,7 +193,7 @@ Function UN.onInit ; Use the 64bit registry SetRegView 64 - ;begin uninstall, could be added on top of uninstall section instead - !insertmacro UNINSTALL.LOG_BEGIN_UNINSTALL + ;begin uninstall, could be added on top of uninstall section instead + !insertmacro UNINSTALL.LOG_BEGIN_UNINSTALL FunctionEnd From afcf2d5af98e9c801bc4dd638bd2f7da996437c8 Mon Sep 17 00:00:00 2001 From: RichardTea <31507749+RichardTea@users.noreply.github.com> Date: Wed, 10 Apr 2024 12:23:03 +0100 Subject: [PATCH 03/13] New OpenGL based oscilloscope Use global tock throughout Add more timing detail to SACNSourceTableModel BigDisplay performance improvement Link new scope triggers to Universe View start/stop --- CMakeLists.txt | 29 +- src/main.cpp | 11 + src/models/sacnsourcetablemodel.cpp | 143 ++- src/models/sacnsourcetablemodel.h | 35 +- src/sacn/ACNShare/tock.cpp | 6 - src/sacn/ACNShare/tock.h | 2 +- src/sacn/fpscounter.cpp | 136 ++- src/sacn/fpscounter.h | 54 +- src/sacn/sacndiscovery.cpp | 2 +- src/sacn/sacndiscovery.h | 2 +- src/sacn/sacnlistener.cpp | 1327 ++++++++++---------- src/sacn/sacnlistener.h | 230 ++-- src/sacn/sacnsynchronization.cpp | 6 +- src/sacn/sacnsynchronization.h | 6 +- src/sacn/securesacn.cpp | 16 +- src/sacn/securesacn.h | 4 +- src/sacn/streamcommon.cpp | 21 +- src/sacn/streamcommon.h | 12 +- src/sacn/streamingacn.cpp | 439 ++++--- src/sacn/streamingacn.h | 196 +-- src/ui/bigdisplay.cpp | 285 +++-- src/ui/bigdisplay.h | 38 +- src/ui/glscopewindow.cpp | 562 +++++++++ src/ui/glscopewindow.h | 116 ++ src/ui/mdimainwindow.cpp | 18 +- src/ui/mdimainwindow.h | 5 + src/ui/multiview.cpp | 31 +- src/ui/multiview.h | 5 + src/ui/scopewindow.cpp | 300 ----- src/ui/scopewindow.h | 68 -- src/ui/universeview.cpp | 15 +- src/ui/universeview.h | 6 + src/widgets/glscopewidget.cpp | 1751 +++++++++++++++++++++++++++ src/widgets/glscopewidget.h | 429 +++++++ src/widgets/monitorspinbox.cpp | 95 -- src/widgets/monitorspinbox.h | 43 - src/widgets/scopewidget.cpp | 384 ------ src/widgets/scopewidget.h | 112 -- src/widgets/steppedspinbox.cpp | 64 + src/widgets/steppedspinbox.h | 39 + ui/bigdisplay.ui | 21 +- ui/multiview.ui | 141 ++- ui/scopewindow.ui | 360 ------ 43 files changed, 4811 insertions(+), 2754 deletions(-) create mode 100644 src/ui/glscopewindow.cpp create mode 100644 src/ui/glscopewindow.h delete mode 100644 src/ui/scopewindow.cpp delete mode 100644 src/ui/scopewindow.h create mode 100644 src/widgets/glscopewidget.cpp create mode 100644 src/widgets/glscopewidget.h delete mode 100644 src/widgets/monitorspinbox.cpp delete mode 100644 src/widgets/monitorspinbox.h delete mode 100644 src/widgets/scopewidget.cpp delete mode 100644 src/widgets/scopewidget.h create mode 100644 src/widgets/steppedspinbox.cpp create mode 100644 src/widgets/steppedspinbox.h delete mode 100644 ui/scopewindow.ui diff --git a/CMakeLists.txt b/CMakeLists.txt index 17a56c85..949e329a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -53,11 +53,9 @@ set(SACNVIEW_HEADER_PATHS set(SACNVIEW_HEADERS src/ui/mdimainwindow.h src/sacn/securesacn.h - src/widgets/monitorspinbox.h src/widgets/qpushbutton_rightclick.h src/widgets/qspinbox_resizetocontent.h src/ui/newversiondialog.h - src/ui/scopewindow.h src/ui/universeview.h src/sacn/sacnsynchronization.h src/models/sacnsynclistmodel.h @@ -78,7 +76,6 @@ set(SACNVIEW_HEADERS src/sacn/sacnsender.h src/ui/configureperchanpriodlg.h src/widgets/gridwidget.h - src/widgets/scopewidget.h src/ui/aboutdialog.h src/sacn/sacneffectengine.h src/models/sacnuniverselistmodel.h @@ -102,13 +99,15 @@ set(SACNVIEW_HEADERS src/ui/multiview.h src/models/sacnsourcetablemodel.h src/models/csvmodelexport.h + src/widgets/glscopewidget.h + src/widgets/steppedspinbox.h ) set(SACNVIEW_SOURCES + sacnview.natvis src/commandline.cpp src/firewallcheck.cpp src/ipc.cpp - src/main.cpp src/preferences.cpp src/models/sacnsynclistmodel.cpp src/models/sacndiscoveredsourcelistmodel.cpp @@ -133,7 +132,6 @@ set(SACNVIEW_SOURCES src/ui/configureperchanpriodlg.cpp src/ui/newversiondialog.cpp src/ui/mdimainwindow.cpp - src/ui/scopewindow.cpp src/ui/universeview.cpp src/ui/nicselectdialog.cpp src/ui/preferencesdialog.cpp @@ -145,21 +143,24 @@ set(SACNVIEW_SOURCES src/ui/bigdisplay.cpp src/ui/addmultidialog.cpp src/ui/multiview.cpp - src/widgets/monitorspinbox.cpp + src/ui/glscopewindow.cpp src/widgets/qpushbutton_rightclick.cpp src/widgets/qspinbox_resizetocontent.cpp + src/widgets/steppedspinbox.cpp src/widgets/universedisplay.cpp src/widgets/gridwidget.cpp - src/widgets/scopewidget.cpp src/widgets/clssnapshot.cpp src/widgets/grideditwidget.cpp + src/widgets/glscopewidget.cpp src/models/sacnsourcetablemodel.cpp src/models/csvmodelexport.cpp ) +# Prepend the path to all sources +LIST(TRANSFORM SACNVIEW_SOURCES PREPEND ${CMAKE_CURRENT_LIST_DIR}/) + set(SACNVIEW_FORMS ui/mdimainwindow.ui - ui/scopewindow.ui ui/universeview.ui ui/nicselectdialog.ui ui/preferencesdialog.ui @@ -199,8 +200,18 @@ set(CMAKE_AUTORCC ON) # Find Qt 5.15 or Qt 6 find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Core) find_package(Qt${QT_VERSION_MAJOR} COMPONENTS Core Gui Network Multimedia Widgets REQUIRED) +set(SACNVIEW_QT_LIBRARIES + Qt::Core Qt::Gui Qt::Network Qt::Multimedia Qt::Widgets +) + +# Qt 6 moved some components +if (Qt6_FOUND) + find_package(Qt6 COMPONENTS OpenGLWidgets REQUIRED) + list(APPEND SACNVIEW_QT_LIBRARIES Qt::OpenGLWidgets) +endif() add_executable(sACNView WIN32 + src/main.cpp ${SACNVIEW_HEADERS} ${SACNVIEW_SOURCES} ${SACNVIEW_FORMS} @@ -219,7 +230,7 @@ set_target_properties(sACNView PROPERTIES ) # Link Qt libraries -target_link_libraries(sACNView PRIVATE Qt::Core Qt::Gui Qt::Network Qt::Multimedia Qt::Widgets) +target_link_libraries(sACNView PRIVATE ${SACNVIEW_QT_LIBRARIES}) # Link PCap/WinPCap libraries target_link_libraries(sACNView PRIVATE ${PCAP_LIBS}) diff --git a/src/main.cpp b/src/main.cpp index eaa4e289..c7f13b89 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -26,12 +26,15 @@ #include #include #include +#include + #include "themes.h" #include "sacnsender.h" #include "newversiondialog.h" #include "firewallcheck.h" #include "ipc.h" #include "translations/translationdialog.h" + #ifdef USE_BREAKPAD #include "crash_handler.h" #include "crash_test.h" @@ -46,6 +49,13 @@ int main(int argc, char *argv[]) QCoreApplication::setAttribute(Qt::AA_UseHighDpiPixmaps); QGuiApplication::setHighDpiScaleFactorRoundingPolicy(Qt::HighDpiScaleFactorRoundingPolicy::PassThrough); + // Share the OpenGL Contexts + QCoreApplication::setAttribute(Qt::AA_ShareOpenGLContexts); + { + QSurfaceFormat format; + QSurfaceFormat::setDefaultFormat(format); + } + QApplication a(argc, argv); a.setApplicationName(APP_NAME); @@ -165,6 +175,7 @@ int main(int argc, char *argv[]) int result = a.exec(); + w->saveSubWindows(); delete w; Preferences::Instance().savePreferences(); diff --git a/src/models/sacnsourcetablemodel.cpp b/src/models/sacnsourcetablemodel.cpp index 1960d9c9..dc66b678 100644 --- a/src/models/sacnsourcetablemodel.cpp +++ b/src/models/sacnsourcetablemodel.cpp @@ -65,8 +65,10 @@ QVariant SACNSourceTableModel::data(const QModelIndex& index, int role) const // Text case COL_NAME: case COL_CID: + case COL_TIME_SUMMARY: return static_cast(Qt::AlignVCenter | Qt::AlignLeft); // Numeric + case COL_UNIVERSE: case COL_PRIO: case COL_SYNC: case COL_FPS: @@ -106,6 +108,7 @@ QVariant SACNSourceTableModel::getDisplayData(const RowData& rowData, int column return QStringLiteral("??"); } case COL_CID: return rowData.cid.operator QString(); + case COL_UNIVERSE: return rowData.universe; case COL_PRIO: return rowData.per_address ? QStringLiteral("(*) ") + QString::number(rowData.priority) : QString::number(rowData.priority); case COL_SYNC: if (rowData.protocol_version == sACNProtocolDraft) @@ -117,6 +120,7 @@ QVariant SACNSourceTableModel::getDisplayData(const RowData& rowData, int column case COL_PREVIEW: return (rowData.preview ? tr("Yes") : tr("No")); case COL_IP: return rowData.ip.toString(); case COL_FPS: return QStringLiteral("%1Hz").arg(QString::number(rowData.fps, 'f', 2)); + case COL_TIME_SUMMARY: return getTimingSummary(rowData); case COL_SEQ_ERR: return rowData.seq_err; case COL_JUMPS: return rowData.jumps; case COL_VER: return GetProtocolVersionString(rowData.protocol_version); @@ -162,6 +166,7 @@ QVariant SACNSourceTableModel::getBackgroundData(const RowData& rowData, int col case SourceSecure::Yes: return Preferences::Instance().colorForStatus(Preferences::Status::Good); } case COL_CID: + case COL_UNIVERSE: case COL_PRIO: case COL_SYNC: case COL_PREVIEW: @@ -178,6 +183,33 @@ QVariant SACNSourceTableModel::getBackgroundData(const RowData& rowData, int col return QVariant(); } +QVariant SACNSourceTableModel::getTimingSummary(const RowData& rowData) const +{ + const FpsCounter::Histogram& histogram = rowData.histogram; + if (histogram.empty()) + return tr("N/A"); + + // Min - max + QString result = QStringLiteral("%1-%2ms").arg(std::chrono::milliseconds(histogram.begin()->first).count()).arg(std::chrono::milliseconds(histogram.rbegin()->first).count()); + // And count of interesting ranges + size_t shortCount = 0; + size_t longCount = 0; + size_t staticCount = 0; + for (const auto& item : histogram) + { + // Times are rounded up, don't count anything twice + if (item.first <= m_shortInterval) + shortCount += item.second; + else if (item.first > m_staticInterval) + staticCount += item.second; + else if (item.first > m_longInterval) + longCount += item.second; + } + + result.append(QStringLiteral(" (%1 %2 %3)").arg(shortCount).arg(longCount).arg(staticCount)); + return result; +} + QVariant SACNSourceTableModel::headerData(int section, Qt::Orientation orientation, int role) const { @@ -192,11 +224,16 @@ QVariant SACNSourceTableModel::headerData(int section, Qt::Orientation orientati case COL_NAME: return tr("Name"); case COL_ONLINE: return tr("Online"); case COL_CID: return tr("CID"); + case COL_UNIVERSE: return tr("Universe"); case COL_PRIO: return tr("Priority"); case COL_SYNC: return tr("Sync"); case COL_PREVIEW: return tr("Preview"); case COL_IP: return tr("IP Address"); case COL_FPS: return tr("FPS"); + case COL_TIME_SUMMARY: return tr("Times (<%1 >%2 >%3)") + .arg(std::chrono::milliseconds(m_shortInterval).count()) + .arg(std::chrono::milliseconds(m_longInterval).count()) + .arg(std::chrono::milliseconds(m_staticInterval).count()); case COL_SEQ_ERR: return tr("SeqErr"); case COL_JUMPS: return tr("Jumps"); case COL_VER: return tr("Ver"); @@ -211,7 +248,8 @@ QVariant SACNSourceTableModel::headerData(int section, Qt::Orientation orientati case COL_NAME: return tr("The human readable name the source has been given"); case COL_ONLINE: return tr("Online status of the source"); case COL_CID: return tr("The Component IDentifier of the source, a Universally Unique Identifier"); - case COL_PRIO: return tr("Source priority 0 (ignore) to 200 (most important)"); + case COL_UNIVERSE: return tr("sACN Universe number (%1-%1)").arg(MIN_SACN_UNIVERSE).arg(MAX_SACN_UNIVERSE); + case COL_PRIO: return tr("Source priority %1 (ignore) to %2 (most important). Default %1").arg(MIN_SACN_PRIORITY).arg(MAX_SACN_PRIORITY).arg(DEFAULT_SACN_PRIORITY); case COL_SYNC: return tr("Does the source support Universe Synchronization?"); case COL_PREVIEW: return tr("Indicates that the data in this packet is intended for use in visualization or media server preview applications and shall not be used to generate live output."); case COL_IP: return tr("The IP address of the source"); @@ -229,6 +267,39 @@ QVariant SACNSourceTableModel::headerData(int section, Qt::Orientation orientati return QVariant(); } +void SACNSourceTableModel::setShortInterval(int millisec) +{ + if (millisec == shortInterval()) + return; + + m_shortInterval = std::chrono::milliseconds(millisec); + + emit headerDataChanged(Qt::Horizontal, COL_TIME_SUMMARY, COL_TIME_SUMMARY); + emit dataChanged(index(0, COL_TIME_SUMMARY), index(rowCount() - 1, COL_TIME_SUMMARY)); +} + +void SACNSourceTableModel::setLongInterval(int millisec) +{ + if (millisec == longInterval()) + return; + + m_longInterval = std::chrono::milliseconds(millisec); + + emit headerDataChanged(Qt::Horizontal, COL_TIME_SUMMARY, COL_TIME_SUMMARY); + emit dataChanged(index(0, COL_TIME_SUMMARY), index(rowCount() - 1, COL_TIME_SUMMARY)); +} + +void SACNSourceTableModel::setStaticInterval(int millisec) +{ + if (millisec == longInterval()) + return; + + m_staticInterval = std::chrono::milliseconds(millisec); + + emit headerDataChanged(Qt::Horizontal, COL_TIME_SUMMARY, COL_TIME_SUMMARY); + emit dataChanged(index(0, COL_TIME_SUMMARY), index(rowCount() - 1, COL_TIME_SUMMARY)); +} + void SACNSourceTableModel::addListener(const sACNManager::tListener& listener) { if (!listener) @@ -247,35 +318,69 @@ void SACNSourceTableModel::addListener(const sACNManager::tListener& listener) m_listeners.push_back(listener); } -void SACNSourceTableModel::removeListener(const sACNManager::tListener& listener) +void SACNSourceTableModel::pause() { - if (!listener) - return; - disconnect(listener.data(), nullptr, this, nullptr); - - // TODO: Remove sources from this listener + // Stop listening (queued events will still happen) + for (size_t i = 0; i < m_listeners.size(); ++i) + { + sACNManager::tListener listener(m_listeners[i]); + if (listener) + disconnect(listener.data(), nullptr, this, nullptr); + } } -void SACNSourceTableModel::clear() +void SACNSourceTableModel::restart() { - // Stop listening for new sources - for (size_t i = 0; i < m_listeners.size(); ++i) + // Restart listening on all still extant universes + for (auto it = m_listeners.begin(); it != m_listeners.end(); /**/) { - disconnect(m_listeners[i].data(), nullptr, this, nullptr); + sACNManager::tListener listener(*it); + if (listener) + { + connect(listener.data(), &sACNListener::sourceFound, this, &SACNSourceTableModel::sourceOnline); + connect(listener.data(), &sACNListener::sourceLost, this, &SACNSourceTableModel::sourceChanged); + connect(listener.data(), &sACNListener::sourceChanged, this, &SACNSourceTableModel::sourceChanged); + ++it; + } + else + { + // Remove nulled weak pointers + it = m_listeners.erase(it); + } } +} + +void SACNSourceTableModel::clear() +{ + pause(); m_listeners.clear(); + // Clear the model beginResetModel(); m_rows.clear(); + m_sourceToTableRow.clear(); endResetModel(); } +void SACNSourceTableModel::resetTimeSummaryCounters() +{ + for (auto it = m_sourceToTableRow.begin(); it != m_sourceToTableRow.end(); ++it) + { + it.key()->fpscounter.ClearHistogram(); + } + emit dataChanged(index(0, COL_TIME_SUMMARY), index(rowCount() - 1, COL_TIME_SUMMARY)); +} + void SACNSourceTableModel::resetSequenceCounters() { for (auto it = m_sourceToTableRow.begin(); it != m_sourceToTableRow.end(); ++it) { it.key()->resetSeqErr(); } + for (auto& row : m_rows) + { + row.seq_err = 0; + } emit dataChanged(index(0, COL_SEQ_ERR), index(rowCount() - 1, COL_SEQ_ERR)); } @@ -285,6 +390,10 @@ void SACNSourceTableModel::resetJumpsCounters() { it.key()->resetJumps(); } + for (auto& row : m_rows) + { + row.jumps = 0; + } emit dataChanged(index(0, COL_JUMPS), index(rowCount() - 1, COL_JUMPS)); } @@ -294,8 +403,14 @@ void SACNSourceTableModel::resetCounters() { it.key()->resetSeqErr(); it.key()->resetJumps(); + it.key()->fpscounter.ClearHistogram(); + } + for (auto& row : m_rows) + { + row.seq_err = 0; + row.jumps = 0; } - emit dataChanged(index(0, COL_SEQ_ERR), index(rowCount() - 1, COL_JUMPS)); + emit dataChanged(index(0, COL_TIME_SUMMARY), index(rowCount() - 1, COL_JUMPS)); } void SACNSourceTableModel::sourceChanged(sACNSource* source) @@ -312,7 +427,7 @@ void SACNSourceTableModel::sourceChanged(sACNSource* source) // Update and signal m_rows[row_num].Update(source); - emit dataChanged(index(row_num, 0), index(row_num, COL_END)); + emit dataChanged(index(row_num, 0), index(row_num, COL_END - 1)); } void SACNSourceTableModel::sourceOnline(sACNSource* source) @@ -339,6 +454,7 @@ void SACNSourceTableModel::RowData::Update(const sACNSource* source) name = source->name; cid = source->src_cid; + universe = source->universe; protocol_version = source->protocol_version; ip = source->ip; fps = source->fpscounter.FPS(); @@ -378,4 +494,5 @@ void SACNSourceTableModel::RowData::Update(const sACNSource* source) priority = source->priority; preview = source->isPreview; per_address = source->doing_per_channel; + histogram = source->fpscounter.GetHistogram(); } diff --git a/src/models/sacnsourcetablemodel.h b/src/models/sacnsourcetablemodel.h index d52494ae..0454782a 100644 --- a/src/models/sacnsourcetablemodel.h +++ b/src/models/sacnsourcetablemodel.h @@ -29,11 +29,13 @@ class SACNSourceTableModel : public QAbstractTableModel COL_NAME, COL_ONLINE, COL_CID, + COL_UNIVERSE, COL_PRIO, COL_SYNC, COL_PREVIEW, COL_IP, COL_FPS, + COL_TIME_SUMMARY, COL_SEQ_ERR, COL_JUMPS, COL_VER, @@ -54,12 +56,25 @@ class SACNSourceTableModel : public QAbstractTableModel QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; + // Time interval summary + Q_SLOT void setShortInterval(int millisec); + int shortInterval() const { return m_shortInterval.count(); } + Q_SLOT void setLongInterval(int millisec); + int longInterval() const { return m_longInterval.count(); } + Q_SLOT void setStaticInterval(int millisec); + int staticInterval() const { return m_staticInterval.count(); } + + // Add a listener. Does not take ownership void addListener(const sACNManager::tListener& listener); - void removeListener(const sACNManager::tListener& listener); - // Remove all listeners and sources + // Stop updating + void pause(); + // Restart updates of the existing list of sources and listeners + void restart(); + // Clear all data and remove all listeners void clear(); // Convenience + void resetTimeSummaryCounters(); void resetSequenceCounters(); void resetJumpsCounters(); void resetCounters(); @@ -98,23 +113,33 @@ private Q_SLOTS: StreamingACNProtocolVersion protocol_version = sACNProtocolUnknown; QHostAddress ip; float fps = 0; - int seq_err = 0; - int jumps = 0; + unsigned int seq_err = 0; + unsigned int jumps = 0; SourceState online = SourceState::Offline; SourceSecure security = SourceSecure::None; + uint16_t universe = 0; uint16_t sync_universe = 0; uint16_t slot_count = 0; uint8_t priority = 0; bool preview = false; bool per_address = false; + FpsCounter::Histogram histogram; void Update(const sACNSource* source); }; std::vector m_rows; - std::vector m_listeners; + std::vector m_listeners; + + // Interval shorter than expected + FpsCounter::HistogramBucket m_shortInterval = std::chrono::milliseconds(19); + // Interval longer than expected + FpsCounter::HistogramBucket m_longInterval = std::chrono::milliseconds(25); + // Is either a static level or something has gone very wrong + FpsCounter::HistogramBucket m_staticInterval = std::chrono::milliseconds(500); // Data QVariant getDisplayData(const RowData& rowData, int column) const; QVariant getBackgroundData(const RowData& rowData, int column) const; + QVariant getTimingSummary(const RowData& rowData) const; }; diff --git a/src/sacn/ACNShare/tock.cpp b/src/sacn/ACNShare/tock.cpp index 43f76cce..7278623f 100644 --- a/src/sacn/ACNShare/tock.cpp +++ b/src/sacn/ACNShare/tock.cpp @@ -58,12 +58,6 @@ void Tock_StopLib() tock::tock():v(0) {} -template -tock::tock(std::chrono::duration duration) -{ - v = duration; -} - tock::resolution_t tock::Get() const { return v; diff --git a/src/sacn/ACNShare/tock.h b/src/sacn/ACNShare/tock.h index aa474980..fe85cb1a 100644 --- a/src/sacn/ACNShare/tock.h +++ b/src/sacn/ACNShare/tock.h @@ -68,7 +68,7 @@ class tock tock& operator=(tock&&) = default; template - tock(std::chrono::duration duration); + tock(std::chrono::duration duration) { v = duration; } //Returns the number of nanoseconds that this tock represents resolution_t Get() const; diff --git a/src/sacn/fpscounter.cpp b/src/sacn/fpscounter.cpp index ea13b9ed..3feea1ff 100644 --- a/src/sacn/fpscounter.cpp +++ b/src/sacn/fpscounter.cpp @@ -1,85 +1,105 @@ #include "fpscounter.h" -#define updateInterval 1000 +#include "streamingacn.h" -FpsCounter::FpsCounter(QObject *parent) : QObject(parent) +constexpr std::chrono::milliseconds updateInterval(1000); + +FpsCounter::FpsCounter(QObject* parent) : QObject(parent) { - // Maximum fps permitted by standard is 44, so this should never reallocate - frameTimes.reserve(50); - elapsedTimer.start(); - timerId = startTimer(updateInterval); + // Maximum fps permitted by standard is 44, so this should never reallocate + m_frameTimes.reserve(127); + m_timerId = startTimer(updateInterval.count()); } FpsCounter::~FpsCounter() { - if (timerId != 0) - killTimer(timerId); + if (m_timerId != 0) + killTimer(m_timerId); } -void FpsCounter::timerEvent(QTimerEvent * /*e*/) +void FpsCounter::timerEvent(QTimerEvent* /*ev*/) { - QMutexLocker queueLocker(&queueMutex); + QMutexLocker queueLocker(&m_queueMutex); - // We need at least two frames to calculate interval - if (Q_UNLIKELY(frameTimes.count() < 2)) + // We need at least two frames to calculate interval + if (Q_UNLIKELY(m_frameTimes.size() < 2)) + { + // No frames, or very old single frame + if ( + m_frameTimes.empty() || + ((m_frameTimes.size() == 1) && (sACNManager::GetTock().Get() > (m_frameTimes.back() + (updateInterval * 2)))) + ) { - // No frames, or very old single frame - if ( - (frameTimes.count() == 0) || - ((frameTimes.count() == 1) && (elapsedTimer.elapsed() > (frameTimes.back() + (updateInterval * 2)))) - ) - { - previousFps = currentFps; - currentFps = 0; - } - } else { - // Calculate average of the intervals - qint64 intervalTotal = 0; - qint64 intervalCount = 0; + m_previousFps = m_currentFps; + m_currentFps = 0; + } + } + else + { + // Calculate average of the intervals + tock::resolution_t intervalTotal{0}; + int64_t intervalCount = 0; - int frameIndex = 0; - if (lastFrameTime == 0) - { - lastFrameTime = frameTimes[0]; - frameIndex = 1; - } + tock::resolution_t lastFrameTime = m_frameTimes.front(); + + for (size_t frameIndex = 1; frameIndex < m_frameTimes.size(); ++frameIndex) + { + const auto& time = m_frameTimes[frameIndex]; - for (/* init above */ ; frameIndex < frameTimes.count() ; ++frameIndex) + if (time > lastFrameTime) + { + const auto interval = time - lastFrameTime; + lastFrameTime = time; + // Ignore very long intervals + if (interval < (updateInterval * 2)) { - auto time = frameTimes[frameIndex]; - - if (time > lastFrameTime) - { - auto interval = time - lastFrameTime; - lastFrameTime = time; - intervalTotal += interval; - ++intervalCount; - } + // Add to histogram + ++m_frameDeltaHistogram[std::chrono::ceil(interval)]; + // Add to total + intervalTotal += interval; + ++intervalCount; } + } + } - frameTimes.clear(); + if (intervalCount == 0) + return; - if (intervalCount == 0) - return; + m_frameTimes.clear(); + m_frameTimes.push_back(lastFrameTime); - auto intervalAvg = intervalTotal / intervalCount; + const auto intervalAvg = intervalTotal / intervalCount; - // Calculate the current FPS - previousFps = currentFps; - currentFps = 1000 / static_cast(intervalAvg); - } + // Calculate the current FPS + m_previousFps = m_currentFps; + m_currentFps = 1.0f / std::chrono::duration(intervalAvg).count(); + } + + // Flag if the FPS has changed + m_newFps = (m_previousFps < m_currentFps) || (m_previousFps > m_currentFps); + + queueLocker.unlock(); + + if (m_newFps) + { + emit updatedFPS(); + } +} - // Flag if the FPS has changed - newFps = ((previousFps < currentFps) | (previousFps > currentFps)); +FpsCounter::Histogram FpsCounter::GetHistogram() const +{ + QMutexLocker queueLocker(&m_queueMutex); + return m_frameDeltaHistogram; +} - if (newFps) - emit updatedFPS(); +void FpsCounter::ClearHistogram() +{ + QMutexLocker queueLocker(&m_queueMutex); + m_frameDeltaHistogram.clear(); } -void FpsCounter::newFrame() +void FpsCounter::newFrame(tock timePoint) { - qint64 elapsedTime = elapsedTimer.elapsed(); - QMutexLocker queueLocker(&queueMutex); - frameTimes.append(elapsedTime); - // TODO: Consider using an IIR filter to produce a rolling average + QMutexLocker queueLocker(&m_queueMutex); + m_frameTimes.push_back(timePoint.Get()); } diff --git a/src/sacn/fpscounter.h b/src/sacn/fpscounter.h index a5eb1827..f336b090 100644 --- a/src/sacn/fpscounter.h +++ b/src/sacn/fpscounter.h @@ -2,43 +2,55 @@ #define FPSCounter_H #include -#include -#include #include +#include "tock.h" + +#include + class FpsCounter : public QObject { - Q_OBJECT + Q_OBJECT public: - explicit FpsCounter(QObject *parent = nullptr); - ~FpsCounter(); + // Histogram bucket size + using HistogramBucket = std::chrono::milliseconds; + using Histogram = std::map; + +public: + explicit FpsCounter(QObject* parent = nullptr); + ~FpsCounter(); + + // Return current FPS + float FPS() const { m_newFps = false; return m_currentFps; } - // Return current FPS - float FPS() const { newFps = false; return currentFps;} + // Returns true if FPS has changed since last checked + bool isNewFPS() const { return m_newFps; } - // Returns true if FPS has changed since last checked - bool isNewFPS() const { return newFps; } + // Get a copy of the frame time histogram + Histogram GetHistogram() const; + + // Clear the histogram + void ClearHistogram(); - // Log new frame - void newFrame(); + // Log new frame + void newFrame(tock timePoint); signals: - void updatedFPS(); + void updatedFPS(); protected: - void timerEvent(QTimerEvent *e) final; + void timerEvent(QTimerEvent* ev) final; private: - QElapsedTimer elapsedTimer; - int timerId = 0; + int m_timerId = 0; - float currentFps = 0.0f; - float previousFps = 0.0f; - mutable bool newFps = false; + float m_currentFps = 0.0f; + float m_previousFps = 0.0f; + mutable bool m_newFps = false; - QMutex queueMutex; - QVector frameTimes; - qint64 lastFrameTime = 0; + mutable QMutex m_queueMutex; + std::vector m_frameTimes; + Histogram m_frameDeltaHistogram; }; #endif // FPSCounter_H diff --git a/src/sacn/sacndiscovery.cpp b/src/sacn/sacndiscovery.cpp index c5e50257..33bc6140 100644 --- a/src/sacn/sacndiscovery.cpp +++ b/src/sacn/sacndiscovery.cpp @@ -221,7 +221,7 @@ void sACNDiscoveryRX::timeoutUniverses() } } -void sACNDiscoveryRX::processPacket(quint8* pbuf, uint buflen) +void sACNDiscoveryRX::processPacket(const quint8* pbuf, uint buflen) { bool flag1, flag2, flag3; quint32 length; diff --git a/src/sacn/sacndiscovery.h b/src/sacn/sacndiscovery.h index 284ab594..53656bad 100644 --- a/src/sacn/sacndiscovery.h +++ b/src/sacn/sacndiscovery.h @@ -68,7 +68,7 @@ class sACNDiscoveryRX : public QObject typedef QHash tDiscoveryList; - void processPacket(quint8* pbuf, uint buflen); + void processPacket(const quint8* pbuf, uint buflen); const tDiscoveryList &getDiscoveryList() const { return m_discoveryList; } signals: diff --git a/src/sacn/sacnlistener.cpp b/src/sacn/sacnlistener.cpp index 62b6bfd0..b7d0ad3e 100644 --- a/src/sacn/sacnlistener.cpp +++ b/src/sacn/sacnlistener.cpp @@ -40,776 +40,777 @@ //Background merge interval #define BACKGROUND_MERGE 500 -sACNListener::sACNListener(int universe, QObject *parent) - : QObject(parent) - , m_merged_levels(DMX_SLOT_MAX, sACNMergedAddress()) - , m_universe(universe) +sACNListener::sACNListener(int universe, QObject* parent) + : QObject(parent) + , m_merged_levels(DMX_SLOT_MAX, sACNMergedAddress()) + , m_universe(universe) { - qRegisterMetaType("QHostAddress"); + qRegisterMetaType("QHostAddress"); + m_current_levels.fill(-1); + m_current_priorities.fill(-1); } sACNListener::~sACNListener() { - m_initalSampleTimer->deleteLater(); - m_mergeTimer->deleteLater(); - qDeleteAll(m_sockets); - qDebug() << "sACNListener" << QThread::currentThreadId() << ": stopping"; + m_initalSampleTimer->deleteLater(); + m_mergeTimer->deleteLater(); + qDeleteAll(m_sockets); + qDebug() << this << ": stopping"; } void sACNListener::startReception() { - qDebug() << "sACNListener" << QThread::currentThreadId() << ": Starting universe" << m_universe; - - // Clear the levels array - std::fill(std::begin(m_last_levels), std::end(m_last_levels), -1); - - if (Preferences::Instance().GetNetworkListenAll() && !Preferences::Instance().networkInterface().flags().testFlag(QNetworkInterface::IsLoopBack)) { - // Listen on ALL interfaces and not working offline - for (const auto &interface : QNetworkInterface::allInterfaces()) - { - // If the interface is ok for use... - if(Preferences::Instance().interfaceSuitable(interface)) - { - startInterface(interface); - } - } - } else { - // Listen only to selected interface - startInterface(Preferences::Instance().networkInterface()); - if (!m_sockets.empty()) { - m_bindStatus.multicast = (m_sockets.back()->multicastInterface().isValid()) ? sACNRxSocket::BIND_OK : sACNRxSocket::BIND_FAILED; - m_bindStatus.unicast = (m_sockets.back()->state() == QAbstractSocket::BoundState) ? sACNRxSocket::BIND_OK : sACNRxSocket::BIND_FAILED; - } + qDebug() << this << ": Starting universe" << m_universe; + m_last_levels.fill(-1); + m_last_priorities.fill(-1); + m_current_levels.fill(-1); + m_current_priorities.fill(-1); + + if (Preferences::Instance().GetNetworkListenAll() && !Preferences::Instance().networkInterface().flags().testFlag(QNetworkInterface::IsLoopBack)) { + // Listen on ALL interfaces and not working offline + for (const auto& interface : QNetworkInterface::allInterfaces()) + { + // If the interface is ok for use... + if (Preferences::Instance().interfaceSuitable(interface)) + { + startInterface(interface); + } } - - // Start intial sampling - m_initalSampleTimer = new QTimer(this); - m_initalSampleTimer->setSingleShot(true); - m_initalSampleTimer->setInterval(SAMPLE_TIME); - connect(m_initalSampleTimer, &QTimer::timeout, this, &sACNListener::sampleExpiration, Qt::DirectConnection); - m_initalSampleTimer->start(); - - // Merge is performed whenever a packet arrives and every BACKGROUND_MERGE interval - m_elapsedTime.start(); - m_mergesPerSecondTimer.start(); - m_mergeTimer = new QTimer(this); - m_mergeTimer->setInterval(BACKGROUND_MERGE); - connect(m_mergeTimer, &QTimer::timeout, this, &sACNListener::performMerge, Qt::DirectConnection); - connect(m_mergeTimer, &QTimer::timeout, this, &sACNListener::checkSourceExpiration, Qt::DirectConnection); - m_mergeTimer->start(); - - // Everything is set - emit listenerStarted(m_universe); + } + else { + // Listen only to selected interface + startInterface(Preferences::Instance().networkInterface()); + if (!m_sockets.empty()) { + m_bindStatus.multicast = (m_sockets.back()->multicastInterface().isValid()) ? sACNRxSocket::BIND_OK : sACNRxSocket::BIND_FAILED; + m_bindStatus.unicast = (m_sockets.back()->state() == QAbstractSocket::BoundState) ? sACNRxSocket::BIND_OK : sACNRxSocket::BIND_FAILED; + } + } + + // Start intial sampling + m_initalSampleTimer = new QTimer(this); + m_initalSampleTimer->setSingleShot(true); + m_initalSampleTimer->setInterval(SAMPLE_TIME); + connect(m_initalSampleTimer, &QTimer::timeout, this, &sACNListener::sampleExpiration, Qt::DirectConnection); + m_initalSampleTimer->start(); + + // Merge is performed whenever a packet arrives and every BACKGROUND_MERGE interval + m_mergesPerSecondTimer.start(); + m_mergeTimer = new QTimer(this); + m_mergeTimer->setInterval(BACKGROUND_MERGE); + connect(m_mergeTimer, &QTimer::timeout, this, &sACNListener::performMerge, Qt::DirectConnection); + connect(m_mergeTimer, &QTimer::timeout, this, &sACNListener::checkSourceExpiration, Qt::DirectConnection); + m_mergeTimer->start(); + + // Everything is set + emit listenerStarted(m_universe); } -void sACNListener::startInterface(const QNetworkInterface &iface) +void sACNListener::startInterface(const QNetworkInterface& iface) { - m_sockets.push_back(new sACNRxSocket(iface)); - sACNRxSocket::sBindStatus status = m_sockets.back()->bind(m_universe); - if (status.unicast == sACNRxSocket::BIND_OK || status.multicast == sACNRxSocket::BIND_OK) { - connect(m_sockets.back(), &QUdpSocket::readyRead, this, &sACNListener::readPendingDatagrams, Qt::DirectConnection); - } else { - // Failed to bind - m_sockets.pop_back(); - } - - if ((m_bindStatus.unicast == sACNRxSocket::BIND_UNKNOWN) || (m_bindStatus.unicast == sACNRxSocket::BIND_OK)) - m_bindStatus.unicast = status.unicast; - if ((m_bindStatus.multicast == sACNRxSocket::BIND_UNKNOWN) || (m_bindStatus.multicast == sACNRxSocket::BIND_OK)) - m_bindStatus.multicast = status.multicast; + m_sockets.push_back(new sACNRxSocket(iface)); + sACNRxSocket::sBindStatus status = m_sockets.back()->bind(m_universe); + if (status.unicast == sACNRxSocket::BIND_OK || status.multicast == sACNRxSocket::BIND_OK) { + connect(m_sockets.back(), &QUdpSocket::readyRead, this, &sACNListener::readPendingDatagrams, Qt::DirectConnection); + } + else { + // Failed to bind + m_sockets.pop_back(); + } + + if ((m_bindStatus.unicast == sACNRxSocket::BIND_UNKNOWN) || (m_bindStatus.unicast == sACNRxSocket::BIND_OK)) + m_bindStatus.unicast = status.unicast; + if ((m_bindStatus.multicast == sACNRxSocket::BIND_UNKNOWN) || (m_bindStatus.multicast == sACNRxSocket::BIND_OK)) + m_bindStatus.multicast = status.multicast; } void sACNListener::sampleExpiration() { - m_isSampling = false; - qDebug() << "sACNListener" << QThread::currentThreadId() << ": Sampling has ended"; + m_isSampling = false; + qDebug() << this << ": Sampling has ended"; } void sACNListener::checkSourceExpiration() { - char cidstr [CID::CIDSTRINGBYTES]; - for(std::vector::iterator it = m_sources.begin(); it != m_sources.end(); ++it) + char cidstr[CID::CIDSTRINGBYTES]; + for (std::vector::iterator it = m_sources.begin(); it != m_sources.end(); ++it) + { + if ((*it)->src_valid) { - if((*it)->src_valid) - { - if((*it)->active.Expired() && (*it)->priority_wait.Expired()) - { - (*it)->src_valid = false; - (*it)->src_stable = false; - CID::CIDIntoString((*it)->src_cid, cidstr); - emit sourceLost(*it); - m_mergeAll = true; - qDebug() << "Lost source " << cidstr; - } - else if ((*it)->doing_per_channel && (*it)->priority_wait.Expired()) - { - CID::CIDIntoString((*it)->src_cid, cidstr); - (*it)->doing_per_channel = false; - emit sourceChanged(*it); - m_mergeAll = true; - qDebug() << "sACNListener" << QThread::currentThreadId() << ": Source stopped sending per-channel priority" << cidstr; - } - } + if ((*it)->active.Expired() && (*it)->priority_wait.Expired()) + { + (*it)->src_valid = false; + (*it)->src_stable = false; + CID::CIDIntoString((*it)->src_cid, cidstr); + emit sourceLost(*it); + m_mergeAll = true; + qDebug() << "Lost source " << cidstr; + } + else if ((*it)->doing_per_channel && (*it)->priority_wait.Expired()) + { + CID::CIDIntoString((*it)->src_cid, cidstr); + (*it)->doing_per_channel = false; + emit sourceChanged(*it); + m_mergeAll = true; + qDebug() << this << ": Source stopped sending per-channel priority" << cidstr; + } } + } } void sACNListener::readPendingDatagrams() { - #if (QT_VERSION == QT_VERSION_CHECK(5, 9, 3)) - #error "QT5.9.3 QUdpSocket::readDatagram Returns incorrect infomation: https://bugreports.qt.io/browse/QTBUG-64784" - #endif - #if (QT_VERSION == QT_VERSION_CHECK(5, 10, 0)) - #error "QT5.10.0 QUdpSocket::readDatagram Returns incorrect infomation: https://bugreports.qt.io/browse/QTBUG-65099" - #endif - - // Check all sockets - for (auto m_socket : m_sockets) +#if (QT_VERSION == QT_VERSION_CHECK(5, 9, 3)) +#error "QT5.9.3 QUdpSocket::readDatagram Returns incorrect infomation: https://bugreports.qt.io/browse/QTBUG-64784" +#endif +#if (QT_VERSION == QT_VERSION_CHECK(5, 10, 0)) +#error "QT5.10.0 QUdpSocket::readDatagram Returns incorrect infomation: https://bugreports.qt.io/browse/QTBUG-65099" +#endif + + // Check all sockets + for (auto m_socket : m_sockets) + { + while (m_socket->hasPendingDatagrams()) { - while(m_socket->hasPendingDatagrams()) - { - QNetworkDatagram datagram = m_socket->receiveDatagram(); - - if (datagram.data().isEmpty()) - break; - - /* Localhost - Allowed - * Relevant Multicast - Allowed - * Unicast for this interface - Allowed (Universe checked later) - * Broadcast - Rejected - */ - if (datagram.destinationAddress().isBroadcast()) - break; - - QList interfaceAddress; - for (const auto &address : m_socket->getBoundInterface().addressEntries()) - interfaceAddress << address.ip(); - - if ( - // Relevant Multicast - (datagram.destinationAddress().isMulticast() && datagram.destinationAddress() == m_socket->getMulticastAddr()) - || - // Unicast for this interface - interfaceAddress.contains(QHostAddress(datagram.destinationAddress())) - ) - { - processDatagram( - datagram.data(), - datagram.destinationAddress(), - datagram.senderAddress()); - } - } + QNetworkDatagram datagram = m_socket->receiveDatagram(); + + if (datagram.data().isEmpty()) + break; + + /* Localhost - Allowed + * Relevant Multicast - Allowed + * Unicast for this interface - Allowed (Universe checked later) + * Broadcast - Rejected + */ + if (datagram.destinationAddress().isBroadcast()) + break; + + QList interfaceAddress; + for (const auto& address : m_socket->getBoundInterface().addressEntries()) + interfaceAddress << address.ip(); + + if ( + // Relevant Multicast + (datagram.destinationAddress().isMulticast() && datagram.destinationAddress() == m_socket->getMulticastAddr()) + || + // Unicast for this interface + interfaceAddress.contains(QHostAddress(datagram.destinationAddress())) + ) + { + processDatagram( + datagram.data(), + datagram.destinationAddress(), + datagram.senderAddress()); + } } + } } -void sACNListener::processDatagram(const QByteArray &data, const QHostAddress &destination, const QHostAddress &sender) +void sACNListener::processDatagram(const QByteArray& data, const QHostAddress& destination, const QHostAddress& sender) { - if(QThread::currentThread()!=this->thread()) + if (QThread::currentThread() != this->thread()) + { + QMetaObject::invokeMethod( + this, + "processDatagram", + Q_ARG(QByteArray, data), + Q_ARG(QHostAddress, destination), + Q_ARG(QHostAddress, sender)); + return; + }; + + const tock packet_tock = sACNManager::GetTock(); + + QMutexLocker locker(&m_processMutex); + + // Process packet + quint32 root_vector = 0; + CID source_cid; + quint8 start_code = 0; + quint8 sequence = 0; + quint16 universe = 0; + quint16 slot_count = 0; + const quint8* pdata = nullptr; + char source_name[SOURCE_NAME_SIZE] = {}; + quint8 priority = 0; + /* + * These only apply to the ratified version of the spec, so we will hardwire + * them to defaults just in case they never get set. + */ + quint16 synchronization = NOT_SYNCHRONIZED_VALUE; // E1.31:2018 + quint8 options = NO_OPTIONS_VALUE; + + const e_ValidateStreamHeader streamHeaderVersion = ValidateStreamHeader(reinterpret_cast(data.data()), data.length(), root_vector, source_cid, source_name, priority, + start_code, synchronization, sequence, options, universe, slot_count, pdata); + + switch (streamHeaderVersion) + { + case e_ValidateStreamHeader::StreamHeader_Invalid: + // Recieved a packet but not valid. Log and discard + qDebug() << this << ": Invalid Packet"; + return; + + case e_ValidateStreamHeader::StreamHeader_Draft: + case e_ValidateStreamHeader::StreamHeader_Ratified: + break; + + case e_ValidateStreamHeader::StreamHeader_Extended: + { + quint32 vector; + if (static_cast(data.length()) > ROOT_VECTOR_ADDR + sizeof(vector)) { - QMetaObject::invokeMethod( - this, - "processDatagram", - Q_ARG(QByteArray, data), - Q_ARG(QHostAddress, destination), - Q_ARG(QHostAddress, sender)); - return; - }; - - QMutexLocker locker(&m_processMutex); - - // Process packet - quint32 root_vector; - CID source_cid; - quint8 start_code; - quint8 sequence; - quint16 universe; - quint16 slot_count; - quint8* pdata; - char source_name [SOURCE_NAME_SIZE]; - quint8 priority; - /* - * These only apply to the ratified version of the spec, so we will hardwire - * them to be 0 just in case they never get set. - */ - quint16 synchronization = NOT_SYNCHRONIZED_VALUE; - quint8 options = NO_OPTIONS_VALUE; - bool preview = false; - - switch (ValidateStreamHeader((quint8*)data.data(), data.length(), root_vector, source_cid, source_name, priority, - start_code, synchronization, sequence, options, universe, slot_count, pdata)) - { - case e_ValidateStreamHeader::StreamHeader_Invalid: - // Recieved a packet but not valid. Log and discard - qDebug() << "sACNListener" << QThread::currentThreadId() << ": Invalid Packet"; - return; - - case e_ValidateStreamHeader::StreamHeader_Unknown: - qDebug() << "sACNListener" << QThread::currentThreadId() << ": Unkown Root Vector"; - return; - - case e_ValidateStreamHeader::StreamHeader_Extended: - quint32 vector; - if (static_cast(data.length()) > ROOT_VECTOR_ADDR + sizeof(vector)) - { - vector = UpackBUint32((quint8*)data.data() + FRAMING_VECTOR_ADDR); - switch (vector) - { - case VECTOR_E131_EXTENDED_DISCOVERY: - sACNDiscoveryRX::getInstance()->processPacket((quint8*)data.data(), data.length()); - break; - - case VECTOR_E131_EXTENDED_SYNCHRONIZATION: - sACNSynchronizationRX::getInstance()->processPacket((quint8*)data.data(), data.length(), destination, sender); - break; - - default: - qDebug() << "sACNListener" << QThread::currentThreadId() << ": Unknown Extended Packet"; - } - } - return; - - case e_ValidateStreamHeader::StreamHeader_Pathway_Secure: - if (!Preferences::Instance().GetPathwaySecureRx()) - { - qDebug() << "sACNListener" << QThread::currentThreadId() << ": Ignore Pathway secure"; - return; - } + vector = UpackBUint32(reinterpret_cast(data.data()) + FRAMING_VECTOR_ADDR); + switch (vector) + { + case VECTOR_E131_EXTENDED_DISCOVERY: + sACNDiscoveryRX::getInstance()->processPacket(reinterpret_cast(data.data()), data.length()); + break; - default: + case VECTOR_E131_EXTENDED_SYNCHRONIZATION: + sACNSynchronizationRX::getInstance()->processPacket(reinterpret_cast(data.data()), data.length(), destination, sender); break; - } - // Wrong universe - if(m_universe != universe) - { - // Was it unicast? Send to correct listener (if listening) - if (!destination.isMulticast() && !destination.isBroadcast()) - { - // Unicast, send to correct listener! - decltype(sACNManager::Instance().getListenerList()) listenerList - = sACNManager::Instance().getListenerList(); - if (listenerList.contains(universe)) - listenerList[universe].toStrongRef()->processDatagram(data, destination, sender); - return; - } else { - // Log and discard - qDebug() << "sACNListener" << QThread::currentThreadId() - << ": Rejecting universe" << universe << "sent to" << destination; - return; - } + default: + qDebug() << this << ": Unknown Extended Packet"; + } } + } return; - // Listen to preview? - preview = (PREVIEW_DATA_OPTION == (options & PREVIEW_DATA_OPTION)); - if ((preview) && !Preferences::Instance().GetBlindVisualizer()) + case e_ValidateStreamHeader::StreamHeader_Pathway_Secure: + if (!Preferences::Instance().GetPathwaySecureRx()) { - qDebug() << "sACNListener" << QThread::currentThreadId() << ": Ignore preview"; - return; + qDebug() << this << ": Ignore Pathway secure"; + return; } + break; - sACNSource *ps = NULL; // Pointer to the source - bool foundsource = false; - bool newsourcenotify = false; - bool validpacket = true; //whether or not we will actually process the packet + default: + case e_ValidateStreamHeader::StreamHeader_Unknown: + qDebug() << this << ": Unkown Root Vector"; + return; + } - for(std::vector::iterator it = m_sources.begin(); it != m_sources.end(); ++it) + // Packet is now know to contain DMX Level Data + + // Wrong universe + if (m_universe != universe) + { + // Was it unicast? Send to correct listener (if listening) + if (!destination.isMulticast() && !destination.isBroadcast()) { - if((*it)->src_cid == source_cid) - { - foundsource = true; - ps = *it; - - // Verify Pathway Secure DMX security features, do this before updating the active flag - if (root_vector == VECTOR_ROOT_E131_DATA_PATHWAY_SECURE) { - PathwaySecure::VerifyStreamSecurity( - (quint8*)data.data(), data.size(), - Preferences::Instance().GetPathwaySecureRxPassword(), - *ps); - } else { - ps->pathway_secure.passwordOk = false; - ps->pathway_secure.sequenceOk = false; - ps->pathway_secure.digestOk = false; - } - - if(!ps->src_valid) - { - // This is a source which is coming back online, so we need to repeat the steps - // for initial source aquisition - ps->active.SetInterval(std::chrono::milliseconds(E131_NETWORK_DATA_LOSS_TIMEOUT + m_ssHLL)); - ps->lastseq = sequence; - ps->src_cid = source_cid; - ps->src_valid = true; - ps->doing_dmx = (start_code == STARTCODE_DMX); - ps->doing_per_channel = ps->waited_for_dd = false; - newsourcenotify = false; - ps->priority_wait.SetInterval(std::chrono::milliseconds(WAIT_PRIORITY)); - } - - if( - ((root_vector == VECTOR_ROOT_E131_DATA) || root_vector == VECTOR_ROOT_E131_DATA_PATHWAY_SECURE) - && ((options & STREAM_TERMINATED_OPTION) == STREAM_TERMINATED_OPTION)) - { - //by setting this flag to false, 0xdd packets that may come in while the terminated data - //packets come in won't reset the priority_wait timer - (*it)->waited_for_dd = false; - if(start_code == STARTCODE_DMX) - (*it)->doing_dmx = false; - - //"Upon receipt of a packet containing this bit set - //to a value of 1, a receiver shall enter network - //data loss condition. Any property values in - //these packets shall be ignored" - (*it)->active.SetInterval(std::chrono::milliseconds(m_ssHLL)); //We factor in the hold last look time here, rather than 0 - - if((*it)->doing_per_channel) - (*it)->priority_wait.SetInterval(std::chrono::milliseconds(m_ssHLL)); //We factor in the hold last look time here, rather than 0 - - validpacket = false; - break; - } - - //Based on the start code, update the timers - if(start_code == STARTCODE_DMX) - { - //No matter how valid, we got something -- but we'll tweak the interval for any hll change - (*it)->doing_dmx = true; - (*it)->active.SetInterval(std::chrono::milliseconds(E131_NETWORK_DATA_LOSS_TIMEOUT + m_ssHLL)); - } - else if(start_code == STARTCODE_PRIORITY && (*it)->waited_for_dd) - { - (*it)->doing_per_channel = true; //The source could have stopped sending dd for a while. - (*it)->priority_wait.Reset(); - } - - //Validate the sequence number, updating the stored one - //The two's complement math is to handle rollover, and we're explicitly - //doing assignment to force the type sizes. A negative number means - //we got an "old" one, but we assume that anything really old is possibly - //due the device having rebooted and starting the sequence over. - qint8 result = ((qint8)sequence) - ((qint8)((*it)->lastseq)); - if(result!=1) - (*it)->jumps++; - if((result <= 0) && (result > -20)) - { - validpacket = false; - (*it)->seqErr++; - } - else - (*it)->lastseq = sequence; - - //This next bit is a little tricky. We want to wait for dd packets (sampling period - //tweaks aside) and notify them with the dd packet first, but we don't want to do that - //if we've never seen a dmx packet from the source. - if(!(*it)->doing_dmx) - { - /* For fault finding an installation which only sends 0xdd for a universe - * (For example: ETC Cobalt does this for, currenlty, unpatched universes with in it's network map) - * We do want to say that having not sent dimmer data is a valid source/packet - */ - // validpacket = false; - (*it)->priority_wait.Reset(); //We don't want to let the priority timer run out - } - else if(!(*it)->waited_for_dd && validpacket) - { - if(start_code == STARTCODE_PRIORITY) - { - (*it)->waited_for_dd = true; - (*it)->doing_per_channel = true; - (*it)->priority_wait.SetInterval(std::chrono::milliseconds(E131_NETWORK_DATA_LOSS_TIMEOUT + m_ssHLL)); - newsourcenotify = true; - } - else if((*it)->priority_wait.Expired()) - { - (*it)->waited_for_dd = true; - (*it)->doing_per_channel = false; - (*it)->priority_wait.SetInterval(std::chrono::milliseconds(E131_NETWORK_DATA_LOSS_TIMEOUT + m_ssHLL)); //In case the source later decides to sent 0xdd packets - newsourcenotify = true; - } - else - newsourcenotify = validpacket = false; - } - - //Found the source, and we're ready to process the packet - } + // Unicast, send to correct listener! + decltype(sACNManager::Instance().getListenerList()) listenerList + = sACNManager::Instance().getListenerList(); + auto it = listenerList.find(universe); + if (it != listenerList.end()) + it.value().toStrongRef()->processDatagram(data, destination, sender); + } + else { + // Log and discard + qDebug() << this << ": Rejecting universe" << universe << "sent to" << destination; + } + return; + } + + // Listen to preview? + const bool preview = (PREVIEW_DATA_OPTION == (options & PREVIEW_DATA_OPTION)); + if (preview && !Preferences::Instance().GetBlindVisualizer()) + { + qDebug() << this << ": Ignore preview"; + return; + } + + sACNSource* ps = nullptr; // Pointer to the source + bool newsourcenotify = false; + bool validpacket = true; //whether or not we will actually process the packet + + // Find existing known source + auto it = std::find_if(m_sources.begin(), m_sources.end(), [source_cid](sACNSource* source) { return source && source->src_cid == source_cid; }); + if (it != m_sources.end()) + { + ps = (*it); + + // Verify Pathway Secure DMX security features, do this before updating the active flag + if (streamHeaderVersion == e_ValidateStreamHeader::StreamHeader_Pathway_Secure) { + PathwaySecure::VerifyStreamSecurity(reinterpret_cast(data.data()), data.size(), + Preferences::Instance().GetPathwaySecureRxPassword(), *ps); + } + else { + ps->pathway_secure.passwordOk = false; + ps->pathway_secure.sequenceOk = false; + ps->pathway_secure.digestOk = false; } - if(!validpacket) + if (!ps->src_valid) { - qDebug() << "sACNListener" << QThread::currentThreadId() << ": Source coming up, not processing packet"; - return; + // This is a source which is coming back online, so we need to repeat the steps + // for initial source aquisition + ps->active.SetInterval(std::chrono::milliseconds(E131_NETWORK_DATA_LOSS_TIMEOUT + m_ssHLL)); + ps->lastseq = sequence; + ps->src_valid = true; + ps->doing_dmx = (start_code == STARTCODE_DMX); + ps->doing_per_channel = ps->waited_for_dd = false; + newsourcenotify = false; + ps->priority_wait.SetInterval(std::chrono::milliseconds(WAIT_PRIORITY)); } - if(!foundsource) //Add a new source to the list + if ((options & STREAM_TERMINATED_OPTION) == STREAM_TERMINATED_OPTION) { - ps = new sACNSource(); + // Source is terminating - m_sources.push_back(ps); + //by setting this flag to false, 0xdd packets that may come in while the terminated data + //packets come in won't reset the priority_wait timer + ps->waited_for_dd = false; + if (start_code == STARTCODE_DMX) + ps->doing_dmx = false; - ps->name = QString::fromUtf8(source_name); - ps->ip = sender; - ps->universe = universe; - ps->synchronization = synchronization; + //"Upon receipt of a packet containing this bit set + //to a value of 1, a receiver shall enter network + //data loss condition. Any property values in + //these packets shall be ignored" + ps->active.SetInterval(std::chrono::milliseconds(m_ssHLL)); //We factor in the hold last look time here, rather than 0 + + if (ps->doing_per_channel) + ps->priority_wait.SetInterval(std::chrono::milliseconds(m_ssHLL)); //We factor in the hold last look time here, rather than 0 + + validpacket = false; + } + else + { + //Based on the start code, update the timers + switch (start_code) + { + case STARTCODE_DMX: + { + //No matter how valid, we got something -- but we'll tweak the interval for any hll change + ps->doing_dmx = true; ps->active.SetInterval(std::chrono::milliseconds(E131_NETWORK_DATA_LOSS_TIMEOUT + m_ssHLL)); - ps->lastseq = sequence; - ps->src_cid = source_cid; - ps->src_valid = true; - ps->src_stable = true; - ps->doing_dmx = (start_code == STARTCODE_DMX); - //If we are in the sampling period, let all packets through - if(m_isSampling) + } break; + + case STARTCODE_PRIORITY: if (ps->waited_for_dd) + { + ps->doing_per_channel = true; //The source could have stopped sending dd for a while. + ps->priority_wait.Reset(); + } break; + } + + //Validate the sequence number, updating the stored one + //The two's complement math is to handle rollover, and we're explicitly + //doing assignment to force the type sizes. A negative number means + //we got an "old" one, but we assume that anything really old is possibly + //due the device having rebooted and starting the sequence over. + const qint16 result = reinterpret_cast(sequence) - reinterpret_cast(ps->lastseq); + if (result != 1) + { + ps->jumps++; + if ((result <= 0) && (result > -20)) { - ps->waited_for_dd = true; - ps->doing_per_channel = (start_code == STARTCODE_PRIORITY); - newsourcenotify = true; - ps->priority_wait.SetInterval(std::chrono::milliseconds(E131_NETWORK_DATA_LOSS_TIMEOUT + m_ssHLL)); + validpacket = false; + ps->seqErr++; } - else + } + + ps->lastseq = sequence; + + //This next bit is a little tricky. We want to wait for dd packets (sampling period + //tweaks aside) and notify them with the dd packet first, but we don't want to do that + //if we've never seen a dmx packet from the source. + if (!ps->doing_dmx) + { + /* For fault finding an installation which only sends 0xdd for a universe + * (For example: ETC Cobalt does this for, currenlty, unpatched universes with in it's network map) + * We do want to say that having not sent dimmer data is a valid source/packet + */ + // validpacket = false; + ps->priority_wait.Reset(); //We don't want to let the priority timer run out + } + else if (!ps->waited_for_dd && validpacket) + { + if (start_code == STARTCODE_PRIORITY) + { + ps->waited_for_dd = true; + ps->doing_per_channel = true; + ps->priority_wait.SetInterval(std::chrono::milliseconds(E131_NETWORK_DATA_LOSS_TIMEOUT + m_ssHLL)); + newsourcenotify = true; + } + else if (ps->priority_wait.Expired()) { - //If we aren't sampling, we want the earlier logic to set the state - ps->doing_per_channel = ps->waited_for_dd = false; - newsourcenotify = false; - ps->priority_wait.SetInterval(std::chrono::milliseconds(WAIT_PRIORITY)); + ps->waited_for_dd = true; + ps->doing_per_channel = false; + ps->priority_wait.SetInterval(std::chrono::milliseconds(E131_NETWORK_DATA_LOSS_TIMEOUT + m_ssHLL)); //In case the source later decides to sent 0xdd packets + newsourcenotify = true; } + else + newsourcenotify = validpacket = false; + } + } + // Found the source, and we're ready to process the packet + } + + + if (!validpacket) + { + qDebug() << this << ": Source coming up, not processing packet"; + return; + } + + if (ps == nullptr) // Add a new source to the list + { + ps = new sACNSource(source_cid, universe); + + m_sources.push_back(ps); + + ps->name = QString::fromUtf8(source_name); + ps->ip = sender; + ps->synchronization = synchronization; + ps->active.SetInterval(std::chrono::milliseconds(E131_NETWORK_DATA_LOSS_TIMEOUT + m_ssHLL)); + ps->lastseq = sequence; + ps->src_valid = true; + ps->src_stable = true; + ps->doing_dmx = (start_code == STARTCODE_DMX); + //If we are in the sampling period, let all packets through + if (m_isSampling) + { + ps->waited_for_dd = true; + ps->doing_per_channel = (start_code == STARTCODE_PRIORITY); + newsourcenotify = true; + ps->priority_wait.SetInterval(std::chrono::milliseconds(E131_NETWORK_DATA_LOSS_TIMEOUT + m_ssHLL)); + } + else + { + //If we aren't sampling, we want the earlier logic to set the state + ps->doing_per_channel = ps->waited_for_dd = false; + newsourcenotify = false; + ps->priority_wait.SetInterval(std::chrono::milliseconds(WAIT_PRIORITY)); + } - validpacket = newsourcenotify; + validpacket = newsourcenotify; - // This is a brand new source - qDebug() << "sACNListener" << QThread::currentThreadId() << ": Found new source name " << source_name; - m_mergeAll = true; - emit sourceFound(ps); - } + // This is a brand new source + qDebug() << this << ": Found new source name " << source_name; + m_mergeAll = true; + emit sourceFound(ps); + } + + if (newsourcenotify) + { + // This is a source that came back online + qDebug() << this << ": Source came back name " << source_name; + m_mergeAll = true; + emit sourceResumed(ps); + emit sourceChanged(ps); + } - if (newsourcenotify) - { - // This is a source that came back online - qDebug() << "sACNListener" << QThread::currentThreadId() << ": Source came back name " << source_name; - m_mergeAll = true; - emit sourceResumed(ps); - emit sourceChanged(ps); - } + //Finally, Process the buffer + if (validpacket) + { + ps->source_params_change = false; - //Finally, Process the buffer - if(validpacket) + QString name = QString::fromUtf8(source_name); + + if (ps->ip != sender) { - ps->source_params_change = false; + ps->ip = sender; + ps->source_params_change = true; + } - QString name = QString::fromUtf8(source_name); + StreamingACNProtocolVersion protocolVersion = sACNProtocolUnknown; + switch (root_vector) { + case VECTOR_ROOT_E131_DATA: + protocolVersion = sACNProtocolRelease; + break; - if(ps->ip != sender) - { - ps->ip = sender; - ps->source_params_change = true; - } + case VECTOR_ROOT_E131_DATA_DRAFT: + protocolVersion = sACNProtocolDraft; + break; - StreamingACNProtocolVersion protocolVersion = sACNProtocolUnknown; - switch (root_vector) { - case VECTOR_ROOT_E131_DATA: - protocolVersion = sACNProtocolRelease; - break; + case VECTOR_ROOT_E131_DATA_PATHWAY_SECURE: + protocolVersion = sACNProtocolPathwaySecure; + break; - case VECTOR_ROOT_E131_DATA_DRAFT: - protocolVersion = sACNProtocolDraft; - break; + default: + protocolVersion = sACNProtocolUnknown; + break; + } - case VECTOR_ROOT_E131_DATA_PATHWAY_SECURE: - protocolVersion = sACNProtocolPathwaySecure; - break; + if (ps->protocol_version != protocolVersion) + { + ps->protocol_version = protocolVersion; + ps->source_params_change = true; + } - default: - protocolVersion = sACNProtocolUnknown; - break; - } + if (start_code == STARTCODE_DMX) + { + if (ps->name != name) + { + ps->name = name; + ps->source_params_change = true; + } + if (ps->isPreview != preview) + { + ps->isPreview = preview; + ps->source_params_change = true; + } + if (ps->priority != priority) + { + ps->priority = priority; + ps->source_params_change = true; + } + if (ps->synchronization != synchronization) + { + ps->synchronization = synchronization; + ps->source_params_change = true; + } - if(ps->protocol_version!=protocolVersion) - { - ps->protocol_version = protocolVersion; - ps->source_params_change = true; - } + ps->storeReceivedLevels(pdata, slot_count); - if(start_code == STARTCODE_DMX) - { - if(ps->name!=name) - { - ps->name = name; - ps->source_params_change = true; - } - if(ps->isPreview != preview) - { - ps->isPreview = preview; - ps->source_params_change = true; - } - if(ps->priority != priority) - { - ps->priority = priority; - ps->source_params_change = true; - } - if(ps->synchronization != synchronization) - { - ps->synchronization = synchronization; - ps->source_params_change = true; - } - // This is DMX - // Copy the last array back - memcpy(ps->last_level_array, ps->level_array, DMX_SLOT_MAX); - // Fill in the new array - memset(ps->level_array, 0, DMX_SLOT_MAX); - memcpy(ps->level_array, pdata, slot_count); - - // Slot count change, re-merge all slots - if(ps->slot_count != slot_count) - { - ps->slot_count = slot_count; - ps->source_params_change = true; - ps->source_levels_change = true; - for(int i=0; idirty_array[i] |= true; - } - - // Compare the two - for(int i=0; ilevel_array[i]!=ps->last_level_array[i]) - { - ps->dirty_array[i] |= true; - ps->source_levels_change = true; - } - } - - // FPS Counter - we count only DMX frames - ps->fpscounter.newFrame(); - if (ps->fpscounter.isNewFPS()) - ps->source_params_change = true; - } - else if(start_code == STARTCODE_PRIORITY) - { - // Copy the last array back - memcpy(ps->last_priority_array, ps->priority_array, DMX_SLOT_MAX); - // Fill in the new array - memset(ps->priority_array, 0, DMX_SLOT_MAX); - if (!Preferences::Instance().GetETCDD()) - { // DD is disabled, fill with universe priority - std::fill(std::begin(ps->priority_array), std::end(ps->priority_array), ps->priority); - } else { - memcpy(ps->priority_array, pdata, slot_count); - } - // Compare the two - for(int i=0; ipriority_array[i]!=ps->last_priority_array[i]) - { - ps->dirty_array[i] |= true; - ps->source_levels_change = true; - } - } - } + // FPS Counter - we count only DMX frames + ps->fpscounter.newFrame(packet_tock); + if (ps->fpscounter.isNewFPS()) + ps->source_params_change = true; + } + else if (start_code == STARTCODE_PRIORITY) + { + if (!Preferences::Instance().GetETCDD()) + { // DD is disabled, fill with universe priority. + // TODO: Does this actually ever do anything? + std::array univ_priority; + univ_priority.fill(ps->priority); + ps->storeReceivedPriorities(univ_priority.data(), slot_count); + } + else + { + ps->storeReceivedPriorities(pdata, slot_count); + } + } - if(ps->source_params_change) - { - emit sourceChanged(ps); - ps->source_params_change = false; - } + if (ps->source_params_change) + { + emit sourceChanged(ps); + ps->source_params_change = false; + } - // Listen to synchronization source - if (ps->synchronization && - (!ps->sync_listener || ps->sync_listener->universe() != ps->synchronization)) { - ps->sync_listener = sACNManager::Instance().getListener(ps->synchronization); - } + // Listen to synchronization source + if (ps->synchronization && + (!ps->sync_listener || ps->sync_listener->universe() != ps->synchronization)) { + ps->sync_listener = sACNManager::Instance().getListener(ps->synchronization); + } - // Merge - performMerge(); + // Merge + performMerge(); + // Inform everyone who cares that a DMX packet has been received + if (start_code == STARTCODE_DMX) + { + QMutexLocker locker(&m_directCallbacksMutex); + for (IDmxReceivedCallback* callback : m_dmxReceivedCallbacks) + { + callback->sACNListenerDmxReceived(packet_tock, m_universe, m_current_levels); + } } + } } -inline bool isPatched(const sACNSource &source, uint16_t address) +void sACNListener::addDirectCallback(IDmxReceivedCallback* callback) { - // Can only be unpatched if we have DD packets - if (!source.doing_per_channel) - return true; - - // Priority not 0 - return source.priority_array[address] != 0; + QMutexLocker locker(&m_directCallbacksMutex); + for (const IDmxReceivedCallback* existing : m_dmxReceivedCallbacks) + { + if (existing == callback) + return; + } + m_dmxReceivedCallbacks.push_back(callback); } -void sACNListener::performMerge() +void sACNListener::removeDirectCallback(IDmxReceivedCallback* callback) { - //array of addresses to merge. to prevent duplicates and because you can have - //an odd collection of addresses, addresses[n] would be 'n' for the value in question - // and -1 if not required - int addresses_to_merge[DMX_SLOT_MAX]; - int number_of_addresses_to_merge = 0; + QMutexLocker locker(&m_directCallbacksMutex); + for (auto it = m_dmxReceivedCallbacks.begin(); it != m_dmxReceivedCallbacks.end(); /**/) + { + if (callback == (*it)) + it = m_dmxReceivedCallbacks.erase(it); + else + ++it; + } +} - memset(addresses_to_merge, -1, sizeof(int) * DMX_SLOT_MAX); +inline bool isPatched(const sACNSource& source, uint16_t address) +{ + // Can only be unpatched if we have DD packets + if (!source.doing_per_channel) + return true; - { - QMutexLocker locker(&m_monitoredChannelsMutex); - for(auto const &chan: qAsConst(m_monitoredChannels)) - { - QPointF data; - data.setX(m_elapsedTime.nsecsElapsed()/1000000.0); - data.setY(mergedLevels().at(chan).level); - emit dataReady(chan, data); - } - } + // Priority not 0 + return source.priority_array[address] != 0; +} - if(m_mergesPerSecondTimer.hasExpired(1000)) +void sACNListener::performMerge() +{ + if (m_mergesPerSecondTimer.hasExpired(1000)) + { + m_mergesPerSecond = m_mergeCounter; + m_mergeCounter = 0; + m_mergesPerSecondTimer.restart(); + } + + ++m_mergeCounter; + + //array of addresses to merge. to prevent duplicates and because you can have + //an odd collection of addresses, addresses[n] would be 'n' for the value in question + // and -1 if not required + std::array addresses_to_merge; + int number_of_addresses_to_merge = 0; + + // Step one : find any addresses which have changed + if (m_mergeAll) // Act like all addresses changed + { + number_of_addresses_to_merge = addresses_to_merge.size(); + for (int i = 0; i < addresses_to_merge.size(); i++) { - m_mergesPerSecond = m_mergeCounter; - m_mergeCounter = 0; - m_mergesPerSecondTimer.restart(); + addresses_to_merge[i] = i; } - m_mergeCounter++; - - // Step one : find any addresses which have changed - if(m_mergeAll) // Act like all addresses changed - { - number_of_addresses_to_merge = DMX_SLOT_MAX; - for(int i=0; i::iterator it = m_sources.begin(); it != m_sources.end(); ++it) + if (!ps->src_valid) + continue; // Inactive source, ignore it + if (!ps->source_levels_change) + continue; // We don't need to consider this one, no change + for (int i = 0; i < DMX_SLOT_MAX; i++) + { + if (ps->dirty_array[i]) { - sACNSource *ps = *it; - if(!ps->src_valid) - continue; // Inactive source, ignore it - if(!ps->source_levels_change) - continue; // We don't need to consider this one, no change - for(int i=0; idirty_array[i]) - { - addresses_to_merge[i] = i; - number_of_addresses_to_merge++; - } - } - // Clear the flags - memset(ps->dirty_array, 0 , DMX_SLOT_MAX); - ps->source_levels_change = false; + addresses_to_merge[i] = i; + number_of_addresses_to_merge++; } + } + // Clear the flags + ps->dirty_array.fill(false); + ps->source_levels_change = false; } + } - if(number_of_addresses_to_merge == 0) return; // Nothing to do + if (number_of_addresses_to_merge == 0) return; // Nothing to do - // Clear out the sources list for all the affected channels, we'll be refreshing it + // Clear out the sources list for all the affected channels, we'll be refreshing it + QMutexLocker mergeLocker(&m_merged_levelsMutex); - int skipCounter = 0; - for(int i=0; i < DMX_SLOT_MAX && i<(number_of_addresses_to_merge + skipCounter); i++) - { - QMutexLocker mergeLocker(&m_merged_levelsMutex); - m_merged_levels[i].changedSinceLastMerge = false; - if(addresses_to_merge[i] == -1) { - ++skipCounter; - continue; - } - sACNMergedAddress *pAddr = &m_merged_levels[addresses_to_merge[i]]; - pAddr->otherSources.clear(); + int skipCounter = 0; + for (int i = 0; i < DMX_SLOT_MAX && i < (number_of_addresses_to_merge + skipCounter); i++) + { + m_merged_levels[i].changedSinceLastMerge = false; + if (addresses_to_merge[i] == -1) { + ++skipCounter; + continue; } + sACNMergedAddress* pAddr = &m_merged_levels[addresses_to_merge[i]]; + pAddr->otherSources.clear(); + } - // Find the highest priority source for each address we need to work on - - int levels[DMX_SLOT_MAX]; - memset(&levels, -1, sizeof(levels)); + // Find the highest priority source for each address we need to work on + m_last_levels.fill(-1); + m_last_priorities.fill(-1); - int priorities[DMX_SLOT_MAX]; - memset(&priorities, -1, sizeof(priorities)); + QMultiMap addressToSourceMap; - QMultiMap addressToSourceMap; + // Find the highest priority for the address + bool secureDataOnly = false; + if (Preferences::Instance().GetPathwaySecureRx()) + secureDataOnly = Preferences::Instance().GetPathwaySecureRxDataOnly(); + for (std::vector::iterator it = m_sources.begin(); it != m_sources.end(); ++it) + { + sACNSource* ps = *it; - // Find the highest priority for the address - bool secureDataOnly = false; - if (Preferences::Instance().GetPathwaySecureRx()) - secureDataOnly = Preferences::Instance().GetPathwaySecureRxDataOnly(); - for(std::vector::iterator it = m_sources.begin(); it != m_sources.end(); ++it) + if (ps->src_valid && !ps->active.Expired() && !ps->doing_per_channel) { - sACNSource *ps = *it; - - if(ps->src_valid && !ps->active.Expired() && !ps->doing_per_channel) - { - // Set the priority array for sources which are not doing per-channel - memset(ps->priority_array, ps->priority, sizeof(ps->priority_array)); - } - - skipCounter = 0; - for(int i=0; i < DMX_SLOT_MAX && i<(number_of_addresses_to_merge + skipCounter); i++) - { - QMutexLocker mergeLocker(&m_merged_levelsMutex); - if(addresses_to_merge[i] == -1) { - ++skipCounter; - continue; - } - int address = addresses_to_merge[i]; - - if ( - ps->src_valid // Valid Source - && !ps->active.Expired() // Not expired - && !(ps->priority_array[address] < priorities[address]) // Not lesser priority - && isPatched(*ps, address) // Priority > 0 if DD - && (address < ps->slot_count) // Sending the required slot - && ((!secureDataOnly) || (secureDataOnly && ps->pathway_secure.isSecure())) // Is secure, if only displaying secure sources - ) - { - if (ps->priority_array[address] > priorities[address]) - { - // Source of higher priority - priorities[address] = ps->priority_array[address]; - addressToSourceMap.remove(address); - } - addressToSourceMap.insert(address, ps); - } - - if( - ps->src_valid // Valid Source - && !ps->active.Expired() // Not Expired - && isPatched(*ps, address) // Priority > 0 if DD - && (address < ps->slot_count) // Sending the required slot - ) - m_merged_levels[addresses_to_merge[i]].otherSources.insert(ps); - } + // Set the priority array for sources which are not doing per-channel + ps->priority_array.fill(ps->priority); } - // Next, find highest level for the highest prioritized sources skipCounter = 0; - for(int i=0; i < DMX_SLOT_MAX && i<(number_of_addresses_to_merge + skipCounter); i++) + for (int i = 0; i < DMX_SLOT_MAX && i < (number_of_addresses_to_merge + skipCounter); i++) { - QMutexLocker mergeLocker(&m_merged_levelsMutex); - if(addresses_to_merge[i] == -1) { - ++skipCounter; - continue; - } - int address = addresses_to_merge[i]; - QList sourceList = addressToSourceMap.values(address); - - if(sourceList.count() == 0) - { - m_merged_levels[address].level = -1; - m_merged_levels[address].winningSource = NULL; - m_merged_levels[address].otherSources.clear(); - m_merged_levels[address].winningPriority = priorities[address]; - } - for (auto s : sourceList) + if (addresses_to_merge[i] == -1) { + ++skipCounter; + continue; + } + int address = addresses_to_merge[i]; + + if ( + ps->src_valid // Valid Source + && !ps->active.Expired() // Not expired + && !(ps->priority_array[address] < m_last_priorities[address]) // Not lesser priority + && isPatched(*ps, address) // Priority > 0 if DD + && (address < ps->slot_count) // Sending the required slot + && ((!secureDataOnly) || (secureDataOnly && ps->pathway_secure.isSecure())) // Is secure, if only displaying secure sources + ) + { + if (ps->priority_array[address] > m_last_priorities[address]) { - if(s->level_array[address] > levels[address]) - { - levels[address] = s->level_array[address]; - m_merged_levels[address].changedSinceLastMerge = (m_merged_levels[address].level != levels[address]); - m_merged_levels[address].level = levels[address]; - m_merged_levels[address].winningSource = s; - m_merged_levels[address].winningPriority = priorities[address]; - } + // Source of higher priority + m_last_priorities[address] = ps->priority_array[address]; + addressToSourceMap.remove(address); } - // Remove the winning source from the list of others - if(m_merged_levels[address].winningSource) - m_merged_levels[address].otherSources.remove(m_merged_levels[address].winningSource); + addressToSourceMap.insert(address, ps); + } + + if ( + ps->src_valid // Valid Source + && !ps->active.Expired() // Not Expired + && isPatched(*ps, address) // Priority > 0 if DD + && (address < ps->slot_count) // Sending the required slot + ) + m_merged_levels[addresses_to_merge[i]].otherSources.insert(ps); + } + } + + // Next, find highest level for the highest prioritized sources + skipCounter = 0; + for (int i = 0; i < DMX_SLOT_MAX && i < (number_of_addresses_to_merge + skipCounter); i++) + { + if (addresses_to_merge[i] == -1) { + ++skipCounter; + continue; } + int address = addresses_to_merge[i]; + QList sourceList = addressToSourceMap.values(address); - // Tell people.. - emit levelsChanged(); -} + if (sourceList.empty()) + { + m_merged_levels[address].level = -1; + m_merged_levels[address].winningSource = nullptr; + m_merged_levels[address].otherSources.clear(); + m_merged_levels[address].winningPriority = m_last_priorities[address]; + } + for (sACNSource* s : sourceList) + { + if (s->level_array[address] > m_last_levels[address]) + { + m_last_levels[address] = s->level_array[address]; + m_merged_levels[address].changedSinceLastMerge = (m_merged_levels[address].level != m_last_levels[address]); + m_merged_levels[address].level = m_last_levels[address]; + m_merged_levels[address].winningSource = s; + m_merged_levels[address].winningPriority = m_last_priorities[address]; + } + } + // Remove the winning source from the list of others + if (m_merged_levels[address].winningSource) + m_merged_levels[address].otherSources.remove(m_merged_levels[address].winningSource); + + // Update current final merge + m_current_levels[address] = m_last_levels[address]; + m_current_priorities[address] = m_last_priorities[address]; + } + mergeLocker.unlock(); + + // Tell people.. + emit levelsChanged(); +} diff --git a/src/sacn/sacnlistener.h b/src/sacn/sacnlistener.h index 962db035..40a7f49a 100644 --- a/src/sacn/sacnlistener.h +++ b/src/sacn/sacnlistener.h @@ -27,21 +27,18 @@ #include "streamingacn.h" #include "sacnsocket.h" +#include + Q_DECLARE_METATYPE(QHostAddress) struct sACNMergedAddress { - sACNMergedAddress() { - level = -1; - winningSource = NULL; - changedSinceLastMerge = false; - winningPriority = 0; - } - int level; - sACNSource *winningSource; - QSet otherSources; - bool changedSinceLastMerge; - int winningPriority; + sACNMergedAddress() = default; + sACNSource* winningSource = nullptr; + QSet otherSources; + int level = -1; + int winningPriority = 0; + bool changedSinceLastMerge = false; }; typedef QVector sACNMergedSourceList; @@ -53,106 +50,145 @@ typedef QVector sACNMergedSourceList; */ class sACNListener : public QObject { - Q_OBJECT -public: - sACNListener(int universe, QObject *parent = 0); - virtual ~sACNListener(); + Q_OBJECT +public: + class IDmxReceivedCallback + { + public: + virtual ~IDmxReceivedCallback() {} /** - * @brief universe - * @return the universe which this listener is listening for - */ - int universe() const { return m_universe; } - /** - * @brief mergedLevels - * @return an sACNMergerdSourceList, a list of merged address structures, allowing you to see - * the result of the merge algorithm together with all the sub-sources, by address - */ - sACNMergedSourceList mergedLevels() { - QMutexLocker mergeLocker(&m_merged_levelsMutex); - return m_merged_levels; - } - - int sourceCount() const { return static_cast(m_sources.size()); } - sACNSource *source(int index) { return m_sources[index];} - const std::vector getSourceList() const { return m_sources; } + * @brief Called in the listener thread after a DMX packet has been received and merged + * @param packet_tock global tock of this packet + * @param universe number + * @param mergedLevels array of merged levels + */ + virtual void sACNListenerDmxReceived(tock packet_tock, int universe, const std::array &mergedLevels) = 0; + }; - /** - * @brief processDatagram Process a suspected sACN datagram. - * This allows other listeners to pass on unicast datagrams for other universes - * - */ - Q_INVOKABLE void processDatagram(const QByteArray &data, const QHostAddress &destination, const QHostAddress &sender); +public: + sACNListener(int universe, QObject* parent = 0); + virtual ~sACNListener(); + + /** + * @brief universe + * @return the universe which this listener is listening for + */ + int universe() const { return m_universe; } + + /** + * @brief mergedLevels + * @return an sACNMergedSourceList, a list of merged address structures, allowing you to see + * the result of the merge algorithm together with all the sub-sources, by address + */ + sACNMergedSourceList mergedLevels() { + QMutexLocker mergeLocker(&m_merged_levelsMutex); + return m_merged_levels; + } + + /** + * @brief mergedLevels + * @param address The address to return + * @return an sACNMergedAddress containing the result of the merge algorithm together with all the sub-sources + */ + sACNMergedAddress mergedLevel(int address) { + if (address < 0 || address >= m_merged_levels.size()) return sACNMergedAddress(); + QMutexLocker mergeLocker(&m_merged_levelsMutex); + return m_merged_levels[address]; + } + + /** + * @brief mergedLevelsOnly + * @return an array of merged levels. -1 means no source at all + */ + std::array mergedLevelsOnly() { + QMutexLocker mergeLocker(&m_merged_levelsMutex); + return m_current_levels; + } + + /** + * @brief mergedPrioritiesOnly + * @return an array of final priorities. -1 means no source at all + */ + std::array mergedPrioritiesOnly() { + QMutexLocker mergeLocker(&m_merged_levelsMutex); + return m_current_priorities; + } - // Diagnostic - the number of merge operations per second + int sourceCount() const { return static_cast(m_sources.size()); } + sACNSource* source(int index) { return m_sources[index]; } + const std::vector getSourceList() const { return m_sources; } - int mergesPerSecond() const { return (m_mergesPerSecond > 0) ? m_mergesPerSecond : 0;} + /** + * @brief processDatagram Process a suspected sACN datagram. + * This allows other listeners to pass on unicast datagrams for other universes + * + */ + Q_INVOKABLE void processDatagram(const QByteArray& data, const QHostAddress& destination, const QHostAddress& sender); - /** - * @brief getBindStatus Get interface bind status of listener - * @return A struct of bind types and status - */ - sACNRxSocket::sBindStatus getBindStatus() const { return m_bindStatus; } + // Diagnostic - the number of merge operations per second - /** - * @brief Force the listener to performa a full merge - */ - void doFullMerge() { m_mergeAll = true; } + int mergesPerSecond() const { return (m_mergesPerSecond > 0) ? m_mergesPerSecond : 0; } + + /** + * @brief getBindStatus Get interface bind status of listener + * @return A struct of bind types and status + */ + sACNRxSocket::sBindStatus getBindStatus() const { return m_bindStatus; } + + /** + * @brief Force the listener to perform a full merge + */ + void doFullMerge() { m_mergeAll = true; } + + // Objects that want a direct callback for levels in the listener thread + void addDirectCallback(IDmxReceivedCallback* callback); + void removeDirectCallback(IDmxReceivedCallback* callback); public slots: - void startReception(); - void monitorAddress(int address, const QObject *owner) { - QMutexLocker locker(&m_monitoredChannelsMutex); - m_monitoredChannels.insert(owner, address); - connect(owner, &QObject::destroyed, this, [this](const QObject *owner) { - QMutexLocker locker(&m_monitoredChannelsMutex); - m_monitoredChannels.remove(owner); - }); - } - void unMonitorAddress(int address, const QObject *owner) { - QMutexLocker locker(&m_monitoredChannelsMutex); - m_monitoredChannels.remove(owner, address); - } + void startReception(); + signals: - void listenerStarted(int universe); - void sourceFound(sACNSource *source); - void sourceLost(sACNSource *source); - void sourceResumed(sACNSource *source); - void sourceChanged(sACNSource *source); - void levelsChanged(); - void dataReady(int address, QPointF data); // Used by ScopeWidget + void listenerStarted(int universe); + void sourceFound(sACNSource* source); + void sourceLost(sACNSource* source); + void sourceResumed(sACNSource* source); + void sourceChanged(sACNSource* source); + void levelsChanged(); private slots: - void readPendingDatagrams(); - void performMerge(); - void checkSourceExpiration(); - void sampleExpiration(); + void readPendingDatagrams(); + void performMerge(); + void checkSourceExpiration(); + void sampleExpiration(); private: - QMutex m_processMutex; - void startInterface(const QNetworkInterface &iface); - std::list m_sockets; - std::vector m_sources; - int m_last_levels[MAX_DMX_ADDRESS]; - sACNMergedSourceList m_merged_levels; - QMutex m_merged_levelsMutex; - int m_universe; - // The per-source hold last look time - int m_ssHLL = 1000; - // Are we in the initial sampling state - bool m_isSampling = true; - QTimer *m_initalSampleTimer = nullptr; - QTimer *m_mergeTimer = nullptr; - QElapsedTimer m_elapsedTime; - int m_predictableTimerValue; - QMutex m_monitoredChannelsMutex; - QMultiMap m_monitoredChannels; - bool m_mergeAll; // A flag to initiate a complete remerge of everything - unsigned int m_mergesPerSecond = 0; - int m_mergeCounter; - QElapsedTimer m_mergesPerSecondTimer; - - sACNRxSocket::sBindStatus m_bindStatus; + QMutex m_processMutex; + void startInterface(const QNetworkInterface& iface); + std::list m_sockets; + std::vector m_sources; + std::array m_last_levels = {}; + std::array m_last_priorities = {}; + std::array m_current_levels = {}; + std::array m_current_priorities = {}; + sACNMergedSourceList m_merged_levels; + QMutex m_merged_levelsMutex; + const int m_universe = 0; + // The per-source hold last look time + int m_ssHLL = 1000; + // Are we in the initial sampling state + bool m_isSampling = true; + QTimer* m_initalSampleTimer = nullptr; + QTimer* m_mergeTimer = nullptr; + int m_predictableTimerValue; + QMutex m_directCallbacksMutex; + std::vector m_dmxReceivedCallbacks; + bool m_mergeAll = true; // A flag to initiate a complete remerge of everything + unsigned int m_mergesPerSecond = 0; + int m_mergeCounter = 0; + QElapsedTimer m_mergesPerSecondTimer; + + sACNRxSocket::sBindStatus m_bindStatus; }; diff --git a/src/sacn/sacnsynchronization.cpp b/src/sacn/sacnsynchronization.cpp index eeb367d3..217d7e11 100644 --- a/src/sacn/sacnsynchronization.cpp +++ b/src/sacn/sacnsynchronization.cpp @@ -107,12 +107,14 @@ void sACNSynchronizationRX::timeoutSyncAddresses() { } } -void sACNSynchronizationRX::processPacket(quint8* pbuf, uint buflen, QHostAddress destination, QHostAddress sender) +void sACNSynchronizationRX::processPacket(const quint8* pbuf, uint buflen, QHostAddress destination, QHostAddress sender) { bool flag1, flag2, flag3; quint32 length; CID cid; + const tock packet_tock = sACNManager::GetTock(); + QMutexLocker locker(&m_mutex); // Check length @@ -157,7 +159,7 @@ void sACNSynchronizationRX::processPacket(quint8* pbuf, uint buflen, QHostAddres m_synchronizationSources[syncAddress][cid].sender = sender; m_synchronizationSources[syncAddress][cid].dataLoss.SetInterval(std::chrono::milliseconds(E131_NETWORK_DATA_LOSS_TIMEOUT)); - m_synchronizationSources[syncAddress][cid].fps->newFrame(); + m_synchronizationSources[syncAddress][cid].fps->newFrame(packet_tock); emit synchronize(syncAddress); } diff --git a/src/sacn/sacnsynchronization.h b/src/sacn/sacnsynchronization.h index 2af26695..67d1dc19 100644 --- a/src/sacn/sacnsynchronization.h +++ b/src/sacn/sacnsynchronization.h @@ -59,8 +59,8 @@ class sACNSynchronizationRX : public QObject sourceSequence(quint8 sequence) : lastNum(sequence) {} quint8 lastNum = 0; - quint16 jumps = 0; - quint16 seqErr = 0; + unsigned int jumps = 0; + unsigned int seqErr = 0; // Check sequence number bool checkSeq(quint8 newNum) { @@ -103,7 +103,7 @@ class sACNSynchronizationRX : public QObject tCIDDetails getSynchronizationSources(tsyncAddress syncAddress) const { return m_synchronizationSources.value(syncAddress); } QList getSynchronizationAddresses() const { return m_synchronizationSources.keys(); } - void processPacket(quint8* pbuf, uint buflen, QHostAddress destination, QHostAddress sender); + void processPacket(const quint8* pbuf, uint buflen, QHostAddress destination, QHostAddress sender); signals: void newSyncAddress(tsyncAddress syncAddress); diff --git a/src/sacn/securesacn.cpp b/src/sacn/securesacn.cpp index 25130ad9..b7837ec8 100644 --- a/src/sacn/securesacn.cpp +++ b/src/sacn/securesacn.cpp @@ -181,12 +181,12 @@ PathwaySecure::Sequence::value_t PathwaySecure::Sequence::next(const CID &cid, t } bool PathwaySecure::VerifyStreamHeader( - quint8* pbuf, uint buflen, + const quint8* pbuf, uint buflen, CID &source_cid, char* source_name, quint8 &priority, quint8 &start_code, quint16 &synchronization, quint8 &sequence, quint8 &options, quint16 &universe, - quint16 &slot_count, quint8* &pdata) + quint16 &slot_count, const quint8* &pdata) { if(!pbuf) return false; @@ -201,22 +201,14 @@ bool PathwaySecure::VerifyStreamHeader( if (UpackBUint32(pbuf + ROOT_VECTOR_ADDR) != RootLayer::VECTOR) return false; - /* Verify header as if release version - * Temporarily replacing root vector to fake release version, so that the stock VerifyStreamHeader works ok - */ - PackBUint32(pbuf + ROOT_VECTOR_ADDR, VECTOR_ROOT_E131_DATA); - bool verify = ::VerifyStreamHeader( + /* Verify header as if release version. The stock VerifyStreamHeader assumes root vector is VECTOR_ROOT_E131_DATA */ + return ::VerifyStreamHeader( pbuf, buflen, source_cid, source_name, priority, start_code, synchronization, sequence, options, universe, slot_count, pdata); - PackBUint32(pbuf + ROOT_VECTOR_ADDR, RootLayer::VECTOR); - if (!verify) - return false; - - return true; } void PathwaySecure::InitStreamHeader( diff --git a/src/sacn/securesacn.h b/src/sacn/securesacn.h index 7b47825d..787128e7 100644 --- a/src/sacn/securesacn.h +++ b/src/sacn/securesacn.h @@ -97,12 +97,12 @@ class PathwaySecure * @return True if header ok */ static bool VerifyStreamHeader( - quint8* pbuf, uint buflen, + const quint8* pbuf, uint buflen, CID &source_cid, char* source_name, quint8 &priority, quint8 &start_code, quint16 &synchronization, quint8 &sequence, quint8 &options, quint16 &universe, - quint16 &slot_count, quint8* &pdata); + quint16 &slot_count, const quint8* &pdata); /** * @brief Setup stream header diff --git a/src/sacn/streamcommon.cpp b/src/sacn/streamcommon.cpp index ea942252..693afedb 100644 --- a/src/sacn/streamcommon.cpp +++ b/src/sacn/streamcommon.cpp @@ -275,12 +275,12 @@ void SetStreamHeaderSequence(quint8* pbuf, quint8 seq, bool draft) * pdata is the offset into the buffer where the data is stored */ e_ValidateStreamHeader ValidateStreamHeader( - quint8* pbuf, uint buflen, + const quint8* pbuf, uint buflen, quint32 &root_vector, CID &source_cid, char* source_name, quint8 &priority, quint8 &start_code, quint16 &synchronization, quint8 &sequence, quint8 &options, quint16 &universe, - quint16 &slot_count, quint8* &pdata) + quint16 &slot_count, const quint8* &pdata) { if(!pbuf) return e_ValidateStreamHeader::StreamHeader_Invalid; @@ -325,11 +325,11 @@ e_ValidateStreamHeader ValidateStreamHeader( * helper function that does the actual validation of a header * that carries the post-ratification root vector */ -bool VerifyStreamHeader(quint8* pbuf, uint buflen, CID &source_cid, +bool VerifyStreamHeader(const quint8* pbuf, uint buflen, CID &source_cid, char* source_name, quint8 &priority, quint8 &start_code, quint16 &synchronization, quint8 &sequence, quint8 &options, quint16 &universe, - quint16 &slot_count, quint8* &pdata) + quint16 &slot_count, const quint8* &pdata) { if(!pbuf) return false; @@ -377,7 +377,7 @@ bool VerifyStreamHeader(quint8* pbuf, uint buflen, CID &source_cid, source_cid.Unpack(pbuf + CID_ADDR); std::fill(source_name, source_name + SOURCE_NAME_SIZE, '\0'); - std::string_view(reinterpret_cast(pbuf + SOURCE_NAME_ADDR), SOURCE_NAME_SIZE).copy(source_name, SOURCE_NAME_SIZE - 1); + std::string_view(reinterpret_cast(pbuf + SOURCE_NAME_ADDR), SOURCE_NAME_SIZE).copy(source_name, SOURCE_NAME_SIZE - 1); priority = UpackBUint8(pbuf + PRIORITY_ADDR); start_code = UpackBUint8(pbuf + START_CODE_ADDR); synchronization = UpackBUint16(pbuf + SYNC_ADDR); @@ -386,6 +386,11 @@ bool VerifyStreamHeader(quint8* pbuf, uint buflen, CID &source_cid, universe = UpackBUint16(pbuf + UNIVERSE_ADDR); slot_count = UpackBUint16(pbuf + PROP_COUNT_ADDR) - 1; //The property value count includes the start code byte pdata = pbuf + STREAM_HEADER_SIZE; + + // Validate maximum slot count + if (slot_count > DMX_SLOT_MAX) + return false; + quint16 post_amble_size = UpackBUint16(pbuf + POSTAMBLE_SIZE_ADDR); /*Do final length validation*/ @@ -402,11 +407,11 @@ bool VerifyStreamHeader(quint8* pbuf, uint buflen, CID &source_cid, * ratification of the standard. */ bool VerifyStreamHeaderForDraft( - quint8* pbuf, uint buflen, + const quint8* pbuf, uint buflen, CID &source_cid, char* source_name, quint8 &priority, quint8 &start_code, quint8 &sequence, quint16 &universe, quint16 &slot_count, - quint8* &pdata) + const quint8* &pdata) { if(!pbuf) return false; @@ -457,7 +462,7 @@ bool VerifyStreamHeaderForDraft( source_cid.Unpack(pbuf + CID_ADDR); std::fill(source_name, source_name + SOURCE_NAME_SIZE, '\0'); - std::string_view(reinterpret_cast(pbuf + DRAFT_SOURCE_NAME_ADDR), DRAFT_SOURCE_NAME_SIZE).copy(source_name, DRAFT_SOURCE_NAME_SIZE - 1); + std::string_view(reinterpret_cast(pbuf + DRAFT_SOURCE_NAME_ADDR), DRAFT_SOURCE_NAME_SIZE).copy(source_name, DRAFT_SOURCE_NAME_SIZE - 1); priority = UpackBUint8(pbuf + DRAFT_PRIORITY_ADDR); if(priority == 0) diff --git a/src/sacn/streamcommon.h b/src/sacn/streamcommon.h index f45ce6db..0540b835 100644 --- a/src/sacn/streamcommon.h +++ b/src/sacn/streamcommon.h @@ -215,23 +215,23 @@ enum e_ValidateStreamHeader StreamHeader_Unknown }; e_ValidateStreamHeader ValidateStreamHeader( - quint8* pbuf, uint buflen, + const quint8* pbuf, uint buflen, quint32 &root_vector, CID &source_cid, char* source_sp, quint8 &priority, quint8 &start_code, quint16 &synchronization, quint8 &sequence, quint8 &options, quint16 &universe, - quint16 &slot_count, quint8* &pdata); + quint16 &slot_count, const quint8* &pdata); /* * helper function that does the actual validation of a header * that carries the post-ratification root vector */ bool VerifyStreamHeader( - quint8 *pbuf, uint buflen, + const quint8 *pbuf, uint buflen, CID &source_cid, char* source_name, quint8 &priority, quint8 &start_code, quint16 &synchronization, quint8 &sequence, quint8 &options, quint16 &universe, - quint16 &slot_count, quint8* &pdata); + quint16 &slot_count, const quint8* &pdata); /* * helper function that does the actual validation of a header * that carries the early draft's root vector @@ -239,11 +239,11 @@ bool VerifyStreamHeader( * ratification of the standard. */ bool VerifyStreamHeaderForDraft( - quint8* pbuf, uint buflen, + const quint8* pbuf, uint buflen, CID &source_cid, char* source_space, quint8 &priority, quint8 &start_code, quint8 &sequence, quint16 &universe, quint16 &slot_count, - quint8* &pdata); + const quint8* &pdata); /* * Returns true if contains draft root vector value diff --git a/src/sacn/streamingacn.cpp b/src/sacn/streamingacn.cpp index 95ebcaaa..147b9c2d 100644 --- a/src/sacn/streamingacn.cpp +++ b/src/sacn/streamingacn.cpp @@ -42,8 +42,7 @@ #include #include -#include -#include + #ifdef QT_GUI_LIB #include #else @@ -66,254 +65,328 @@ QString GetProtocolVersionString(StreamingACNProtocolVersion value) } } -sACNSource::sACNSource() : - src_valid(false), - lastseq(0), - waited_for_dd(false), - doing_dmx(false), //if true, we are processing dmx data from this source - doing_per_channel(false), //If true, we are tracking per-channel priority messages for this source - isPreview(false), - universe(0), - slot_count(0), - priority(0), - seqErr(0), - jumps(0) +sACNSource::sACNSource(const CID& source_cid, uint16_t universe) + : src_cid(source_cid) + , universe(universe) { - std::fill(level_array, level_array + sizeof(level_array), 0); - std::fill(priority_array, priority_array + sizeof(priority_array), 0); } -sACNManager &sACNManager::Instance() +void sACNSource::storeReceivedLevels(const uint8_t* pdata, uint16_t rx_slot_count) { - static sACNManager s_instance; - return s_instance; + // Copy the last array back + last_level_array = level_array; + // Fill in the new array + if (rx_slot_count < level_array.size()) + level_array.fill(0); + memcpy(level_array.data(), pdata, rx_slot_count); + + // Slot count change, re-merge all slots + if (slot_count != rx_slot_count) + { + slot_count = rx_slot_count; + source_params_change = true; + source_levels_change = true; + dirty_array.fill(true); + return; + } + + // Compare the two + for (int i = 0; i < slot_count; ++i) + { + if (level_array[i] != last_level_array[i]) + { + dirty_array[i] |= true; + source_levels_change = true; + } + } +} + +void sACNSource::storeReceivedPriorities(const uint8_t* pdata, uint16_t rx_slot_count) +{ + // Copy the last array back + last_priority_array = priority_array; + // Fill in the new array + if (rx_slot_count < priority_array.size()) + priority_array.fill(0); + + memcpy(priority_array.data(), pdata, rx_slot_count); + + // Compare the two + for (int i = 0; i < DMX_SLOT_MAX; i++) + { + if (priority_array[i] != last_priority_array[i]) + { + dirty_array[i] |= true; + source_levels_change = true; + } + } +} + +sACNManager& sACNManager::Instance() +{ + static sACNManager s_instance; + return s_instance; +} + +sACNManager::~sACNManager() +{ + QMutexLocker lock(&sACNManager_mutex); + + // Stop and delete all the threads + for (QThread* thread : m_threadPool) + { + thread->exit(); + thread->wait(); + delete thread; + } + m_threadPool.clear(); + + // Stop the Tock layer + Tock_StopLib(); } sACNManager::sACNManager() : QObject() { - // Start Tock layer - Tock_StartLib(); + // Start Tock layer + Tock_StartLib(); - // Start E1.31 Universe Discovery - sACNDiscoveryTX::start(); - sACNDiscoveryRX::start(); + // Start E1.31 Universe Discovery + sACNDiscoveryTX::start(); + sACNDiscoveryRX::start(); + + // Create the threadpool + const int threadCount = QThread::idealThreadCount(); + for (int i = 0; i < threadCount; ++i) + { + QThread* thread = new QThread(this); + thread->setObjectName(QStringLiteral("sACNManagerPool %1").arg(i)); + thread->start(QThread::HighPriority); + m_threadPool.push_back(thread); + } } -static void strongPointerDeleteListener(sACNListener *obj) +static void strongPointerDeleteListener(sACNListener* obj) { - obj->deleteLater(); - sACNManager::Instance().listenerDelete(obj); + obj->deleteLater(); + sACNManager::Instance().listenerDelete(obj); } -static void strongPointerDeleteSender(sACNSentUniverse *obj) +static void strongPointerDeleteSender(sACNSentUniverse* obj) { - obj->deleteLater(); - sACNManager::Instance().senderDelete(obj); + obj->deleteLater(); + sACNManager::Instance().senderDelete(obj); } sACNManager::tListener sACNManager::getListener(quint16 universe) { - QMutexLocker locker(&sACNManager_mutex); - // Notes on the memory management of sACNListeners : - // This function creates a QSharedPointer to the listener, which is handed to the classes that - // want to use it. It stores a QWeakPointer, which gets set to null when all instances of the shared - // pointer are gone - - QSharedPointer strongPointer; - if(!m_listenerHash.contains(universe)) - { - qDebug() << "Creating listener for universe " << universe; - - // Create thread and move listener to thread - QThread *thread = new QThread; - thread->setObjectName(QString("Universe %1 RX").arg(universe)); - - sACNListener *listener = new sACNListener(universe); - listener->moveToThread(thread); - connect(thread, &QThread::started, listener, &sACNListener::startReception); - connect(thread, &QThread::finished, listener, &sACNListener::deleteLater); - connect(thread, &QThread::finished, thread, &sACNListener::deleteLater); - thread->start(QThread::HighPriority); - - // Emit sources from all, known, universes - connect(listener, &sACNListener::sourceFound, this, - [=](sACNSource *source) - { - emit sourceFound(universe, source); - }); - connect(listener, &sACNListener::sourceLost, this, - [=](sACNSource *source) - { - emit sourceLost(universe, source); - }); - connect(listener, &sACNListener::sourceResumed, this, - [=](sACNSource *source) - { - emit sourceResumed(universe, source); - }); - connect(listener, &sACNListener::sourceChanged, this, - [=](sACNSource *source) - { - emit sourceChanged(universe, source); - }); - - m_listenerThreads[universe] = thread; - - // Create strong pointer to return - strongPointer = QSharedPointer(listener, strongPointerDeleteListener); - m_listenerHash[universe] = strongPointer.toWeakRef(); - m_objToUniverse[listener] = universe; - - locker.unlock(); - emit newListener(universe); - } - else - { - strongPointer = m_listenerHash[universe].toStrongRef(); - } - if(strongPointer.isNull()) - { - #ifdef QT_GUI_LIB - QMessageBox msgBox; - msgBox.setText(tr("Unable to allocate listener object\r\n\r\nsACNView must close now")); - msgBox.exec(); - #else - qDebug() << "Unable to allocate listener object\r\n\r\nsACNView must close now"; - #endif - qApp->exit(-1); + QMutexLocker locker(&sACNManager_mutex); + // Notes on the memory management of sACNListeners : + // This function creates a QSharedPointer to the listener, which is handed to the classes that + // want to use it. It stores a QWeakPointer, which gets set to null when all instances of the shared + // pointer are gone + + QSharedPointer strongPointer; + if (!m_listenerHash.contains(universe)) + { + qDebug() << "Creating listener for universe " << universe; + + // Create listener and move to thread + sACNListener* listener = new sACNListener(universe); + + // Choose a thread + QThread* thread = GetThread(); + listener->moveToThread(thread); + + connect(thread, &QThread::finished, listener, &sACNListener::deleteLater); + connect(thread, &QThread::finished, thread, &sACNListener::deleteLater); + + // Emit sources from all, known, universes + connect(listener, &sACNListener::sourceFound, this, + [=](sACNSource* source) + { + emit sourceFound(universe, source); + }); + connect(listener, &sACNListener::sourceLost, this, + [=](sACNSource* source) + { + emit sourceLost(universe, source); + }); + connect(listener, &sACNListener::sourceResumed, this, + [=](sACNSource* source) + { + emit sourceResumed(universe, source); + }); + connect(listener, &sACNListener::sourceChanged, this, + [=](sACNSource* source) + { + emit sourceChanged(universe, source); + }); - } + // Create strong pointer to return + strongPointer = QSharedPointer(listener, strongPointerDeleteListener); + m_listenerHash[universe] = strongPointer.toWeakRef(); + m_objToUniverse[listener] = universe; - return strongPointer; + // Start listening + QMetaObject::invokeMethod(listener, "startReception", Qt::QueuedConnection); + + locker.unlock(); + emit newListener(universe); + } + else + { + strongPointer = m_listenerHash[universe].toStrongRef(); + } + if (strongPointer.isNull()) + { +#ifdef QT_GUI_LIB + QMessageBox msgBox; + msgBox.setText(tr("Unable to allocate listener object\r\n\r\nsACNView must close now")); + msgBox.exec(); +#else + qDebug() << "Unable to allocate listener object\r\n\r\nsACNView must close now"; +#endif + qApp->exit(-1); + + } + + return strongPointer; } -void sACNManager::listenerDelete(QObject *obj) +void sACNManager::listenerDelete(QObject* obj) { - QMutexLocker locker(&sACNManager_mutex); - int universe = m_objToUniverse[obj]; + QMutexLocker locker(&sACNManager_mutex); + int universe = m_objToUniverse[obj]; - qDebug() << "Destroying listener for universe " << universe; + qDebug() << "Destroying listener for universe " << universe; - m_listenerHash.remove(universe); + m_listenerHash.remove(universe); - m_objToUniverse.remove(obj); + m_objToUniverse.remove(obj); - m_listenerThreads[universe]->exit(); - m_listenerThreads[universe]->wait(); - m_listenerThreads.remove(universe); - emit deletedListener(universe); + emit deletedListener(universe); +} + +QThread* sACNManager::GetThread() +{ + QThread* result = m_threadPool[m_nextThread]; + ++m_nextThread; + if (m_nextThread == m_threadPool.size()) + m_nextThread = 0; + return result; } sACNManager::tSender sACNManager::createSender(CID cid, quint16 universe) { - qDebug() << "Creating sender for CID" << CID::CIDIntoQString(cid) << "universe" << universe; + qDebug() << "Creating sender for CID" << CID::CIDIntoQString(cid) << "universe" << universe; - sACNSentUniverse *sender = new sACNSentUniverse(universe); - sender->setCID(cid); - connect(sender, &sACNSentUniverse::universeChange, this, &sACNManager::senderUniverseChanged); - connect(sender, &sACNSentUniverse::cidChange, this, &sACNManager::senderCIDChanged); + sACNSentUniverse* sender = new sACNSentUniverse(universe); + sender->setCID(cid); + connect(sender, &sACNSentUniverse::universeChange, this, &sACNManager::senderUniverseChanged); + connect(sender, &sACNSentUniverse::cidChange, this, &sACNManager::senderCIDChanged); - // Create strong pointer to return - QSharedPointer strongPointer = QSharedPointer(sender, strongPointerDeleteSender); - m_senderHash[cid][universe] = strongPointer.toWeakRef(); - m_objToUniverse[sender] = universe; - m_objToCid[sender] = cid; + // Create strong pointer to return + QSharedPointer strongPointer = QSharedPointer(sender, strongPointerDeleteSender); + m_senderHash[cid][universe] = strongPointer.toWeakRef(); + m_objToUniverse[sender] = universe; + m_objToCid[sender] = cid; - sACNDiscoveryTX::getInstance()->sendDiscoveryPacketNow(); + sACNDiscoveryTX::getInstance()->sendDiscoveryPacketNow(); - return strongPointer; + return strongPointer; } sACNManager::tSender sACNManager::getSender(quint16 universe, CID cid) { - QMutexLocker locker(&sACNManager_mutex); - // Notes on the memory management of sACNSenders : - // This function creates a QSharedPointer to the sender, which is handed to the classes that - // want to use it. It stores a QWeakPointer, which gets set to null when all instances of the shared - // pointer are gone - - QSharedPointer strongPointer; - if(!m_senderHash.contains(cid)) - { - strongPointer = createSender(cid, universe); + QMutexLocker locker(&sACNManager_mutex); + // Notes on the memory management of sACNSenders : + // This function creates a QSharedPointer to the sender, which is handed to the classes that + // want to use it. It stores a QWeakPointer, which gets set to null when all instances of the shared + // pointer are gone + + QSharedPointer strongPointer; + if (!m_senderHash.contains(cid)) + { + strongPointer = createSender(cid, universe); + } + else + { + if (!m_senderHash[cid].contains(universe)) { + strongPointer = createSender(cid, universe); } - else - { - if(!m_senderHash[cid].contains(universe)) { - strongPointer = createSender(cid, universe); - } else { - strongPointer = m_senderHash[cid][universe].toStrongRef(); - } + else { + strongPointer = m_senderHash[cid][universe].toStrongRef(); } + } - if(strongPointer.isNull()) - { - #ifdef QT_GUI_LIB - QMessageBox msgBox; - msgBox.setText(tr("Unable to allocate sender object\r\n\r\nsACNView must close now")); - msgBox.exec(); - #else - qDebug() << "Unable to allocate sender object\r\n\r\nsACNView must close now"; - #endif - qApp->exit(-1); + if (strongPointer.isNull()) + { +#ifdef QT_GUI_LIB + QMessageBox msgBox; + msgBox.setText(tr("Unable to allocate sender object\r\n\r\nsACNView must close now")); + msgBox.exec(); +#else + qDebug() << "Unable to allocate sender object\r\n\r\nsACNView must close now"; +#endif + qApp->exit(-1); - } + } - return strongPointer; + return strongPointer; } -void sACNManager::senderDelete(QObject *obj) +void sACNManager::senderDelete(QObject* obj) { - if (!m_objToUniverse.contains(obj) && !m_objToCid.contains(obj)) - return; + if (!m_objToUniverse.contains(obj) && !m_objToCid.contains(obj)) + return; - QMutexLocker locker(&sACNManager_mutex); - quint16 universe = m_objToUniverse[obj]; - CID cid = m_objToCid[obj]; + QMutexLocker locker(&sACNManager_mutex); + quint16 universe = m_objToUniverse[obj]; + CID cid = m_objToCid[obj]; - qDebug() << "Destroying sender for CID" << CID::CIDIntoQString(cid) << "universe" << universe; + qDebug() << "Destroying sender for CID" << CID::CIDIntoQString(cid) << "universe" << universe; - m_senderHash[cid].remove(universe); + m_senderHash[cid].remove(universe); - m_objToUniverse.remove(obj); - m_objToCid.remove(obj); - emit deletedSender(cid, universe); + m_objToUniverse.remove(obj); + m_objToCid.remove(obj); + emit deletedSender(cid, universe); } void sACNManager::senderUniverseChanged() { - if (!m_objToUniverse.contains(sender()) && !m_objToCid.contains(sender())) - return; + if (!m_objToUniverse.contains(sender()) && !m_objToCid.contains(sender())) + return; - sACNSentUniverse *sACNSender = (sACNSentUniverse*)sender(); - if (!sACNSender) return; - CID cid = m_objToCid[sender()]; - quint16 oldUniverse = m_objToUniverse[sender()]; - quint16 newUniverse = sACNSender->universe(); + sACNSentUniverse* sACNSender = (sACNSentUniverse*)sender(); + if (!sACNSender) return; + CID cid = m_objToCid[sender()]; + quint16 oldUniverse = m_objToUniverse[sender()]; + quint16 newUniverse = sACNSender->universe(); - m_senderHash[cid][newUniverse] = m_senderHash[cid][oldUniverse]; - m_senderHash[cid].remove(oldUniverse); + m_senderHash[cid][newUniverse] = m_senderHash[cid][oldUniverse]; + m_senderHash[cid].remove(oldUniverse); - m_objToUniverse[sender()] = newUniverse; + m_objToUniverse[sender()] = newUniverse; - qDebug() << "Sender for CID" << CID::CIDIntoQString(cid) << "was universe" << oldUniverse << "now universe" << newUniverse; + qDebug() << "Sender for CID" << CID::CIDIntoQString(cid) << "was universe" << oldUniverse << "now universe" << newUniverse; } void sACNManager::senderCIDChanged() { - if (!m_objToUniverse.contains(sender()) && !m_objToCid.contains(sender())) - return; + if (!m_objToUniverse.contains(sender()) && !m_objToCid.contains(sender())) + return; - sACNSentUniverse *sACNSender = (sACNSentUniverse*)sender(); - if (!sACNSender) return; - CID oldCID = m_objToCid[sender()]; - CID newCID = sACNSender->cid(); + sACNSentUniverse* sACNSender = (sACNSentUniverse*)sender(); + if (!sACNSender) return; + CID oldCID = m_objToCid[sender()]; + CID newCID = sACNSender->cid(); - m_senderHash[newCID] = m_senderHash[oldCID]; - m_senderHash.remove(oldCID); + m_senderHash[newCID] = m_senderHash[oldCID]; + m_senderHash.remove(oldCID); - m_objToCid[sender()] = newCID; + m_objToCid[sender()] = newCID; - qDebug() << "Sender CID" << CID::CIDIntoQString(oldCID) << "now CID" << CID::CIDIntoQString(newCID); + qDebug() << "Sender CID" << CID::CIDIntoQString(oldCID) << "now CID" << CID::CIDIntoQString(newCID); } diff --git a/src/sacn/streamingacn.h b/src/sacn/streamingacn.h index 68eed843..ede0cc75 100644 --- a/src/sacn/streamingacn.h +++ b/src/sacn/streamingacn.h @@ -21,13 +21,11 @@ #ifndef STREAMINGACN_H #define STREAMINGACN_H -#include #include #include #include #include #include -#include #include #include "CID.h" @@ -35,6 +33,8 @@ #include "streamcommon.h" #include "fpscounter.h" +#include + // Forward Declarations class sACNListener; class sACNSentUniverse; @@ -42,10 +42,10 @@ class sACNSource; enum StreamingACNProtocolVersion { - sACNProtocolUnknown = 0, - sACNProtocolDraft, - sACNProtocolRelease, - sACNProtocolPathwaySecure // Pathway Connectivity Secure DMX Protocol + sACNProtocolUnknown = 0, + sACNProtocolDraft, + sACNProtocolRelease, + sACNProtocolPathwaySecure // Pathway Connectivity Secure DMX Protocol }; QString GetProtocolVersionString(StreamingACNProtocolVersion value); @@ -53,116 +53,128 @@ QString GetProtocolVersionString(StreamingACNProtocolVersion value); // The sACNManager class is a singleton that manages the lifespan of sACNTransmitters and sACNListeners. class sACNManager : public QObject { - Q_OBJECT + Q_OBJECT public: - static sACNManager &Instance(); + static sACNManager& Instance(); + + typedef QSharedPointer tListener; + typedef QWeakPointer wListener; + + typedef QSharedPointer tSender; + typedef QWeakPointer wSender; - typedef QSharedPointer tListener; - typedef QSharedPointer tSender; + ~sACNManager(); + + inline static tock GetTock() { return Tock_GetTock(); } + inline static qint64 nsecsElapsed() { return Tock_GetTock().Get().count(); } + inline static qint64 elapsed() { return std::chrono::duration_cast(Tock_GetTock().Get()).count(); } + inline static qreal secsElapsed() { return std::chrono::duration(Tock_GetTock().Get()).count(); } public slots: - void listenerDelete(QObject *obj = Q_NULLPTR); + void listenerDelete(QObject* obj = Q_NULLPTR); - void senderDelete(QObject *obj = Q_NULLPTR); + void senderDelete(QObject* obj = Q_NULLPTR); private: - sACNManager(); - QMutex sACNManager_mutex; + sACNManager(); + QMutex sACNManager_mutex; + + QHash m_objToUniverse; + QHash m_objToCid; - QHash m_objToUniverse; - QHash m_objToCid; + QHash m_listenerHash; - QHash> m_listenerHash; - QHash m_listenerThreads; + // The pool of threads to use for listener objects + std::vector m_threadPool; + size_t m_nextThread = 0; - tSender createSender(CID cid, quint16 universe); - QHash> > m_senderHash; + QThread* GetThread(); + + tSender createSender(CID cid, quint16 universe); + QHash > m_senderHash; public: - tListener getListener(quint16 universe); - const decltype(m_listenerHash) &getListenerList() { return m_listenerHash; } + tListener getListener(quint16 universe); + const decltype(m_listenerHash)& getListenerList() const { return m_listenerHash; } - tSender getSender(quint16 universe, CID cid = CID::CreateCid()); - const decltype(m_senderHash) &getSenderList() { return m_senderHash; } + tSender getSender(quint16 universe, CID cid = CID::CreateCid()); + const decltype(m_senderHash)& getSenderList() const { return m_senderHash; } signals: - void newSender(); - void deletedSender(CID cid, quint16 universe); + void newSender(); + void deletedSender(CID cid, quint16 universe); - void newListener(quint16 universe); - void deletedListener(quint16 universe); + void newListener(quint16 universe); + void deletedListener(quint16 universe); - void sourceFound(quint16 universe, sACNSource *source); - void sourceLost(quint16 universe, sACNSource *source); - void sourceResumed(quint16 universe, sACNSource *source); - void sourceChanged(quint16 universe, sACNSource *source); + void sourceFound(quint16 universe, sACNSource* source); + void sourceLost(quint16 universe, sACNSource* source); + void sourceResumed(quint16 universe, sACNSource* source); + void sourceChanged(quint16 universe, sACNSource* source); private slots: - void senderUniverseChanged(); - void senderCIDChanged(); + void senderUniverseChanged(); + void senderCIDChanged(); private: }; -class sACNSource : public QObject +class sACNSource { - Q_OBJECT + Q_DISABLE_COPY(sACNSource) public: - explicit sACNSource(); - CID src_cid; - bool src_valid; - bool src_stable; - quint8 lastseq; - ttimer active; //If this expires, we haven't received any data in over a second - //The per-channel priority alternate start code policy requires we detect the source only after - //a STARTCODE_PRIORITY packet was received or 1.5 seconds have expired - bool waited_for_dd; - bool doing_dmx; //if true, we are processing dmx data from this source - bool doing_per_channel; //If true, we are tracking per-channel priority messages for this source - bool isPreview; - ttimer priority_wait; //if !initially_notified, used to track if a source is finally detected - //(either by receiving priority or timeout). If doing_per_channel, - //used to time out the 0xdd packets to see if we lost per-channel priority - quint16 universe; - quint8 level_array[DMX_SLOT_MAX]; - quint16 slot_count; // Number of slots actually received - quint8 priority_array[DMX_SLOT_MAX]; - quint8 last_level_array[DMX_SLOT_MAX]; - quint8 last_priority_array[DMX_SLOT_MAX]; - bool dirty_array[DMX_SLOT_MAX]; // Set if an individual level or priority has changed - bool source_params_change; // Set if any parameter of the source changes between packets - bool source_levels_change; - - quint8 priority; - quint16 synchronization; - sACNManager::tListener sync_listener; - QString name; - QHostAddress ip; - FpsCounter fpscounter; - // The number of sequence errors from this source - int seqErr; - // The number of jumps (increments by anything other than 1) of this source - int jumps; - // Protocol Version - StreamingACNProtocolVersion protocol_version; - - // Pathways Secure DMX - struct { - bool passwordOk = false; - bool sequenceOk = false; - bool digestOk = false; - bool isSecure() const { - return passwordOk && sequenceOk && digestOk; - } - } pathway_secure; - -public slots: - void resetSeqErr() { - seqErr = 0; - } - - void resetJumps() { - jumps = 0; - } + explicit sACNSource(const CID& source_cid, uint16_t universe); + const CID src_cid; + const uint16_t universe = 0; + bool src_valid = false; + bool src_stable = false; + uint8_t lastseq = 0; + ttimer active; //If this expires, we haven't received any data in over a second + //The per-channel priority alternate start code policy requires we detect the source only after + //a STARTCODE_PRIORITY packet was received or 1.5 seconds have expired + bool waited_for_dd = false; + bool doing_dmx = false; //if true, we are processing dmx data from this source + bool doing_per_channel = false; // If true, we are tracking per-channel priority messages for this source + bool isPreview = false; + ttimer priority_wait; // If !initially_notified, used to track if a source is finally detected + // (either by receiving priority or timeout). If doing_per_channel, + // used to time out the 0xdd packets to see if we lost per-channel priority + std::array level_array = {}; + std::array last_level_array = {}; + std::array priority_array = {}; + std::array last_priority_array = {}; + std::array dirty_array = {}; // Set if an individual level or priority has changed + quint16 slot_count = 0; // Number of slots actually received + bool source_params_change = false; // Set if any parameter of the source changes between packets + bool source_levels_change = false; // Set if any leveles + + uint8_t priority = 0; + uint16_t synchronization = 0; + sACNManager::tListener sync_listener; + QString name; + QHostAddress ip; + FpsCounter fpscounter; + // The number of sequence errors from this source + unsigned int seqErr = 0; + // The number of jumps (increments by anything other than 1) of this source + unsigned int jumps = 0; + // Protocol Version + StreamingACNProtocolVersion protocol_version; + + // Pathways Secure DMX + struct { + bool passwordOk = false; + bool sequenceOk = false; + bool digestOk = false; + bool isSecure() const { return passwordOk && sequenceOk && digestOk; } + } pathway_secure; + + void resetSeqErr() { seqErr = 0; } + void resetJumps() { jumps = 0; } + + // Have received new levels, store and check for changes + void storeReceivedLevels(const uint8_t* pdata, uint16_t rx_slot_count); + // Have received new priority array, store and check for changes + void storeReceivedPriorities(const uint8_t* pdata, uint16_t rx_slot_count); }; #endif // STREAMINGACN_H diff --git a/src/ui/bigdisplay.cpp b/src/ui/bigdisplay.cpp index 3e700813..f511e21f 100644 --- a/src/ui/bigdisplay.cpp +++ b/src/ui/bigdisplay.cpp @@ -2,150 +2,183 @@ #include "ui_bigdisplay.h" #include "preferences.h" -BigDisplay::BigDisplay(int universe, quint16 address, QWidget *parent) : - QWidget(parent), - ui(new Ui::BigDisplay) +#include "sacn/sacnlistener.h" + +BigDisplay::BigDisplay(int universe, quint16 slot_index, QWidget* parent) + : QWidget(parent) + , ui(new Ui::BigDisplay) + , m_listener(sACNManager::Instance().getListener(universe)) { - ui->setupUi(this); - - // 8bit controls - ui->spinBox_8->setAddress(universe, address); - connect(ui->spinBox_8, &monitorspinbox::dataReady, this, &BigDisplay::dataReady); - - // 16bit controls - ui->spinBox_16_Coarse->setAddress(universe, address); - connect(ui->spinBox_16_Coarse, &monitorspinbox::dataReady, this, &BigDisplay::dataReady); - //-- - ui->spinBox_16_Fine->setAddress(universe, address + 1); - connect(ui->spinBox_16_Fine, &monitorspinbox::dataReady, this, &BigDisplay::dataReady); - - // Colour controls - ui->spinBox_RGB_1->setAddress(universe, address); - connect(ui->spinBox_RGB_1, &monitorspinbox::dataReady, this, &BigDisplay::dataReady); - //-- - ui->spinBox_RGB_2->setAddress(universe, address + 1); - connect(ui->spinBox_RGB_2, &monitorspinbox::dataReady, this, &BigDisplay::dataReady); - //-- - ui->spinBox_RGB_3->setAddress(universe, address + 2); - connect(ui->spinBox_RGB_3, &monitorspinbox::dataReady, this, &BigDisplay::dataReady); - - // Window - this->setWindowTitle(tr("Big Display Universe %1").arg(universe)); - this->setWindowIcon(parent->windowIcon()); - - m_level = 0; - displayLevel(); + ui->setupUi(this); + + int address = slot_index + 1; + + // 8bit controls + ui->spinBox_8->setMinimum(MIN_DMX_ADDRESS); + ui->spinBox_8->setMaximum(MAX_DMX_ADDRESS); + ui->spinBox_8->setValue(address); + + // 16bit controls + ui->spinBox_16_Coarse->setMinimum(MIN_DMX_ADDRESS); + ui->spinBox_16_Coarse->setMaximum(MAX_DMX_ADDRESS); + ui->spinBox_16_Fine->setMinimum(MIN_DMX_ADDRESS); + ui->spinBox_16_Fine->setMaximum(MAX_DMX_ADDRESS); + + ui->spinBox_16_Coarse->setValue(address); + ui->spinBox_16_Fine->setValue(address + 1); + + // Colour controls + ui->spinBox_RGB_1->setMinimum(MIN_DMX_ADDRESS); + ui->spinBox_RGB_1->setMaximum(MAX_DMX_ADDRESS); + ui->spinBox_RGB_2->setMinimum(MIN_DMX_ADDRESS); + ui->spinBox_RGB_2->setMaximum(MAX_DMX_ADDRESS); + ui->spinBox_RGB_3->setMinimum(MIN_DMX_ADDRESS); + ui->spinBox_RGB_3->setMaximum(MAX_DMX_ADDRESS); + + ui->spinBox_RGB_1->setValue(address); + ui->spinBox_RGB_2->setValue(address + 1); + ui->spinBox_RGB_3->setValue(address + 2); + + // Window + this->setWindowTitle(tr("Big Display Universe %1").arg(universe)); + this->setWindowIcon(parent->windowIcon()); + + updateLevels(); + + // Refresh every four DMX frames or so + startTimer(88); } BigDisplay::~BigDisplay() { - delete ui; + delete ui; } void BigDisplay::displayLevel() { - QPalette palette = ui->lcdNumber->palette(); - QColor colour(m_level | 0xFF000000); - - // Display format - if((ui->tabWidget->currentIndex() == tabModes_bit8) || - (ui->tabWidget->currentIndex() == tabModes_bit16) ) + QPalette palette = ui->lcdNumber->palette(); + + // Display format + if ((ui->tabWidget->currentIndex() == tabModes_bit8) || + (ui->tabWidget->currentIndex() == tabModes_bit16)) + { + ui->lcdNumber->setDigitCount(5); + // Grey when not active + palette.setColor(QPalette::WindowText, m_active ? Qt::red : Qt::gray); + ui->lcdNumber->setPalette(palette); + + switch (Preferences::Instance().GetDisplayFormat()) + { + default: + case DisplayFormat::DECIMAL: { - ui->lcdNumber->setDigitCount(5); - palette.setColor(QPalette::WindowText, Qt::red); - ui->lcdNumber->setPalette(palette); - - switch (Preferences::Instance().GetDisplayFormat()) - { - default: - case DisplayFormat::DECIMAL: - { - ui->lcdNumber->setMode(QLCDNumber::Dec); - ui->lcdNumber->display((int)m_level); - break; - } - case DisplayFormat::HEXADECIMAL: - { - ui->lcdNumber->setMode(QLCDNumber::Hex); - ui->lcdNumber->display((int)m_level); - break; - } - case DisplayFormat::PERCENT: - { - ui->lcdNumber->setMode(QLCDNumber::Dec); - // Display percent with an optional fraction - if(ui->tabWidget->currentIndex() == tabModes_bit8) - { - ui->lcdNumber->display(HTOPT[m_level & 0xFF]); - } - else - { - int percent = HTOPT[(m_level & 0xFF00) >> 8]; - int fraction = m_level & 0xFF; - double value = percent + fraction/255.0; - ui->lcdNumber->display(value); - } - break; - } - } + ui->lcdNumber->setMode(QLCDNumber::Dec); + ui->lcdNumber->display(m_level); + break; } - - if(ui->tabWidget->currentIndex() == tabModes_rgb) + case DisplayFormat::HEXADECIMAL: { - // Only ever display RGB as hex - - palette.setColor(QPalette::WindowText, colour); - ui->lcdNumber->setPalette(palette); - ui->lcdNumber->setDigitCount(6); - ui->lcdNumber->setMode(QLCDNumber::Hex); - ui->lcdNumber->display((int)m_level); + ui->lcdNumber->setMode(QLCDNumber::Hex); + ui->lcdNumber->display(m_level); + break; } + case DisplayFormat::PERCENT: + { + ui->lcdNumber->setMode(QLCDNumber::Dec); + // Display percent with an optional fraction + if (ui->tabWidget->currentIndex() == tabModes_bit8) + { + ui->lcdNumber->display(HTOPT[m_level & 0xFF]); + } + else + { + int percent = HTOPT[(m_level & 0xFF00) >> 8]; + int fraction = m_level & 0xFF; + double value = percent + fraction / 255.0; + ui->lcdNumber->display(value); + } + break; + } + } + } + + if (ui->tabWidget->currentIndex() == tabModes_rgb) + { + // Only ever display RGB as hex + QColor colour(static_cast(m_level) | 0xFF000000); + palette.setColor(QPalette::WindowText, colour); + ui->lcdNumber->setPalette(palette); + ui->lcdNumber->setDigitCount(6); + ui->lcdNumber->setMode(QLCDNumber::Hex); + ui->lcdNumber->display(m_level); + } } -void BigDisplay::dataReady(int universe, quint16 address, QPointF data) +void BigDisplay::timerEvent(QTimerEvent* /*ev*/) { - quint8 level = data.y() > 0 ? data.y() : 0; - - switch (ui->tabWidget->currentIndex()) - { - case tabModes_bit8: - if (address == ui->spinBox_8->address() && universe == ui->spinBox_8->universe()) - m_level = level; - - break; - - case tabModes_bit16: - if (address == ui->spinBox_16_Coarse->address() && universe == ui->spinBox_16_Coarse->universe()) - m_level = (m_level & 0x00FF) | (0xFF00 & (level << 8)); - - if (address == ui->spinBox_16_Fine->address() && universe == ui->spinBox_16_Fine->universe()) - m_level = (m_level & 0xFF00) | (0x00FF & level); - - break; - - case tabModes_rgb: - if (address == ui->spinBox_RGB_1->address() && universe == ui->spinBox_RGB_1->universe()) - m_level = (m_level & 0x00FFFFu) | (0xFF0000u & ((quint32)level << 16)); - - if (address == ui->spinBox_RGB_2->address() && universe == ui->spinBox_RGB_2->universe()) - m_level = (m_level & 0xFF00FFu) | (0x00FF00u & ((quint32)level << 8)); - - if (address == ui->spinBox_RGB_3->address() && universe == ui->spinBox_RGB_3->universe()) - m_level = (m_level & 0xFFFF00u) | (0x0000FFu & level); - - break; - - default: - m_level = -1; - break; - } + updateLevels(); +} - displayLevel(); +void BigDisplay::on_tabWidget_currentChanged(int /*index*/) +{ + updateLevels(); } -void BigDisplay::on_tabWidget_currentChanged(int index) +void BigDisplay::updateLevels() { - Q_UNUSED(index); - m_level = 0; - displayLevel(); + // Get and calculate levels + const auto levels = m_listener->mergedLevelsOnly(); + + int level = -1; + + switch (ui->tabWidget->currentIndex()) + { + default: break; + + case tabModes_bit8: + { + level = levels[ui->spinBox_8->value() - 1]; + } + break; + + case tabModes_bit16: + { + const int coarse = levels[ui->spinBox_16_Coarse->value() - 1]; + const int fine = levels[ui->spinBox_16_Fine->value() - 1]; + if (coarse >= 0 && fine >= 0) + { + level = static_cast(coarse) << 8 | static_cast(fine); + } + } + break; + + case tabModes_rgb: + { + const int red = levels[ui->spinBox_RGB_1->value() - 1]; + const int green = levels[ui->spinBox_RGB_2->value() - 1]; + const int blue = levels[ui->spinBox_RGB_3->value() - 1]; + if (red >= 0 && green >= 0 && blue >= 0) + { + level = static_cast(red) << 16 | static_cast(green) << 8 | static_cast(blue); + } + } + break; + } + + if (level < 0) + { + if (!m_active) + return; + m_active = false; + } + else + { + if (m_active && m_level == level) + return; + + m_level = level; + m_active = true; + } + + displayLevel(); } diff --git a/src/ui/bigdisplay.h b/src/ui/bigdisplay.h index 4b5e27e5..451150dc 100644 --- a/src/ui/bigdisplay.h +++ b/src/ui/bigdisplay.h @@ -3,38 +3,42 @@ #include #include "consts.h" +#include "streamingacn.h" namespace Ui { -class BigDisplay; + class BigDisplay; } class BigDisplay : public QWidget { - Q_OBJECT + Q_OBJECT public: - explicit BigDisplay(int universe, quint16 address, QWidget *parent = 0); - ~BigDisplay(); + explicit BigDisplay(int universe, quint16 slot_index, QWidget* parent = 0); + ~BigDisplay(); private: - Ui::BigDisplay *ui; + Ui::BigDisplay* ui; - enum tabModes - { - tabModes_bit8, - tabModes_bit16, - tabModes_rgb - }; + enum tabModes + { + tabModes_bit8, + tabModes_bit16, + tabModes_rgb + }; -private slots: - void dataReady(int universe, quint16 address, QPointF data); +protected: + void timerEvent(QTimerEvent* ev) override; - void on_tabWidget_currentChanged(int index); +private slots: + void on_tabWidget_currentChanged(int index); private: - void displayLevel(); - - quint32 m_level; + void updateLevels(); + void displayLevel(); + sACNManager::tListener m_listener; + int m_level = 0; + bool m_active = false; }; #endif // BIGDISPLAY_H diff --git a/src/ui/glscopewindow.cpp b/src/ui/glscopewindow.cpp new file mode 100644 index 00000000..0a90eb61 --- /dev/null +++ b/src/ui/glscopewindow.cpp @@ -0,0 +1,562 @@ +// Copyright 2023 Electronic Theatre Controls, Inc. or its affiliates +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "glscopewindow.h" + +#include "widgets/glscopewidget.h" +#include "widgets/steppedspinbox.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +GlScopeWindow::GlScopeWindow(int universe, QWidget* parent) + : QWidget(parent) + , m_defaultUniverse(universe) +{ + setWindowTitle(tr("Scope")); + setWindowIcon(QIcon(QStringLiteral(":/icons/scope.png"))); + + QBoxLayout* layout = new QVBoxLayout(this); + layout->setContentsMargins(2, 2, 2, 2); + m_splitter = new QSplitter(Qt::Vertical, this); + layout->addWidget(m_splitter); + + // The oscilloscope view + QWidget* scopeWidget = new QWidget(this); + { + QBoxLayout* layout = new QVBoxLayout(scopeWidget); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(1); + + m_scope = new GlScopeWidget(scopeWidget); + connect(m_scope->model(), &ScopeModel::runningChanged, this, &GlScopeWindow::onRunningChanged); + connect(m_scope->model(), &ScopeModel::triggered, this, &GlScopeWindow::onTriggered); + layout->addWidget(m_scope); + + m_scrollTime = new QScrollBar(Qt::Horizontal, scopeWidget); + connect(m_scrollTime, &QScrollBar::sliderMoved, this, &GlScopeWindow::onTimeSliderMoved); + layout->addWidget(m_scrollTime); + m_disableWhenRunning.push_back(m_scrollTime); + } + m_splitter->addWidget(scopeWidget); + + QWidget* confWidget = new QWidget(this); + { + QBoxLayout* layoutConf = new QHBoxLayout(confWidget); + { + QLabel* lbl = nullptr; + QGroupBox* grpScope = new QGroupBox(tr("Scope"), confWidget); + QGridLayout* layoutGrp = new QGridLayout(grpScope); + + int row = 0; + m_btnStop = new QPushButton(tr("Stop"), confWidget); + m_btnStop->setCheckable(true); + connect(m_btnStop, &QPushButton::clicked, m_scope->model(), &ScopeModel::stop); + layoutGrp->addWidget(m_btnStop, row, 0); + + m_btnStart = new QPushButton(tr("Start"), confWidget); + m_btnStart->setCheckable(true); + connect(m_btnStart, &QPushButton::clicked, m_scope->model(), &ScopeModel::start); + layoutGrp->addWidget(m_btnStart, row, 1); + + ++row; + + lbl = new QLabel(tr("Store:"), confWidget); + layoutGrp->addWidget(lbl, row, 0); + m_recordMode = new QComboBox(confWidget); + m_recordMode->addItems({ tr("All Packets"), tr("Level Changes") }); + connect(m_recordMode, QOverload::of(&QComboBox::activated), this, &GlScopeWindow::setRecordMode); + layoutGrp->addWidget(m_recordMode, row, 1); + + m_disableWhenRunning.push_back(lbl); + m_disableWhenRunning.push_back(m_recordMode); + + ++row; + + lbl = new QLabel(tr("Run For:"), confWidget); + layoutGrp->addWidget(lbl, row, 0); + m_spinRunTime = new QSpinBox(confWidget); + m_spinRunTime->setRange(0, 300); // Five minutes + m_spinRunTime->setSingleStep(10); + //! Seconds suffix + m_spinRunTime->setSuffix(tr("s")); + m_spinRunTime->setSpecialValueText(tr("Forever")); + connect(m_spinRunTime, QOverload::of(&QSpinBox::valueChanged), m_scope->model(), &ScopeModel::setRunTime); + connect(m_scope->model(), &ScopeModel::runTimeChanged, m_spinRunTime, &QSpinBox::setValue); + layoutGrp->addWidget(m_spinRunTime, row, 1); + + m_disableWhenRunning.push_back(lbl); + m_disableWhenRunning.push_back(m_spinRunTime); + + ++row; + + // Scope scale configuration + lbl = new QLabel(tr("Vertical Scale:"), confWidget); + layoutGrp->addWidget(lbl, row, 0); + QComboBox* verticalScale = new QComboBox(confWidget); + verticalScale->addItems({ tr("Percent"), tr("DMX8"), tr("DMX16") }); + connect(verticalScale, QOverload::of(&QComboBox::activated), this, &GlScopeWindow::setVerticalScaleMode); + verticalScale->setCurrentIndex(static_cast(m_scope->verticalScaleMode())); + layoutGrp->addWidget(verticalScale, row, 1); + + ++row; + lbl = new QLabel(tr("Time Scale:"), confWidget); + layoutGrp->addWidget(lbl, row, 0); + + m_spinTimeScale = new SteppedSpinBox(confWidget); + m_spinTimeScale->setStepList({ 5,10,20,50,100,200,500,1000,2000 }); // Milliseconds + //! Milliseconds suffix + m_spinTimeScale->setSuffix(tr("ms")); + m_spinTimeScale->setValue(m_scope->timeDivisions()); + connect(m_spinTimeScale, QOverload::of(&QSpinBox::valueChanged), this, &GlScopeWindow::onTimeDivisionsChanged); + connect(m_scope, &GlScopeWidget::timeDivisionsChanged, m_spinTimeScale, &QSpinBox::setValue); + layoutGrp->addWidget(m_spinTimeScale, row, 1); + + // Divider + ++row; + QFrame* line = new QFrame(confWidget); + line->setFrameShape(QFrame::HLine); + layoutGrp->addWidget(line, row, 0, 1, 2); + + ++row; + // Trigger setup + m_triggerType = new QComboBox(confWidget); + m_triggerType->addItems({ + //! No trigger, runs immediately + tr("Free Run"), + //! Triggers when above the target level + tr("Above"), + //! Triggers when below the target level + tr("Below"), + //! Triggers when passes through or leaves the target level + tr("Crossed Level") }); + connect(m_triggerType, QOverload::of(&QComboBox::currentIndexChanged), this, &GlScopeWindow::setTriggerType); + layoutGrp->addWidget(m_triggerType, row, 0, 1, 2); + + m_disableWhenRunning.push_back(m_triggerType); + + ++row; + lbl = new QLabel(tr("Trigger Level:"), confWidget); + layoutGrp->addWidget(lbl, row, 0); + m_spinTriggerLevel = new QSpinBox(this); + m_spinTriggerLevel->setRange(0, 65535); + connect(m_spinTriggerLevel, QOverload::of(&QSpinBox::valueChanged), m_scope->model(), &ScopeModel::setTriggerLevel); + layoutGrp->addWidget(m_spinTriggerLevel, row, 1); + + m_triggerSetup.push_back(lbl); + m_triggerSetup.push_back(m_spinTriggerLevel); + + // Set initial trigger type and values + m_triggerType->setCurrentIndex(0); + m_spinTriggerLevel->setValue(127); + + ++row; + m_chkSyncViews = new QCheckBox(tr("Trigger Receive Views"), confWidget); + layoutGrp->addWidget(m_chkSyncViews, row, 0, 1, 2, Qt::AlignHCenter); + m_disableWhenRunning.push_back(m_chkSyncViews); + + // Spacer at the bottom + ++row; + layoutGrp->setRowStretch(row, 1); + + layoutConf->addWidget(grpScope); + } + + { + QGroupBox* grpChans = new QGroupBox(tr("Channels"), confWidget); + QBoxLayout* layoutGrp = new QVBoxLayout(grpChans); + + m_tableView = new QTableView(this); + m_tableView->verticalHeader()->hide(); + m_tableView->setSelectionMode(QAbstractItemView::ExtendedSelection); + m_tableView->setAlternatingRowColors(true); + m_tableView->setItemDelegateForColumn(ScopeModel::COL_COLOUR, new ColorPickerDelegate(this)); + m_tableView->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn); + m_tableView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + + // Sorting + QSortFilterProxyModel* sortProxy = new QSortFilterProxyModel(m_tableView); + sortProxy->setSourceModel(m_scope->model()); + sortProxy->setSortRole(ScopeModel::DataSortRole); + + m_tableView->setModel(sortProxy); + m_tableView->setSortingEnabled(true); + m_tableView->sortByColumn(0, Qt::AscendingOrder); + m_tableView->horizontalHeader()->setStretchLastSection(true); + m_tableView->resizeColumnsToContents(); + + layoutGrp->addWidget(m_tableView); + + { + QBoxLayout* layoutBtns = new QHBoxLayout(); + + QPushButton* addChan = new QPushButton(tr("Add"), confWidget); + connect(addChan, &QPushButton::clicked, this, &GlScopeWindow::addTrace); + layoutBtns->addWidget(addChan); + m_disableWhenRunning.push_back(addChan); + + QPushButton* removeChan = new QPushButton(tr("Remove"), confWidget); + connect(removeChan, &QPushButton::clicked, this, &GlScopeWindow::removeTrace); + layoutBtns->addWidget(removeChan); + m_disableWhenRunning.push_back(removeChan); + + QPushButton* removeAllChan = new QPushButton(tr("Remove All"), confWidget); + connect(removeAllChan, &QPushButton::clicked, this, &GlScopeWindow::removeAllTraces); + layoutBtns->addWidget(removeAllChan); + m_disableWhenRunning.push_back(removeAllChan); + + QFrame* line = new QFrame(this); + line->setFrameShape(QFrame::VLine); + layoutBtns->addWidget(line); + + QPushButton* btnSave = new QPushButton(tr("Save"), confWidget); + connect(btnSave, &QPushButton::clicked, this, &GlScopeWindow::saveTraces); + layoutBtns->addWidget(btnSave); + m_disableWhenRunning.push_back(btnSave); + + QPushButton* btnLoad = new QPushButton(tr("Load"), confWidget); + connect(btnLoad, &QPushButton::clicked, this, &GlScopeWindow::loadTraces); + layoutBtns->addWidget(btnLoad); + m_disableWhenRunning.push_back(btnLoad); + + layoutBtns->addStretch(); + + layoutGrp->addLayout(layoutBtns); + } + + layoutConf->addWidget(grpChans); + } + } + + m_splitter->addWidget(confWidget); + + // Can't collapse the scope + m_splitter->setCollapsible(0, false); + + // Refresh + updateConfiguration(); + onRunningChanged(m_scope->model()->isRunning()); + + // Now connect signals for other receiver views + connect(this, SIGNAL(startOtherViews()), parent, SIGNAL(startReceiverViews())); + connect(this, SIGNAL(stopOtherViews()), parent, SIGNAL(stopReceiverViews())); +} + +GlScopeWindow::~GlScopeWindow() +{ + // Must delete the scope first + delete m_scope; +} + +void GlScopeWindow::onRunningChanged(bool running) +{ + if (m_chkSyncViews->isChecked() && !running) + emit stopOtherViews(); + + m_btnStart->setChecked(running); + m_btnStop->setChecked(!running); + + // Enable/disable the start and stop buttons + m_btnStart->setEnabled(!running && m_scope->model()->rowCount() > 0); + m_btnStop->setEnabled(running); + + for (QWidget* w : m_disableWhenRunning) + w->setEnabled(!running); + + // Disable invalid trigger setup + for (QWidget* w : m_triggerSetup) + w->setEnabled(!running && m_triggerType->currentIndex() != static_cast(ScopeModel::Trigger::FreeRun)); + + // Reset to start + if (running) + m_scope->setScopeView(); + + // Force to follow now when running + m_scope->setFollowNow(running); + + updateTimeScrollBars(); +} + +void GlScopeWindow::onTimeSliderMoved(int value) +{ + qreal startTime = value; + startTime = startTime / 1000; + QRectF scopeView = m_scope->scopeView(); + scopeView.moveLeft(startTime); + m_scope->setScopeView(scopeView); +} + +void GlScopeWindow::onTimeDivisionsChanged(int value) +{ + m_scope->setTimeDivisions(value); + updateTimeScrollBars(); +} + +void GlScopeWindow::setRecordMode(int idx) +{ + m_scope->model()->setStoreAllPoints(idx == 0); +} + +void GlScopeWindow::setVerticalScaleMode(int idx) +{ + m_scope->setVerticalScaleMode(static_cast(idx)); +} + +void GlScopeWindow::setTriggerType(int idx) +{ + m_scope->model()->setTriggerType(static_cast(idx)); + // Enable/disable trigger settings + for (QWidget* w : m_triggerSetup) + w->setEnabled(idx != static_cast(ScopeModel::Trigger::FreeRun)); +} + +void GlScopeWindow::addTrace(bool) +{ + QColor traceColor = QColor::fromHsv(m_lastTraceHue, m_lastTraceSat, 255); + // Shift colour, won't quickly repeat + m_lastTraceHue += 40; + if (m_lastTraceHue >= 360) + { + m_lastTraceHue -= 360; + m_lastTraceSat -= 30; + if (m_lastTraceSat < 0) + m_lastTraceSat += 255; + } + + if (m_scope->model()->rowCount() == 0) + { + m_scope->model()->addTrace(traceColor, m_defaultUniverse, MIN_DMX_ADDRESS); + m_btnStart->setEnabled(true); + return; + } + + // Add an 8bit trace defaulting to the next item, based on the current index + QModelIndex current = m_tableView->currentIndex(); + if (!current.isValid()) + current = m_tableView->model()->index(m_tableView->model()->rowCount() - 1, ScopeModel::COL_UNIVERSE); + + uint16_t universe = current.model()->index(current.row(), ScopeModel::COL_UNIVERSE).data(Qt::DisplayRole).toUInt(); + + uint16_t address_hi; + uint16_t address_lo; + { + const QString addr_string = current.model()->index(current.row(), ScopeModel::COL_ADDRESS).data(Qt::DisplayRole).toString(); + if (!ScopeTrace::extractAddress(addr_string, address_hi, address_lo)) + { + qDebug() << "Bad format, logic error:" << addr_string; + address_hi = 1; + address_lo = 0; + } + } + + if (current.column() == ScopeModel::COL_UNIVERSE) + { + // If Universe column is selected, add the same slot(s) in the next unused Universe + do + { + ++universe; + } while (m_scope->model()->addTrace(traceColor, universe, address_hi, address_lo) == ScopeModel::AddResult::Exists); + } + else + { + // If any other column is selected, add the next unused 8/16bit slot on the same universe + if (universe < MIN_SACN_UNIVERSE) + universe = MIN_SACN_UNIVERSE; + do + { + ++address_hi; + + // 16 bit, default to coarse/fine as is by far the most common + if (address_lo != 0) + { + ++address_hi; + address_lo = address_hi + 1; + } + + // Next universe + if (address_hi > MAX_DMX_ADDRESS) + { + ++universe; + address_hi = MIN_DMX_ADDRESS; + if (address_lo != 0) + address_lo = address_hi + 1; + } + } while (m_scope->model()->addTrace(traceColor, universe, address_hi, address_lo) == ScopeModel::AddResult::Exists); + } + + // Select the item that was added + QAbstractProxyModel* proxy = qobject_cast(m_tableView->model()); + if (proxy) + { + const QModelIndex srcIndex = m_scope->model()->findFirstTraceIndex(universe, address_hi, address_lo, proxy->mapToSource(current).column()); + if (srcIndex.isValid()) + m_tableView->setCurrentIndex(proxy->mapFromSource(srcIndex)); + } +} + +void GlScopeWindow::removeTrace(bool) +{ + QItemSelectionModel* selection = m_tableView->selectionModel(); + if (!selection->hasSelection()) + return; + + // Get the items to delete + QModelIndexList selected = selection->selectedIndexes(); + m_scope->model()->removeTraces(selected); +} + +void GlScopeWindow::removeAllTraces(bool) +{ + m_scope->model()->removeAllTraces(); + m_btnStart->setEnabled(false); +} + +void GlScopeWindow::saveTraces(bool) +{ + const QString filename = QFileDialog::getSaveFileName(this, tr("Save Traces"), QString(), QStringLiteral("CSV (*.csv)")); + if (filename.isEmpty()) + return; + + QFile csvExport(filename); + if (!csvExport.open(QIODevice::WriteOnly)) + return; + + m_scope->model()->saveTraces(csvExport); +} + +void GlScopeWindow::loadTraces(bool) +{ + const QString filename = QFileDialog::getOpenFileName(this, tr("Load Traces"), QString(), QStringLiteral("CSV (*.csv)")); + if (filename.isEmpty()) + return; + + QFile csvExport(filename); + if (!csvExport.open(QIODevice::ReadOnly)) + return; + + m_scope->model()->loadTraces(csvExport); + + // Update + updateTimeScrollBars(); + updateConfiguration(); + m_btnStart->setEnabled(m_scope->model()->rowCount() > 0); +} + +void GlScopeWindow::onTriggered() +{ + if (m_chkSyncViews->isChecked()) + emit startOtherViews(); +} + +void GlScopeWindow::updateTimeScrollBars() +{ + // Disable scrolling when running + if (m_scope->model()->isRunning()) + { + m_scrollTime->setMinimum(0); + m_scrollTime->setMaximum(0); + return; + } + + const QRectF extents = m_scope->model()->traceExtents(); + const qreal viewWidth = m_scope->scopeView().width(); + m_scrollTime->setEnabled(extents.width() > viewWidth); + + // Use milliseconds + m_scrollTime->setMinimum(extents.left() * 1000); + const qreal maxVal = (extents.right() - viewWidth) * 1000; + m_scrollTime->setMaximum(maxVal > 0 ? maxVal : 0); + m_scrollTime->setPageStep(viewWidth * 1000); + + // And jump to an appropriate end + if (extents.right() > viewWidth) + m_scrollTime->setValue(m_scrollTime->maximum()); + else + m_scrollTime->setValue(m_scrollTime->minimum()); + + onTimeSliderMoved(m_scrollTime->value()); + m_scrollTime->setEnabled(true); +} + +void GlScopeWindow::updateConfiguration() +{ + // Read values back from the scope model + m_recordMode->setCurrentIndex(m_scope->model()->storeAllPoints() ? 0 : 1); + m_spinRunTime->setValue(m_scope->model()->runTime()); + m_triggerType->setCurrentIndex(static_cast(m_scope->model()->triggerType())); + m_spinTriggerLevel->setValue(m_scope->model()->triggerLevel()); +} + +// QColorDialog doesn't autocentre when used as a delegate +void ColorDialog::showEvent(QShowEvent* ev) +{ + QRect parentRect(parentWidget()->mapToGlobal(QPoint(0, 0)), parentWidget()->size()); + move(QStyle::alignedRect(Qt::LeftToRight, Qt::AlignCenter, size(), parentRect).topLeft()); + + QColorDialog::showEvent(ev); +} + + +ColorPickerDelegate::ColorPickerDelegate(QWidget* parent) + : QStyledItemDelegate(parent) +{ + m_dialog = new ColorDialog(parent); +} + +QWidget* ColorPickerDelegate::createEditor(QWidget* parent, const QStyleOptionViewItem& /*option*/, const QModelIndex& /*idx*/) const +{ + return m_dialog; +} + +void ColorPickerDelegate::destroyEditor(QWidget* editor, const QModelIndex& /*idx*/) const +{ + // Don't delete it, reuse it instead + if (editor == m_dialog) + return; +} + +void ColorPickerDelegate::setEditorData(QWidget* editor, const QModelIndex& idx) const +{ + ColorDialog* dialog = qobject_cast(editor); + if (dialog) + { + QColor color = idx.data(Qt::BackgroundRole).value(); + dialog->setCurrentColor(color); + } +} + +void ColorPickerDelegate::setModelData(QWidget* editor, QAbstractItemModel* model, const QModelIndex& idx) const +{ + ColorDialog* dialog = qobject_cast(editor); + if (dialog) + { + QColor color = dialog->selectedColor(); + if (color.isValid()) + model->setData(idx, color, Qt::EditRole); + } +} diff --git a/src/ui/glscopewindow.h b/src/ui/glscopewindow.h new file mode 100644 index 00000000..83d53d14 --- /dev/null +++ b/src/ui/glscopewindow.h @@ -0,0 +1,116 @@ +// Copyright 2023 Electronic Theatre Controls, Inc. or its affiliates +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include "consts.h" + +#include +#include +#include + +class GlScopeWidget; +class SteppedSpinBox; + +class QCheckBox; +class QComboBox; +class QPushButton; +class QScrollBar; +class QSpinBox; +class QSplitter; +class QTableView; + +class GlScopeWindow : public QWidget +{ + Q_OBJECT +public: + explicit GlScopeWindow(int universe, QWidget* parent = nullptr); + ~GlScopeWindow(); + +private: + Q_SLOT void onRunningChanged(bool running); + Q_SLOT void onTimeSliderMoved(int value); + Q_SLOT void onTimeDivisionsChanged(int value); + + Q_SLOT void setRecordMode(int idx); + Q_SLOT void setVerticalScaleMode(int idx); + Q_SLOT void setTriggerType(int idx); + + Q_SLOT void addTrace(bool); + Q_SLOT void removeTrace(bool); + Q_SLOT void removeAllTraces(bool); + + Q_SLOT void saveTraces(bool); + Q_SLOT void loadTraces(bool); + + Q_SLOT void onTriggered(); + + // Signals to start/stop other open rx views + Q_SIGNAL void startOtherViews(); + Q_SIGNAL void stopOtherViews(); + +private: + QSplitter* m_splitter = nullptr; + GlScopeWidget* m_scope = nullptr; + QScrollBar* m_scrollTime = nullptr; + QComboBox* m_recordMode = nullptr; + QSpinBox* m_spinRunTime = nullptr; + SteppedSpinBox* m_spinTimeScale = nullptr; + QComboBox* m_triggerType = nullptr; + QSpinBox* m_spinTriggerLevel = nullptr; + QPushButton* m_btnStart = nullptr; + QPushButton* m_btnStop = nullptr; + QCheckBox* m_chkSyncViews = nullptr; + QTableView* m_tableView = nullptr; + + // Widgets to disable when running and enable when stopped + std::vector m_disableWhenRunning; + // Widgets to disable when trigger is Free Run + std::vector m_triggerSetup; + + int m_defaultUniverse = MIN_SACN_UNIVERSE; + + int m_lastTraceHue = 0; + int m_lastTraceSat = 255; + + void updateTimeScrollBars(); + void updateConfiguration(); +}; + +class ColorDialog : public QColorDialog +{ + Q_OBJECT + +public: + ColorDialog(QWidget* parent = nullptr) : QColorDialog(parent) {} + +protected: + void showEvent(QShowEvent* ev) override; +}; + +class ColorPickerDelegate : public QStyledItemDelegate +{ + Q_OBJECT + +public: + ColorPickerDelegate(QWidget* parent = nullptr); + + QWidget* createEditor(QWidget* parent, const QStyleOptionViewItem& option, const QModelIndex& idx) const override; + void destroyEditor(QWidget* editor, const QModelIndex& idx) const override; + void setEditorData(QWidget* editor, const QModelIndex& idx) const override; + void setModelData(QWidget* editor, QAbstractItemModel* model, const QModelIndex& idx) const override; + +private: + QDialog* m_dialog = nullptr; +}; diff --git a/src/ui/mdimainwindow.cpp b/src/ui/mdimainwindow.cpp index d50e2e8f..8354a2b4 100644 --- a/src/ui/mdimainwindow.cpp +++ b/src/ui/mdimainwindow.cpp @@ -15,7 +15,7 @@ #include "mdimainwindow.h" #include "ui_mdimainwindow.h" -#include "scopewindow.h" +#include "glscopewindow.h" #include "universeview.h" #include "transmitwindow.h" #include "preferences.h" @@ -98,7 +98,7 @@ void MDIMainWindow::closeEvent(QCloseEvent* ev) void MDIMainWindow::on_actionScopeView_triggered(bool checked) { Q_UNUSED(checked); - ScopeWindow* scopeWindow = new ScopeWindow(ui->sbUniverseList->value(), this); + GlScopeWindow* scopeWindow = new GlScopeWindow(ui->sbUniverseList->value(), this); showWidgetAsSubWindow(scopeWindow); } @@ -212,6 +212,16 @@ void MDIMainWindow::on_actionMultiUniverse_triggered() QWidget* MDIMainWindow::showWidgetAsSubWindow(QWidget* w) { + // Connect cross-window triggering signals + // Check if extant + const QMetaObject* meta = w->metaObject(); + if (meta->indexOfSlot("startRx()") != -1) + { + // Classes should support both or neither + connect(this, SIGNAL(startReceiverViews()), w, SLOT(startRx())); + connect(this, SIGNAL(stopReceiverViews()), w, SLOT(stopRx())); + } + switch (Preferences::Instance().GetWindowMode()) { default: @@ -262,7 +272,7 @@ void MDIMainWindow::restoreSubWindows() { if (window.name == "Scope") { - ScopeWindow* scopeWindow = new ScopeWindow(MIN_SACN_UNIVERSE, this); + GlScopeWindow* scopeWindow = new GlScopeWindow(MIN_SACN_UNIVERSE, this); showWidgetAsSubWindow(scopeWindow)->restoreGeometry(window.geometry); } @@ -344,7 +354,7 @@ QWidget* MDIMainWindow::addFloatWidget(QWidget* w) void MDIMainWindow::StoreWidgetGeometry(const QWidget* window, const QWidget* widget, QList& result) { - if (qobject_cast(widget) != Q_NULLPTR) + if (qobject_cast(widget) != Q_NULLPTR) { SubWindowInfo i; i.name = "Scope"; diff --git a/src/ui/mdimainwindow.h b/src/ui/mdimainwindow.h index 90a01b93..0e428c82 100644 --- a/src/ui/mdimainwindow.h +++ b/src/ui/mdimainwindow.h @@ -40,6 +40,11 @@ class MDIMainWindow : public QMainWindow explicit MDIMainWindow(QWidget* parent = 0); ~MDIMainWindow(); + /// Start all receiver views + Q_SIGNAL void startReceiverViews(); + /// Stop all receiver views + Q_SIGNAL void stopReceiverViews(); + protected: void showEvent(QShowEvent* ev) override; void closeEvent(QCloseEvent* ev) override; diff --git a/src/ui/multiview.cpp b/src/ui/multiview.cpp index 8953e711..bf1b7a99 100644 --- a/src/ui/multiview.cpp +++ b/src/ui/multiview.cpp @@ -3,11 +3,13 @@ #include "ui_multiview.h" #include "consts.h" +#include "preferences.h" #include "models/sacnsourcetablemodel.h" #include "models/csvmodelexport.h" #include #include +#include MultiView::MultiView(QWidget* parent) : QWidget(parent) @@ -15,14 +17,31 @@ MultiView::MultiView(QWidget* parent) , m_sourceTableModel(new SACNSourceTableModel(this)) { ui->setupUi(this); - ui->sourceTableView->setModel(m_sourceTableModel); - ui->sourceTableView->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); + QSortFilterProxyModel* sortProxy = new QSortFilterProxyModel(this); + sortProxy->setSourceModel(m_sourceTableModel); + ui->sourceTableView->setModel(sortProxy); + ui->sourceTableView->horizontalHeader()->resizeSections(QHeaderView::ResizeToContents); ui->spinUniverseMin->setMinimum(MIN_SACN_UNIVERSE); ui->spinUniverseMin->setMaximum(MAX_SACN_UNIVERSE); ui->spinUniverseMax->setMinimum(MIN_SACN_UNIVERSE); ui->spinUniverseMax->setMaximum(MAX_SACN_UNIVERSE); + + ui->spinShort->setMaximum(800); + ui->spinShort->setValue(m_sourceTableModel->shortInterval()); + connect(ui->spinShort, QOverload::of(&QSpinBox::valueChanged), m_sourceTableModel, &SACNSourceTableModel::setShortInterval); + + ui->spinLong->setMaximum(ui->spinShort->maximum()); + ui->spinLong->setValue(m_sourceTableModel->longInterval()); + connect(ui->spinLong, QOverload::of(&QSpinBox::valueChanged), m_sourceTableModel, &SACNSourceTableModel::setLongInterval); + + ui->spinStatic->setMaximum(ui->spinShort->maximum()); + ui->spinStatic->setValue(m_sourceTableModel->staticInterval()); + connect(ui->spinStatic, QOverload::of(&QSpinBox::valueChanged), m_sourceTableModel, &SACNSourceTableModel::setStaticInterval); + + // Maybe don't show the Secure column + ui->sourceTableView->setColumnHidden(SACNSourceTableModel::COL_PATHWAY_SECURE, !Preferences::Instance().GetPathwaySecureRx()); } MultiView::~MultiView() @@ -34,13 +53,13 @@ void MultiView::on_btnStartStop_clicked(bool checked) { if (checked) { + ui->btnStartStop->setText(tr("Stop")); ui->spinUniverseMin->setEnabled(false); ui->spinUniverseMax->setEnabled(false); // Clear and restart listening for the large number of universes - m_sourceTableModel->resetCounters(); m_sourceTableModel->clear(); - + // Hold onto the old listeners so any overlaps will not be destructed std::map old_listeners; old_listeners.swap(m_listeners); @@ -58,10 +77,10 @@ void MultiView::on_btnStartStop_clicked(bool checked) } else { + ui->btnStartStop->setText(tr("Start")); ui->spinUniverseMin->setEnabled(true); ui->spinUniverseMax->setEnabled(true); - - // TODO: Actually pause + m_sourceTableModel->pause(); } } diff --git a/src/ui/multiview.h b/src/ui/multiview.h index a10ad531..61336a37 100644 --- a/src/ui/multiview.h +++ b/src/ui/multiview.h @@ -34,6 +34,11 @@ class MultiView : public QWidget explicit MultiView(QWidget* parent = 0); ~MultiView(); + // Trigger API + Q_SLOT void startRx() { on_btnStartStop_clicked(true); } + Q_SLOT void stopRx() { on_btnStartStop_clicked(false); } + + protected slots: void on_btnStartStop_clicked(bool checked); void on_btnResetCounters_clicked(); diff --git a/src/ui/scopewindow.cpp b/src/ui/scopewindow.cpp deleted file mode 100644 index 69414ee3..00000000 --- a/src/ui/scopewindow.cpp +++ /dev/null @@ -1,300 +0,0 @@ -// Copyright 2016 Tom Steer -// http://www.tomsteer.net -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -#include "scopewindow.h" -#include "ui_scopewindow.h" -#include "consts.h" -#include "preferences.h" -#include "sacnlistener.h" -#include -#include -#include - -static const QList COLOURS({ - Qt::white, - Qt::red , - Qt::darkRed, - Qt::green, - Qt::darkGreen, - Qt::blue, - Qt::darkBlue, - Qt::cyan, - Qt::darkCyan, - Qt::magenta, - Qt::darkMagenta, - Qt::yellow, - Qt::darkYellow - }); - -static const QList TIMEBASES({ - 2000, - 1000, - 500, - 200, - 100, - 50, - 20, - 10, - 5, -}); - -ScopeWindow::ScopeWindow(int universe, QWidget *parent) : - QWidget(parent), - ui(new Ui::ScopeWindow), - m_defaultUniverse(universe) -{ - ui->setupUi(this); - ui->dlTimebase->setMinimum(0); - ui->dlTimebase->setMaximum(TIMEBASES.count() - 1); - connect(ui->dlTimebase, &QAbstractSlider::valueChanged, this, &ScopeWindow::timebaseChanged); - ui->tableWidget->setRowCount(0); - ui->btnStart->setEnabled(false); - ui->btnStop->setEnabled(false); - - m_radioGroup = new QButtonGroup(this); - connect(m_radioGroup, &QButtonGroup::idPressed, this, &ScopeWindow::on_buttonGroup_buttonPressed); - connect(ui->sbTriggerDelay, QOverload::of(&QSpinBox::valueChanged), ui->widget, &ScopeWidget::setTriggerDelay); - connect(ui->widget, &ScopeWidget::stopped, this, &ScopeWindow::on_scopeWidget_stopped); - - // Set initial value - ui->dlTimebase->setValue(2); - - // Setup trigger spinbox - ui->sbTriggerLevel->setMinimum(0); - ui->sbTriggerLevel->setMaximum(Preferences::Instance().GetMaxLevel()); - ui->sbTriggerLevel->setValue(Preferences::Instance().GetMaxLevel()/2); -} - -ScopeWindow::~ScopeWindow() -{ - delete ui; -} - -void ScopeWindow::timebaseChanged(int value) -{ - int timebase = TIMEBASES[value]; - if(timebase<1000) - ui->lbTimebase->setText(tr("%1 ms/div").arg(timebase)); - else - ui->lbTimebase->setText(tr("%1 s/div").arg(timebase/1000)); - ui->widget->setTimebase(timebase); -} - -void ScopeWindow::on_btnStart_pressed() -{ - ui->widget->start(); - ui->btnStart->setEnabled(false); - ui->btnStop->setEnabled(true); -} - -void ScopeWindow::on_btnStop_pressed() -{ - ui->widget->stop(); - ui->btnStart->setEnabled(true); - ui->btnStop->setEnabled(false); -} - -void ScopeWindow::on_btnAddChannel_pressed() -{ - int rowNumber = m_channels.count(); - ScopeChannel *channel = new ScopeChannel(m_defaultUniverse, rowNumber % 512); - - QColor col((Qt::GlobalColor)COLOURS[rowNumber % COLOURS.count()]); - channel->setColor(col); - ui->widget->addChannel(channel); - m_channels << channel; - - ui->tableWidget->setRowCount(m_channels.count()); - QTableWidgetItem *item = new QTableWidgetItem(QString::number(channel->universe())); - ui->tableWidget->setItem(m_channels.count()-1, COL_UNIVERSE, item); - - item = new QTableWidgetItem(QString::number(channel->address()+1)); - ui->tableWidget->setItem(m_channels.count()-1, COL_ADDRESS, item); - - item = new QTableWidgetItem(tr("Yes")); - item->setCheckState(Qt::Checked); - ui->tableWidget->setItem(m_channels.count()-1, COL_ENABLED, item); - - item = new QTableWidgetItem(); - item->setBackground(col); - item->setFlags(Qt::ItemIsEnabled); - ui->tableWidget->setItem(m_channels.count()-1, COL_COLOUR, item); - - QWidget *containerWidget = new QWidget(this); - QRadioButton *radio = new QRadioButton(containerWidget); - m_radioGroup->addButton(radio, m_channels.count()-1); - QHBoxLayout *layout = new QHBoxLayout; - layout->setContentsMargins(0,0,0,0); - layout->addStretch(); - layout->addWidget(radio); - layout->addStretch(); - containerWidget->setLayout(layout); - - ui->tableWidget->setCellWidget(m_channels.count()-1, COL_TRIGGER, containerWidget); - - - item = new QTableWidgetItem(tr("No")); - item->setCheckState(Qt::Unchecked); - ui->tableWidget->setItem(m_channels.count()-1, COL_16BIT, item); - - if(!ui->widget->running()) - ui->btnStart->setEnabled(true); -} - -void ScopeWindow::on_btnRemoveChannel_pressed() -{ - int index = ui->tableWidget->currentRow(); - if(index<0) return; - - ScopeChannel *channel = m_channels[index]; - ui->widget->removeChannel(channel); - ui->tableWidget->removeRow(index); - m_channels.removeAt(index); - - if(ui->tableWidget->rowCount() == 0) - { - ui->widget->stop(); - ui->btnStart->setEnabled(false); - ui->btnStop->setEnabled(false); - } -} - - -void ScopeWindow::on_tableWidget_cellDoubleClicked(int row, int col) -{ - if(col==COL_COLOUR) - { - ScopeChannel *channel = m_channels[row]; - QColorDialog dlg; - QColor newColor = dlg.getColor(channel->color(), this); - channel->setColor(newColor); - QTableWidgetItem *w = ui->tableWidget->item(row, col); - w->setBackground(newColor); - } -} - -void ScopeWindow::on_tableWidget_itemChanged(QTableWidgetItem * item) -{ - ScopeChannel *ch = m_channels[item->row()]; - int address = ch->address(); - int universe = ch->universe(); - bool ok; - - switch(item->column()) - { - case COL_ADDRESS: - address = item->text().toInt(&ok); - if(address>=1 && address<=512 && ok) - { - sACNManager::tListener listener; - if(!m_universes.contains(ch->universe())) - { - m_universes[ch->universe()] = sACNManager::Instance().getListener(ch->universe()); - } - - listener = m_universes[ch->universe()]; - - listener->unMonitorAddress(ch->address(), this); - ch->setAddress(address-1); - listener->monitorAddress(ch->address(), this); - ch->clear(); - } - else - { - ui->tableWidget->blockSignals(true); - item->setText(QString::number(ch->address()+1)); - ui->tableWidget->blockSignals(false); - } - break; - - case COL_UNIVERSE: - universe = item->text().toInt(&ok); - if(universe>=1 && universe<=MAX_SACN_UNIVERSE && ok && ch->universe()!=universe) - { - // Changing universe - sACNManager::tListener listener; - if(!m_universes.contains(universe)) - { - m_universes[ch->universe()] = sACNManager::Instance().getListener(universe); - } - - listener = m_universes[ch->universe()]; - - ch->setUniverse(universe); - disconnect(listener.data(), 0, this->ui->widget, 0); - connect(listener.data(), &sACNListener::dataReady, this->ui->widget, &ScopeWidget::dataReady); - listener->monitorAddress(ch->address(), this); - ch->clear(); - } - else - { - ui->tableWidget->blockSignals(true); - item->setText(QString::number(ch->universe())); - ui->tableWidget->blockSignals(false); - } - break; - case COL_ENABLED: - if(item->checkState() == Qt::Checked) - { - item->setText(tr("Yes")); - ch->setEnabled(true); - } - else - { - item->setText(tr("No")); - ch->setEnabled(false); - } - break; - - case COL_16BIT: - if(item->checkState() == Qt::Checked) - { - item->setText(tr("Yes")); - ch->setSixteenBit(true); - } - else - { - item->setText(tr("No")); - ch->setSixteenBit(false); - } - break; - } -} - -void ScopeWindow::on_buttonGroup_buttonPressed(int id) -{ - int row = id; - - int universe = ui->tableWidget->item(row, COL_UNIVERSE)->text().toInt(); - int address = ui->tableWidget->item(row, COL_ADDRESS)->text().toInt()-1; - - ui->widget->setTriggerAddress(universe, address); -} - -void ScopeWindow::on_cbTriggerMode_currentIndexChanged(int index) -{ - ui->widget->setTriggerMode((ScopeWidget::TriggerMode)index); -} - -void ScopeWindow::on_sbTriggerLevel_valueChanged(int value) -{ - ui->widget->setTriggerThreshold(value); -} - -void ScopeWindow::on_scopeWidget_stopped() -{ - ui->btnStart->setEnabled(true); - ui->btnStop->setEnabled(false); -} diff --git a/src/ui/scopewindow.h b/src/ui/scopewindow.h deleted file mode 100644 index c9d9e2fd..00000000 --- a/src/ui/scopewindow.h +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright 2016 Tom Steer -// http://www.tomsteer.net -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -#ifndef SCOPEWINDOW_H -#define SCOPEWINDOW_H - -class ScopeChannel; -class QTableWidgetItem; - -#include -#include -#include -#include "streamingacn.h" -#include "consts.h" - -namespace Ui { -class ScopeWindow; -} - -class ScopeWindow : public QWidget -{ - Q_OBJECT - -public: - explicit ScopeWindow(int universe = MIN_SACN_UNIVERSE, QWidget *parent = 0); - ~ScopeWindow(); -private slots: - void timebaseChanged(int value); - void on_btnStart_pressed(); - void on_btnStop_pressed(); - void on_btnAddChannel_pressed(); - void on_btnRemoveChannel_pressed(); - void on_tableWidget_cellDoubleClicked(int row, int col); - void on_tableWidget_itemChanged(QTableWidgetItem * item); - void on_buttonGroup_buttonPressed(int id); - void on_cbTriggerMode_currentIndexChanged(int index); - void on_sbTriggerLevel_valueChanged(int value); - void on_scopeWidget_stopped(); -private: - Ui::ScopeWindow *ui; - QList m_channels; - QButtonGroup *m_radioGroup; - QHash m_universes; - - enum { - COL_UNIVERSE, - COL_ADDRESS, - COL_ENABLED, - COL_COLOUR, - COL_TRIGGER, - COL_16BIT - }; - int m_defaultUniverse; -}; - -#endif // SCOPEWINDOW_H diff --git a/src/ui/universeview.cpp b/src/ui/universeview.cpp index 3b80b346..fcd382fa 100644 --- a/src/ui/universeview.cpp +++ b/src/ui/universeview.cpp @@ -57,8 +57,16 @@ UniverseView::UniverseView(int universe, QWidget *parent) : ui->sbUniverse->setValue(universe); m_sourceTableModel = new SACNSourceTableModel(this); - ui->tableView->setModel(m_sourceTableModel); - ui->tableView->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); + QSortFilterProxyModel* sortProxy = new QSortFilterProxyModel(this); + sortProxy->setSourceModel(m_sourceTableModel); + ui->tableView->setModel(sortProxy); + ui->tableView->horizontalHeader()->resizeSections(QHeaderView::ResizeToContents); + // Don't need to display the Universe column + ui->tableView->setColumnHidden(SACNSourceTableModel::COL_UNIVERSE, true); + // Don't show the time summary column + ui->tableView->setColumnHidden(SACNSourceTableModel::COL_TIME_SUMMARY, true); + // Maybe don't show the Secure column + ui->tableView->setColumnHidden(SACNSourceTableModel::COL_PATHWAY_SECURE, !Preferences::Instance().GetPathwaySecureRx()); } UniverseView::~UniverseView() @@ -260,12 +268,15 @@ void UniverseView::openBigDisplay(quint16 address) void UniverseView::on_btnPause_clicked() { ui->universeDisplay->pause(); + m_sourceTableModel->pause(); + this->disconnect(m_listener.data()); ui->btnGo->setEnabled(true); ui->btnPause->setEnabled(false); ui->sbUniverse->setEnabled(true); m_bindWarningShown = false; + setWindowTitle(tr("Universe View")); } diff --git a/src/ui/universeview.h b/src/ui/universeview.h index a506fc2c..ce9c4fbb 100644 --- a/src/ui/universeview.h +++ b/src/ui/universeview.h @@ -37,6 +37,11 @@ class UniverseView : public QWidget explicit UniverseView(int universe = MIN_SACN_UNIVERSE, QWidget *parent = 0); ~UniverseView(); void startListening(int universe); + + // Trigger API + Q_SLOT void startRx() { on_btnGo_clicked(); } + Q_SLOT void stopRx() { on_btnPause_clicked(); } + protected slots: void refreshTitle(); void on_btnGo_clicked(); @@ -54,6 +59,7 @@ protected slots: protected: virtual void resizeEvent(QResizeEvent *event); virtual void showEvent(QShowEvent *event); + private: void resizeColumns(); bool m_bindWarningShown = false; diff --git a/src/widgets/glscopewidget.cpp b/src/widgets/glscopewidget.cpp new file mode 100644 index 00000000..46687710 --- /dev/null +++ b/src/widgets/glscopewidget.cpp @@ -0,0 +1,1751 @@ +// Copyright 2023 Electronic Theatre Controls, Inc. or its affiliates +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "glscopewidget.h" + +#include +#include +#include +#include + +static constexpr qreal AXIS_LABEL_WIDTH = 45.0; +static constexpr qreal AXIS_LABEL_HEIGHT = 20.0; +static constexpr qreal TOP_GAP = 10.0; +static constexpr qreal RIGHT_GAP = 15.0; +static constexpr qreal AXIS_TO_WINDOW_GAP = 5.0; +static constexpr qreal AXIS_TICK_SIZE = 10.0; + +static const QString CaptureOptionsTitle = QStringLiteral("Capture Options"); +static const QString RowTitleColor = QStringLiteral("Color"); +static const QString ColumnTitleTimestamp = QStringLiteral("Time (s)"); + +static constexpr qreal kMaxDmx16 = 65535; +static constexpr qreal kMaxDmx8 = 255; + +template +T roundCeilMultiple(T value, T multiple) +{ + if (multiple == 0) return value; + return static_cast(std::ceil(static_cast(value) / static_cast(multiple)) * static_cast(multiple)); +} + +bool ScopeTrace::extractUniverseAddress(QStringView address_string, uint16_t& universe, uint16_t& address_hi, uint16_t& address_lo) +{ + if (address_string.front() != QLatin1Char('U')) + return false; + + // Extract the universe + const qsizetype univ_str_end = address_string.indexOf(QLatin1Char('.')); + bool ok = false; + if (univ_str_end > 0) + { + const QStringView univ_str = address_string.mid(1, univ_str_end - 1); + universe = univ_str.toUInt(&ok); + if (!ok || universe == 0 || universe > MAX_SACN_UNIVERSE) + return false; + + return extractAddress(address_string.mid(univ_str_end + 1), address_hi, address_lo); + } + + return false; +} + +bool ScopeTrace::extractAddress(QStringView address_string, uint16_t& address_hi, uint16_t& address_lo) +{ + // Extract address_hi + const qsizetype addr_str_end = address_string.indexOf(QLatin1Char('/')); + bool ok = false; + if (addr_str_end > 0) + { + const QStringView slot_hi_str = address_string.left(addr_str_end); + address_hi = slot_hi_str.toUInt(&ok); + if (!ok) + return false; + + // Extract slot_lo + const QStringView slot_lo_str = address_string.mid(addr_str_end + 1); + address_lo = slot_lo_str.toUInt(&ok); + } + else + { + const QStringView slot_hi_str = address_string.left(addr_str_end); + address_hi = slot_hi_str.toUInt(&ok); + address_lo = 0; + if (!ok) + return false; + } + + return ok; +} + +QString ScopeTrace::universeAddressString(uint16_t universe, uint16_t address_hi, uint16_t address_lo) +{ + return QStringLiteral("U") + QString::number(universe) + QLatin1Char('.') + addressString(address_hi, address_lo); +} + +QString ScopeTrace::addressString(uint16_t address_hi, uint16_t address_lo) +{ + if (address_lo > 0 && address_lo <= MAX_DMX_ADDRESS) + return QString::number(address_hi) + QLatin1Char('/') + QString::number(address_lo); + return QString::number(address_hi); +} + +QString ScopeTrace::universeAddressString() const +{ + return universeAddressString(universe(), addressHi(), addressLo()); +} + +QString ScopeTrace::addressString() const +{ + return addressString(addressHi(), addressLo()); +} + +bool ScopeTrace::setUniverse(uint16_t new_universe, bool clear_values) +{ + // Invalid or unchanged + if (new_universe == m_universe || new_universe < 0 || new_universe > MAX_SACN_UNIVERSE) + return false; + + m_universe = new_universe; + + if (clear_values) + clear(); + + return true; +} + +bool ScopeTrace::setAddress(uint16_t address_hi, uint16_t address_lo, bool clear_values) +{ + // Validate + if (address_hi < 1 || address_hi > MAX_DMX_ADDRESS || address_lo > MAX_DMX_ADDRESS) + return false; + + if (addressHi() == address_hi && addressLo() == address_lo) + return false; // No change + + m_slot_hi = address_hi - 1; + m_slot_lo = address_lo == 0 ? MAX_DMX_ADDRESS : address_lo - 1; + + if (clear_values) + clear(); + + return true; +} + +bool ScopeTrace::setAddress(QStringView addressString, bool clear_values) +{ + uint16_t address_hi, address_lo; + if (extractAddress(addressString, address_hi, address_lo)) + return setAddress(address_hi, address_lo, clear_values); + + return false; +} + +template::value, bool> = true> +inline bool fillValue(T& value, uint16_t slot_hi, uint16_t slot_lo, const std::array& level_array) +{ + // Assumes slot numbers are valid + if (slot_lo < MAX_DMX_ADDRESS) + { + // 16 bit + // Do nothing if no level yet + if (level_array[slot_hi] < 0 || level_array[slot_lo] < 0) + return false; + + value = static_cast(level_array[slot_hi] << 8) | static_cast(level_array[slot_lo]); + return true; + } + // 8 bit + // Do nothing if no level + if (level_array[slot_hi] < 0) + return false; + + value = static_cast(level_array[slot_hi]); + return true; +} + +void ScopeTrace::addPoint(float timestamp, const std::array& level_array, bool storeAllPoints) +{ + float value; + if (fillValue(value, m_slot_hi, m_slot_lo, level_array)) + { + QMutexLocker lock(&m_mutex); + if (storeAllPoints) + { + m_trace.emplace_back(timestamp, value); + return; + } + // If level did not change in the last two, only update timestamp + const size_t trace_size = m_trace.size(); + if (trace_size > 2 && m_trace[trace_size - 1].y() == value && m_trace[trace_size - 2].y() == value) + { + m_trace.back().setX(timestamp); + } + else + { + m_trace.emplace_back(timestamp, value); + } + } +} + +void ScopeTrace::setFirstPoint(float timestamp, const std::array& level_array) +{ + float value; + if (fillValue(value, m_slot_hi, m_slot_lo, level_array)) + { + QMutexLocker lock(&m_mutex); + if (m_trace.empty()) + m_trace.emplace_back(timestamp, value); + else + m_trace[0] = { timestamp,value }; + } +} + +void ScopeTrace::applyOffset(float offset) +{ + QMutexLocker lock(&m_mutex); + for (auto& point : m_trace) + point.setX(point.x() - offset); +} + +ScopeModel::AddResult ScopeModel::addTrace(const QColor& color, uint16_t universe, uint16_t address_hi, uint16_t address_lo) +{ + // Verify validity + if (!color.isValid()) + return AddResult::Invalid; + + if (universe < MIN_SACN_UNIVERSE) + return AddResult::Invalid; + + if (address_hi == 0) + return AddResult::Invalid; + + if (universe > MAX_SACN_UNIVERSE) + return AddResult::Invalid; + + if (address_hi > MAX_DMX_ADDRESS) + return AddResult::Invalid; + + if (address_lo > MAX_DMX_ADDRESS) + address_lo = 0; + else if (address_lo > 0) + { + // 16bit, adjust vertical scale + setMaxValue(kMaxDmx16); + } + + auto univ_it = m_traceLookup.find(universe); + if (univ_it == m_traceLookup.end()) + { + // Set the first one to be the trigger + if (m_traceTable.empty()) + { + m_trigger.universe = universe; + m_trigger.address_hi = address_hi; + m_trigger.address_lo = address_lo; + } + + beginInsertRows(QModelIndex(), m_traceTable.size(), m_traceTable.size()); + ScopeTrace* trace = new ScopeTrace(color, universe, address_hi, address_lo, m_reservation); + m_traceTable.push_back(trace); + m_traceLookup.emplace(universe, std::vector(1, trace)); + endInsertRows(); + + // Maybe start listening + if (isRunning()) + { + addListener(universe); + } + + return AddResult::Added; + } + + // If we have already got this trace, skip + auto& univs_item = univ_it->second; + for (auto& item : univs_item) + { + if (item->addressHi() == address_hi && item->addressLo() == address_lo) + { + return AddResult::Exists; + } + } + + // Add this new trace to the existing universe + beginInsertRows(QModelIndex(), m_traceTable.size(), m_traceTable.size()); + ScopeTrace* trace = new ScopeTrace(color, universe, address_hi, address_lo, m_reservation); + m_traceTable.push_back(trace); + univs_item.push_back(trace); + endInsertRows(); + return AddResult::Added; +} + +// Removes all traces that match this +void ScopeModel::removeTrace(uint16_t universe, uint16_t address_hi, uint16_t address_lo) +{ + auto univ_it = m_traceLookup.find(universe); + if (univ_it == m_traceLookup.end()) + return; + + bool found = false; + auto& univ_item = univ_it->second; + for (auto it = univ_item.begin(); it != univ_item.end(); /**/) + { + if ((*it)->addressHi() == address_hi && (*it)->addressLo() == address_lo) + { + found = true; + it = univ_item.erase(it); + } + else + { + ++it; + } + } + + // Find the index in the tracetable and delete the object + if (found) + { + for (auto it = m_traceTable.begin(); it != m_traceTable.end(); /**/) + { + ScopeTrace* trace = (*it); + if (trace->universe() == universe && trace->addressHi() == address_hi && trace->addressLo() == address_lo) + { + const int row = it - m_traceTable.begin(); + beginRemoveRows(QModelIndex(), row, row); + it = m_traceTable.erase(it); + delete trace; + endRemoveRows(); + } + else + { + ++it; + } + } + } + + // If last trace on a universe, stop listening + if (univ_item.empty()) + { + for (auto it = m_listeners.begin(); it != m_listeners.end(); /**/) + { + if ((*it)->universe() == universe) + { + // Disconnect signal + (*it)->removeDirectCallback(this); + it = m_listeners.erase(it); + } + else + { + ++it; + } + } + } +} + +void ScopeModel::removeTraces(const QModelIndexList& indexes) +{ + // Create list of items to remove + std::vector traceRows; + for (const QModelIndex& idx : indexes) + { + if (idx.isValid() && idx.row() < rowCount()) + { + traceRows.push_back(idx.row()); + } + } + + // Start at the end + std::sort(traceRows.begin(), traceRows.end(), std::greater()); + + size_t prevRow = std::numeric_limits::max(); + + for (size_t row : traceRows) + { + if (prevRow == row) + continue; // This was a duplicate + + beginRemoveRows(QModelIndex(), row, row); + + ScopeTrace* trace = m_traceTable[row]; + removeFromLookup(trace, trace->universe()); + + m_traceTable.erase(m_traceTable.begin() + row); + delete trace; + endRemoveRows(); + + prevRow = row; + } +} + +ScopeTrace* ScopeModel::findTrace(uint16_t universe, uint16_t address_hi, uint16_t address_lo) +{ + const auto univ_it = m_traceLookup.find(universe); + if (univ_it != m_traceLookup.end()) + { + const auto& univ_item = univ_it->second; + for (const auto& item : univ_item) + { + if (item->addressHi() == address_hi && item->addressLo() == address_lo) + return item; + } + } + + // Not found + return nullptr; +} + +QModelIndex ScopeModel::findFirstTraceIndex(uint16_t universe, uint16_t address_hi, uint16_t address_lo, int column) const +{ + for (size_t row = 0; row < m_traceTable.size(); ++row) + { + const ScopeTrace* trace = m_traceTable[row]; + if (trace && trace->universe() == universe && trace->addressHi() == address_hi && trace->addressLo() == address_lo) + { + return index(row, column); + } + } + return QModelIndex(); +} + +ScopeModel::ScopeModel(QObject* parent) + : QAbstractTableModel(parent) +{ + private_removeAllTraces(); + connect(this, &ScopeModel::queueStop, this, &ScopeModel::stop, Qt::QueuedConnection); + connect(this, &ScopeModel::queueTriggered, this, &ScopeModel::triggered, Qt::QueuedConnection); +} + +ScopeModel::~ScopeModel() +{ + private_removeAllTraces(); +} + +QVariant ScopeModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (role == Qt::DisplayRole && orientation == Qt::Horizontal) + { + switch (section) + { + case COL_UNIVERSE: return tr("Universe"); + case COL_ADDRESS: return tr("Address"); + case COL_COLOUR: return tr("Colour"); + case COL_TRIGGER: return tr("Trigger"); + } + } + return QVariant(); +} + +int ScopeModel::rowCount(const QModelIndex& parent) const +{ + if (parent.isValid()) + return 0; + + return m_traceTable.size(); +} + +Qt::ItemFlags ScopeModel::flags(const QModelIndex& index) const +{ + if (!index.isValid()) + return Qt::ItemFlags(); + + switch (index.column()) + { + default: return Qt::ItemFlags(); + case COL_UNIVERSE: return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsEditable | Qt::ItemIsUserCheckable; + case COL_ADDRESS: return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsEditable; + case COL_COLOUR: return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsEditable; + case COL_TRIGGER: return Qt::ItemIsEnabled | Qt::ItemIsUserCheckable; + } +} + +QVariant ScopeModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) + return QVariant(); + + if (index.column() < COL_COUNT && index.row() < rowCount()) + { + const ScopeTrace* trace = m_traceTable.at(index.row()); + if (trace) + { + switch (index.column()) + { + default: break; + case COL_UNIVERSE: + if (role == Qt::DisplayRole || role == Qt::EditRole || role == DataSortRole) return trace->universe(); + if (role == Qt::CheckStateRole) return trace->enabled() ? Qt::Checked : Qt::Unchecked; + break; + case COL_ADDRESS: + if (role == Qt::DisplayRole || role == Qt::EditRole) return trace->addressString(); + if (role == DataSortRole) return uint32_t(trace->addressHi()) << 16 | trace->addressLo(); + break; + case COL_COLOUR: + if (role == Qt::BackgroundRole || role == Qt::DisplayRole || role == Qt::EditRole) return trace->color(); + if (role == DataSortRole) return static_cast(trace->color().rgba()); + break; + case COL_TRIGGER: + if (role == Qt::CheckStateRole) + return m_trigger.isTriggerTrace(*trace) ? Qt::Checked : Qt::Unchecked; + if (role == DataSortRole) return m_trigger.isTriggerTrace(*trace) ? 0 : 1; + break; + } + } + } + + return QVariant(); +} + +bool ScopeModel::setData(const QModelIndex& idx, const QVariant& value, int role) +{ + if (!idx.isValid()) + return false; + + if (idx.column() < COL_COUNT && idx.row() < rowCount()) + { + ScopeTrace* trace = m_traceTable.at(idx.row()); + if (trace) + { + switch (idx.column()) + { + default: break; + + case COL_UNIVERSE: + if (role == Qt::CheckStateRole) + { + trace->setEnabled(value.toBool()); + emit dataChanged(idx, idx, { Qt::CheckStateRole }); + emit traceVisibilityChanged(); + return true; + } + if (role == Qt::EditRole) + { + // Maybe update the trigger + const bool isTrigger = m_trigger.isTriggerTrace(*trace); + if (moveTrace(trace, value.toUInt())) + { + if (isTrigger) + m_trigger.setTrigger(*trace); + emit dataChanged(idx, idx, { Qt::DisplayRole, Qt::EditRole, DataSortRole }); + return true; + } + } + break; + + case COL_ADDRESS: + if (role == Qt::EditRole) + { + // If 16bit changed, recheck the vertical scale + const bool was16bit = trace->isSixteenBit(); + // Maybe update the trigger + const bool isTrigger = m_trigger.isTriggerTrace(*trace); + if (trace->setAddress(value.toString(), true)) + { + if (was16bit != trace->isSixteenBit()) + { + if (trace->isSixteenBit()) + setMaxValue(kMaxDmx16); + else + updateMaxValue(); + } + if (isTrigger) + m_trigger.setTrigger(*trace); + emit dataChanged(idx, idx, { Qt::DisplayRole, Qt::EditRole, DataSortRole }); + return true; + } + } + break; + case COL_COLOUR: + if (role == Qt::EditRole) + { + QColor color = value.value(); + if (color.isValid()) + { + trace->setColor(color); + emit dataChanged(idx, idx, { Qt::BackgroundRole, Qt::DisplayRole, Qt::EditRole, DataSortRole }); + return true; + } + } + break; + + case COL_TRIGGER: + if (role == Qt::CheckStateRole) + { + if (trace->isValid()) + { + m_trigger.setTrigger(*trace); + emit dataChanged(index(0, COL_TRIGGER), index(rowCount() - 1, COL_TRIGGER), { Qt::CheckStateRole, DataSortRole }); + return true; + } + } + break; + } + } + } + + return false; +} + +void ScopeModel::removeAllTraces() +{ + beginResetModel(); + private_removeAllTraces(); + endResetModel(); +} + +void ScopeModel::private_removeAllTraces() +{ + stop(); + m_traceLookup.clear(); + + for (ScopeTrace* trace : m_traceTable) + delete trace; + + m_traceTable.clear(); + + // Set extents back to default + m_endTime = 0; + setMaxValue(kMaxDmx8); +} + +void ScopeModel::clearValues() +{ + // Clear all the trace values + for (ScopeTrace* trace : m_traceTable) + { + trace->clear(); + } + + // Reset time extents + m_endTime = 0; +} + +QString ScopeModel::captureConfigurationString() const +{ + QString result = (m_storeAllPoints ? QLatin1String("All Packets,") : QLatin1String("Level Changes,")); + + result.append(m_trigger.configurationString()); + result.append(QLatin1Char(',')); + + if (m_runTime > 0) + result.append(QStringLiteral("Run For %1 sec,").arg(m_runTime)); + + result.chop(1); + return result; +} + +void ScopeModel::setCaptureConfiguration(const QString& configString) +{ + if (configString.isEmpty()) + return; + + m_storeAllPoints = configString.contains(QLatin1String("All Packets"), Qt::CaseInsensitive); + + m_trigger.setConfiguration(configString); + + qsizetype runTimePos = configString.indexOf(QLatin1String("Run For")); + const qsizetype runTimeSecPos = configString.indexOf(QLatin1String("sec"), runTimePos); + if (runTimePos < 0 || runTimeSecPos < 0) + { + m_runTime = 0; + } + else + { + bool ok; + runTimePos += 8; + const qreal time = configString.mid(runTimePos, runTimeSecPos - runTimePos).toDouble(&ok); + if (ok) + m_runTime = time; + } +} + +bool ScopeModel::saveTraces(QIODevice& file) const +{ + if (!file.isWritable()) + return false; + + // Cannot write while running as would block the receive threads + if (isRunning()) + return false; + + QTextStream out(&file); + out.setCodec("UTF-8"); + out.setLocale(QLocale::c()); + out.setRealNumberNotation(QTextStream::FixedNotation); + out.setRealNumberPrecision(3); + + // Table: + // Capture Options:,All Packets/Level Changes + // + // Color, red, green, ... + // Time (s), U1.1, U1.2/3, ... (Given as Universe.CoarseDMX/FineDmx (1-512) + // 0.000, 255, 0, ... + // 0.020, 128, 128, ... + // 0.040, 127, 255, ... + + // Export capture configuration line + out << CaptureOptionsTitle << QLatin1String(":,") << captureConfigurationString(); + out << "\n\n"; + + // First row time + float this_row_time = std::numeric_limits::max(); + + // Iterators for each trace + using ValueIterator = std::vector::const_iterator; + // Copy the values + struct ValueItem + { + ValueItem(const ScopeTrace* trace) : values(trace->values().value()) + { + current = values.begin(); + } + const std::vector values; + ValueIterator current; + }; + std::vector traces_values; + traces_values.reserve(rowCount()); + + QString color_header = RowTitleColor; + QString name_header = ColumnTitleTimestamp; + + // Header rows sorted by universe + for (const auto& universe : m_traceLookup) + { + for (const ScopeTrace* trace : universe.second) + { + // Get the values for this column + traces_values.emplace_back(trace); + + // Assemble the color header string + color_header.append(QLatin1Char(',')); + color_header.append(trace->color().name()); + + // Assemble the name header string + name_header.append(QLatin1String(", ")); + name_header.append(trace->universeAddressString()); + + // Find the first and last row timestamps + if (!traces_values.back().values.empty()) + { + if (traces_values.back().values.front()[0] < this_row_time) + this_row_time = traces_values.back().values.front()[0]; + } + } + } + out << color_header << '\n' << name_header; + + // Value rows + while (this_row_time < std::numeric_limits::max()) + { + // Start new row and output timestamp + out << '\n' << this_row_time; + float next_row_time = std::numeric_limits::max(); + + for (auto& value_its : traces_values) + { + // Next Column + out << ','; + if (value_its.current != value_its.values.end()) + { + // This column has a value for this time + if (qFuzzyCompare(this_row_time, (*value_its.current)[0])) + { + // Output a value for this timestamp and step forward + out << static_cast((*value_its.current)[1]); + ++value_its.current; + } + // Determine the next row time + if (value_its.current != value_its.values.end() && (*value_its.current)[0] < next_row_time) + { + next_row_time = (*value_its.current)[0]; + } + } + } + // On to the next row + this_row_time = next_row_time; + } + + return true; +} + +struct TitleRows { + QString config; + QString colors; + QString universes; +}; + +TitleRows FindUniverseTitles(QTextStream& in) +{ + TitleRows result; + // Find start of data + QString line; + while (in.readLineInto(&line)) + { + if (line.startsWith(CaptureOptionsTitle)) + { + result.config = line; + } + else if (line.startsWith(RowTitleColor)) + { + // Probably the title line + result.colors = line; + result.universes = in.readLine(); + if (!result.universes.startsWith(ColumnTitleTimestamp)) + return TitleRows(); // Failed + + return result; + } + } + return TitleRows(); +} + +bool ScopeModel::loadTraces(QIODevice& file) +{ + if (!file.isReadable()) + return false; + + QTextStream in(&file); + in.setCodec("UTF-8"); + in.setLocale(QLocale::c()); + in.setRealNumberNotation(QTextStream::FixedNotation); + in.setRealNumberPrecision(3); + + const auto title_line = FindUniverseTitles(in); + if (title_line.universes.isEmpty()) + return false; + + // Split the title lines to find the trace colors and names + auto colors = title_line.colors.splitRef(QLatin1Char(','), Qt::KeepEmptyParts); + auto titles = title_line.universes.splitRef(QLatin1Char(','), Qt::KeepEmptyParts); + + // Remove the first column as these are known titles + colors.pop_front(); + titles.pop_front(); + + // Remove empty colors from the end + while (colors.last().isEmpty()) + colors.pop_back(); + // Remove empty items from the end + while (titles.last().isEmpty()) + titles.pop_back(); + + if (colors.size() != titles.size() || titles.empty()) + return false; // No or invalid data + + // Fairly likely to be valid, stop and clear my data now + beginResetModel(); + private_removeAllTraces(); + + struct UnivSlots + { + uint16_t universe = 0; + uint16_t address_hi = 0; + uint16_t address_lo = 0; + }; + std::vector trace_idents; + + for (qsizetype i = 0; i < titles.size(); ++i) + { + // Grab color + const QColor color(colors[i]); + + // Find universe and patch + const auto& full_title = titles[i]; + + UnivSlots univ_slots; + if (!ScopeTrace::extractUniverseAddress(full_title.trimmed(), univ_slots.universe, univ_slots.address_hi, univ_slots.address_lo)) + { + trace_idents.push_back(UnivSlots()); // Skip this column + continue; + } + + if (addTrace(color, univ_slots.universe, univ_slots.address_hi, univ_slots.address_lo) == AddResult::Added) + trace_idents.push_back(univ_slots); + else + trace_idents.push_back(UnivSlots()); + } + + // Now have all the trace idents and the container will not change + // Get all the pointers in order with null for bad columns + std::vector traces; + for (const auto& ident : trace_idents) + { + traces.push_back(findTrace(ident.universe, ident.address_hi, ident.address_lo)); + } + + QString data_line; + float prev_timestamp = 0; + while (in.readLineInto(&data_line)) + { + if (data_line.isEmpty()) + continue; + + const auto data = data_line.splitRef(QLatin1Char(','), Qt::KeepEmptyParts); + // Ignore any lines that do not have a column for all traces + if (data.size() < traces.size() + 1) + continue; + + // Time moves ever forward. Ignore any lines in the past + bool ok = false; + const float timestamp = data[0].toFloat(&ok); + if (!ok || prev_timestamp > timestamp) + continue; + + prev_timestamp = timestamp; + + for (size_t i = 1; i <= traces.size(); ++i) + { + ScopeTrace* trace = traces[i - 1]; + if (trace) + { + bool ok = false; + const float level = data[i].toFloat(&ok); + if (ok) + trace->addValue({ timestamp, level }); + } + } + } + + m_endTime = prev_timestamp; + + // Finally, update the config + setCaptureConfiguration(title_line.config); + + endResetModel(); + // Force a re-render + emit traceVisibilityChanged(); + return true; +} + +bool ScopeModel::listeningToUniverse(uint16_t universe) const +{ + return m_traceLookup.count(universe) != 0; +} + +void ScopeModel::start() +{ + if (isRunning()) + return; + + // Clear all values and start the clock + // Cannot permit time to go backwards + clearValues(); + + m_running = true; + + // Set up the Trigger or start immediately + if (m_trigger.isTrigger()) + { + m_trigger.last_level = -1; + } + else + { + triggerNow(sACNManager::secsElapsed()); + } + + for (const auto& universe : m_traceLookup) + { + addListener(universe.first); + } + + emit runningChanged(true); +} + +void ScopeModel::stop() +{ + if (!isRunning()) + return; + + m_running = false; + + // Disconnect all + for (auto& listener : m_listeners) + { + listener->removeDirectCallback(this); + } + // And clear/shutdown + m_listeners.clear(); + m_startOffset = 0; + emit runningChanged(false); +} + +void ScopeModel::setRunTime(qreal seconds) +{ + if (runTime() == seconds) + return; + + // Validate and emit new or existing value + if (seconds >= 0) + m_runTime = seconds; + + emit runTimeChanged(runTime()); +} + +// Triggers +void ScopeModel::setTriggerType(Trigger mode) +{ + m_trigger.mode = mode; + emit traceVisibilityChanged(); +} + +void ScopeModel::setTriggerLevel(uint16_t level) +{ + m_trigger.level = level; + emit traceVisibilityChanged(); +} + +bool ScopeModel::moveTrace(ScopeTrace* trace, uint16_t new_universe, bool clear_values) +{ + const uint16_t old_universe = trace->universe(); + + if (!trace->setUniverse(new_universe, clear_values)) + return false; // New universe invalid or unchanged + + // Remove it from the old lookup + removeFromLookup(trace, old_universe); + + // Add the ScopeTrace to the new universe in map + auto univ_it = m_traceLookup.find(new_universe); + if (univ_it == m_traceLookup.end()) + { + // First trace on this universe + m_traceLookup.emplace(new_universe, std::vector(1, trace)); + + // Maybe start listening + if (isRunning()) + addListener(new_universe); + } + else + { + // Add this new trace to the existing universe + auto& univs_item = univ_it->second; + univs_item.push_back(trace); + } + return true; +} + +void ScopeModel::removeFromLookup(ScopeTrace* trace, uint16_t old_universe) +{ + // Remove from old universe + auto univ_it = m_traceLookup.find(old_universe); + if (univ_it != m_traceLookup.end()) + { + // Remove from old Universe + auto& univ_item = univ_it->second; + for (auto it = univ_item.begin(); it != univ_item.end(); /**/) + { + if ((*it) == trace) + { + it = univ_item.erase(it); + } + else + { + ++it; + } + } + + // If was last trace on a universe, stop listening + if (univ_item.empty()) + { + for (auto it = m_listeners.begin(); it != m_listeners.end(); /**/) + { + if ((*it)->universe() == old_universe) + { + // Disconnect signal + (*it)->removeDirectCallback(this); + it = m_listeners.erase(it); + } + else + { + ++it; + } + } + } + } +} + +void ScopeModel::addListener(uint16_t universe) +{ + auto listener = sACNManager::Instance().getListener(universe); + listener->addDirectCallback(this); + m_listeners.push_back(listener); +} + +void ScopeModel::updateMaxValue() +{ + for (const ScopeTrace* trace : m_traceTable) + { + if (trace->isSixteenBit()) + { + setMaxValue(kMaxDmx16); + return; + } + } + setMaxValue(kMaxDmx8); +} + +void ScopeModel::setMaxValue(qreal maxValue) +{ + if (m_maxValue == maxValue) + return; + m_maxValue = maxValue; + maxValueChanged(); +} + +void ScopeModel::triggerNow(qreal offset) +{ + m_startOffset = offset; + // Update the offsets of all traces + for (ScopeTrace* trace : m_traceTable) + { + trace->applyOffset(offset); + } + // Reset the counters + for (sACNManager::tListener& listener : m_listeners) + { + auto sources = listener->getSourceList(); + for (sACNSource* source : sources) + { + source->resetSeqErr(); + source->resetJumps(); + source->fpscounter.ClearHistogram(); + } + } + + emit queueTriggered(); +} + +QRectF ScopeModel::traceExtents() const +{ + return QRectF(0, 0, m_endTime, m_maxValue); +} + +qreal ScopeModel::endTime() const +{ + if (isTriggered()) + return (qreal(sACNManager::elapsed()) / 1000) - m_startOffset; + + return m_endTime; +} + +void ScopeModel::sACNListenerDmxReceived(tock packet_tock, int universe, const std::array& levels) +{ + if (!m_running) + return; + + // Find traces for universe + auto it = m_traceLookup.find(universe); + if (it == m_traceLookup.end()) + return; + + const qreal timestamp = std::chrono::duration(packet_tock.Get()).count(); + + if (!isTriggered()) + { + // Only store one level for each trace until the trigger is fired + for (ScopeTrace* trace : it->second) + { + trace->setFirstPoint(timestamp, levels); + } + + uint16_t current_level; + if (universe == m_trigger.universe && fillValue(current_level, m_trigger.address_hi - 1, m_trigger.address_lo - 1, levels)) + { + // Have a current level, maybe start the clock + switch (m_trigger.mode) + { + default: break; + case Trigger::Above: + if (current_level > m_trigger.level) + triggerNow(timestamp); + break; + case Trigger::Below: + if (current_level < m_trigger.level) + triggerNow(timestamp); + break; + case Trigger::LevelCross: + if ((m_trigger.last_level == m_trigger.level && current_level != m_trigger.level) // Was at, now not + || (m_trigger.last_level > m_trigger.level && current_level < m_trigger.level) // Was above, now below + || (m_trigger.last_level != -1 && m_trigger.last_level < m_trigger.level && current_level > m_trigger.level) // Was below, now above + ) + triggerNow(timestamp); + break; + } + m_trigger.last_level = current_level; + } + return; + } + + // Time in seconds + { + m_endTime = timestamp - m_startOffset; + qDebug() << m_endTime; + if (m_runTime > 0 && m_endTime >= m_runTime) + { + emit queueStop(); + return; + } + } + + for (ScopeTrace* trace : it->second) + { + trace->addPoint(m_endTime, levels, m_storeAllPoints); + } +} + +GlScopeWidget::GlScopeWidget(QWidget* parent) + : QOpenGLWidget(parent) +{ + setUpdateBehavior(QOpenGLWidget::NoPartialUpdate); + + m_model = new ScopeModel(this); + connect(m_model, &ScopeModel::runningChanged, this, &GlScopeWidget::onRunningChanged); + connect(m_model, &ScopeModel::traceVisibilityChanged, this, QOverload::of(&QOpenGLWidget::update)); + + setMinimumSize(200, 200); + setVerticalScaleMode(VerticalScale::Percent); + setScopeView(); +} + +GlScopeWidget::~GlScopeWidget() +{ + cleanupGL(); +} + +void GlScopeWidget::setVerticalScaleMode(VerticalScale scaleMode) +{ + switch (scaleMode) + { + default: + return; // Invalid, do nothing + case VerticalScale::Percent: + { + m_levelInterval = 10; + m_scopeView.setBottom(kMaxDmx8); // The 16 bit matrix downscales to 8bit + } break; + case VerticalScale::Dmx8: + { + m_levelInterval = 20; + m_scopeView.setBottom(kMaxDmx8); + } break; + case VerticalScale::Dmx16: + { + m_levelInterval = 10000; + m_scopeView.setBottom(kMaxDmx16); + } break; + } + + m_verticalScaleMode = scaleMode; + + updateMVPMatrix(); + update(); +} + +void GlScopeWidget::setScopeView(const QRectF& rect) +{ + if (rect.isEmpty()) + { + m_scopeView = m_model->traceExtents(); + m_scopeView.setRight(m_defaultIntervalCount * m_timeInterval); + setVerticalScaleMode(m_verticalScaleMode); + } + else if (rect == m_scopeView) + { + return; + } + else + { + m_scopeView = rect; + } + + updateMVPMatrix(); + update(); +} + +void GlScopeWidget::setTimeDivisions(int milliseconds) +{ + if (timeDivisions() == milliseconds) + return; + + // Must not go lower than 1ms + if (milliseconds < 1) + milliseconds = 1; + + m_timeInterval = static_cast(milliseconds) / 1000.0; + m_scopeView.setWidth(m_timeInterval * m_defaultIntervalCount); + updateMVPMatrix(); + update(); + + emit timeDivisionsChanged(milliseconds); +} + +void GlScopeWidget::initializeGL() +{ + // Reparenting to a different top-level window causes the OpenGL Context to be destroyed and recreated + connect(context(), &QOpenGLContext::aboutToBeDestroyed, this, &GlScopeWidget::cleanupGL); + + initializeOpenGLFunctions(); + + glClearColor(0, 0, 0, 1); + + // Compile shaders + // 2D passthrough shader + const char* vertexShaderSource = + "in vec2 vertex;\n" + "uniform mat4 mvp;\n" + "void main()\n" + "{\n" + " gl_Position = mvp * vec4(vertex.x, vertex.y, 0, 1.0);\n" + "}\n"; + + const char* fragmentShaderSource = + "uniform vec4 color;\n" + "void main()\n" + "{\n" + " gl_FragColor = color;\n" + "}\n"; + + m_program = new QOpenGLShaderProgram(this); + if (!m_program->addShaderFromSourceCode(QOpenGLShader::Vertex, vertexShaderSource)) + { + qDebug() << "Vertex Shader Failed:" << m_program->log(); + cleanupGL(); + return; + } + + if (!m_program->addShaderFromSourceCode(QOpenGLShader::Fragment, fragmentShaderSource)) + { + qDebug() << "Fragment Shader Failed:" << m_program->log(); + cleanupGL(); + return; + } + + if (!m_program->link()) + { + qDebug() << "Shader Link Failed:" << m_program->log(); + cleanupGL(); + return; + } + + m_vertexLocation = m_program->attributeLocation("vertex"); + Q_ASSERT(m_vertexLocation != -1); + m_matrixUniform = m_program->uniformLocation("mvp"); + Q_ASSERT(m_matrixUniform != -1); + m_colorUniform = m_program->uniformLocation("color"); + Q_ASSERT(m_colorUniform != -1); +} + +void GlScopeWidget::cleanupGL() +{ + // The context is about to be destroyed, it may be recreated later + if (m_program == nullptr) + return; // Never started + + makeCurrent(); + + delete m_program; + m_program = nullptr; + + doneCurrent(); +} + +inline void DrawLevelAxisText(QPainter& painter, const QFontMetricsF& metrics, const QRectF& scopeWindow, int level, qreal y_scale, const QString& postfix) +{ + qreal y = scopeWindow.height() - (level * y_scale); + + // TODO: use QStaticText to optimise the text layout + const QString text = QString::number(level) + postfix; + + QRectF fontRect = metrics.boundingRect(text); + fontRect.moveBottomRight(QPointF(-AXIS_TICK_SIZE, y + (fontRect.height() / 2.0))); + + painter.drawText(fontRect, text, QTextOption(Qt::AlignLeft)); +} + +void GlScopeWidget::paintGL() +{ + const qreal endTime = m_model->endTime(); + if (m_followNow) + { + if (endTime > m_scopeView.width()) + { + m_scopeView.moveRight(endTime); + updateMVPMatrix(); + } + } + + glClear(GL_COLOR_BUFFER_BIT); + + // Pixel-perfect grid lines + std::vector gridLines; + gridLines.reserve((11 + 14) * 2); // 10 ticks across, 13 up for 0-255 + + // Colors + static const QColor gridColor(0x43, 0x43, 0x43); + static const QColor textColor(Qt::white); + static const QColor timeCursorColor(Qt::white); + static const QColor triggerCursorColor(Qt::gray); + + // Draw the axes using QPainter as is easiest way to get the text + // It's ok if the labels aren't pixel perfect + { + QPen textPen; + textPen.setColor(textColor); + + QPainter painter(this); + + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setPen(textPen); + + // Origin is the bottom left of the scope window + QPointF origin(rect().bottomLeft().x() + AXIS_LABEL_WIDTH, rect().bottomLeft().y() - AXIS_LABEL_HEIGHT); + + QRectF scopeWindow; + scopeWindow.setBottomLeft(origin); + scopeWindow.setTop(rect().top() + TOP_GAP); + scopeWindow.setRight(rect().right() - RIGHT_GAP); + + // Draw nothing if tiny + if (scopeWindow.width() < AXIS_TICK_SIZE) + return; + + QFont font; + QFontMetricsF metrics(font); + + painter.translate(scopeWindow.topLeft().x(), scopeWindow.topLeft().y()); + + // Draw vertical (level) axis + { + const float lineLeft = m_scopeView.left(); + const float lineRight = std::fmaxf(m_scopeView.right(), endTime); + + if (m_verticalScaleMode == VerticalScale::Percent) + { + const QString postfix = QStringLiteral("%"); + const qreal max_value = 100.0; + const qreal y_scale = scopeWindow.height() / 100.0; // Percent + const float value_scale = kMaxDmx8 / 100.0f; + + // From bottom to top + for (qreal value = m_scopeView.top(); value < max_value; value += m_levelInterval) + { + // Grid lines in trace space + gridLines.emplace_back(lineLeft, static_cast(value) * value_scale); + gridLines.emplace_back(lineRight, static_cast(value) * value_scale); + DrawLevelAxisText(painter, metrics, scopeWindow, value, y_scale, postfix); + } + + // Final row, may be uneven + // Grid lines in trace space + gridLines.emplace_back(lineLeft, static_cast(m_scopeView.bottom())); + gridLines.emplace_back(lineRight, static_cast(m_scopeView.bottom())); + DrawLevelAxisText(painter, metrics, scopeWindow, max_value, y_scale, postfix); + } + else + { + const QString postfix; + const int max_value = m_scopeView.bottom(); + const qreal y_scale = scopeWindow.height() / m_scopeView.bottom(); + + // From bottom to top + for (int value = m_scopeView.top(); value < max_value; value += m_levelInterval) + { + // Grid lines in trace space + gridLines.emplace_back(lineLeft, static_cast(value)); + gridLines.emplace_back(lineRight, static_cast(value)); + DrawLevelAxisText(painter, metrics, scopeWindow, value, y_scale, postfix); + } + + // Final row, may be uneven + // Grid lines in trace space + gridLines.emplace_back(lineLeft, max_value); + gridLines.emplace_back(lineRight, max_value); + DrawLevelAxisText(painter, metrics, scopeWindow, max_value, y_scale, postfix); + } + } + + // Draw horizontal (time) axis + painter.resetTransform(); + painter.translate(scopeWindow.bottomLeft().x(), scopeWindow.bottomLeft().y()); + + const qreal x_scale = scopeWindow.width() / m_scopeView.width(); + const bool milliseconds = (m_timeInterval < 1.0); + + for (qreal time = roundCeilMultiple(m_scopeView.left(), m_timeInterval); time < m_scopeView.right() + 0.001; time += m_timeInterval) + { + // Grid lines in trace space + gridLines.emplace_back(static_cast(time), 0.0f); + gridLines.emplace_back(static_cast(time), m_scopeView.bottom()); + + const qreal x = (time - m_scopeView.left()) * x_scale; + + // TODO: use QStaticText to optimise the text layout + const QString text = milliseconds ? QStringLiteral("%1ms").arg(time * 1000.0) : QStringLiteral("%1s").arg(time); + QRectF fontRect = metrics.boundingRect(text); + fontRect.moveCenter(QPointF(x, AXIS_LABEL_HEIGHT / 2.0)); + painter.drawText(fontRect, text, QTextOption(Qt::AlignLeft)); + } + } + + // Unable to render at all + if (!m_program) + return; + + m_program->bind(); + glEnableVertexAttribArray(m_vertexLocation); + + m_program->setUniformValue(m_matrixUniform, m_mvpMatrix); + + // Draw the gridlines + { + m_program->setUniformValue(m_colorUniform, gridColor); + glVertexAttribPointer(m_vertexLocation, 2, GL_FLOAT, GL_FALSE, 0, gridLines.data()); + glDrawArrays(GL_LINES, 0, gridLines.size()); + } + + if (m_model->isTriggered()) + { + // Draw the current time + m_program->setUniformValue(m_colorUniform, timeCursorColor); + const std::vector nowLine = { + {static_cast(m_model->endTime()), 0}, + {static_cast(m_model->endTime()), static_cast(m_scopeView.bottom())} + }; + glVertexAttribPointer(m_vertexLocation, 2, GL_FLOAT, GL_FALSE, 0, nowLine.data()); + glDrawArrays(GL_LINE_STRIP, 0, 2); + } + else if (m_model->triggerType() != ScopeModel::Trigger::FreeRun) + { + // Draw the trigger level marker + m_program->setUniformValue(m_colorUniform, triggerCursorColor); + + const std::vector triggerLine = makeTriggerLine(m_model->triggerType()); + + glVertexAttribPointer(m_vertexLocation, 2, GL_FLOAT, GL_FALSE, 0, triggerLine.data()); + glDrawArrays(GL_TRIANGLES, 0, triggerLine.size()); + } + + + for (ScopeTrace* trace : m_model->traces()) + { + if (!trace->enabled()) + continue; + + // Change the scale as needed + if (m_verticalScaleMode == VerticalScale::Percent) + { + m_program->setUniformValue(m_matrixUniform, trace->isSixteenBit() ? m_mvpMatrix16 : m_mvpMatrix); + } + + m_program->setUniformValue(m_colorUniform, trace->color()); + const auto levels = trace->values(); + glVertexAttribPointer(m_vertexLocation, 2, GL_FLOAT, GL_FALSE, 0, levels.value().data()); + glDrawArrays(GL_LINE_STRIP, 0, levels.value().size()); + } + + glDisableVertexAttribArray(m_vertexLocation); + m_program->release(); +} + +void GlScopeWidget::resizeGL(int w, int h) +{ + // Pixel-to-pixel projection + m_viewMatrix.setToIdentity(); + m_viewMatrix.ortho(0, w, 0, h, -1.0f, 1.0f); + + updateMVPMatrix(); +} + +void GlScopeWidget::timerEvent(QTimerEvent* /*ev*/) +{ + // Schedule an update + update(); +} + +void GlScopeWidget::onRunningChanged(bool running) +{ + if (running) + { + if (m_renderTimer == 0) + { + // Redraw at the screen refresh or 5fps, whichever is greater + const qreal framerate = screen()->refreshRate(); + m_renderTimer = startTimer(framerate > 5.0 ? static_cast(std::ceil(1000.0 / framerate)) : 200); + } + } + else + { + killTimer(m_renderTimer); + m_renderTimer = 0; + } +} + +void GlScopeWidget::updateMVPMatrix() +{ + QMatrix4x4 modelMatrix; + + // Translate to origin + modelMatrix.translate(AXIS_LABEL_WIDTH, AXIS_LABEL_HEIGHT); + + // Horizontal scale + const qreal pix_width = rect().width() - AXIS_LABEL_WIDTH - RIGHT_GAP; + const qreal x_scale = pix_width / m_scopeView.width(); + + // Vertical scale + const qreal pix_height = rect().height() - AXIS_LABEL_HEIGHT - TOP_GAP; + const qreal y_scale = pix_height / m_scopeView.height(); + + modelMatrix.scale(x_scale, y_scale, 1); + + // Translate to current time and vertical offset + modelMatrix.translate(-m_scopeView.left(), -m_scopeView.top(), 0); + + m_mvpMatrix = m_viewMatrix * modelMatrix; + + if (m_verticalScaleMode == VerticalScale::Percent) + { + // Vertical scale for 16bit + const qreal y_scale16 = pix_height / (m_scopeView.height() * 256.0); + + QMatrix4x4 modelMatrix16; + modelMatrix16.translate(AXIS_LABEL_WIDTH, AXIS_LABEL_HEIGHT); + modelMatrix16.scale(x_scale, y_scale16, 1); + + // Translate to current time and vertical offset + modelMatrix16.translate(-m_scopeView.left(), -m_scopeView.top(), 0); + + m_mvpMatrix16 = m_viewMatrix * modelMatrix16; + } +} + +std::vector GlScopeWidget::makeTriggerLine(ScopeModel::Trigger type) +{ + const float level = static_cast(m_model->triggerLevel()); + const float h_offset = m_timeInterval / 6.0; + float v_offset = 0; + + switch (type) + { + default: return std::vector(); + + case ScopeModel::Trigger::Above: v_offset = 10.0f; break; + case ScopeModel::Trigger::Below: v_offset = -10.0f; break; + case ScopeModel::Trigger::LevelCross: + // Two triangles point-to-point + return { + { static_cast(m_scopeView.left()) - h_offset, level + 5.0f}, + { static_cast(m_scopeView.left()), level }, + { static_cast(m_scopeView.left()) - h_offset, level - 5.0f }, + { static_cast(m_scopeView.left()) + h_offset, level + 5.0f}, + { static_cast(m_scopeView.left()), level }, + { static_cast(m_scopeView.left()) + h_offset, level - 5.0f } + }; + } + + return { + {static_cast(m_scopeView.left()) - h_offset, level}, + {static_cast(m_scopeView.left()) + h_offset , level}, + {static_cast(m_scopeView.left()), level + v_offset} + }; +} + +bool ScopeModel::TriggerConfig::isTrigger() const +{ + // Free Run is not a trigger + if (mode == Trigger::FreeRun) + return false; + + if (universe > 0 && universe <= MAX_SACN_UNIVERSE && address_hi > 0 && address_hi <= MAX_DMX_ADDRESS + && address_lo <= MAX_DMX_ADDRESS) + { + // Valid coarse byte + + // Check level could trigger + // 8 or 16bit + const uint16_t max_level = (address_lo == 0 ? kMaxDmx8 : kMaxDmx16); + if (level > max_level) + return false; + + // Can't rise above max + if (mode == Trigger::Above && level == max_level) + return false; + + // Can't fall below 0 + if (mode == Trigger::Below && level == 0) + return false; + + return true; + } + return false; +} + +bool ScopeModel::TriggerConfig::isTriggerTrace(const ScopeTrace& trace) const +{ + return trace.universe() == universe && trace.addressHi() == address_hi && trace.addressLo() == address_lo; +} + +void ScopeModel::TriggerConfig::setTrigger(const ScopeTrace& trace) +{ + universe = trace.universe(); + address_hi = trace.addressHi(); + address_lo = trace.addressLo(); +} + +QString ScopeModel::TriggerConfig::configurationString() const +{ + if (isTrigger()) + { + return QStringLiteral("Trigger %1 %2@%3") + .arg(QMetaEnum::fromType().valueToKey(static_cast(mode))) + .arg(ScopeTrace::universeAddressString(universe, address_hi, address_lo)) + .arg(level); + } + return QString(); +} + +void ScopeModel::TriggerConfig::setConfiguration(const QString& configString) +{ + const qsizetype triggerPos = configString.indexOf(QLatin1String("Trigger")); + if (triggerPos < 0) + { + mode = Trigger::FreeRun; + } + else + { + const auto metaEnum = QMetaEnum::fromType(); + for (int i = 0; i < metaEnum.keyCount(); ++i) + { + if (configString.indexOf(QLatin1String(metaEnum.key(i)), triggerPos) > -1) + { + mode = static_cast(metaEnum.value(i)); + break; + } + } + + // Read trigger value + const qsizetype triggerUnivPos = configString.indexOf(QLatin1Char(' '), triggerPos + 8); + const qsizetype triggerLevelPos = configString.indexOf(QLatin1Char('@'), triggerUnivPos); + uint16_t new_univ, new_addr_hi, new_addr_lo; + if (ScopeTrace::extractUniverseAddress(configString.mid(triggerUnivPos + 1, triggerLevelPos - triggerUnivPos - 1), new_univ, new_addr_hi, new_addr_lo)) + { + universe = new_univ; + address_hi = new_addr_hi; + address_lo = new_addr_lo; + } + + const qsizetype triggerLevelEnd = configString.indexOf(QLatin1Char(','), triggerLevelPos) - 1; + bool ok; + const qsizetype triggerLevel = configString.mid(triggerLevelPos + 1, triggerLevelEnd - triggerLevelPos).toInt(&ok); + if (ok) + level = triggerLevel; + } +} diff --git a/src/widgets/glscopewidget.h b/src/widgets/glscopewidget.h new file mode 100644 index 00000000..512ad642 --- /dev/null +++ b/src/widgets/glscopewidget.h @@ -0,0 +1,429 @@ +// Copyright 2023 Electronic Theatre Controls, Inc. or its affiliates +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include + +#include "sacn/sacnlistener.h" + +#include +#include +#include + +class QOpenGLShaderProgram; + +template +class InterlockedReader +{ +public: + InterlockedReader(const InterlockedReader&) = delete; + InterlockedReader& operator=(const InterlockedReader&) = delete; + + inline InterlockedReader(const T& item, QMutex& mutex) noexcept : m_value(item), m_mutex(mutex) { m_mutex.lock(); } + inline ~InterlockedReader() { m_mutex.unlock(); } + inline const T& value() const { return m_value; } + +private: + const T& m_value; + QMutex& m_mutex; +}; + +class ScopeTrace +{ +public: + /** + * @brief + * @param color The color to render the trace + * @param universe The sACN universe number + * @param address_hi The DMX address (1-512) of the Coarse byte + * @param address_lo The DMX address (1-512) of the Fine byte + * @param reservation How many points to reserve ahead of time + */ + ScopeTrace(const QColor& color, uint16_t universe, uint16_t address_hi, uint16_t address_lo, size_t reservation) + : m_color(color) + , m_universe(universe) + , m_slot_hi(address_hi - 1) + , m_slot_lo(address_lo - 1) + { + reserve(reservation); + } + + // String conversion + static bool extractUniverseAddress(QStringView address_string, uint16_t& universe, uint16_t& address_hi, uint16_t& address_lo); + static bool extractAddress(QStringView address_string, uint16_t& address_hi, uint16_t& address_lo); + + static QString universeAddressString(uint16_t universe, uint16_t address_hi, uint16_t address_lo); + static QString addressString(uint16_t address_hi, uint16_t address_lo); + + QString universeAddressString() const; + QString addressString() const; + + uint16_t universe() const { return m_universe; } + bool setUniverse(uint16_t new_universe, bool clear_values = true); + + uint16_t addressHi() const { return m_slot_hi + 1; } + uint16_t addressLo() const { return m_slot_lo + 1; } + bool setAddress(uint16_t address_hi, uint16_t address_lo, bool clear_values = true); + bool setAddress(QStringView addressString, bool clear_values = true); + bool isSixteenBit() const { return m_slot_lo < MAX_DMX_ADDRESS; } + + bool isValid() const { return m_color.isValid() && m_universe != 0 && m_slot_hi < MAX_DMX_ADDRESS; } + + bool enabled() const { return m_enabled; }; + void setEnabled(bool value) { m_enabled = value; }; + + const QColor& color() const { return m_color; }; + void setColor(const QColor& color) { m_color = color; }; + + void clear() { QMutexLocker lock(&m_mutex); m_trace.clear(); } + void reserve(size_t point_count) { QMutexLocker lock(&m_mutex); m_trace.reserve(point_count); } + + void addPoint(float timestamp, const std::array& level_array, bool storeAllPoints); + // For pretrigger + void setFirstPoint(float timestamp, const std::array& level_array); + // Add an offset to all times (trigger has fired) + void applyOffset(float offset); + + // For rendering + InterlockedReader> values() const { return InterlockedReader>(m_trace, m_mutex); } + + // For loading from CSV + void addValue(const QVector2D& value) { QMutexLocker lock(&m_mutex); m_trace.push_back(value); } + +private: + mutable QMutex m_mutex; + std::vector m_trace; + QColor m_color; + uint16_t m_universe = 0; + uint16_t m_slot_hi = 0; + uint16_t m_slot_lo = 0xFFFF; + bool m_enabled = true; +}; + +class ScopeModel : public QAbstractTableModel, public sACNListener::IDmxReceivedCallback +{ + Q_OBJECT +public: + enum Columns + { + COL_UNIVERSE, + COL_ADDRESS, + COL_COLOUR, + COL_TRIGGER, + COL_COUNT + }; + + enum UserRoles : int + { + DataSortRole = Qt::UserRole, // Provides the data as a number suitable for sorting + }; + + enum class Trigger + { + FreeRun, + Above, + Below, + LevelCross + }; + Q_ENUM(Trigger); + + enum class AddResult + { + Invalid, + Added, + Exists + }; + +public: + ScopeModel(QObject* parent = nullptr); + ~ScopeModel(); + + /// QAbstractTableModel interface + int columnCount(const QModelIndex& parent = QModelIndex()) const override { return COL_COUNT; } + QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; + + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + Qt::ItemFlags flags(const QModelIndex& index = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + bool setData(const QModelIndex& index, const QVariant& value, int role = Qt::EditRole) override; + + /** + * @brief Add a new trace + * @param color Color to use for the trace. Must be valid + * @param universe Universe for the trace. (1 - Max sACN Universe) + * @param address_hi DMX address of the Coarse byte. (1-512) + * @param address_lo DMX address of the Fine byte. Out of range for 8bit. + * @return Added if added the trace, Invalid for invalid parameters, Exists for already extant + */ + AddResult addTrace(const QColor& color, uint16_t universe, uint16_t address_hi, uint16_t address_lo = 0); + + /** + * @brief Remove a trace + * @param universe Universe + * @param address_hi DMX address of the Coarse byte. (1-512) + * @param address_lo DMX address of the Fine byte. Out of range for 8bit. + */ + void removeTrace(uint16_t universe, uint16_t address_hi, uint16_t address_lo = 0); + + /// Remove traces by modelindex + void removeTraces(const QModelIndexList& indexes); + + /** + * @brief Find the scope trace pointer for a universe and slot pair + * Caution: Invalidated if any traces are added or removed + * @param universe Universe + * @param address_hi DMX address of the Coarse byte. (1-512) + * @param address_lo DMX address of the Fine byte. Out of range for 8bit. (Valid range 1-512) + */ + ScopeTrace* findTrace(uint16_t universe, uint16_t address_hi, uint16_t address_lo = 0); + + /** + * @brief Find the scope trace index for a universe and slot pair + * Caution: Invalidated if any traces are added or removed + * @param universe Universe + * @param address_hi DMX address of the Coarse byte. (1-512) + * @param address_lo DMX address of the Fine byte. Out of range for 8bit. (Valid range 1-512) + */ + QModelIndex findFirstTraceIndex(uint16_t universe, uint16_t address_hi, uint16_t address_lo = 0, int column = 0) const; + + /** + * @brief Get all traces for rendering + */ + const std::vector& traces() const { return m_traceTable; } + + /** + * @brief Stop and remove all traces + */ + void removeAllTraces(); + + /** + * @brief Clear all trace values but leave the traces ready for another run + */ + void clearValues(); + + /** + * @brief Store the traces as a CSV file segment. Scope must be stopped. + * @param file IODevice to store to. Must be open for writing. + * @return succeeded + */ + bool saveTraces(QIODevice& file) const; + + /** + * @brief Load traces from a CSV file segment. Scope will stop. + * @param file IODevice to load from. Must be open for reading. + * @return succeeded + */ + bool loadTraces(QIODevice& file); + + /** + * @brief Check if already listening to a universe + * @param universe + * @return true if listening + */ + bool listeningToUniverse(uint16_t universe) const; + + /// Start listening and storing trace info + Q_SLOT void start(); + /// Stop adding data to the traces + Q_SLOT void stop(); + /// @return true if currently running + bool isRunning() const { return m_running; } + Q_SIGNAL void runningChanged(bool running); + + /** + * @brief Get a string describing the capture configuration + * All Packets/Level Changes, Trigger setup etc + */ + QString captureConfigurationString() const; + /// Set capture configuration from string + void setCaptureConfiguration(const QString& configString); + + /// Store all points, or only level changes + bool storeAllPoints() const { return m_storeAllPoints; } + void setStoreAllPoints(bool b) { m_storeAllPoints = b; } + + /** + * @brief Length of time in seconds to run after Start or Trigger + * Zero for forever (or until memory is exhausted) + */ + qreal runTime() const { return m_runTime; } + Q_SLOT void setRunTime(qreal seconds); + Q_SIGNAL void runTimeChanged(qreal seconds); + + /// Trace visibility has changed so must re-render + Q_SIGNAL void traceVisibilityChanged(); + + // Triggers + void setTriggerType(Trigger mode); + Trigger triggerType() const { return m_trigger.mode; } + + Q_SLOT void setTriggerLevel(uint16_t level); + uint16_t triggerLevel() const { return m_trigger.level; } + + bool isTriggered() const { return m_startOffset != 0; } + Q_SIGNAL void triggered(); + + /** + * @brief Get current overall trace extents + * Suitable to use as zoom-to-extents + */ + QRectF traceExtents() const; + /// Trace has switched between 8 or 16 bit + Q_SIGNAL void maxValueChanged(); + + /// @brief Get current end time in seconds + qreal endTime() const; + + /// sACNListener::IDmxReceivedCallback + void sACNListenerDmxReceived(tock packet_tock, int universe, const std::array& levels) final; + +private: + Q_SIGNAL void queueStop(); + Q_SIGNAL void queueTriggered(); + +private: + std::vector m_traceTable; + std::map> m_traceLookup; + std::vector m_listeners; // Keep the listeners alive + qreal m_startOffset = 0; // Offset between this scope and global timeframe + qreal m_endTime = 0; // Max. time extents of the scope measurements + qreal m_maxValue = 0; // Max. possible value in DMX + qreal m_runTime = 0; + + struct TriggerConfig + { + uint16_t universe = 0; + uint16_t address_hi = 0; + uint16_t address_lo = 0; + uint16_t level = 0; + Trigger mode = Trigger::FreeRun; + + int last_level = -1; + + bool isTrigger() const; + bool isTriggerTrace(const ScopeTrace& trace) const; + void setTrigger(const ScopeTrace& trace); + + QString configurationString() const; + void setConfiguration(const QString& configString); + }; + + TriggerConfig m_trigger; // Trigger configuration + + bool m_running = false; + bool m_storeAllPoints = true; + + size_t m_reservation = 12000; // Reserve space for this many samples. 300s @ 40Hz + + // Start the trace + void triggerNow(qreal offset); + + void private_removeAllTraces(); + // Move a trace from one universe to another if possible + bool moveTrace(ScopeTrace* trace, uint16_t new_universe, bool clear_values = true); + void removeFromLookup(ScopeTrace* trace, uint16_t old_universe); + void addListener(uint16_t universe); + + // A trace has changed from 16bit to 8bit + void updateMaxValue(); + void setMaxValue(qreal maxValue); +}; + +class GlScopeWidget : public QOpenGLWidget, protected QOpenGLFunctions +{ + Q_OBJECT +public: + enum class VerticalScale + { + Percent, + Dmx8, + Dmx16, + Invalid, + }; + +public: + explicit GlScopeWidget(QWidget* parent = nullptr); + ~GlScopeWidget(); + + QSize minimumSizeHint() const override { return QSize(512, 256); } + + ScopeModel* model() { return m_model; } + const ScopeModel* model() const { return m_model; } + + VerticalScale verticalScaleMode() const { return m_verticalScaleMode; } + void setVerticalScaleMode(VerticalScale scaleMode); + + void setFollowNow(bool follow) { m_followNow = follow; } + bool followNow() const { return m_followNow; } + + /** + * @brief Get the current scope view + * x is time axis in seconds (0 to ...) + * y is scale axis in raw DMX values (0-255 or 65535) + * Note: Y axis is flipped compared to Qt: (0,0) is bottom-left. + * QRectF::top() is the bottom + * QRectF::bottom() is the top + * @return current viewport + */ + const QRectF& scopeView() const { return m_scopeView; } + + /** + * @brief Set the scope view + * @param rect new range rectangle. Null to reset to default extents + */ + Q_SLOT void setScopeView(const QRectF& rect = QRectF()); + + int timeDivisions() const { return m_timeInterval * 1000.0; } + Q_SLOT void setTimeDivisions(int milliseconds); + Q_SIGNAL void timeDivisionsChanged(int milliseconds); + +protected: + void initializeGL() override; + Q_SLOT void cleanupGL(); + + void paintGL() override; + void resizeGL(int w, int h) override; + + void timerEvent(QTimerEvent* ev) override; + + Q_SLOT void onRunningChanged(bool running); + +private: + ScopeModel* m_model = nullptr; + + // View configuration + VerticalScale m_verticalScaleMode = VerticalScale::Invalid; + int m_levelInterval = 20; // Level axis label interval + qreal m_timeInterval = 1.0; // Time axis label interval + qreal m_defaultIntervalCount = 10.0; // Time axis intervals to show when view is reset + + QRectF m_scopeView; // Current scope view range in DMX + bool m_followNow = true; + + // Rendering configuration + int m_renderTimer = 0; + QOpenGLShaderProgram* m_program = nullptr; + int m_vertexLocation = -1; + int m_matrixUniform = -1; + int m_colorUniform = -1; + + QMatrix4x4 m_viewMatrix; + QMatrix4x4 m_mvpMatrix; + QMatrix4x4 m_mvpMatrix16; + + void updateMVPMatrix(); + std::vector makeTriggerLine(ScopeModel::Trigger type); +}; \ No newline at end of file diff --git a/src/widgets/monitorspinbox.cpp b/src/widgets/monitorspinbox.cpp deleted file mode 100644 index ee30f9a3..00000000 --- a/src/widgets/monitorspinbox.cpp +++ /dev/null @@ -1,95 +0,0 @@ -#include "monitorspinbox.h" -#include "consts.h" -#include "sacnlistener.h" -#include - -monitorspinbox::monitorspinbox(QWidget* parent) : QAbstractSpinBox(parent), - m_address(0) -{ - QAbstractSpinBox::setWrapping(true); - - connect( - this->lineEdit(), &QLineEdit::textEdited, - this, [this]() { - QString input = lineEdit()->text(); - int pos = 0; - if (QValidator::Acceptable == validate(input, pos)) - setAddress(addressFromText(input)); - else - lineEdit()->setText(textFromAddress(this->address())); - } - ); - - // Default listener - setupListener(MIN_SACN_UNIVERSE); -} - -void monitorspinbox::setupListener(int universe) { - // New universe? - if (m_listener == Q_NULLPTR || m_listener->universe() != universe) { - m_listener = sACNManager::Instance().getListener(universe); - - disconnect(m_listener.data(), Q_NULLPTR, this, Q_NULLPTR); - - connect( - m_listener.data(), &sACNListener::dataReady, - this, [this, universe](int address, QPointF data) { - if (m_listener->universe() == universe) - emit dataReady(universe, address, data); - } - ); - } -} - -void monitorspinbox::setAddress(int universe, quint16 address) -{ - if (!validate(address)) - return; - - m_listener->unMonitorAddress(this->address(), this); - - m_address = address; - setupListener(universe); - m_listener->monitorAddress(this->address(), this); - - lineEdit()->setText(textFromAddress(this->address())); -} - -void monitorspinbox::stepBy(int steps) -{ - quint16 newAddress = address() + steps; - - if (steps > 0) - while (QValidator::Invalid == validate(newAddress)) ++newAddress; - else if (steps < 0) - while (QValidator::Invalid == validate(newAddress)) --newAddress; - - setAddress(newAddress); -} - -QValidator::State monitorspinbox::validate(QString &input, int &pos) const { - bool ok; - quint16 value = QStringView{input}.mid(pos).toUShort(&ok); - if (!ok) - return QValidator::Invalid; - - return validate(value); -} - -QValidator::State monitorspinbox::validate(const quint16 value) const { - if (value > (MAX_DMX_ADDRESS - 1) || value < (MIN_DMX_ADDRESS - 1)) - return QValidator::Invalid; - - return QValidator::Acceptable; -} - -quint16 monitorspinbox::addressFromText(const QString &text) const -{ - return text.toUShort() - 1; -} - -QString monitorspinbox::textFromAddress(quint16 address) const -{ - return QString::number(address + 1); -} - diff --git a/src/widgets/monitorspinbox.h b/src/widgets/monitorspinbox.h deleted file mode 100644 index 55cfac98..00000000 --- a/src/widgets/monitorspinbox.h +++ /dev/null @@ -1,43 +0,0 @@ -#ifndef MONITORSPINBOX_H -#define MONITORSPINBOX_H - -#include -#include "sacnlistener.h" - -class monitorspinbox : public QAbstractSpinBox -{ - Q_OBJECT -public: - monitorspinbox(QWidget* parent = nullptr); - - quint16 address() const { return m_address; } - void setAddress(int universe, quint16 address); - void setAddress(quint16 address) { setAddress(m_listener->universe(), address); } - - int universe() const { return m_listener->universe(); } - - quint8 level() const { - auto level = m_listener->mergedLevels().at(m_address).level; - return level > 0 ? level : 0; - } - -signals: - void dataReady(int universe, quint16 address, QPointF data); - -protected: - void stepBy(int steps); - QAbstractSpinBox::StepEnabled stepEnabled() const {return StepUpEnabled | StepDownEnabled; } - - QValidator::State validate(QString &input, int &pos) const; - QValidator::State validate(const quint16 value) const; - - quint16 addressFromText(const QString &text) const; - QString textFromAddress(quint16 address) const; - -private: - sACNManager::tListener m_listener; - void setupListener(int universe); - quint16 m_address; -}; - -#endif // MONITORSPINBOX_H diff --git a/src/widgets/scopewidget.cpp b/src/widgets/scopewidget.cpp deleted file mode 100644 index 179df8c3..00000000 --- a/src/widgets/scopewidget.cpp +++ /dev/null @@ -1,384 +0,0 @@ -// Copyright 2016 Tom Steer -// http://www.tomsteer.net -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -#include "scopewidget.h" -#include -#include -#include -#include -#include -#include "sacn/sacnlistener.h" - -#define AXIS_WIDTH 50 -#define TOP_GAP 10 -#define RIGHT_GAP 30 -#define AXIS_TO_WINDOW_GAP 5 - -ScopeChannel::ScopeChannel() -{ - m_universe = 1; - m_address = 1; - m_sixteenBit = false; - m_color = Qt::white; - clear(); -} - - -ScopeChannel::ScopeChannel(int universe, int address) -{ - m_universe = universe; - m_address = address; - m_sixteenBit = false; - m_color = Qt::white; - clear(); -} - -void ScopeChannel::addPoint(QPointF point) -{ - m_points[m_last] = point; - m_last = (m_last+1) % RING_BUF_SIZE; - if(m_size dashPattern; - dashPattern << 1 << 1; - - QPen textPen; - textPen.setColor(Qt::white); - - QPainter painter(this); - - painter.setRenderHint(QPainter::Antialiasing, false); - painter.fillRect(rect(), Qt::black); - - // Origin is the bottom left of the scope window - QPoint origin(rect().bottomLeft().x() + AXIS_WIDTH, rect().bottomLeft().y() - AXIS_WIDTH); - - QRect scopeWindow; - scopeWindow.setBottomLeft(origin); - scopeWindow.setTop(rect().top() + TOP_GAP); - scopeWindow.setRight(rect().right() - RIGHT_GAP); - painter.setBrush(QBrush(Qt::black)); - painter.drawRect(scopeWindow); - - // Draw vertical axis - painter.translate(scopeWindow.topLeft().x(), scopeWindow.topLeft().y()); - gridPen.setDashPattern(dashPattern); - - painter.setPen(gridPen); - painter.drawLine(0, 0, 0, scopeWindow.height()); - int percent = 100; - - QFont font; - QFontMetricsF metrics(font); - - for(int i=0; i<11; i++) - { - qreal y = i*scopeWindow.height()/10.0; - painter.setPen(gridPen); - painter.drawLine(-10, y, scopeWindow.width(), y); - QString text = QString("%1%%").arg(percent); - QRectF fontRect = metrics.boundingRect(text); - - fontRect.moveCenter(QPoint(-20, y)); - - painter.setPen(textPen); - painter.drawText(fontRect, text, QTextOption(Qt::AlignLeft)); - - percent-=10; - } - - // Draw horizontal axis - painter.resetTransform(); - painter.translate(scopeWindow.bottomLeft().x(), scopeWindow.bottomLeft().y()); - - int time = 0; - - for(int i=0; i<11; i++) - { - qreal x = i * scopeWindow.width() / 10; - painter.setPen(gridPen); - painter.drawLine(x, 0, x, -scopeWindow.height()); - - QString text; - if(m_timebase<1000) - text = QString("%1ms").arg(time); - else - text = QString("%1s").arg(time/1000); - - QRectF fontRect = metrics.boundingRect(text); - - fontRect.moveCenter(QPoint(x, 20)); - - painter.setPen(textPen); - painter.drawText(fontRect, text, QTextOption(Qt::AlignLeft)); - - time += m_timebase; - - } - - - // Plot the points - painter.resetTransform(); - painter.setRenderHint(QPainter::Antialiasing, false); - - painter.translate(scopeWindow.topLeft().x() , scopeWindow.topLeft().y()); - // Scale by the timebase, which is units per division, 10 divisions per window - qreal x_scale = (qreal)scopeWindow.width() / ( m_timebase * 10.0 ); - qreal y_scale = (qreal)scopeWindow.height() / 65535.0; - - painter.setBrush(QBrush()); - painter.setRenderHint(QPainter::Antialiasing); - - int maxTime = m_timebase * 10; // The X-axis size of the display, in milliseconds - - if(m_triggerMode==tmNormal || (m_triggerMode!=tmNormal && m_triggered)) - { - foreach(ScopeChannel *ch, m_channels) - { - if(!ch->enabled()) - continue; - - QPen pen; - pen.setColor(ch->color()); - pen.setWidth(2); - painter.setPen(pen); - QPainterPath path; - bool first = true; - - for(int i=0; icount()-1; i++) - { - QPointF p = ch->getPoint(i); - qreal normalizedTime = ch->m_highestTime - p.x(); - - qreal x = x_scale * (maxTime - normalizedTime); - qreal y = y_scale * (65535 - p.y()); - if(x>=0) - { - if(first) - { - path.moveTo(x,y); - first = false; - } - else - path.lineTo(x, y); - } - } - painter.drawPath(path); - } - } - - // Draw the trigger line - if(m_triggerMode!=tmNormal) - { - QPen pen; - pen.setColor(QColor(Qt::yellow)); - - pen.setStyle(Qt::DashDotLine); - painter.setPen(pen); - painter.drawLine(x_scale * m_triggerDelay, 0, x_scale * m_triggerDelay, 65535 * y_scale); - } - -} - - -void ScopeWidget::setTimebase(int timebase) -{ - m_timebase = timebase; - update(); -} - -void ScopeWidget::addChannel(ScopeChannel *channel) -{ - m_channels << channel; -} - -void ScopeWidget::removeChannel(ScopeChannel *channel) -{ - m_channels.removeAll(channel); -} - -void ScopeWidget::dataReady(int address, const QPointF data) -{ - if(!m_running) return; - - - sACNListener *listener = qobject_cast(sender()); - - if(!listener) return; // Check for deletion - - if(m_triggerMode != tmNormal && !m_triggered) - { - // Waiting for trigger - if(listener->universe()==m_triggerUniverse && address==m_triggerChannel) - { - int value = data.y(); - if(m_triggerMode==tmRisingEdge && value>m_triggerLevel) - { - // Triggered by rising edge - m_triggered = true; - m_endTriggerTime = data.x() + m_timebase*10 - m_triggerDelay; - } - if(m_triggerMode==tmFallingEdge && value m_endTriggerTime) - { - // Trigger delay time is done - stop running - m_running = false; - emit stopped(); - return; - } - } - - - ScopeChannel *ch = NULL; - for(int i=0; iaddress() == address && m_channels[i]->universe()==listener->universe()) - { - ch = m_channels[i]; - QPointF p(data); - - // To save data space, no point in storing more than 100 points per timebase division - if(p.x() - ch->m_highestTime < (m_timebase/100.0)) - continue; - if(ch->sixteenBit()) - { - quint8 fineLevel = listener->mergedLevels().at(ch->address()+1).level; - p.setY(p.y() * 255.0 + fineLevel); - ch->addPoint(p); - } - else - { - p.setY(p.y() * 255.0); - ch->addPoint(p); - } - } - } - update(); -} - -void ScopeWidget::start() -{ - m_running = true; - m_triggered = false; - foreach(ScopeChannel *ch, m_channels) - { - ch->clear(); - auto listener = sACNManager::Instance().getListener(ch->universe()); - connect(listener.data(), &sACNListener::dataReady, this, &ScopeWidget::dataReady); - } - update(); - -} - -void ScopeWidget::stop() -{ - m_running = false; -} - -void ScopeWidget::setTriggerMode(TriggerMode mode) -{ - m_triggerMode = mode; - m_triggered = false; - update(); -} - -void ScopeWidget::setTriggerAddress(int universe, int channel) -{ - m_triggerChannel = channel; - m_triggerUniverse = universe; -} - -void ScopeWidget::setTriggerThreshold(int value) -{ - m_triggerLevel = value; -} - -void ScopeWidget::setTriggerDelay(int triggerDelay) -{ - m_triggerDelay = triggerDelay; - update(); -} diff --git a/src/widgets/scopewidget.h b/src/widgets/scopewidget.h deleted file mode 100644 index 47d2e8e6..00000000 --- a/src/widgets/scopewidget.h +++ /dev/null @@ -1,112 +0,0 @@ -// Copyright 2016 Tom Steer -// http://www.tomsteer.net -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -#ifndef SCOPEWIDGET_H -#define SCOPEWIDGET_H - -#include -#include - -#define RING_BUF_SIZE 1000 - - -class ScopeChannel -{ -public: - ScopeChannel(); - ScopeChannel(int universe, int address); - - int universe() { return m_universe;}; - void setUniverse(int value); - - int address() { return m_address;}; - void setAddress(int value); - - bool enabled() { return m_enabled;}; - void setEnabled(bool value) { m_enabled = value;}; - - QColor color() {return m_color;}; - void setColor(const QColor &color) { m_color = color;}; - - bool sixteenBit(){ return m_sixteenBit;}; - void setSixteenBit(bool value) { m_sixteenBit = value;}; - - void addPoint(QPointF point); - void clear(); - int count(); - QPointF getPoint(int index); - qreal m_highestTime; -private: - int m_universe; - int m_address; - bool m_enabled; - QColor m_color; - - QPointF m_points[RING_BUF_SIZE]; - int m_size; - int m_last; - bool m_sixteenBit; -}; - - -class ScopeWidget : public QWidget -{ - Q_OBJECT -public: - - enum TriggerMode - { - tmNormal, - tmRisingEdge, - tmFallingEdge - }; - - explicit ScopeWidget(QWidget *parent = 0); - int timebase() const { return m_timebase;}; - void addChannel(ScopeChannel *channel); - void removeChannel(ScopeChannel *channel); - bool running() { return m_running;}; - - void setTriggerMode(TriggerMode mode); - void setTriggerAddress(int universe, int channel); - void setTriggerThreshold(int value); -signals: - -public slots: - void setTimebase(int timebase); - void setTriggerDelay(int triggerDelay); - void start(); - void stop(); - - void dataReady(int address, const QPointF data); -signals: - void stopped(); -protected: - virtual void paintEvent(QPaintEvent *event); -private: - QList m_points; - QList m_channels; - // The timebase in ms - int m_timebase; - bool m_running; - bool m_triggered; - TriggerMode m_triggerMode; - int m_triggerUniverse, m_triggerChannel; - int m_triggerLevel; - int m_triggerDelay; - int m_endTriggerTime; -}; - -#endif // SCOPEWIDGET_H diff --git a/src/widgets/steppedspinbox.cpp b/src/widgets/steppedspinbox.cpp new file mode 100644 index 00000000..81c5050e --- /dev/null +++ b/src/widgets/steppedspinbox.cpp @@ -0,0 +1,64 @@ +// Copyright 2023 Electronic Theatre Controls, Inc. or its affiliates +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "steppedspinbox.h" + +SteppedSpinBox::SteppedSpinBox(QWidget* parent) + : QSpinBox(parent) +{ +} + +void SteppedSpinBox::setStepList(const QVector& steps, bool setLimits) +{ + m_stepList = steps; + if (setLimits && !steps.empty()) + { + setRange(m_stepList.front(), m_stepList.back()); + } +} + +void SteppedSpinBox::stepBy(int steps) +{ + if (!m_stepList.empty()) + { + const int currentValue = value(); + // Step up or down to the next available step + if (steps > 0) + { + // Find first step greater than current value + auto it = std::upper_bound(m_stepList.begin(), m_stepList.end(), currentValue); + if (it != m_stepList.end()) + { + setValue((*it)); + return; + } + } + else if (steps < 0) + { + // Find first step greater or equal to current value + auto it = std::lower_bound(m_stepList.begin(), m_stepList.end(), currentValue); + + if (it != m_stepList.begin()) + { + // Go back one tick + --it; + setValue((*it)); + return; + } + } + } + + // Do the default up/down step + QSpinBox::stepBy(steps); +} diff --git a/src/widgets/steppedspinbox.h b/src/widgets/steppedspinbox.h new file mode 100644 index 00000000..e6e7135b --- /dev/null +++ b/src/widgets/steppedspinbox.h @@ -0,0 +1,39 @@ +// Copyright 2023 Electronic Theatre Controls, Inc. or its affiliates +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include + +class SteppedSpinBox : public QSpinBox +{ + Q_OBJECT + +public: + explicit SteppedSpinBox(QWidget* parent = nullptr); + + /** + * @brief Set the list of step values + * @param steps List of step values. Must be sorted min to max and contain no duplicates + * @param setRange True: also set the min & max to the step min/max + */ + void setStepList(const QVector& steps, bool setRange = true); + /// Get the list of step values + const QVector& stepList() const { return m_stepList; } + + void stepBy(int steps) override; + +protected: + QVector m_stepList; +}; \ No newline at end of file diff --git a/ui/bigdisplay.ui b/ui/bigdisplay.ui index c189af3e..be155e72 100644 --- a/ui/bigdisplay.ui +++ b/ui/bigdisplay.ui @@ -53,7 +53,7 @@ - 1 + 0 @@ -81,7 +81,7 @@ QLayout::SetNoConstraint - + 0 @@ -131,7 +131,7 @@ QLayout::SetNoConstraint - + 0 @@ -173,7 +173,7 @@ QLayout::SetMinimumSize - + 0 @@ -226,7 +226,7 @@ QLayout::SetNoConstraint - + 0 @@ -268,7 +268,7 @@ QLayout::SetNoConstraint - + 0 @@ -310,7 +310,7 @@ QLayout::SetNoConstraint - + 0 @@ -346,13 +346,6 @@ - - - monitorspinbox - QSpinBox -
monitorspinbox.h
-
-
diff --git a/ui/multiview.ui b/ui/multiview.ui index 65d48bf4..0d14026b 100644 --- a/ui/multiview.ui +++ b/ui/multiview.ui @@ -19,12 +19,11 @@ Multi Universe View - - - :/icons/multi_universe_view.png:/icons/multi_universe_view.png - - - + + + :/icons/multi_universe_view.png:/icons/multi_universe_view.png + + 0 @@ -42,6 +41,23 @@ + + + + End + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Static + + + @@ -58,26 +74,70 @@ - - + + + + ms + + + + + + + Reset Counters + + + + + + + Qt::Horizontal + + + + + 0 0 + + + 100 + 0 + + 1 63999 + + + + + + ms + - 20 + 0 - + + + + Start + + + true + + + + Qt::Vertical @@ -90,66 +150,67 @@ - - - - Qt::Horizontal + + + + Export CSV... - - + + 0 0 - - - 100 - 0 - - 1 63999 + + 20 + - - - - End - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + ms - - + + - Start + Short - - true + + + + + + Long - + - Reset Counters + Time Summary + + + Qt::AlignCenter - - - - Export CSV... + + + + Qt::Horizontal diff --git a/ui/scopewindow.ui b/ui/scopewindow.ui deleted file mode 100644 index 5f2fa981..00000000 --- a/ui/scopewindow.ui +++ /dev/null @@ -1,360 +0,0 @@ - - - ScopeWindow - - - - 0 - 0 - 1044 - 665 - - - - - 0 - 0 - - - - Scope - - - - :/icons/scope.png:/icons/scope.png - - - - - - Qt::Vertical - - - - - 0 - 0 - - - - - 600 - 300 - - - - background-color: rgb(0, 0, 0); - - - - - - - - Scope - - - - - - - - Stop - - - - - - - Start - - - - - - - - - Timebase - - - Qt::AlignCenter - - - - - - - - - - 1000 ms/div - - - Qt::AlignCenter - - - - - - - Trigger - - - - - - Trigger Level: - - - - - - - - 75 - 0 - - - - Qt::AlignCenter - - - - - - - - Free Run - - - - - Rising Edge - - - - - Falling Edge - - - - - - - - Trigger Delay: - - - - - - - - 75 - 0 - - - - Qt::AlignCenter - - - ms - - - 3000 - - - - - - - - - - - - - Channels - - - - - - - 0 - 0 - - - - false - - - QAbstractItemView::SingleSelection - - - true - - - false - - - - New Row - - - - - New Row - - - - - New Row - - - - - Universe - - - - - Address - - - - - Enabled - - - - - Colour - - - - - Trigger - - - - - 16-Bit - - - - - 1 - - - - - 1 - - - - - 1 - - - - - 1 - - - - - 1 - - - - - 1 - - - - - 1 - - - - - 1 - - - - - 1 - - - - - 1 - - - - - 1 - - - - - 1 - - - - - - - - - - Add - - - - - - - Remove - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - - - - - - - ScopeWidget - QWidget -
scopewidget.h
- 1 -
-
- - - - -
From 375dd249e955922ab5e30ca3a32695a962f29007 Mon Sep 17 00:00:00 2001 From: RichardTea <31507749+RichardTea@users.noreply.github.com> Date: Wed, 10 Apr 2024 12:34:47 +0100 Subject: [PATCH 04/13] Cleanup listener sort functor Use the typedef --- src/ui/aboutdialog.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/aboutdialog.cpp b/src/ui/aboutdialog.cpp index e3067126..4fdd69a7 100644 --- a/src/ui/aboutdialog.cpp +++ b/src/ui/aboutdialog.cpp @@ -93,7 +93,7 @@ aboutDialog::aboutDialog(QWidget* parent) : // Sort list struct { - bool operator()(const QWeakPointer& a, const QWeakPointer& b) const + bool operator()(const sACNManager::wListener& a, const sACNManager::wListener& b) const { auto aStrong = a.toStrongRef(); auto bStrong = b.toStrongRef(); From 098e212a2930c03f8b9a9cdb47b26a1f18358daf Mon Sep 17 00:00:00 2001 From: RichardTea <31507749+RichardTea@users.noreply.github.com> Date: Wed, 10 Apr 2024 12:42:38 +0100 Subject: [PATCH 05/13] Update qmake project file --- sACNView.pro | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/sACNView.pro b/sACNView.pro index 8f28b548..3336d82a 100644 --- a/sACNView.pro +++ b/sACNView.pro @@ -89,12 +89,12 @@ INCLUDEPATH += src \ SOURCES += src/main.cpp\ src/sacn/securesacn.cpp \ - src/widgets/monitorspinbox.cpp \ + src/widgets/steppedspinbox.cpp \ src/widgets/qpushbutton_rightclick.cpp \ src/widgets/qspinbox_resizetocontent.cpp \ src/ui/newversiondialog.cpp \ src/ui/mdimainwindow.cpp \ - src/ui/scopewindow.cpp \ + src/ui/glscopewindow.cpp \ src/ui/universeview.cpp \ src/ui/multiview.cpp \ src/sacn/sacnsynchronization.cpp \ @@ -138,11 +138,11 @@ SOURCES += src/main.cpp\ HEADERS += src/ui/mdimainwindow.h \ src/sacn/securesacn.h \ - src/widgets/monitorspinbox.h \ + src/widgets/steppedspinbox.h \ src/widgets/qpushbutton_rightclick.h \ src/widgets/qspinbox_resizetocontent.h \ src/ui/newversiondialog.h \ - src/ui/scopewindow.h \ + src/ui/glscopewindow.h \ src/ui/universeview.h \ src/ui/multiview.h \ src/sacn/sacnsynchronization.h \ @@ -164,7 +164,7 @@ HEADERS += src/ui/mdimainwindow.h \ src/sacn/sacnsender.h \ src/ui/configureperchanpriodlg.h \ src/widgets/gridwidget.h \ - src/widgets/scopewidget.h \ + src/widgets/glscopewidget.h \ src/ui/aboutdialog.h \ src/sacn/sacneffectengine.h \ src/models/sacnuniverselistmodel.h \ @@ -189,7 +189,6 @@ HEADERS += src/ui/mdimainwindow.h \ src/widgets/grideditwidget.h FORMS += ui/mdimainwindow.ui \ - ui/scopewindow.ui \ ui/universeview.ui \ ui/multiview.ui \ ui/nicselectdialog.ui \ From 03793086f7107bfbfc5d84291e6e1ceb6e5fbcb3 Mon Sep 17 00:00:00 2001 From: RichardTea <31507749+RichardTea@users.noreply.github.com> Date: Wed, 10 Apr 2024 12:43:20 +0100 Subject: [PATCH 06/13] Add sacnview natvis Defines CID and QHostAddress --- sacnview.natvis | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 sacnview.natvis diff --git a/sacnview.natvis b/sacnview.natvis new file mode 100644 index 00000000..15611501 --- /dev/null +++ b/sacnview.natvis @@ -0,0 +1,12 @@ + + + + {{{(m_cid[0]),nvoXb}{(m_cid[1]),nvoXb}{(m_cid[2]),nvoXb}{(m_cid[3]),nvoXb}-{(m_cid[4]),nvoXb}{(m_cid[5]),nvoXb}-{(m_cid[6]),nvoXb}{(m_cid[7]),nvoXb}-{(m_cid[8]),nvoXb}{(m_cid[9]),nvoXb}-{(m_cid[10]),nvoXb}{(m_cid[11]),nvoXb}{(m_cid[12]),nvoXb}{(m_cid[13]),nvoXb}{(m_cid[14]),nvoXb}{(m_cid[15]),nvoXb}}} + + + {m_cid} + + + {{ IPv4Addr = {(((uint32_t*)d.d)[8]>>24)&0xff,d}.{(((uint32_t*)d.d)[8]>>16)&0xff,d}.{(((uint32_t*)d.d)[8]>>8)&0xff,d}.{(((uint32_t*)d.d)[8]>>0)&0xff,d} }} + + \ No newline at end of file From 452eb6b41580c579488e67eb9e67da87fbcc8386 Mon Sep 17 00:00:00 2001 From: RichardTea <31507749+RichardTea@users.noreply.github.com> Date: Wed, 10 Apr 2024 12:00:55 +0100 Subject: [PATCH 07/13] CMake unit tests for fps and glscopewidget --- tests/CMakeLists.txt | 62 +++++++++++ tests/main.cpp | 23 ++++ tests/test_fpscounter.cpp | 161 +++++++++++++++++++++++++++ tests/test_glscopewidget.cpp | 209 +++++++++++++++++++++++++++++++++++ 4 files changed, 455 insertions(+) create mode 100644 tests/CMakeLists.txt create mode 100644 tests/main.cpp create mode 100644 tests/test_fpscounter.cpp create mode 100644 tests/test_glscopewidget.cpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 00000000..78dceb07 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,62 @@ +include(FetchContent) +FetchContent_Declare( + googletest + # Specify the googletest commit to download. Update this regularly. + URL https://github.com/google/googletest/archive/refs/tags/v1.13.0.zip + DOWNLOAD_EXTRACT_TIMESTAMP true +) + +if (WIN32) + # For Windows: Prevent overriding the parent project's compiler/linker settings + set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) + FetchContent_MakeAvailable(googletest) +endif() + +add_executable(unit_test_sACNView + main.cpp + ${SACNVIEW_SOURCES} + test_glscopewidget.cpp + test_fpscounter.cpp +) + +target_include_directories(unit_test_sACNView PRIVATE + ${SACNVIEW_HEADER_PATHS} +) + +target_compile_features(unit_test_sACNView PRIVATE cxx_std_17) + +set_target_properties(unit_test_sACNView PROPERTIES + CXX_EXTENSIONS OFF + AUTOUIC_SEARCH_PATHS ../ui +) + +target_compile_definitions(unit_test_sACNView PRIVATE + "GIT_CURRENT_SHA1=\"${GIT_VERSION}\"" + "GIT_DATE_DAY=\"${GIT_DATE_DAY}\"" + "GIT_DATE_DATE=\"${GIT_DATE_DATE}\"" + "GIT_DATE_MONTH=\"${GIT_DATE_MONTH}\"" + "GIT_DATE_YEAR=\"${GIT_DATE_YEAR}\"" + "VERSION=\"${GIT_TAG}\"" +) + +# Blake2 +target_compile_definitions(unit_test_sACNView PRIVATE ${BLAKE2_DEFINES}) +target_sources(unit_test_sACNView PRIVATE ${BLAKE2_SOURCES}) +target_include_directories(unit_test_sACNView PRIVATE ${BLAKE2_PATH}) + +target_link_libraries(unit_test_sACNView + gtest + ${SACNVIEW_QT_LIBRARIES} + ${PCAP_LIBS} +) + +if(WIN32) + # Copy WinPCap DLLs + foreach(DLLFILE IN LISTS PCAP_LIBS) + add_custom_command (TARGET unit_test_sACNView POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ $) + endforeach() +endif() + +add_test(NAME unit_test_sACNView COMMAND unit_test_sACNView) diff --git a/tests/main.cpp b/tests/main.cpp new file mode 100644 index 00000000..7e354df5 --- /dev/null +++ b/tests/main.cpp @@ -0,0 +1,23 @@ +#include "gtest/gtest.h" + +#include +#include + +GTEST_API_ int main(int argc, char* argv[]) +{ + // Ensure that the tests run after QApplication is running its event loop + // Prevents a crash at shutdown + // Allows tests that require signals & slots to execute + QApplication app(argc, argv); + + // Invoke the test suite after exec() + // Uses lambda to capture argc and argv + QTimer::singleShot(0, [&]() { + ::testing::InitGoogleTest(&argc, argv); + int testResult = RUN_ALL_TESTS(); + app.exit(testResult); + } + ); + + return app.exec(); +} diff --git a/tests/test_fpscounter.cpp b/tests/test_fpscounter.cpp new file mode 100644 index 00000000..837bc076 --- /dev/null +++ b/tests/test_fpscounter.cpp @@ -0,0 +1,161 @@ +// Copyright 2023 Electronic Theatre Controls, Inc. or its affiliates +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "gtest/gtest.h" + +#include "sacn/fpscounter.h" + +// Custom wrapper to allow external ticking +class T_FpsCounter : public FpsCounter +{ +public: + T_FpsCounter() : FpsCounter() {} + void timerEvent() { FpsCounter::timerEvent(nullptr); } +}; + +TEST(FpsCounter, Empty) +{ + T_FpsCounter fps; + EXPECT_EQ(0.0f, fps.FPS()); + EXPECT_FALSE(fps.isNewFPS()); + EXPECT_EQ(0u, fps.GetHistogram().size()); + + // Refresh, should still be empty + fps.timerEvent(); + + EXPECT_FALSE(fps.isNewFPS()); + EXPECT_EQ(0.0f, fps.FPS()); + EXPECT_EQ(0u, fps.GetHistogram().size()); +} + +TEST(FpsCounter, SingleFps) +{ + T_FpsCounter fps; + + tock timestamp; + constexpr FpsCounter::HistogramBucket ms10 = std::chrono::milliseconds(10); + constexpr FpsCounter::HistogramBucket ms20 = std::chrono::milliseconds(20); + + // Add one tick + fps.newFrame(timestamp); + fps.timerEvent(); + // Should still be empty + EXPECT_FALSE(fps.isNewFPS()); + EXPECT_EQ(0.0f, fps.FPS()); + EXPECT_EQ(0u, fps.GetHistogram().size()); + + // Add a second tick after 20ms + timestamp.Set(timestamp.Get() + ms20); + fps.newFrame(timestamp); + fps.timerEvent(); + + // Should now have 50 FPS + EXPECT_TRUE(fps.isNewFPS()); + EXPECT_FLOAT_EQ(50.0f, fps.FPS()); + // And one interval value + EXPECT_EQ(1u, fps.GetHistogram().size()); + + // Add a second tick after 20ms + timestamp.Set(timestamp.Get() + ms20); + fps.newFrame(timestamp); + fps.timerEvent(); + // Still 50 FPS + EXPECT_FALSE(fps.isNewFPS()); + EXPECT_FLOAT_EQ(50.0f, fps.FPS()); + EXPECT_EQ(1u, fps.GetHistogram().size()); + + // And a third tick after 10ms + timestamp.Set(timestamp.Get() + ms10); + fps.newFrame(timestamp); + fps.timerEvent(); + // Not 50 FPS + EXPECT_TRUE(fps.isNewFPS()); + EXPECT_LT(50.0f, fps.FPS()); + EXPECT_FALSE(fps.isNewFPS()); + + { + // Have seen values, 10 and 20 + const auto hist = fps.GetHistogram(); + EXPECT_EQ(2u, hist.size()); + auto it = hist.begin(); + // 10ms once + EXPECT_EQ(ms10, (*it).first); + EXPECT_EQ(1u, (*it).second); + // 20ms twice + ++it; + if (it != hist.end()) + { + EXPECT_EQ(ms20, (*it).first); + EXPECT_EQ(2u, (*it).second); + } + } + + // Clear the histogram and confirm empty + fps.ClearHistogram(); + EXPECT_EQ(0u, fps.GetHistogram().size()); + fps.timerEvent(); + EXPECT_EQ(0u, fps.GetHistogram().size()); +} + +TEST(FpsCounter, ExpectedPacketRefresh) +{ + T_FpsCounter fps; + + tock timestamp; + constexpr FpsCounter::HistogramBucket ms10 = std::chrono::milliseconds(10); + constexpr FpsCounter::HistogramBucket ms20 = std::chrono::milliseconds(20); + + // Add 1 second worth of 20ms intervals + while (timestamp < std::chrono::seconds(1)) + { + timestamp.Set(timestamp.Get() + ms20); + fps.newFrame(timestamp); + } + + // Haven't updated yet so should appear empty + EXPECT_FALSE(fps.isNewFPS()); + EXPECT_EQ(0.0f, fps.FPS()); + EXPECT_EQ(0u, fps.GetHistogram().size()); + + // Now tick + fps.timerEvent(); + + // Should now have 50 FPS + EXPECT_TRUE(fps.isNewFPS()); + EXPECT_FLOAT_EQ(50.0f, fps.FPS()); + // And one interval value + EXPECT_EQ(1u, fps.GetHistogram().size()); + + // And do it again, but faster + // Add 1 more second worth of 10ms intervals + while (timestamp < std::chrono::seconds(2)) + { + timestamp.Set(timestamp.Get() + ms10); + fps.newFrame(timestamp); + } + + // Haven't updated yet so should appear unchanged + EXPECT_FALSE(fps.isNewFPS()); + EXPECT_FLOAT_EQ(50.0f, fps.FPS()); + EXPECT_EQ(1u, fps.GetHistogram().size()); + + // Now tick + fps.timerEvent(); + + // Should now have 100 FPS as the entire sample period was 10ms intervals + EXPECT_TRUE(fps.isNewFPS()); + EXPECT_FLOAT_EQ(100.0f, fps.FPS()); + // And two interval values + EXPECT_EQ(2u, fps.GetHistogram().size()); +} diff --git a/tests/test_glscopewidget.cpp b/tests/test_glscopewidget.cpp new file mode 100644 index 00000000..d1e1b640 --- /dev/null +++ b/tests/test_glscopewidget.cpp @@ -0,0 +1,209 @@ +// Copyright 2023 Electronic Theatre Controls, Inc. or its affiliates +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "gtest/gtest.h" + +#include "widgets/glscopewidget.h" + +#include +#include + +TEST(ScopeTrace, AddPoint) +{ + // Storing all points, even if same level + + ScopeTrace trace(Qt::red, 1, 1, 0, 10); + EXPECT_TRUE(trace.isValid()); + EXPECT_FALSE(trace.isSixteenBit()); + EXPECT_TRUE(trace.values().value().empty()); + + const std::array levels = { 1, 2 }; + float time = 0; + trace.addPoint(time, levels, true); + ASSERT_EQ(1, trace.values().value().size()); + EXPECT_EQ(QVector2D(time, 1), trace.values().value().back()); + + trace.addPoint(++time, levels, true); + EXPECT_EQ(2, trace.values().value().size()); + EXPECT_EQ(QVector2D(time, 1), trace.values().value().back()); + + trace.addPoint(++time, levels, true); + EXPECT_EQ(3, trace.values().value().size()); + EXPECT_EQ(QVector2D(time, 1), trace.values().value().back()); + + trace.addPoint(++time, levels, true); + EXPECT_EQ(4, trace.values().value().size()); + EXPECT_EQ(QVector2D(time, 1), trace.values().value().back()); + + trace.addPoint(++time, levels, true); + EXPECT_EQ(5, trace.values().value().size()); + EXPECT_EQ(QVector2D(time, 1), trace.values().value().back()); +} + +TEST(ScopeTrace, CompressPoints) +{ + // Only storing level changes + + ScopeTrace trace(Qt::red, 1, 1, 0, 10); + // Add three identical levels + std::array levels = { 1, 2 }; + float time = 0; + trace.addPoint(time, levels, false); + trace.addPoint(++time, levels, false); + trace.addPoint(++time, levels, false); + EXPECT_EQ(3, trace.values().value().size()); + EXPECT_EQ(time, trace.values().value().back().x()); + // Should now start compressing points + trace.addPoint(++time, levels, false); + EXPECT_EQ(3, trace.values().value().size()); + EXPECT_EQ(time, trace.values().value().back().x()); + // Change level + levels[0] = 3; + trace.addPoint(++time, levels, false); + EXPECT_EQ(4, trace.values().value().size()); + trace.addPoint(++time, levels, false); + EXPECT_EQ(5, trace.values().value().size()); + EXPECT_EQ(time, trace.values().value().back().x()); + // Should now start compressing points + trace.addPoint(++time, levels, false); + EXPECT_EQ(5, trace.values().value().size()); + EXPECT_EQ(time, trace.values().value().back().x()); +} + +void VerifyRedGreenTrace(ScopeModel& glscope, const char* note) +{ + // There are Two! Traces! + EXPECT_EQ(2, glscope.rowCount()) << note; + + { + // Get the Red one + const ScopeTrace* red_trace = glscope.findTrace(1, 1); + EXPECT_NE(nullptr, red_trace); + if (red_trace) + { + EXPECT_TRUE(red_trace->isValid()) << note; + EXPECT_FALSE(red_trace->isSixteenBit()) << note; + EXPECT_EQ(QColor(Qt::red), red_trace->color()) << note; + EXPECT_EQ(6, red_trace->values().value().size()) << note; + // Verify first and last values + EXPECT_EQ(QVector2D(10, 1), red_trace->values().value().front()) << note; + EXPECT_EQ(QVector2D(16, 4), red_trace->values().value().back()) << note; + } + } + + { + // Get the Green one + const ScopeTrace* green_trace = glscope.findTrace(1, 2, 3); + EXPECT_NE(nullptr, green_trace) << note; + if (green_trace) + { + EXPECT_TRUE(green_trace->isValid()) << note; + EXPECT_TRUE(green_trace->isSixteenBit()) << note; + EXPECT_EQ(QColor("green"), green_trace->color()) << note; + EXPECT_EQ(7, green_trace->values().value().size()) << note; + // Verify first and last values + EXPECT_EQ(QVector2D(10, 5), green_trace->values().value().front()) << note; + EXPECT_EQ(QVector2D(16, 7), green_trace->values().value().back()) << note; + } + } +} + +TEST(ScopeModel, CSVImportExport) +{ + ScopeModel glscope; + + // Data to load with a duff column + QByteArray csvData = ("Color,red,brown,green\n" + "Time (s), U1.1, U1.nope, U1.2/3\n" + "10, 1, 1, 5\n" + "11, 2, 1, 6\n" + "12, 3, 1, 4\n" + "13, , 1, 7\n" + "14, 4, 1, 7\n" + "15, 4, 1, 7\n" + "16, 4, 1, 7\n"); + + { + QBuffer buf(&csvData); + buf.open(QIODevice::ReadOnly); + + EXPECT_TRUE(glscope.loadTraces(buf)); + + VerifyRedGreenTrace(glscope, "import"); + } + + // Export the data + { + QFile csvExport(QStringLiteral("CSVImportExport.csv")); + csvExport.open(QIODevice::WriteOnly); + + EXPECT_TRUE(glscope.saveTraces(csvExport)); + } + + glscope.removeAllTraces(); + EXPECT_EQ(0, glscope.rowCount()) << "after removeAllTraces"; + + { + // Then re-import and retest + QFile csvExport(QStringLiteral("CSVImportExport.csv")); + csvExport.open(QIODevice::ReadOnly); + + EXPECT_TRUE(glscope.loadTraces(csvExport)); + + VerifyRedGreenTrace(glscope, "round_trip"); + } +} + +TEST(ScopeModel, CaptureConfigImportExport) +{ + const ScopeModel defaultScope; + ScopeModel scope; + scope.addTrace(Qt::red, 1, 2); + + const QString defaultConfig = scope.captureConfigurationString(); + scope.setCaptureConfiguration(defaultConfig); + QString currentConfig = scope.captureConfigurationString(); + EXPECT_STREQ(qUtf8Printable(defaultConfig), qUtf8Printable(currentConfig)); + + // Write and read trigger setting + scope.setTriggerType(ScopeModel::Trigger::Above); + scope.setTriggerLevel(120); + currentConfig = scope.captureConfigurationString(); + EXPECT_STRNE(qUtf8Printable(defaultConfig), qUtf8Printable(currentConfig)); + + scope.setTriggerType(ScopeModel::Trigger::FreeRun); + scope.setTriggerLevel(0); + scope.setCaptureConfiguration(currentConfig); + + EXPECT_EQ(ScopeModel::Trigger::Above, scope.triggerType()); + EXPECT_EQ(120, scope.triggerLevel()); + + // Write and read run time + scope.setRunTime(10.0); + currentConfig = scope.captureConfigurationString(); + EXPECT_STRNE(qUtf8Printable(defaultConfig), qUtf8Printable(currentConfig)); + + scope.setRunTime(0); + scope.setCaptureConfiguration(currentConfig); + EXPECT_EQ(10.0, scope.runTime()); + + // Clear and set trigger again + scope.setTriggerType(ScopeModel::Trigger::FreeRun); + scope.setTriggerLevel(0); + scope.setCaptureConfiguration(currentConfig); + + EXPECT_EQ(ScopeModel::Trigger::Above, scope.triggerType()); + EXPECT_EQ(120, scope.triggerLevel()); + +} From 8750aa06d0c34f37c69221185d027e8b27cf9f75 Mon Sep 17 00:00:00 2001 From: RichardTea <31507749+RichardTea@users.noreply.github.com> Date: Wed, 10 Apr 2024 13:24:56 +0100 Subject: [PATCH 08/13] qmake: Add openglwidgets for Qt6 --- sACNView.pro | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sACNView.pro b/sACNView.pro index 3336d82a..4dee82e7 100644 --- a/sACNView.pro +++ b/sACNView.pro @@ -15,6 +15,10 @@ QT += core gui network multimedia widgets +greaterThan(QT_MAJOR_VERSION, 5) { + QT += openglwidgets +} + TARGET = sACNView TEMPLATE = app DESCRIPTION = $$shell_quote("A tool for sending and receiving the Streaming ACN control protocol") From b916dc4ad7502bc86f6b7d732896b4f690e073a9 Mon Sep 17 00:00:00 2001 From: RichardTea <31507749+RichardTea@users.noreply.github.com> Date: Wed, 10 Apr 2024 14:28:43 +0100 Subject: [PATCH 09/13] qmake: Add missing glscopewidget.cpp file --- sACNView.pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sACNView.pro b/sACNView.pro index 4dee82e7..1ed5a347 100644 --- a/sACNView.pro +++ b/sACNView.pro @@ -118,7 +118,7 @@ SOURCES += src/main.cpp\ src/sacn/sacnsender.cpp \ src/ui/configureperchanpriodlg.cpp \ src/widgets/gridwidget.cpp \ - src/widgets/scopewidget.cpp \ + src/widgets/glscopewidget.cpp \ src/ui/aboutdialog.cpp \ src/sacn/sacneffectengine.cpp \ src/models/sacnuniverselistmodel.cpp \ From 9a1dd48d155956fa135a40c7f0bd82bec123a78e Mon Sep 17 00:00:00 2001 From: RichardTea <31507749+RichardTea@users.noreply.github.com> Date: Wed, 10 Apr 2024 15:29:33 +0100 Subject: [PATCH 10/13] Qt6 Compatibility QTextStream::setCodec() is removed, use QTextStream::setEncoding() QString::splitRef() is removed, use QStringView::split() --- src/widgets/glscopewidget.cpp | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/widgets/glscopewidget.cpp b/src/widgets/glscopewidget.cpp index 46687710..70df27a4 100644 --- a/src/widgets/glscopewidget.cpp +++ b/src/widgets/glscopewidget.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include static constexpr qreal AXIS_LABEL_WIDTH = 45.0; @@ -668,6 +669,18 @@ void ScopeModel::setCaptureConfiguration(const QString& configString) } } +inline void ConfigureTextStream(QTextStream& stream) +{ +#if QT_VERSION < QT_VERSION_CHECK(6,0,0) + stream.setCodec("UTF-8"); +#else + stream.setEncoding(QStringConverter::Utf8); +#endif + stream.setLocale(QLocale::c()); + stream.setRealNumberNotation(QTextStream::FixedNotation); + stream.setRealNumberPrecision(3); +} + bool ScopeModel::saveTraces(QIODevice& file) const { if (!file.isWritable()) @@ -678,10 +691,7 @@ bool ScopeModel::saveTraces(QIODevice& file) const return false; QTextStream out(&file); - out.setCodec("UTF-8"); - out.setLocale(QLocale::c()); - out.setRealNumberNotation(QTextStream::FixedNotation); - out.setRealNumberPrecision(3); + ConfigureTextStream(out); // Table: // Capture Options:,All Packets/Level Changes @@ -814,18 +824,20 @@ bool ScopeModel::loadTraces(QIODevice& file) return false; QTextStream in(&file); - in.setCodec("UTF-8"); - in.setLocale(QLocale::c()); - in.setRealNumberNotation(QTextStream::FixedNotation); - in.setRealNumberPrecision(3); + ConfigureTextStream(in); const auto title_line = FindUniverseTitles(in); if (title_line.universes.isEmpty()) return false; // Split the title lines to find the trace colors and names +#if QT_VERSION < QT_VERSION_CHECK(6,0,0) auto colors = title_line.colors.splitRef(QLatin1Char(','), Qt::KeepEmptyParts); auto titles = title_line.universes.splitRef(QLatin1Char(','), Qt::KeepEmptyParts); +#else + auto colors = QStringView{ title_line.colors }.split(QLatin1Char(','), Qt::KeepEmptyParts); + auto titles = QStringView{ title_line.universes }.split(QLatin1Char(','), Qt::KeepEmptyParts); +#endif // Remove the first column as these are known titles colors.pop_front(); @@ -889,7 +901,11 @@ bool ScopeModel::loadTraces(QIODevice& file) if (data_line.isEmpty()) continue; +#if QT_VERSION < QT_VERSION_CHECK(6,0,0) const auto data = data_line.splitRef(QLatin1Char(','), Qt::KeepEmptyParts); +#else + const auto data = QStringView{ data_line }.split(QLatin1Char(','), Qt::KeepEmptyParts); +#endif // Ignore any lines that do not have a column for all traces if (data.size() < traces.size() + 1) continue; From d683dabc94497278d6495bb609e6fcf295c0993c Mon Sep 17 00:00:00 2001 From: RichardTea <31507749+RichardTea@users.noreply.github.com> Date: Wed, 10 Apr 2024 15:52:12 +0100 Subject: [PATCH 11/13] gcc/clang do not like QOverload() --- src/widgets/glscopewidget.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/widgets/glscopewidget.cpp b/src/widgets/glscopewidget.cpp index 70df27a4..c53fc24c 100644 --- a/src/widgets/glscopewidget.cpp +++ b/src/widgets/glscopewidget.cpp @@ -1225,7 +1225,7 @@ GlScopeWidget::GlScopeWidget(QWidget* parent) m_model = new ScopeModel(this); connect(m_model, &ScopeModel::runningChanged, this, &GlScopeWidget::onRunningChanged); - connect(m_model, &ScopeModel::traceVisibilityChanged, this, QOverload::of(&QOpenGLWidget::update)); + connect(m_model, &ScopeModel::traceVisibilityChanged, this, qOverload<>(&QOpenGLWidget::update)); setMinimumSize(200, 200); setVerticalScaleMode(VerticalScale::Percent); From 6fec4426079dd7934eed9d67e9ba77ebbbb604a9 Mon Sep 17 00:00:00 2001 From: Richard Thompson Date: Thu, 11 Jan 2024 14:41:58 +0000 Subject: [PATCH 12/13] Display and export/import wallclock time Allows easier comparison with other tools, eg Wireshark Note that Excel does the wrong thing by default when importing --- src/ui/glscopewindow.cpp | 20 +++- src/ui/glscopewindow.h | 2 + src/widgets/glscopewidget.cpp | 174 +++++++++++++++++++++++++++------- src/widgets/glscopewidget.h | 27 +++++- 4 files changed, 187 insertions(+), 36 deletions(-) diff --git a/src/ui/glscopewindow.cpp b/src/ui/glscopewindow.cpp index 0a90eb61..33ad50a6 100644 --- a/src/ui/glscopewindow.cpp +++ b/src/ui/glscopewindow.cpp @@ -137,6 +137,18 @@ GlScopeWindow::GlScopeWindow(int universe, QWidget* parent) connect(m_scope, &GlScopeWidget::timeDivisionsChanged, m_spinTimeScale, &QSpinBox::setValue); layoutGrp->addWidget(m_spinTimeScale, row, 1); + ++row; + m_timeFormat = new QComboBox(confWidget); + m_timeFormat->addItems({ + //! Elapsed time + tr("Elapsed"), + //! Wallclock time + tr("Wallclock") + }); + + connect(m_timeFormat, QOverload::of(&QComboBox::currentIndexChanged), this, &GlScopeWindow::setTimeFormat); + layoutGrp->addWidget(m_timeFormat, row, 0, 1, 2); + // Divider ++row; QFrame* line = new QFrame(confWidget); @@ -154,7 +166,8 @@ GlScopeWindow::GlScopeWindow(int universe, QWidget* parent) //! Triggers when below the target level tr("Below"), //! Triggers when passes through or leaves the target level - tr("Crossed Level") }); + tr("Crossed Level") + }); connect(m_triggerType, QOverload::of(&QComboBox::currentIndexChanged), this, &GlScopeWindow::setTriggerType); layoutGrp->addWidget(m_triggerType, row, 0, 1, 2); @@ -317,6 +330,11 @@ void GlScopeWindow::onTimeDivisionsChanged(int value) updateTimeScrollBars(); } +void GlScopeWindow::setTimeFormat(int value) +{ + m_scope->setTimeFormat(static_cast(value)); +} + void GlScopeWindow::setRecordMode(int idx) { m_scope->model()->setStoreAllPoints(idx == 0); diff --git a/src/ui/glscopewindow.h b/src/ui/glscopewindow.h index 83d53d14..b75a7c71 100644 --- a/src/ui/glscopewindow.h +++ b/src/ui/glscopewindow.h @@ -42,6 +42,7 @@ class GlScopeWindow : public QWidget Q_SLOT void onRunningChanged(bool running); Q_SLOT void onTimeSliderMoved(int value); Q_SLOT void onTimeDivisionsChanged(int value); + Q_SLOT void setTimeFormat(int value); Q_SLOT void setRecordMode(int idx); Q_SLOT void setVerticalScaleMode(int idx); @@ -67,6 +68,7 @@ class GlScopeWindow : public QWidget QComboBox* m_recordMode = nullptr; QSpinBox* m_spinRunTime = nullptr; SteppedSpinBox* m_spinTimeScale = nullptr; + QComboBox* m_timeFormat = nullptr; QComboBox* m_triggerType = nullptr; QSpinBox* m_spinTriggerLevel = nullptr; QPushButton* m_btnStart = nullptr; diff --git a/src/widgets/glscopewidget.cpp b/src/widgets/glscopewidget.cpp index c53fc24c..ac01c480 100644 --- a/src/widgets/glscopewidget.cpp +++ b/src/widgets/glscopewidget.cpp @@ -29,8 +29,13 @@ static constexpr qreal AXIS_TICK_SIZE = 10.0; static const QString CaptureOptionsTitle = QStringLiteral("Capture Options"); static const QString RowTitleColor = QStringLiteral("Color"); +static const QString ColumnTitleWallclockTime = QStringLiteral("Wallclock"); static const QString ColumnTitleTimestamp = QStringLiteral("Time (s)"); +static const QString ShortTimeFormatString = QStringLiteral("hh:mm:ss"); +static const QString TimeFormatString = QStringLiteral("hh:mm:ss.zzz"); +static const QString DateTimeFormatString = QStringLiteral("yyyy-MM-dd ") + TimeFormatString; + static constexpr qreal kMaxDmx16 = 65535; static constexpr qreal kMaxDmx8 = 255; @@ -628,6 +633,7 @@ void ScopeModel::clearValues() // Reset time extents m_endTime = 0; + m_wallclockTrigger_ms = 0; } QString ScopeModel::captureConfigurationString() const @@ -696,14 +702,14 @@ bool ScopeModel::saveTraces(QIODevice& file) const // Table: // Capture Options:,All Packets/Level Changes // - // Color, red, green, ... - // Time (s), U1.1, U1.2/3, ... (Given as Universe.CoarseDMX/FineDmx (1-512) - // 0.000, 255, 0, ... - // 0.020, 128, 128, ... - // 0.040, 127, 255, ... + // 2024-01-15, Color, red, green, ... + // Wallclock, Time (s),U1.1, U1.2/3, ... (Given as Universe.CoarseDMX/FineDmx (1-512) + // 12:00:00.000, 0.000, 255, 0, ... + // 12:00:00.020, 0.020, 128, 128, ... + // 12:00:00.040, 0.040, 127, 255, ... // Export capture configuration line - out << CaptureOptionsTitle << QLatin1String(":,") << captureConfigurationString(); + out << CaptureOptionsTitle << QLatin1String(":,") << captureConfigurationString() << QStringLiteral(",hh:mm:ss.000"); out << "\n\n"; // First row time @@ -724,8 +730,16 @@ bool ScopeModel::saveTraces(QIODevice& file) const std::vector traces_values; traces_values.reserve(rowCount()); - QString color_header = RowTitleColor; - QString name_header = ColumnTitleTimestamp; + QDateTime datetime = QDateTime::currentDateTime(); + + QString color_header; + if (asWallclockTime(datetime, 0.0)) + { + color_header = datetime.toString(DateTimeFormatString); + } + color_header = color_header + QStringLiteral(",") + RowTitleColor; + + QString name_header = ColumnTitleWallclockTime + QStringLiteral(",") + ColumnTitleTimestamp; // Header rows sorted by universe for (const auto& universe : m_traceLookup) @@ -757,7 +771,12 @@ bool ScopeModel::saveTraces(QIODevice& file) const while (this_row_time < std::numeric_limits::max()) { // Start new row and output timestamp - out << '\n' << this_row_time; + QString wallclock; + if (asWallclockTime(datetime, this_row_time)) + { + wallclock = datetime.toString(TimeFormatString); + } + out << '\n' << wallclock << ',' << this_row_time; float next_row_time = std::numeric_limits::max(); for (auto& value_its : traces_values) @@ -804,15 +823,17 @@ TitleRows FindUniverseTitles(QTextStream& in) { result.config = line; } - else if (line.startsWith(RowTitleColor)) + else if (line.contains(RowTitleColor)) { // Probably the title line result.colors = line; result.universes = in.readLine(); - if (!result.universes.startsWith(ColumnTitleTimestamp)) - return TitleRows(); // Failed + if (result.universes.startsWith(ColumnTitleTimestamp) || result.universes.startsWith(ColumnTitleWallclockTime)) + { + return result; + } + return TitleRows(); // Failed - return result; } } return TitleRows(); @@ -839,9 +860,33 @@ bool ScopeModel::loadTraces(QIODevice& file) auto titles = QStringView{ title_line.universes }.split(QLatin1Char(','), Qt::KeepEmptyParts); #endif - // Remove the first column as these are known titles - colors.pop_front(); - titles.pop_front(); + // Find data columns + int timeColumn = -1; // Column index for time offset + QDateTime wallclockTrigger; + // Data is always in the column after time offset + for (int i = 0; timeColumn == -1 && i < colors.size(); ++i) + { + if (colors.isEmpty()) + return false; + if (titles.isEmpty()) + return false; + + // Grab the zero datetime from Colors + if (titles.front() == ColumnTitleWallclockTime) + wallclockTrigger = QDateTime::fromString(colors.front().toString(), DateTimeFormatString); + else if (titles.front() == ColumnTitleTimestamp) + timeColumn = i; + + // Remove the row header titles + colors.pop_front(); + titles.pop_front(); + } + + // Time is required + if (timeColumn < 0) + return false; + + const int firstTraceColumn = timeColumn + 1; // Column index of first trace // Remove empty colors from the end while (colors.last().isEmpty()) @@ -857,6 +902,12 @@ bool ScopeModel::loadTraces(QIODevice& file) beginResetModel(); private_removeAllTraces(); + // Read the wallclock trigger datetime + if (wallclockTrigger.isValid()) + m_wallclockTrigger_ms = wallclockTrigger.toMSecsSinceEpoch(); + else // Or set it to a midnight + m_wallclockTrigger_ms = QDateTime::fromString(QStringLiteral("1975-01-01 00:00:00.000"), DateTimeFormatString).toMSecsSinceEpoch(); + struct UnivSlots { uint16_t universe = 0; @@ -907,24 +958,24 @@ bool ScopeModel::loadTraces(QIODevice& file) const auto data = QStringView{ data_line }.split(QLatin1Char(','), Qt::KeepEmptyParts); #endif // Ignore any lines that do not have a column for all traces - if (data.size() < traces.size() + 1) + if (data.size() < traces.size() + firstTraceColumn) continue; // Time moves ever forward. Ignore any lines in the past bool ok = false; - const float timestamp = data[0].toFloat(&ok); + const float timestamp = data[timeColumn].toFloat(&ok); if (!ok || prev_timestamp > timestamp) continue; prev_timestamp = timestamp; - for (size_t i = 1; i <= traces.size(); ++i) + for (size_t i = 0; i < traces.size(); ++i) { - ScopeTrace* trace = traces[i - 1]; + ScopeTrace* trace = traces[i]; if (trace) { bool ok = false; - const float level = data[i].toFloat(&ok); + const float level = data[i + firstTraceColumn].toFloat(&ok); if (ok) trace->addValue({ timestamp, level }); } @@ -1119,6 +1170,13 @@ void ScopeModel::setMaxValue(qreal maxValue) void ScopeModel::triggerNow(qreal offset) { + { + // Determine approximate offset to wallclock time by grabbing both + const qint64 now_ms = sACNManager::nsecsElapsed() / 1000000; + const qint64 nowWallclock_ms = QDateTime::currentMSecsSinceEpoch(); + m_wallclockTrigger_ms = (nowWallclock_ms - now_ms) + (offset / 1000.0); + } + m_startOffset = offset; // Update the offsets of all traces for (ScopeTrace* trace : m_traceTable) @@ -1153,6 +1211,15 @@ qreal ScopeModel::endTime() const return m_endTime; } +bool ScopeModel::asWallclockTime(QDateTime& datetime, qreal time) const +{ + if (m_wallclockTrigger_ms == 0) + return false; + + datetime.setMSecsSinceEpoch(m_wallclockTrigger_ms + ((time + m_startOffset) * 1000)); + return true; +} + void ScopeModel::sACNListenerDmxReceived(tock packet_tock, int universe, const std::array& levels) { if (!m_running) @@ -1304,6 +1371,19 @@ void GlScopeWidget::setTimeDivisions(int milliseconds) emit timeDivisionsChanged(milliseconds); } +void GlScopeWidget::setTimeFormat(TimeFormat format) +{ + if (format == m_timeFormat) + return; + + m_timeFormat = format; + update(); + + onRunningChanged(m_model->isRunning()); + + emit timeFormatChanged(); +} + void GlScopeWidget::initializeGL() { // Reparenting to a different top-level window causes the OpenGL Context to be destroyed and recreated @@ -1494,21 +1574,48 @@ void GlScopeWidget::paintGL() painter.translate(scopeWindow.bottomLeft().x(), scopeWindow.bottomLeft().y()); const qreal x_scale = scopeWindow.width() / m_scopeView.width(); - const bool milliseconds = (m_timeInterval < 1.0); - for (qreal time = roundCeilMultiple(m_scopeView.left(), m_timeInterval); time < m_scopeView.right() + 0.001; time += m_timeInterval) + if (m_timeFormat == TimeFormat::Elapsed) + { + const bool milliseconds = (m_timeInterval < 1.0); + for (qreal time = roundCeilMultiple(m_scopeView.left(), m_timeInterval); time < m_scopeView.right() + 0.001; time += m_timeInterval) + { + // Grid lines in trace space + gridLines.emplace_back(static_cast(time), 0.0f); + gridLines.emplace_back(static_cast(time), m_scopeView.bottom()); + + const qreal x = (time - m_scopeView.left()) * x_scale; + + const QString text = milliseconds ? QStringLiteral("%1ms").arg(time * 1000.0) : QStringLiteral("%1s").arg(time); + QRectF fontRect = metrics.boundingRect(text); + fontRect.moveCenter(QPointF(x, AXIS_LABEL_HEIGHT / 2.0)); + painter.drawText(fontRect, text, QTextOption(Qt::AlignLeft)); + } + } + else { - // Grid lines in trace space - gridLines.emplace_back(static_cast(time), 0.0f); - gridLines.emplace_back(static_cast(time), m_scopeView.bottom()); + QDateTime datetime = QDateTime::currentDateTime(); - const qreal x = (time - m_scopeView.left()) * x_scale; + for (qreal time = roundCeilMultiple(m_scopeView.left(), m_timeInterval); time < m_scopeView.right() + 0.001; time += m_timeInterval) + { + // Grid lines in trace space + gridLines.emplace_back(static_cast(time), 0.0f); + gridLines.emplace_back(static_cast(time), m_scopeView.bottom()); - // TODO: use QStaticText to optimise the text layout - const QString text = milliseconds ? QStringLiteral("%1ms").arg(time * 1000.0) : QStringLiteral("%1s").arg(time); - QRectF fontRect = metrics.boundingRect(text); - fontRect.moveCenter(QPointF(x, AXIS_LABEL_HEIGHT / 2.0)); - painter.drawText(fontRect, text, QTextOption(Qt::AlignLeft)); + const qreal x = (time - m_scopeView.left()) * x_scale; + + if (!m_model->asWallclockTime(datetime, time)) + { + // Add time interval + datetime = datetime.addMSecs(m_timeInterval * 1000); + } + + const QString text = x_scale > 80.0 ? datetime.toString(TimeFormatString) : datetime.toString(ShortTimeFormatString); + + QRectF fontRect = metrics.boundingRect(text); + fontRect.moveCenter(QPointF(x, AXIS_LABEL_HEIGHT / 2.0)); + painter.drawText(fontRect, text, QTextOption(Qt::AlignLeft)); + } } } @@ -1589,7 +1696,8 @@ void GlScopeWidget::timerEvent(QTimerEvent* /*ev*/) void GlScopeWidget::onRunningChanged(bool running) { - if (running) + // Must update the timescale when displaying wallclock time + if (running || (m_timeFormat == TimeFormat::Wallclock && !m_model->isTriggered())) { if (m_renderTimer == 0) { diff --git a/src/widgets/glscopewidget.h b/src/widgets/glscopewidget.h index 512ad642..68b72f23 100644 --- a/src/widgets/glscopewidget.h +++ b/src/widgets/glscopewidget.h @@ -14,8 +14,9 @@ #pragma once -#include #include +#include +#include #include "sacn/sacnlistener.h" @@ -287,6 +288,15 @@ class ScopeModel : public QAbstractTableModel, public sACNListener::IDmxReceived /// @brief Get current end time in seconds qreal endTime() const; + /** + * @brief Get the datetime for a given sample timestamp + * Valid after Triggering. Note that wallclock time changes during the capture are not considered + * @param datetime out parameter QDateTime object to fill. Configure this with required timezone. + * @param time seconds since capture triggered + * @return true if valid + */ + bool asWallclockTime(QDateTime& datetime, qreal time) const; + /// sACNListener::IDmxReceivedCallback void sACNListenerDmxReceived(tock packet_tock, int universe, const std::array& levels) final; @@ -298,10 +308,11 @@ class ScopeModel : public QAbstractTableModel, public sACNListener::IDmxReceived std::vector m_traceTable; std::map> m_traceLookup; std::vector m_listeners; // Keep the listeners alive - qreal m_startOffset = 0; // Offset between this scope and global timeframe + qreal m_startOffset = 0; // Offset in seconds between this scope and global timeframe qreal m_endTime = 0; // Max. time extents of the scope measurements qreal m_maxValue = 0; // Max. possible value in DMX qreal m_runTime = 0; + qint64 m_wallclockTrigger_ms = 0; // Wallclock time of trigger in milliseconds since epoch struct TriggerConfig { @@ -354,6 +365,12 @@ class GlScopeWidget : public QOpenGLWidget, protected QOpenGLFunctions Invalid, }; + enum class TimeFormat + { + Elapsed, + Wallclock, + }; + public: explicit GlScopeWidget(QWidget* parent = nullptr); ~GlScopeWidget(); @@ -390,6 +407,11 @@ class GlScopeWidget : public QOpenGLWidget, protected QOpenGLFunctions Q_SLOT void setTimeDivisions(int milliseconds); Q_SIGNAL void timeDivisionsChanged(int milliseconds); + + Q_SLOT void setTimeFormat(TimeFormat format); + TimeFormat timeFormat() const { return m_timeFormat; } + Q_SIGNAL void timeFormatChanged(); + protected: void initializeGL() override; Q_SLOT void cleanupGL(); @@ -409,6 +431,7 @@ class GlScopeWidget : public QOpenGLWidget, protected QOpenGLFunctions int m_levelInterval = 20; // Level axis label interval qreal m_timeInterval = 1.0; // Time axis label interval qreal m_defaultIntervalCount = 10.0; // Time axis intervals to show when view is reset + TimeFormat m_timeFormat = TimeFormat::Elapsed; // Time display format QRectF m_scopeView; // Current scope view range in DMX bool m_followNow = true; From a7d4d21a21bcf50568ef787cdf58a2b522dab8e4 Mon Sep 17 00:00:00 2001 From: Richard Thompson Date: Tue, 20 Feb 2024 14:46:35 +0000 Subject: [PATCH 13/13] Reset the base timestamp at Clear, not Stop Was clearing the trigger offset when the capture stopped --- src/widgets/glscopewidget.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/widgets/glscopewidget.cpp b/src/widgets/glscopewidget.cpp index ac01c480..c604d1ca 100644 --- a/src/widgets/glscopewidget.cpp +++ b/src/widgets/glscopewidget.cpp @@ -632,6 +632,7 @@ void ScopeModel::clearValues() } // Reset time extents + m_startOffset = 0; m_endTime = 0; m_wallclockTrigger_ms = 0; } @@ -1041,7 +1042,6 @@ void ScopeModel::stop() } // And clear/shutdown m_listeners.clear(); - m_startOffset = 0; emit runningChanged(false); }