From a91685736737a1b6bcd72cb962b58bdca1f48671 Mon Sep 17 00:00:00 2001 From: probonopd Date: Sat, 19 Apr 2025 11:42:30 +0200 Subject: [PATCH] Network --- .github/workflows/build.yml | 2 +- build.sh | 10 + src/Makefile | 3 +- src/arm_float_to_q23.c | 4 - src/config.cpp | 68 ++ src/config.h | 25 + src/kernel.cpp | 5 +- src/kernel.h | 1 + src/mididevice.cpp | 14 +- src/minidexed.cpp | 221 ++++++- src/minidexed.h | 25 +- src/minidexed.ini | 12 + src/net/applemidi.cpp | 874 +++++++++++++++++++++++++ src/net/applemidi.h | 111 ++++ src/net/byteorder.h | 42 ++ src/net/ftpdaemon.cpp | 111 ++++ src/net/ftpdaemon.h | 47 ++ src/net/ftpworker.cpp | 1218 +++++++++++++++++++++++++++++++++++ src/net/ftpworker.h | 157 +++++ src/net/mdnspublisher.cpp | 345 ++++++++++ src/net/mdnspublisher.h | 90 +++ src/net/udpmidi.cpp | 89 +++ src/net/udpmidi.h | 57 ++ src/net/utility.h | 193 ++++++ src/udpmididevice.cpp | 90 +++ src/udpmididevice.d | 182 ++++++ src/udpmididevice.h | 55 ++ src/udpmididevice.o | Bin 0 -> 159264 bytes 28 files changed, 4034 insertions(+), 17 deletions(-) create mode 100644 src/net/applemidi.cpp create mode 100644 src/net/applemidi.h create mode 100644 src/net/byteorder.h create mode 100644 src/net/ftpdaemon.cpp create mode 100644 src/net/ftpdaemon.h create mode 100644 src/net/ftpworker.cpp create mode 100644 src/net/ftpworker.h create mode 100644 src/net/mdnspublisher.cpp create mode 100644 src/net/mdnspublisher.h create mode 100644 src/net/udpmidi.cpp create mode 100644 src/net/udpmidi.h create mode 100644 src/net/utility.h create mode 100644 src/udpmididevice.cpp create mode 100644 src/udpmididevice.d create mode 100644 src/udpmididevice.h create mode 100644 src/udpmididevice.o diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4299396..1c72c7c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -60,7 +60,7 @@ jobs: export PATH=$(readlink -f ./gcc-*arm-none*/bin/):$PATH RPI=1 bash -ex build.sh cp ./src/kernel*.img ./kernels/ - - name: Get Raspberry Pi boot files + - name: Get Raspberry Pi boot files and WLAN firmware run: | set -ex export PATH=$(readlink -f ./gcc-*aarch64-none*/bin/):$PATH diff --git a/build.sh b/build.sh index b69ba6b..2db6d59 100755 --- a/build.sh +++ b/build.sh @@ -20,6 +20,11 @@ if [ "${RPI}" -gt "1" ]; then OPTIONS="${OPTIONS} -o ARM_ALLOW_MULTI_CORE" fi +# For wireless access +if [ "${RPI}" == "3" ]; then + OPTIONS="${OPTIONS} -o USE_SDHOST" +fi + # USB Vendor and Device ID for use with USB Gadget Mode source USBID.sh if [ "${USB_VID}" ] ; then @@ -39,6 +44,11 @@ make -j cd libs/circle/addon/display/ make clean || true make -j + +cd ../wlan/ +make clean || true +make -j + cd ../sensor/ make clean || true make -j diff --git a/src/Makefile b/src/Makefile index d1fb051..fb81766 100644 --- a/src/Makefile +++ b/src/Makefile @@ -10,7 +10,8 @@ OBJS = main.o kernel.o minidexed.o config.o userinterface.o uimenu.o \ mididevice.o midikeyboard.o serialmididevice.o pckeyboard.o \ sysexfileloader.o performanceconfig.o perftimer.o \ effect_compressor.o effect_platervbstereo.o uibuttons.o midipin.o \ - arm_float_to_q23.o + arm_float_to_q23.o \ + net/ftpdaemon.o net/ftpworker.o net/applemidi.o net/udpmidi.o net/mdnspublisher.o udpmididevice.o OPTIMIZE = -O3 diff --git a/src/arm_float_to_q23.c b/src/arm_float_to_q23.c index 8eb21be..4f77e2a 100644 --- a/src/arm_float_to_q23.c +++ b/src/arm_float_to_q23.c @@ -22,10 +22,6 @@ void arm_float_to_q23(const float32_t * pSrc, q23_t * pDst, uint32_t blockSize) cvt = vcvtq_n_s32_f32(inV, 23); - /* saturate */ - cvt = vminq_s32(cvt, vdupq_n_s32(0x007fffff)); - cvt = vmaxq_s32(cvt, vdupq_n_s32(0xff800000)); - vst1q_s32(pDst, cvt); pDst += 4; pIn += 4; diff --git a/src/config.cpp b/src/config.cpp index 7a53d58..672d7cc 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -200,6 +200,23 @@ void CConfig::Load (void) m_bProfileEnabled = m_Properties.GetNumber ("ProfileEnabled", 0) != 0; m_bPerformanceSelectToLoad = m_Properties.GetNumber ("PerformanceSelectToLoad", 1) != 0; m_bPerformanceSelectChannel = m_Properties.GetNumber ("PerformanceSelectChannel", 0); + + // Network + m_bNetworkEnabled = m_Properties.GetNumber ("NetworkEnabled", 0) != 0; + m_bNetworkDHCP = m_Properties.GetNumber ("NetworkDHCP", 0) != 0; + m_NetworkType = m_Properties.GetString ("NetworkType", "wlan"); + m_NetworkHostname = m_Properties.GetString ("NetworkHostname", "MiniDexed"); + m_INetworkIPAddress = m_Properties.GetIPAddress("NetworkIPAddress") != 0; + m_INetworkSubnetMask = m_Properties.GetIPAddress("NetworkSubnetMask") != 0; + m_INetworkDefaultGateway = m_Properties.GetIPAddress("NetworkDefaultGateway") != 0; + m_bSyslogEnabled = m_Properties.GetNumber ("SyslogEnabled", 0) != 0; + m_INetworkDNSServer = m_Properties.GetIPAddress("NetworkDNSServer") != 0; + + const u8 *pSyslogServerIP = m_Properties.GetIPAddress ("NetworkSyslogServerIPAddress"); + if (pSyslogServerIP) + { + m_INetworkSyslogServerIPAddress.Set (pSyslogServerIP); + } m_nMasterVolume = m_Properties.GetNumber ("MasterVolume", 64); } @@ -724,3 +741,54 @@ unsigned CConfig::GetPerformanceSelectChannel (void) const { return m_bPerformanceSelectChannel; } + +// Network +bool CConfig::GetNetworkEnabled (void) const +{ + return m_bNetworkEnabled; +} + +bool CConfig::GetNetworkDHCP (void) const +{ + return m_bNetworkDHCP; +} + +const char *CConfig::GetNetworkType (void) const +{ + return m_NetworkType.c_str(); +} + +const char *CConfig::GetNetworkHostname (void) const +{ + return m_NetworkHostname.c_str(); +} + +CIPAddress CConfig::GetNetworkIPAddress (void) const +{ + return m_INetworkIPAddress; +} + +CIPAddress CConfig::GetNetworkSubnetMask (void) const +{ + return m_INetworkSubnetMask; +} + +CIPAddress CConfig::GetNetworkDefaultGateway (void) const +{ + return m_INetworkDefaultGateway; +} + +CIPAddress CConfig::GetNetworkDNSServer (void) const +{ + return m_INetworkDNSServer; +} + +bool CConfig::GetSyslogEnabled (void) const +{ + return m_bSyslogEnabled; +} + +CIPAddress CConfig::GetNetworkSyslogServerIPAddress (void) const +{ + return m_INetworkSyslogServerIPAddress; +} diff --git a/src/config.h b/src/config.h index b0735d3..5369a0a 100644 --- a/src/config.h +++ b/src/config.h @@ -23,6 +23,7 @@ #ifndef _config_h #define _config_h +#include #include #include #include @@ -241,6 +242,18 @@ public: unsigned GetMasterVolume() const { return m_nMasterVolume; } + // Network + bool GetNetworkEnabled (void) const; + bool GetNetworkDHCP (void) const; + const char *GetNetworkType (void) const; + const char *GetNetworkHostname (void) const; + CIPAddress GetNetworkIPAddress (void) const; + CIPAddress GetNetworkSubnetMask (void) const; + CIPAddress GetNetworkDefaultGateway (void) const; + CIPAddress GetNetworkDNSServer (void) const; + bool GetSyslogEnabled (void) const; + CIPAddress GetNetworkSyslogServerIPAddress (void) const; + private: CPropertiesFatFsFile m_Properties; @@ -357,6 +370,18 @@ private: unsigned m_bPerformanceSelectChannel; unsigned m_nMasterVolume; // Master volume 0-127 + + // Network + bool m_bNetworkEnabled; + bool m_bNetworkDHCP; + std::string m_NetworkType; + std::string m_NetworkHostname; + CIPAddress m_INetworkIPAddress; + CIPAddress m_INetworkSubnetMask; + CIPAddress m_INetworkDefaultGateway; + CIPAddress m_INetworkDNSServer; + bool m_bSyslogEnabled; + CIPAddress m_INetworkSyslogServerIPAddress; }; #endif diff --git a/src/kernel.cpp b/src/kernel.cpp index 446747b..c6c86d9 100644 --- a/src/kernel.cpp +++ b/src/kernel.cpp @@ -25,12 +25,15 @@ #include #include "usbminidexedmidigadget.h" +#define NET_DEVICE_TYPE NetDeviceTypeWLAN // or: NetDeviceTypeWLAN + LOGMODULE ("kernel"); CKernel *CKernel::s_pThis = 0; CKernel::CKernel (void) -: CStdlibAppStdio ("minidexed"), +: + CStdlibAppStdio ("minidexed"), m_Config (&mFileSystem), m_GPIOManager (&mInterrupt), m_I2CMaster (CMachineInfo::Get ()->GetDevice (DeviceI2CMaster), TRUE), diff --git a/src/kernel.h b/src/kernel.h index efe5f4f..25ab3ed 100644 --- a/src/kernel.h +++ b/src/kernel.h @@ -26,6 +26,7 @@ #include #include #include +#include #include "config.h" #include "minidexed.h" diff --git a/src/mididevice.cpp b/src/mididevice.cpp index fefe9fc..a927361 100644 --- a/src/mididevice.cpp +++ b/src/mididevice.cpp @@ -154,17 +154,17 @@ void CMIDIDevice::MIDIMessageHandler (const u8 *pMessage, size_t nLength, unsign if ( pMessage[0] != MIDI_TIMING_CLOCK && pMessage[0] != MIDI_ACTIVE_SENSING) { - fprintf (stderr, "MIDI%u: %02X\n", nCable, (unsigned) pMessage[0]); + LOGNOTE ("MIDI%u: %02X\n", nCable, (unsigned) pMessage[0]); } break; case 2: - fprintf (stderr, "MIDI%u: %02X %02X\n", nCable, + LOGNOTE ("MIDI%u: %02X %02X\n", nCable, (unsigned) pMessage[0], (unsigned) pMessage[1]); break; case 3: - fprintf (stderr, "MIDI%u: %02X %02X %02X\n", nCable, + LOGNOTE ("MIDI%u: %02X %02X %02X\n", nCable, (unsigned) pMessage[0], (unsigned) pMessage[1], (unsigned) pMessage[2]); break; @@ -173,17 +173,17 @@ void CMIDIDevice::MIDIMessageHandler (const u8 *pMessage, size_t nLength, unsign switch(pMessage[0]) { case MIDI_SYSTEM_EXCLUSIVE_BEGIN: - fprintf(stderr, "MIDI%u: SysEx data length: [%d]:",nCable, uint16_t(nLength)); + LOGNOTE("MIDI%u: SysEx data length: [%d]:",nCable, uint16_t(nLength)); for (uint16_t i = 0; i < nLength; i++) { if((i % 16) == 0) fprintf(stderr, "\n%04d:",i); fprintf(stderr, " 0x%02x",pMessage[i]); } - fprintf(stderr, "\n"); + LOGNOTE("\n"); break; default: - fprintf(stderr, "MIDI%u: Unhandled MIDI event type %0x02x\n",nCable,pMessage[0]); + LOGNOTE("MIDI%u: Unhandled MIDI event type %0x02x\n",nCable,pMessage[0]); } break; } @@ -323,7 +323,6 @@ void CMIDIDevice::MIDIMessageHandler (const u8 *pMessage, size_t nLength, unsign { if ((ucChannel == nPerfCh) || (nPerfCh == OmniMode)) { - //printf("Performance Select Channel %d\n", nPerfCh); m_pSynthesizer->ProgramChangePerformance (pMessage[1]); } } @@ -378,7 +377,6 @@ void CMIDIDevice::MIDIMessageHandler (const u8 *pMessage, size_t nLength, unsign { break; } - m_pSynthesizer->keyup (pMessage[1], nTG); break; diff --git a/src/minidexed.cpp b/src/minidexed.cpp index f82c603..c2ae8cf 100644 --- a/src/minidexed.cpp +++ b/src/minidexed.cpp @@ -23,6 +23,8 @@ #include #include #include +#include +#include #include #include #include @@ -30,6 +32,11 @@ #include "arm_float_to_q23.h" +const char WLANFirmwarePath[] = "SD:firmware/"; +const char WLANConfigFile[] = "SD:wpa_supplicant.conf"; +#define FTPUSERNAME "admin" +#define FTPPASSWORD "admin" + LOGMODULE ("minidexed"); CMiniDexed::CMiniDexed (CConfig *pConfig, CInterruptSystem *pInterrupt, @@ -53,6 +60,14 @@ CMiniDexed::CMiniDexed (CConfig *pConfig, CInterruptSystem *pInterrupt, m_GetChunkTimer ("GetChunk", 1000000U * pConfig->GetChunkSize ()/2 / pConfig->GetSampleRate ()), m_bProfileEnabled (m_pConfig->GetProfileEnabled ()), + m_pNet(nullptr), + m_pNetDevice(nullptr), + m_WLAN(WLANFirmwarePath), + m_WPASupplicant(WLANConfigFile), + m_bNetworkReady(false), + m_bNetworkInit(false), + m_UDPMIDI (this, pConfig, &m_UI), + m_pmDNSPublisher (nullptr), m_bSavePerformance (false), m_bSavePerformanceNewFile (false), m_bSetNewPerformance (false), @@ -248,6 +263,7 @@ CMiniDexed::CMiniDexed (CConfig *pConfig, CInterruptSystem *pInterrupt, bool CMiniDexed::Initialize (void) { + LOGNOTE("CMiniDexed::Initialize called"); assert (m_pConfig); assert (m_pSoundDevice); @@ -349,20 +365,25 @@ bool CMiniDexed::Initialize (void) return false; } #endif - + InitNetwork(); // returns bool but we continue even if something goes wrong + LOGNOTE("CMiniDexed::Initialize: InitNetwork() called"); + return true; } void CMiniDexed::Process (bool bPlugAndPlayUpdated) { + CScheduler* const pScheduler = CScheduler::Get(); #ifndef ARM_ALLOW_MULTI_CORE ProcessSound (); + pScheduler->Yield(); #endif for (unsigned i = 0; i < CConfig::MaxUSBMIDIDevices; i++) { assert (m_pMIDIKeyboard[i]); m_pMIDIKeyboard[i]->Process (bPlugAndPlayUpdated); + pScheduler->Yield(); } m_PCKeyboard.Process (bPlugAndPlayUpdated); @@ -370,6 +391,7 @@ void CMiniDexed::Process (bool bPlugAndPlayUpdated) if (m_bUseSerial) { m_SerialMIDI.Process (); + pScheduler->Yield(); } m_UI.Process (); @@ -379,12 +401,14 @@ void CMiniDexed::Process (bool bPlugAndPlayUpdated) DoSavePerformance (); m_bSavePerformance = false; + pScheduler->Yield(); } if (m_bSavePerformanceNewFile) { DoSavePerformanceNewFile (); m_bSavePerformanceNewFile = false; + pScheduler->Yield(); } if (m_bSetNewPerformanceBank && !m_bLoadPerformanceBusy && !m_bLoadPerformanceBankBusy) @@ -402,6 +426,7 @@ void CMiniDexed::Process (bool bPlugAndPlayUpdated) { DoSetFirstPerformance(); } + pScheduler->Yield(); } if (m_bSetNewPerformance && !m_bSetNewPerformanceBank && !m_bLoadPerformanceBusy && !m_bLoadPerformanceBankBusy) @@ -411,18 +436,26 @@ void CMiniDexed::Process (bool bPlugAndPlayUpdated) { m_bSetNewPerformance = false; } + pScheduler->Yield(); } if(m_bDeletePerformance) { DoDeletePerformance (); m_bDeletePerformance = false; + pScheduler->Yield(); } if (m_bProfileEnabled) { m_GetChunkTimer.Dump (); + pScheduler->Yield(); } + if (m_pNet) { + UpdateNetwork(); + } + // Allow other tasks to run + pScheduler->Yield(); } #ifdef ARM_ALLOW_MULTI_CORE @@ -772,6 +805,7 @@ void CMiniDexed::SetMIDIChannel (uint8_t uchChannel, unsigned nTG) { m_SerialMIDI.SetChannel (uchChannel, nTG); } + m_UDPMIDI.SetChannel (uchChannel, nTG); #ifdef ARM_ALLOW_MULTI_CORE /* This doesn't appear to be used anywhere... @@ -2205,3 +2239,188 @@ unsigned CMiniDexed::getModController (unsigned controller, unsigned parameter, } } + +void CMiniDexed::UpdateNetwork() +{ + LOGNOTE("CMiniDexed::UpdateNetwork called"); + //CNetSubSystem* const pNet = CNetSubSystem::Get(); + if (!m_pNet) { + LOGNOTE("CMiniDexed::UpdateNetwork: m_pNet is nullptr, returning early"); + return; + } + + bool bNetIsRunning = m_pNet->IsRunning(); + if (m_pNetDevice->GetType() == NetDeviceTypeEthernet) + bNetIsRunning &= m_pNetDevice->IsLinkUp(); + else if (m_pNetDevice->GetType() == NetDeviceTypeWLAN) + bNetIsRunning &= m_WPASupplicant.IsConnected(); + + if (!m_bNetworkInit && bNetIsRunning) + { + LOGNOTE("CMiniDexed::UpdateNetwork: Network became ready, initializing network services"); + m_bNetworkInit = true; + CString IPString; + m_pNet->GetConfig()->GetIPAddress()->Format(&IPString); + + //LOGNOTE("Network up and running at: %s", static_cast(IPString)); + + m_UDPMIDI.Initialize(); + + m_pFTPDaemon = new CFTPDaemon(FTPUSERNAME, FTPPASSWORD); + + if (!m_pFTPDaemon->Initialize()) + { + LOGERR("Failed to init FTP daemon"); + delete m_pFTPDaemon; + m_pFTPDaemon = nullptr; + } + else + { + LOGNOTE("FTP daemon initialized"); + } + m_UI.DisplayWrite (IPString, "", "TG1", 0, 1); // FIXME: Do not hardcode "TG1" here + + m_pmDNSPublisher = new CmDNSPublisher (m_pNet); + assert (m_pmDNSPublisher); + + //static const char *ppText[] = {"RTP-MIDI Receiver", nullptr}; // dont bother adding additional data + if (!m_pmDNSPublisher->PublishService (m_pConfig->GetNetworkHostname(), CmDNSPublisher::ServiceTypeAppleMIDI, + 5004)) + { + LOGPANIC ("Cannot publish mdns service"); + } + + static constexpr const char *ServiceTypeFTP = "_ftp._tcp"; + if (!m_pmDNSPublisher->PublishService (m_pConfig->GetNetworkHostname(), ServiceTypeFTP, 21)) + { + LOGPANIC ("Cannot publish mdns service"); + } + // syslog configuration + if (m_pConfig->GetSyslogEnabled()) + { + CIPAddress ServerIP = m_pConfig->GetNetworkSyslogServerIPAddress(); + if (ServerIP.IsSet () && !ServerIP.IsNull ()) + { + static const u16 usServerPort = 8514; // standard port is 514 + CString IPString; + ServerIP.Format (&IPString); + LOGNOTE ("Sending log messages to syslog server %s:%u", + (const char *) IPString, (unsigned) usServerPort); + + new CSysLogDaemon (m_pNet, ServerIP, usServerPort); + } + } + m_bNetworkReady = true; + } + + if (m_bNetworkReady && !bNetIsRunning) + { + LOGNOTE("CMiniDexed::UpdateNetwork: Network disconnected"); + m_bNetworkReady = false; + m_pmDNSPublisher->UnpublishService (m_pConfig->GetNetworkHostname()); + LOGNOTE("Network disconnected."); + } + else if (!m_bNetworkReady && bNetIsRunning) + { + LOGNOTE("CMiniDexed::UpdateNetwork: Network connection reestablished"); + m_bNetworkReady = true; + + if (!m_pmDNSPublisher->PublishService (m_pConfig->GetNetworkHostname(), CmDNSPublisher::ServiceTypeAppleMIDI, + 5004)) + { + LOGPANIC ("Cannot publish mdns service"); + } + + static constexpr const char *ServiceTypeFTP = "_ftp._tcp"; + if (!m_pmDNSPublisher->PublishService (m_pConfig->GetNetworkHostname(), ServiceTypeFTP, 21)) + { + LOGPANIC ("Cannot publish mdns service"); + } + + m_bNetworkReady = true; + + LOGNOTE("Network connection reestablished."); + + } +} + +bool CMiniDexed::InitNetwork() +{ + LOGNOTE("CMiniDexed::InitNetwork called"); + assert(m_pNet == nullptr); + + TNetDeviceType NetDeviceType = NetDeviceTypeUnknown; + + if (m_pConfig->GetNetworkEnabled()) + { + LOGNOTE("CMiniDexed::InitNetwork: Network is enabled in configuration"); + + LOGNOTE("CMiniDexed::InitNetwork: Network type set in configuration: %s", m_pConfig->GetNetworkType()); + + if (strcmp(m_pConfig->GetNetworkType(), "wlan") == 0) + { + LOGNOTE("CMiniDexed::InitNetwork: Initializing WLAN"); + NetDeviceType = NetDeviceTypeWLAN; + if (m_WLAN.Initialize()) + { + LOGNOTE("CMiniDexed::InitNetwork: WLAN initialized"); + } + else + { + LOGERR("CMiniDexed::InitNetwork: Failed to initialize WLAN, maybe firmware files are missing?"); + return false; + } + } + else if (strcmp(m_pConfig->GetNetworkType(), "ethernet") == 0) + { + LOGNOTE("CMiniDexed::InitNetwork: Initializing Ethernet"); + NetDeviceType = NetDeviceTypeEthernet; + } + else + { + LOGERR("CMiniDexed::InitNetwork: Network type is not set, please check your minidexed configuration file."); + NetDeviceType = NetDeviceTypeUnknown; + } + + if (NetDeviceType != NetDeviceTypeUnknown) + { + LOGNOTE("CMiniDexed::InitNetwork: Creating CNetSubSystem"); + if (m_pConfig->GetNetworkDHCP()) + m_pNet = new CNetSubSystem(0, 0, 0, 0, m_pConfig->GetNetworkHostname(), NetDeviceType); + else + m_pNet = new CNetSubSystem( + m_pConfig->GetNetworkIPAddress().Get(), + m_pConfig->GetNetworkSubnetMask().Get(), + m_pConfig->GetNetworkDefaultGateway().Get(), + m_pConfig->GetNetworkDNSServer().Get(), + m_pConfig->GetNetworkHostname(), + NetDeviceType + ); + if (!m_pNet->Initialize(false)) + { + LOGERR("CMiniDexed::InitNetwork: Failed to initialize network subsystem"); + delete m_pNet; + m_pNet = nullptr; + } + // WPASupplicant needs to be started after netdevice available + if (NetDeviceType == NetDeviceTypeWLAN) + { + LOGNOTE("CMiniDexed::InitNetwork: Initializing WPASupplicant"); + if (!m_WPASupplicant.Initialize()) + { + LOGERR("CMiniDexed::InitNetwork: Failed to initialize WPASupplicant, maybe wlan config is missing?"); + } + } + m_pNetDevice = CNetDevice::GetNetDevice(NetDeviceType); + } + LOGNOTE("CMiniDexed::InitNetwork: returning %d", m_pNet != nullptr); + return m_pNet != nullptr; + } + else + { + LOGNOTE("CMiniDexed::InitNetwork: Network is not enabled in configuration"); + return false; + } + LOGNOTE("CMiniDexed::InitNetwork: Network was not initialized"); + return false; +} diff --git a/src/minidexed.h b/src/minidexed.h index 6a7ef81..bced7dd 100644 --- a/src/minidexed.h +++ b/src/minidexed.h @@ -39,11 +39,18 @@ #include #include #include +#include +#include +#include +#include +#include "net/mdnspublisher.h" #include #include "common.h" #include "effect_mixer.hpp" #include "effect_platervbstereo.h" #include "effect_compressor.h" +#include "udpmididevice.h" +#include "net/ftpdaemon.h" class CMiniDexed #ifdef ARM_ALLOW_MULTI_CORE @@ -61,7 +68,6 @@ public: #ifdef ARM_ALLOW_MULTI_CORE void Run (unsigned nCore); #endif - CSysExFileLoader *GetSysExFileLoader (void); CPerformanceConfig *GetPerformanceConfig (void); @@ -228,12 +234,15 @@ public: bool DoSavePerformance (void); void setMasterVolume (float32_t vol); + bool InitNetwork(); + void UpdateNetwork(); private: int16_t ApplyNoteLimits (int16_t pitch, unsigned nTG); // returns < 0 to ignore note uint8_t m_uchOPMask[CConfig::AllToneGenerators]; void LoadPerformanceParameters(void); void ProcessSound (void); + const char* GetNetworkDeviceShortName() const; #ifdef ARM_ALLOW_MULTI_CORE enum TCoreStatus @@ -325,6 +334,17 @@ private: CSpinLock m_ReverbSpinLock; + // Network + CNetSubSystem* m_pNet; + CNetDevice* m_pNetDevice; + CBcm4343Device m_WLAN; + CWPASupplicant m_WPASupplicant; + bool m_bNetworkReady; + bool m_bNetworkInit; + CUDPMIDIDevice m_UDPMIDI; + CFTPDaemon* m_pFTPDaemon; + CmDNSPublisher *m_pmDNSPublisher; + bool m_bSavePerformance; bool m_bSavePerformanceNewFile; bool m_bSetNewPerformance; @@ -337,6 +357,9 @@ private: bool m_bLoadPerformanceBusy; bool m_bLoadPerformanceBankBusy; bool m_bSaveAsDeault; + + + }; #endif diff --git a/src/minidexed.ini b/src/minidexed.ini index a20b41d..2291f4f 100644 --- a/src/minidexed.ini +++ b/src/minidexed.ini @@ -151,5 +151,17 @@ EncoderPinData=9 MIDIDumpEnabled=0 ProfileEnabled=0 +# Network +NetworkEnabled=0 +NetworkDHCP=1 +# NetworkType ( wlan ; ethernet ) +NetworkType=wlan +NetworkHostname=MiniDexed +NetworkIPAddress=0 +NetworkSubnetMask=0 +NetworkDefaultGateway=0 +NetworkDNSServer=0 +NetworkSyslogServerIPAddress=0 + # Performance PerformanceSelectToLoad=1 diff --git a/src/net/applemidi.cpp b/src/net/applemidi.cpp new file mode 100644 index 0000000..e14b216 --- /dev/null +++ b/src/net/applemidi.cpp @@ -0,0 +1,874 @@ +// +// applemidi.cpp +// +// mt32-pi - A baremetal MIDI synthesizer for Raspberry Pi +// Copyright (C) 2020-2023 Dale Whinham +// +// This file is part of mt32-pi. +// +// mt32-pi is free software: you can redistribute it and/or modify it under the +// terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// mt32-pi is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// mt32-pi. If not, see . +// + +#include +#include +#include +#include +#include +#include +#include + +#include "applemidi.h" +#include "byteorder.h" + +// #define APPLEMIDI_DEBUG + +LOGMODULE("applemidi"); + +constexpr u16 ControlPort = 5004; +constexpr u16 MIDIPort = ControlPort + 1; + +constexpr u16 AppleMIDISignature = 0xFFFF; +constexpr u8 AppleMIDIVersion = 2; + +constexpr u8 RTPMIDIPayloadType = 0x61; +constexpr u8 RTPMIDIVersion = 2; + +// Arbitrary value +constexpr size_t MaxNameLength = 256; + +// Timeout period for invitation (5 seconds in 100 microsecond units) +constexpr unsigned int InvitationTimeout = 5 * 10000; + +// Timeout period for sync packets (60 seconds in 100 microsecond units) +constexpr unsigned int SyncTimeout = 60 * 10000; + +// Receiver feedback packet frequency (1 second in 100 microsecond units) +constexpr unsigned int ReceiverFeedbackPeriod = 1 * 10000; + +constexpr u16 CommandWord(const char Command[2]) { return Command[0] << 8 | Command[1]; } + +enum TAppleMIDICommand : u16 +{ + Invitation = CommandWord("IN"), + InvitationAccepted = CommandWord("OK"), + InvitationRejected = CommandWord("NO"), + Sync = CommandWord("CK"), + ReceiverFeedback = CommandWord("RS"), + EndSession = CommandWord("BY"), +}; + +struct TAppleMIDISession +{ + u16 nSignature; + u16 nCommand; + u32 nVersion; + u32 nInitiatorToken; + u32 nSSRC; + char Name[MaxNameLength]; +} +PACKED; + +// The Name field is optional +constexpr size_t NamelessSessionPacketSize = sizeof(TAppleMIDISession) - sizeof(TAppleMIDISession::Name); + +struct TAppleMIDISync +{ + u16 nSignature; + u16 nCommand; + u32 nSSRC; + u8 nCount; + u8 Padding[3]; + u64 Timestamps[3]; +} +PACKED; + +struct TAppleMIDIReceiverFeedback +{ + u16 nSignature; + u16 nCommand; + u32 nSSRC; + u32 nSequence; +} +PACKED; + +struct TRTPMIDI +{ + u16 nFlags; + u16 nSequence; + u32 nTimestamp; + u32 nSSRC; +} +PACKED; + +u64 GetSyncClock() +{ + static const u64 nStartTime = CTimer::GetClockTicks(); + const u64 nMicrosSinceEpoch = CTimer::GetClockTicks(); + + // Units of 100 microseconds + return (nMicrosSinceEpoch - nStartTime ) / 100; +} + +bool ParseInvitationPacket(const u8* pBuffer, size_t nSize, TAppleMIDISession* pOutPacket) +{ + const TAppleMIDISession* const pInPacket = reinterpret_cast(pBuffer); + + if (nSize < NamelessSessionPacketSize) + return false; + + const u16 nSignature = ntohs(pInPacket->nSignature); + if (nSignature != AppleMIDISignature) + return false; + + const u16 nCommand = ntohs(pInPacket->nCommand); + if (nCommand != Invitation) + return false; + + const u32 nVersion = ntohl(pInPacket->nVersion); + if (nVersion != AppleMIDIVersion) + return false; + + pOutPacket->nSignature = nSignature; + pOutPacket->nCommand = nCommand; + pOutPacket->nVersion = nVersion; + pOutPacket->nInitiatorToken = ntohl(pInPacket->nInitiatorToken); + pOutPacket->nSSRC = ntohl(pInPacket->nSSRC); + + if (nSize > NamelessSessionPacketSize) + strncpy(pOutPacket->Name, pInPacket->Name, sizeof(pOutPacket->Name)); + else + strncpy(pOutPacket->Name, "", sizeof(pOutPacket->Name)); + + return true; +} + +bool ParseEndSessionPacket(const u8* pBuffer, size_t nSize, TAppleMIDISession* pOutPacket) +{ + const TAppleMIDISession* const pInPacket = reinterpret_cast(pBuffer); + + if (nSize < NamelessSessionPacketSize) + return false; + + const u16 nSignature = ntohs(pInPacket->nSignature); + if (nSignature != AppleMIDISignature) + return false; + + const u16 nCommand = ntohs(pInPacket->nCommand); + if (nCommand != EndSession) + return false; + + const u32 nVersion = ntohl(pInPacket->nVersion); + if (nVersion != AppleMIDIVersion) + return false; + + pOutPacket->nSignature = nSignature; + pOutPacket->nCommand = nCommand; + pOutPacket->nVersion = nVersion; + pOutPacket->nInitiatorToken = ntohl(pInPacket->nInitiatorToken); + pOutPacket->nSSRC = ntohl(pInPacket->nSSRC); + + return true; +} + +bool ParseSyncPacket(const u8* pBuffer, size_t nSize, TAppleMIDISync* pOutPacket) +{ + const TAppleMIDISync* const pInPacket = reinterpret_cast(pBuffer); + + if (nSize < sizeof(TAppleMIDISync)) + return false; + + const u32 nSignature = ntohs(pInPacket->nSignature); + if (nSignature != AppleMIDISignature) + return false; + + const u32 nCommand = ntohs(pInPacket->nCommand); + if (nCommand != Sync) + return false; + + pOutPacket->nSignature = nSignature; + pOutPacket->nCommand = nCommand; + pOutPacket->nSSRC = ntohl(pInPacket->nSSRC); + pOutPacket->nCount = pInPacket->nCount; + pOutPacket->Timestamps[0] = ntohll(pInPacket->Timestamps[0]); + pOutPacket->Timestamps[1] = ntohll(pInPacket->Timestamps[1]); + pOutPacket->Timestamps[2] = ntohll(pInPacket->Timestamps[2]); + + return true; +} + +u8 ParseMIDIDeltaTime(const u8* pBuffer) +{ + u8 nLength = 0; + u32 nDeltaTime = 0; + + while (nLength < 4) + { + nDeltaTime <<= 7; + nDeltaTime |= pBuffer[nLength] & 0x7F; + + // Upper bit not set; end of timestamp + if ((pBuffer[nLength++] & (1 << 7)) == 0) + break; + } + + return nLength; +} + +size_t ParseSysExCommand(const u8* pBuffer, size_t nSize, CAppleMIDIHandler* pHandler) +{ + size_t nBytesParsed = 1; + const u8 nHead = pBuffer[0]; + u8 nTail = 0; + + while (nBytesParsed < nSize && !(nTail == 0xF0 || nTail == 0xF7 || nTail == 0xF4)) + nTail = pBuffer[nBytesParsed++]; + + size_t nReceiveLength = nBytesParsed; + + // First segmented SysEx packet + if (nHead == 0xF0 && nTail == 0xF0) + { +#ifdef APPLEMIDI_DEBUG + LOGNOTE("Received segmented SysEx (first)"); +#endif + --nReceiveLength; + } + + // Middle segmented SysEx packet + else if (nHead == 0xF7 && nTail == 0xF0) + { +#ifdef APPLEMIDI_DEBUG + LOGNOTE("Received segmented SysEx (middle)"); +#endif + ++pBuffer; + nBytesParsed -= 2; + } + + // Last segmented SysEx packet + else if (nHead == 0xF7 && nTail == 0xF7) + { +#ifdef APPLEMIDI_DEBUG + LOGNOTE("Received segmented SysEx (last)"); +#endif + ++pBuffer; + --nReceiveLength; + } + + // Cancelled segmented SysEx packet + else if (nHead == 0xF7 && nTail == 0xF4) + { +#ifdef APPLEMIDI_DEBUG + LOGNOTE("Received cancelled SysEx"); +#endif + nReceiveLength = 1; + } + +#ifdef APPLEMIDI_DEBUG + else + { + LOGNOTE("Received complete SysEx"); + } +#endif + + pHandler->OnAppleMIDIDataReceived(pBuffer, nReceiveLength); + + return nBytesParsed; +} + +size_t ParseMIDICommand(const u8* pBuffer, size_t nSize, u8& nRunningStatus, CAppleMIDIHandler* pHandler) +{ + size_t nBytesParsed = 0; + u8 nByte = pBuffer[0]; + + // System Real-Time message - single byte, handle immediately + // Can appear anywhere in the stream, even in between status/data bytes + if (nByte >= 0xF8) + { + // Ignore undefined System Real-Time + if (nByte != 0xF9 && nByte != 0xFD) + pHandler->OnAppleMIDIDataReceived(&nByte, 1); + + return 1; + } + + // Is it a status byte? + if (nByte & 0x80) + { + // Update running status if non Real-Time System status + if (nByte < 0xF0) + nRunningStatus = nByte; + else + nRunningStatus = 0; + + ++nBytesParsed; + } + else + { + // First byte not a status byte and no running status - invalid + if (!nRunningStatus) + return 0; + + // Use running status + nByte = nRunningStatus; + } + + // Channel messages + if (nByte < 0xF0) + { + // How many data bytes? + switch (nByte & 0xF0) + { + case 0x80: // Note off + case 0x90: // Note on + case 0xA0: // Polyphonic key pressure/aftertouch + case 0xB0: // Control change + case 0xE0: // Pitch bend + nBytesParsed += 2; + break; + + case 0xC0: // Program change + case 0xD0: // Channel pressure/aftertouch + nBytesParsed += 1; + break; + } + + // Handle command + pHandler->OnAppleMIDIDataReceived(pBuffer, nBytesParsed); + return nBytesParsed; + } + + // System common commands + switch (nByte) + { + case 0xF0: // Start of System Exclusive + case 0xF7: // End of Exclusive + return ParseSysExCommand(pBuffer, nSize, pHandler); + + case 0xF1: // MIDI Time Code Quarter Frame + case 0xF3: // Song Select + ++nBytesParsed; + break; + + case 0xF2: // Song Position Pointer + nBytesParsed += 2; + break; + } + + pHandler->OnAppleMIDIDataReceived(pBuffer, nBytesParsed); + return nBytesParsed; +} + +bool ParseMIDICommandSection(const u8* pBuffer, size_t nSize, CAppleMIDIHandler* pHandler) +{ + // Must have at least a header byte and a single status byte + if (nSize < 2) + return false; + + size_t nMIDICommandsProcessed = 0; + size_t nBytesRemaining = nSize - 1; + u8 nRunningStatus = 0; + + const u8 nMIDIHeader = pBuffer[0]; + const u8* pMIDICommands = pBuffer + 1; + + // Lower 4 bits of the header is length + u16 nMIDICommandLength = nMIDIHeader & 0x0F; + + // If B flag is set, length value is 12 bits + if (nMIDIHeader & (1 << 7)) + { + nMIDICommandLength <<= 8; + nMIDICommandLength |= pMIDICommands[0]; + ++pMIDICommands; + --nBytesRemaining; + } + + if (nMIDICommandLength > nBytesRemaining) + { + LOGERR("Invalid MIDI command length"); + return false; + } + + // Begin decoding the command list + while (nMIDICommandLength) + { + // If Z flag is set, first list entry is a delta time + if (nMIDICommandsProcessed || nMIDIHeader & (1 << 5)) + { + const u8 nBytesParsed = ParseMIDIDeltaTime(pMIDICommands); + nMIDICommandLength -= nBytesParsed; + pMIDICommands += nBytesParsed; + } + + if (nMIDICommandLength) + { + const size_t nBytesParsed = ParseMIDICommand(pMIDICommands, nMIDICommandLength, nRunningStatus, pHandler); + nMIDICommandLength -= nBytesParsed; + pMIDICommands += nBytesParsed; + ++nMIDICommandsProcessed; + } + } + + return true; +} + +bool ParseMIDIPacket(const u8* pBuffer, size_t nSize, TRTPMIDI* pOutPacket, CAppleMIDIHandler* pHandler) +{ + assert(pHandler != nullptr); + + const TRTPMIDI* const pInPacket = reinterpret_cast(pBuffer); + const u16 nRTPFlags = ntohs(pInPacket->nFlags); + + // Check size (RTP-MIDI header plus MIDI command section header) + if (nSize < sizeof(TRTPMIDI) + 1) + return false; + + // Check version + if (((nRTPFlags >> 14) & 0x03) != RTPMIDIVersion) + return false; + + // Ensure no CSRC identifiers + if (((nRTPFlags >> 8) & 0x0F) != 0) + return false; + + // Check payload type + if ((nRTPFlags & 0xFF) != RTPMIDIPayloadType) + return false; + + pOutPacket->nFlags = nRTPFlags; + pOutPacket->nSequence = ntohs(pInPacket->nSequence); + pOutPacket->nTimestamp = ntohl(pInPacket->nTimestamp); + pOutPacket->nSSRC = ntohl(pInPacket->nSSRC); + + // RTP-MIDI variable-length header + const u8* const pMIDICommandSection = pBuffer + sizeof(TRTPMIDI); + size_t nRemaining = nSize - sizeof(TRTPMIDI); + return ParseMIDICommandSection(pMIDICommandSection, nRemaining, pHandler); +} + +CAppleMIDIParticipant::CAppleMIDIParticipant(CBcmRandomNumberGenerator* pRandom, CAppleMIDIHandler* pHandler) + : CTask(TASK_STACK_SIZE, true), + + m_pRandom(pRandom), + + m_pControlSocket(nullptr), + m_pMIDISocket(nullptr), + + m_nForeignControlPort(0), + m_nForeignMIDIPort(0), + m_nInitiatorControlPort(0), + m_nInitiatorMIDIPort(0), + m_ControlBuffer{0}, + m_MIDIBuffer{0}, + + m_nControlResult(0), + m_nMIDIResult(0), + + m_pHandler(pHandler), + + m_State(TState::ControlInvitation), + + m_nInitiatorToken(0), + m_nInitiatorSSRC(0), + m_nSSRC(0), + m_nLastMIDISequenceNumber(0), + + m_nOffsetEstimate(0), + m_nLastSyncTime(0), + + m_nSequence(0), + m_nLastFeedbackSequence(0), + m_nLastFeedbackTime(0) +{ +} + +CAppleMIDIParticipant::~CAppleMIDIParticipant() +{ + if (m_pControlSocket) + delete m_pControlSocket; + + if (m_pMIDISocket) + delete m_pMIDISocket; +} + +bool CAppleMIDIParticipant::Initialize() +{ + assert(m_pControlSocket == nullptr); + assert(m_pMIDISocket == nullptr); + + CNetSubSystem* const pNet = CNetSubSystem::Get(); + + if ((m_pControlSocket = new CSocket(pNet, IPPROTO_UDP)) == nullptr) + return false; + + if ((m_pMIDISocket = new CSocket(pNet, IPPROTO_UDP)) == nullptr) + return false; + + if (m_pControlSocket->Bind(ControlPort) != 0) + { + LOGERR("Couldn't bind to port %d", ControlPort); + return false; + } + + if (m_pMIDISocket->Bind(MIDIPort) != 0) + { + LOGERR("Couldn't bind to port %d", MIDIPort); + return false; + } + + // We started as a suspended task; run now that initialization is successful + Start(); + + return true; +} + +void CAppleMIDIParticipant::Run() +{ + assert(m_pControlSocket != nullptr); + assert(m_pMIDISocket != nullptr); + + CScheduler* const pScheduler = CScheduler::Get(); + + while (true) + { + if ((m_nControlResult = m_pControlSocket->ReceiveFrom(m_ControlBuffer, sizeof(m_ControlBuffer), MSG_DONTWAIT, &m_ForeignControlIPAddress, &m_nForeignControlPort)) < 0) + LOGERR("Control socket receive error: %d", m_nControlResult); + + if ((m_nMIDIResult = m_pMIDISocket->ReceiveFrom(m_MIDIBuffer, sizeof(m_MIDIBuffer), MSG_DONTWAIT, &m_ForeignMIDIIPAddress, &m_nForeignMIDIPort)) < 0) + LOGERR("MIDI socket receive error: %d", m_nMIDIResult); + + switch (m_State) + { + case TState::ControlInvitation: + ControlInvitationState(); + break; + + case TState::MIDIInvitation: + MIDIInvitationState(); + break; + + case TState::Connected: + ConnectedState(); + break; + } + + // Allow other tasks to run + pScheduler->Yield(); + } +} + +void CAppleMIDIParticipant::ControlInvitationState() +{ + TAppleMIDISession SessionPacket; + + if (m_nControlResult == 0) + return; + + if (!ParseInvitationPacket(m_ControlBuffer, m_nControlResult, &SessionPacket)) + { + LOGERR("Unexpected packet"); + return; + } + +#ifdef APPLEMIDI_DEBUG + LOGNOTE("<-- Control invitation"); +#endif + + // Store initiator details + m_InitiatorIPAddress.Set(m_ForeignControlIPAddress); + m_nInitiatorControlPort = m_nForeignControlPort; + m_nInitiatorToken = SessionPacket.nInitiatorToken; + m_nInitiatorSSRC = SessionPacket.nSSRC; + + // Generate random SSRC and accept + m_nSSRC = m_pRandom->GetNumber(); + if (!SendAcceptInvitationPacket(m_pControlSocket, &m_InitiatorIPAddress, m_nInitiatorControlPort)) + { + LOGERR("Couldn't accept control invitation"); + return; + } + + m_nLastSyncTime = GetSyncClock(); + m_State = TState::MIDIInvitation; +} + +void CAppleMIDIParticipant::MIDIInvitationState() +{ + TAppleMIDISession SessionPacket; + + if (m_nControlResult > 0) + { + if (ParseInvitationPacket(m_ControlBuffer, m_nControlResult, &SessionPacket)) + { + // Unexpected peer; reject invitation + if (m_ForeignControlIPAddress != m_InitiatorIPAddress || m_nForeignControlPort != m_nInitiatorControlPort) + SendRejectInvitationPacket(m_pControlSocket, &m_ForeignControlIPAddress, m_nForeignControlPort, SessionPacket.nInitiatorToken); + else + LOGERR("Unexpected packet"); + } + } + + if (m_nMIDIResult > 0) + { + if (!ParseInvitationPacket(m_MIDIBuffer, m_nMIDIResult, &SessionPacket)) + { + LOGERR("Unexpected packet"); + return; + } + + // Unexpected peer; reject invitation + if (m_ForeignMIDIIPAddress != m_InitiatorIPAddress) + { + SendRejectInvitationPacket(m_pMIDISocket, &m_ForeignMIDIIPAddress, m_nForeignMIDIPort, SessionPacket.nInitiatorToken); + return; + } + +#ifdef APPLEMIDI_DEBUG + LOGNOTE("<-- MIDI invitation"); +#endif + + m_nInitiatorMIDIPort = m_nForeignMIDIPort; + + if (SendAcceptInvitationPacket(m_pMIDISocket, &m_InitiatorIPAddress, m_nInitiatorMIDIPort)) + { + CString IPAddressString; + m_InitiatorIPAddress.Format(&IPAddressString); + LOGNOTE("Connection to %s (%s) established", SessionPacket.Name, static_cast(IPAddressString)); + m_nLastSyncTime = GetSyncClock(); + m_State = TState::Connected; + m_pHandler->OnAppleMIDIConnect(&m_InitiatorIPAddress, SessionPacket.Name); + } + else + { + LOGERR("Couldn't accept MIDI invitation"); + Reset(); + } + } + + // Timeout + else if ((GetSyncClock() - m_nLastSyncTime) > InvitationTimeout) + { + LOGERR("MIDI port invitation timed out"); + Reset(); + } +} + +void CAppleMIDIParticipant::ConnectedState() +{ + TAppleMIDISession SessionPacket; + TRTPMIDI MIDIPacket; + TAppleMIDISync SyncPacket; + + if (m_nControlResult > 0) + { + if (ParseEndSessionPacket(m_ControlBuffer, m_nControlResult, &SessionPacket)) + { +#ifdef APPLEMIDI_DEBUG + LOGNOTE("<-- End session"); +#endif + + if (m_ForeignControlIPAddress == m_InitiatorIPAddress && + m_nForeignControlPort == m_nInitiatorControlPort && + SessionPacket.nSSRC == m_nInitiatorSSRC) + { + LOGNOTE("Initiator ended session"); + m_pHandler->OnAppleMIDIDisconnect(&m_InitiatorIPAddress, SessionPacket.Name); + Reset(); + return; + } + } + else if (ParseInvitationPacket(m_ControlBuffer, m_nControlResult, &SessionPacket)) + { + // Unexpected peer; reject invitation + if (m_ForeignControlIPAddress != m_InitiatorIPAddress || m_nForeignControlPort != m_nInitiatorControlPort) + SendRejectInvitationPacket(m_pControlSocket, &m_ForeignControlIPAddress, m_nForeignControlPort, SessionPacket.nInitiatorToken); + else + LOGERR("Unexpected packet"); + } + } + + if (m_nMIDIResult > 0) + { + if (m_ForeignMIDIIPAddress != m_InitiatorIPAddress || m_nForeignMIDIPort != m_nInitiatorMIDIPort) + LOGERR("Unexpected packet"); + else if (ParseMIDIPacket(m_MIDIBuffer, m_nMIDIResult, &MIDIPacket, m_pHandler)) + m_nSequence = MIDIPacket.nSequence; + else if (ParseSyncPacket(m_MIDIBuffer, m_nMIDIResult, &SyncPacket)) + { +#ifdef APPLEMIDI_DEBUG + LOGNOTE("<-- Sync %d", SyncPacket.nCount); +#endif + + if (SyncPacket.nSSRC == m_nInitiatorSSRC && (SyncPacket.nCount == 0 || SyncPacket.nCount == 2)) + { + if (SyncPacket.nCount == 0) + SendSyncPacket(SyncPacket.Timestamps[0], GetSyncClock()); + else if (SyncPacket.nCount == 2) + { + m_nOffsetEstimate = ((SyncPacket.Timestamps[2] + SyncPacket.Timestamps[0]) / 2) - SyncPacket.Timestamps[1]; +#ifdef APPLEMIDI_DEBUG + LOGNOTE("Offset estimate: %llu", m_nOffsetEstimate); +#endif + } + + m_nLastSyncTime = GetSyncClock(); + } + else + { + LOGERR("Unexpected sync packet"); + } + } + } + + const u64 nTicks = GetSyncClock(); + + if ((nTicks - m_nLastFeedbackTime) > ReceiverFeedbackPeriod) + { + if (m_nSequence != m_nLastFeedbackSequence) + { + SendFeedbackPacket(); + m_nLastFeedbackSequence = m_nSequence; + } + m_nLastFeedbackTime = nTicks; + } + + if ((nTicks - m_nLastSyncTime) > SyncTimeout) + { + LOGERR("Initiator timed out"); + Reset(); + } +} + +void CAppleMIDIParticipant::Reset() +{ + m_State = TState::ControlInvitation; + + m_nInitiatorToken = 0; + m_nInitiatorSSRC = 0; + m_nSSRC = 0; + m_nLastMIDISequenceNumber = 0; + + m_nOffsetEstimate = 0; + m_nLastSyncTime = 0; + + m_nSequence = 0; + m_nLastFeedbackSequence = 0; + m_nLastFeedbackTime = 0; +} + +bool CAppleMIDIParticipant::SendPacket(CSocket* pSocket, CIPAddress* pIPAddress, u16 nPort, const void* pData, size_t nSize) +{ + const int nResult = pSocket->SendTo(pData, nSize, MSG_DONTWAIT, *pIPAddress, nPort); + + if (nResult < 0) + { + LOGERR("Send failure, error code: %d", nResult); + return false; + } + + if (static_cast(nResult) != nSize) + { + LOGERR("Send failure, only %d/%d bytes sent", nResult, nSize); + return false; + } + +#ifdef APPLEMIDI_DEBUG + LOGNOTE("Sent %d bytes to port %d", nResult, nPort); +#endif + + return true; +} + +bool CAppleMIDIParticipant::SendAcceptInvitationPacket(CSocket* pSocket, CIPAddress* pIPAddress, u16 nPort) +{ + TAppleMIDISession AcceptPacket = + { + htons(AppleMIDISignature), + htons(InvitationAccepted), + htonl(AppleMIDIVersion), + htonl(m_nInitiatorToken), + htonl(m_nSSRC), + {'\0'} + }; + + // TODO: configurable name + strncpy(AcceptPacket.Name, "MiniDexed", sizeof(AcceptPacket.Name)); + +#ifdef APPLEMIDI_DEBUG + LOGNOTE("--> Accept invitation"); +#endif + + const size_t nSendSize = NamelessSessionPacketSize + strlen(AcceptPacket.Name) + 1; + return SendPacket(pSocket, pIPAddress, nPort, &AcceptPacket, nSendSize); +} + +bool CAppleMIDIParticipant::SendRejectInvitationPacket(CSocket* pSocket, CIPAddress* pIPAddress, u16 nPort, u32 nInitiatorToken) +{ + TAppleMIDISession RejectPacket = + { + htons(AppleMIDISignature), + htons(InvitationRejected), + htonl(AppleMIDIVersion), + htonl(nInitiatorToken), + htonl(m_nSSRC), + {'\0'} + }; + +#ifdef APPLEMIDI_DEBUG + LOGNOTE("--> Reject invitation"); +#endif + + // Send without name + return SendPacket(pSocket, pIPAddress, nPort, &RejectPacket, NamelessSessionPacketSize); +} + +bool CAppleMIDIParticipant::SendSyncPacket(u64 nTimestamp1, u64 nTimestamp2) +{ + const TAppleMIDISync SyncPacket = + { + htons(AppleMIDISignature), + htons(Sync), + htonl(m_nSSRC), + 1, + {0}, + { + htonll(nTimestamp1), + htonll(nTimestamp2), + 0 + } + }; + +#ifdef APPLEMIDI_DEBUG + LOGNOTE("--> Sync 1"); +#endif + + return SendPacket(m_pMIDISocket, &m_InitiatorIPAddress, m_nInitiatorMIDIPort, &SyncPacket, sizeof(SyncPacket)); +} + +bool CAppleMIDIParticipant::SendFeedbackPacket() +{ + const TAppleMIDIReceiverFeedback FeedbackPacket = + { + htons(AppleMIDISignature), + htons(ReceiverFeedback), + htonl(m_nSSRC), + htonl(m_nSequence << 16) + }; + +#ifdef APPLEMIDI_DEBUG + LOGNOTE("--> Feedback"); +#endif + + return SendPacket(m_pControlSocket, &m_InitiatorIPAddress, m_nInitiatorControlPort, &FeedbackPacket, sizeof(FeedbackPacket)); +} \ No newline at end of file diff --git a/src/net/applemidi.h b/src/net/applemidi.h new file mode 100644 index 0000000..3df68ae --- /dev/null +++ b/src/net/applemidi.h @@ -0,0 +1,111 @@ +// +// applemidi.h +// +// mt32-pi - A baremetal MIDI synthesizer for Raspberry Pi +// Copyright (C) 2020-2023 Dale Whinham +// +// This file is part of mt32-pi. +// +// mt32-pi is free software: you can redistribute it and/or modify it under the +// terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// mt32-pi is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// mt32-pi. If not, see . +// + +#ifndef _applemidi_h +#define _applemidi_h + +#include +#include +#include +#include + +class CAppleMIDIHandler +{ +public: + virtual void OnAppleMIDIDataReceived(const u8* pData, size_t nSize) = 0; + virtual void OnAppleMIDIConnect(const CIPAddress* pIPAddress, const char* pName) = 0; + virtual void OnAppleMIDIDisconnect(const CIPAddress* pIPAddress, const char* pName) = 0; +}; + +class CAppleMIDIParticipant : protected CTask +{ +public: + CAppleMIDIParticipant(CBcmRandomNumberGenerator* pRandom, CAppleMIDIHandler* pHandler); + virtual ~CAppleMIDIParticipant() override; + + bool Initialize(); + + virtual void Run() override; + +private: + void ControlInvitationState(); + void MIDIInvitationState(); + void ConnectedState(); + void Reset(); + + bool SendPacket(CSocket* pSocket, CIPAddress* pIPAddress, u16 nPort, const void* pData, size_t nSize); + bool SendAcceptInvitationPacket(CSocket* pSocket, CIPAddress* pIPAddress, u16 nPort); + bool SendRejectInvitationPacket(CSocket* pSocket, CIPAddress* pIPAddress, u16 nPort, u32 nInitiatorToken); + bool SendSyncPacket(u64 nTimestamp1, u64 nTimestamp2); + bool SendFeedbackPacket(); + + CBcmRandomNumberGenerator* m_pRandom; + + // UDP sockets + CSocket* m_pControlSocket; + CSocket* m_pMIDISocket; + + // Foreign peers + CIPAddress m_ForeignControlIPAddress; + CIPAddress m_ForeignMIDIIPAddress; + u16 m_nForeignControlPort; + u16 m_nForeignMIDIPort; + + // Connected peer + CIPAddress m_InitiatorIPAddress; + u16 m_nInitiatorControlPort; + u16 m_nInitiatorMIDIPort; + + // Socket receive buffers + u8 m_ControlBuffer[FRAME_BUFFER_SIZE]; + u8 m_MIDIBuffer[FRAME_BUFFER_SIZE]; + + int m_nControlResult; + int m_nMIDIResult; + + // Callback handler + CAppleMIDIHandler* m_pHandler; + + // Participant state machine + enum class TState + { + ControlInvitation, + MIDIInvitation, + Connected + }; + + TState m_State; + + u32 m_nInitiatorToken = 0; + u32 m_nInitiatorSSRC = 0; + u32 m_nSSRC = 0; + u32 m_nLastMIDISequenceNumber = 0; + + u64 m_nOffsetEstimate = 0; + u64 m_nLastSyncTime = 0; + + u16 m_nSequence = 0; + u16 m_nLastFeedbackSequence = 0; + u64 m_nLastFeedbackTime = 0; +}; + +#endif \ No newline at end of file diff --git a/src/net/byteorder.h b/src/net/byteorder.h new file mode 100644 index 0000000..5160119 --- /dev/null +++ b/src/net/byteorder.h @@ -0,0 +1,42 @@ +// +// byteorder.h +// +// mt32-pi - A baremetal MIDI synthesizer for Raspberry Pi +// Copyright (C) 2020-2023 Dale Whinham +// +// This file is part of mt32-pi. +// +// mt32-pi is free software: you can redistribute it and/or modify it under the +// terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// mt32-pi is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// mt32-pi. If not, see . +// + +#ifndef _byteorder_h +#define _byteorder_h + +#if __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__ +#define htons(VALUE) (VALUE) +#define htonl(VALUE) (VALUE) +#define htonll(VALUE) (VALUE) +#define ntohs(VALUE) (VALUE) +#define ntohl(VALUE) (VALUE) +#define ntohll(VALUE) (VALUE) +#else +#define htons(VALUE) __builtin_bswap16(VALUE) +#define htonl(VALUE) __builtin_bswap32(VALUE) +#define htonll(VALUE) __builtin_bswap64(VALUE) +#define ntohs(VALUE) __builtin_bswap16(VALUE) +#define ntohl(VALUE) __builtin_bswap32(VALUE) +#define ntohll(VALUE) __builtin_bswap64(VALUE) +#endif + +#endif \ No newline at end of file diff --git a/src/net/ftpdaemon.cpp b/src/net/ftpdaemon.cpp new file mode 100644 index 0000000..0cab51c --- /dev/null +++ b/src/net/ftpdaemon.cpp @@ -0,0 +1,111 @@ +// +// ftpdaemon.cpp +// +// mt32-pi - A baremetal MIDI synthesizer for Raspberry Pi +// Copyright (C) 2020-2023 Dale Whinham +// +// This file is part of mt32-pi. +// +// mt32-pi is free software: you can redistribute it and/or modify it under the +// terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// mt32-pi is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// mt32-pi. If not, see . +// + +#include +#include +#include +#include +#include + +#include "ftpdaemon.h" +#include "ftpworker.h" + +LOGMODULE("ftpd"); + +constexpr u16 ListenPort = 21; +constexpr u8 MaxConnections = 1; + +CFTPDaemon::CFTPDaemon(const char* pUser, const char* pPassword) + : CTask(TASK_STACK_SIZE, true), + m_pListenSocket(nullptr), + m_pUser(pUser), + m_pPassword(pPassword) +{ +} + +CFTPDaemon::~CFTPDaemon() +{ + if (m_pListenSocket) + delete m_pListenSocket; +} + +bool CFTPDaemon::Initialize() +{ + CNetSubSystem* const pNet = CNetSubSystem::Get(); + + if ((m_pListenSocket = new CSocket(pNet, IPPROTO_TCP)) == nullptr) + return false; + + if (m_pListenSocket->Bind(ListenPort) != 0) + { + LOGERR("Couldn't bind to port %d", ListenPort); + return false; + } + + if (m_pListenSocket->Listen() != 0) + { + LOGERR("Failed to listen on control socket"); + return false; + } + + // We started as a suspended task; run now that initialization is successful + Start(); + + return true; +} + +void CFTPDaemon::Run() +{ + assert(m_pListenSocket != nullptr); + + LOGNOTE("Listener task spawned"); + + while (true) + { + CIPAddress ClientIPAddress; + u16 nClientPort; + + LOGDBG("Listener: waiting for connection"); + CSocket* pConnection = m_pListenSocket->Accept(&ClientIPAddress, &nClientPort); + + if (pConnection == nullptr) + { + LOGERR("Unable to accept connection"); + continue; + } + + CString IPAddressString; + ClientIPAddress.Format(&IPAddressString); + LOGNOTE("Incoming connection from %s:%d", static_cast(IPAddressString), nClientPort); + + if (CFTPWorker::GetInstanceCount() >= MaxConnections) + { + pConnection->Send("421 Maximum number of connections reached.\r\n", 45, 0); + delete pConnection; + LOGWARN("Maximum number of connections reached"); + continue; + } + + // Spawn new worker + new CFTPWorker(pConnection, m_pUser, m_pPassword); + } +} \ No newline at end of file diff --git a/src/net/ftpdaemon.h b/src/net/ftpdaemon.h new file mode 100644 index 0000000..4d75762 --- /dev/null +++ b/src/net/ftpdaemon.h @@ -0,0 +1,47 @@ +// +// ftpdaemon.h +// +// mt32-pi - A baremetal MIDI synthesizer for Raspberry Pi +// Copyright (C) 2020-2023 Dale Whinham +// +// This file is part of mt32-pi. +// +// mt32-pi is free software: you can redistribute it and/or modify it under the +// terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// mt32-pi is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// mt32-pi. If not, see . +// + +#ifndef _ftpdaemon_h +#define _ftpdaemon_h + +#include +#include + +class CFTPDaemon : protected CTask +{ +public: + CFTPDaemon(const char* pUser, const char* pPassword); + virtual ~CFTPDaemon() override; + + bool Initialize(); + + virtual void Run() override; + +private: + // TCP sockets + CSocket* m_pListenSocket; + + const char* m_pUser; + const char* m_pPassword; +}; + +#endif \ No newline at end of file diff --git a/src/net/ftpworker.cpp b/src/net/ftpworker.cpp new file mode 100644 index 0000000..6f19f8a --- /dev/null +++ b/src/net/ftpworker.cpp @@ -0,0 +1,1218 @@ +// +// ftpworker.cpp +// +// mt32-pi - A baremetal MIDI synthesizer for Raspberry Pi +// Copyright (C) 2020-2023 Dale Whinham +// +// This file is part of mt32-pi. +// +// mt32-pi is free software: you can redistribute it and/or modify it under the +// terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// mt32-pi is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// mt32-pi. If not, see . +// + +//#define FTPDAEMON_DEBUG + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "ftpworker.h" +#include "utility.h" + +// Use a per-instance name for the log macros +#define From m_LogName + +constexpr u16 PassivePortBase = 9000; +constexpr size_t TextBufferSize = 512; +constexpr unsigned int SocketTimeout = 20; +constexpr unsigned int NumRetries = 3; + +#ifndef MT32_PI_VERSION +#define MT32_PI_VERSION "(version unknown)" +#endif + +const char MOTDBanner[] = "Welcome to the MiniDexed " MT32_PI_VERSION " embedded FTP server!"; +const char* exclude_filename = "SD:/wpa_supplicant.conf"; + +enum class TDirectoryListEntryType +{ + File, + Directory, +}; + +struct TDirectoryListEntry +{ + char Name[FF_LFN_BUF + 1]; + TDirectoryListEntryType Type; + u32 nSize; + u16 nLastModifedDate; + u16 nLastModifedTime; +}; + +using TCommandHandler = bool (CFTPWorker::*)(const char* pArgs); + +struct TFTPCommand +{ + const char* pCmdStr; + TCommandHandler pHandler; +}; + +const TFTPCommand CFTPWorker::Commands[] = +{ + { "SYST", &CFTPWorker::System }, + { "USER", &CFTPWorker::Username }, + { "PASS", &CFTPWorker::Password }, + { "TYPE", &CFTPWorker::Type }, + { "PASV", &CFTPWorker::Passive }, + { "PORT", &CFTPWorker::Port }, + { "RETR", &CFTPWorker::Retrieve }, + { "STOR", &CFTPWorker::Store }, + { "DELE", &CFTPWorker::Delete }, + { "RMD", &CFTPWorker::Delete }, + { "MKD", &CFTPWorker::MakeDirectory }, + { "CWD", &CFTPWorker::ChangeWorkingDirectory }, + { "CDUP", &CFTPWorker::ChangeToParentDirectory }, + { "PWD", &CFTPWorker::PrintWorkingDirectory }, + { "LIST", &CFTPWorker::List }, + { "NLST", &CFTPWorker::ListFileNames }, + { "RNFR", &CFTPWorker::RenameFrom }, + { "RNTO", &CFTPWorker::RenameTo }, + { "BYE", &CFTPWorker::Bye }, + { "QUIT", &CFTPWorker::Bye }, + { "NOOP", &CFTPWorker::NoOp }, +}; + +u8 CFTPWorker::s_nInstanceCount = 0; + +// Volume names from ffconf.h +// TODO: Share with soundfontmanager.cpp +const char* const VolumeNames[] = { FF_VOLUME_STRS }; + +bool ValidateVolumeName(const char* pVolumeName) +{ + for (const auto pName : VolumeNames) + { + if (strcasecmp(pName, pVolumeName) == 0) + return true; + } + + return false; +} + +// Comparator for sorting directory listings +inline bool DirectoryCaseInsensitiveAscending(const TDirectoryListEntry& EntryA, const TDirectoryListEntry& EntryB) +{ + // Directories first in ascending order + if (EntryA.Type != EntryB.Type) + return EntryA.Type == TDirectoryListEntryType::Directory; + + return strncasecmp(EntryA.Name, EntryB.Name, sizeof(TDirectoryListEntry::Name)) < 0; +} + + +CFTPWorker::CFTPWorker(CSocket* pControlSocket, const char* pExpectedUser, const char* pExpectedPassword) + : CTask(TASK_STACK_SIZE), + m_LogName(), + m_pExpectedUser(pExpectedUser), + m_pExpectedPassword(pExpectedPassword), + m_pControlSocket(pControlSocket), + m_pDataSocket(nullptr), + m_nDataSocketPort(0), + m_DataSocketIPAddress(), + m_CommandBuffer{'\0'}, + m_DataBuffer{0}, + m_User(), + m_Password(), + m_DataType(TDataType::ASCII), + m_TransferMode(TTransferMode::Active), + m_CurrentPath(), + m_RenameFrom() +{ + ++s_nInstanceCount; + m_LogName.Format("ftpd[%d]", s_nInstanceCount); +} + +CFTPWorker::~CFTPWorker() +{ + if (m_pControlSocket) + delete m_pControlSocket; + + if (m_pDataSocket) + delete m_pDataSocket; + + --s_nInstanceCount; + + LOGNOTE("Instance count is now %d", s_nInstanceCount); +} + +void CFTPWorker::Run() +{ + assert(m_pControlSocket != nullptr); + + const size_t nWorkerNumber = s_nInstanceCount; + CScheduler* const pScheduler = CScheduler::Get(); + + LOGNOTE("Worker task %d spawned", nWorkerNumber); + + if (!SendStatus(TFTPStatus::ReadyForNewUser, MOTDBanner)) + return; + + CTimer* const pTimer = CTimer::Get(); + unsigned int nTimeout = pTimer->GetTicks(); + + while (m_pControlSocket) + { + // Block while waiting to receive +#ifdef FTPDAEMON_DEBUG + LOGDBG("Waiting for command"); +#endif + const int nReceiveBytes = m_pControlSocket->Receive(m_CommandBuffer, sizeof(m_CommandBuffer), MSG_DONTWAIT); + + if (nReceiveBytes == 0) + { + if (pTimer->GetTicks() - nTimeout >= SocketTimeout * HZ) + { + LOGERR("Socket timed out"); + break; + } + + pScheduler->Yield(); + continue; + } + + if (nReceiveBytes < 0) + { + LOGNOTE("Connection closed"); + break; + } + + // FIXME + m_CommandBuffer[nReceiveBytes - 2] = '\0'; + +#ifdef FTPDAEMON_DEBUG + const u8* pIPAddress = m_pControlSocket->GetForeignIP(); + LOGDBG("<-- Received %d bytes from %d.%d.%d.%d: '%s'", nReceiveBytes, pIPAddress[0], pIPAddress[1], pIPAddress[2], pIPAddress[3], m_CommandBuffer); +#endif + + char* pSavePtr; + char* pToken = strtok_r(m_CommandBuffer, " \r\n", &pSavePtr); + + if (!pToken) + { + LOGERR("String tokenization error (received: '%s')", m_CommandBuffer); + continue; + } + + TCommandHandler pHandler = nullptr; + for (size_t i = 0; i < Utility::ArraySize(Commands); ++i) + { + if (strcasecmp(pToken, Commands[i].pCmdStr) == 0) + { + pHandler = Commands[i].pHandler; + break; + } + } + + if (pHandler) + (this->*pHandler)(pSavePtr); + else + SendStatus(TFTPStatus::CommandNotImplemented, "Command not implemented."); + + nTimeout = pTimer->GetTicks(); + } + + LOGNOTE("Worker task %d shutting down", nWorkerNumber); + + delete m_pControlSocket; + m_pControlSocket = nullptr; +} + +CSocket* CFTPWorker::OpenDataConnection() +{ + CSocket* pDataSocket = nullptr; + u8 nRetries = NumRetries; + + while (pDataSocket == nullptr && nRetries > 0) + { + // Active: Create new socket and connect to client + if (m_TransferMode == TTransferMode::Active) + { + CNetSubSystem* const pNet = CNetSubSystem::Get(); + pDataSocket = new CSocket(pNet, IPPROTO_TCP); + + if (pDataSocket == nullptr) + { + SendStatus(TFTPStatus::DataConnectionFailed, "Could not open socket."); + return nullptr; + } + + if (pDataSocket->Connect(m_DataSocketIPAddress, m_nDataSocketPort) < 0) + { + SendStatus(TFTPStatus::DataConnectionFailed, "Could not connect to data port."); + delete pDataSocket; + pDataSocket = nullptr; + } + } + + // Passive: Use previously-created socket and accept connection from client + else if (m_TransferMode == TTransferMode::Passive && m_pDataSocket != nullptr) + { + CIPAddress ClientIPAddress; + u16 nClientPort; + pDataSocket = m_pDataSocket->Accept(&ClientIPAddress, &nClientPort); + } + + --nRetries; + } + + if (pDataSocket == nullptr) + { + LOGERR("Unable to open data socket after %d attempts", NumRetries); + SendStatus(TFTPStatus::DataConnectionFailed, "Couldn't open data connection."); + } + + return pDataSocket; +} + +bool CFTPWorker::SendStatus(TFTPStatus StatusCode, const char* pMessage) +{ + assert(m_pControlSocket != nullptr); + + const int nLength = snprintf(m_CommandBuffer, sizeof(m_CommandBuffer), "%d %s\r\n", StatusCode, pMessage); + if (m_pControlSocket->Send(m_CommandBuffer, nLength, 0) < 0) + { + LOGERR("Failed to send status"); + return false; + } +#ifdef FTPDAEMON_DEBUG + else + { + m_CommandBuffer[nLength - 2] = '\0'; + LOGDBG("--> Sent: '%s'", m_CommandBuffer); + } +#endif + + return true; +} + +bool CFTPWorker::CheckLoggedIn() +{ +#ifdef FTPDAEMON_DEBUG + LOGDBG("Username compare: expected '%s', actual '%s'", static_cast(m_pExpectedUser), static_cast(m_User)); + LOGDBG("Password compare: expected '%s', actual '%s'", static_cast(m_pExpectedPassword), static_cast(m_Password)); +#endif + + if (m_User.Compare(m_pExpectedUser) == 0 && m_Password.Compare(m_pExpectedPassword) == 0) + return true; + + SendStatus(TFTPStatus::NotLoggedIn, "Not logged in."); + return false; +} + +CString CFTPWorker::RealPath(const char* pInBuffer) const +{ + assert(pInBuffer != nullptr); + + CString Path; + const bool bAbsolute = pInBuffer[0] == '/'; + + if (bAbsolute) + { + char Buffer[TextBufferSize]; + FTPPathToFatFsPath(pInBuffer, Buffer, sizeof(Buffer)); + Path = Buffer; + } + else + Path.Format("%s/%s", static_cast(m_CurrentPath), pInBuffer); + + return Path; +} + +const TDirectoryListEntry* CFTPWorker::BuildDirectoryList(size_t& nOutEntries) const +{ + DIR Dir; + FILINFO FileInfo; + FRESULT Result; + + TDirectoryListEntry* pEntries = nullptr; + nOutEntries = 0; + + // Volume list + if (m_CurrentPath.GetLength() == 0) + { + constexpr size_t nVolumes = Utility::ArraySize(VolumeNames); + bool VolumesAvailable[nVolumes] = { false }; + + for (size_t i = 0; i < nVolumes; ++i) + { + char VolumeName[6]; + strncpy(VolumeName, VolumeNames[i], sizeof(VolumeName) - 1); + strcat(VolumeName, ":"); + + // Returns FR_ + if ((Result = f_opendir(&Dir, VolumeName)) == FR_OK) + { + f_closedir(&Dir); + VolumesAvailable[i] = true; + ++nOutEntries; + } + } + + pEntries = new TDirectoryListEntry[nOutEntries]; + + size_t nCurrentEntry = 0; + for (size_t i = 0; i < nVolumes && nCurrentEntry < nOutEntries; ++i) + { + if (VolumesAvailable[i]) + { + TDirectoryListEntry& Entry = pEntries[nCurrentEntry++]; + strncpy(Entry.Name, VolumeNames[i], sizeof(Entry.Name)); + Entry.Type = TDirectoryListEntryType::Directory; + Entry.nSize = 0; + Entry.nLastModifedDate = 0; + Entry.nLastModifedTime = 0; + } + } + + return pEntries; + } + + // Directory list + Result = f_findfirst(&Dir, &FileInfo, m_CurrentPath, "*"); + if (Result == FR_OK && *FileInfo.fname) + { + // Count how many entries we need + do + { + ++nOutEntries; + Result = f_findnext(&Dir, &FileInfo); + } while (Result == FR_OK && *FileInfo.fname); + + f_closedir(&Dir); + + if (nOutEntries && (pEntries = new TDirectoryListEntry[nOutEntries])) + { + size_t nCurrentEntry = 0; + Result = f_findfirst(&Dir, &FileInfo, m_CurrentPath, "*"); + while (Result == FR_OK && *FileInfo.fname) + { + TDirectoryListEntry& Entry = pEntries[nCurrentEntry++]; + strncpy(Entry.Name, FileInfo.fname, sizeof(Entry.Name)); + + if (FileInfo.fattrib & AM_DIR) + { + Entry.Type = TDirectoryListEntryType::Directory; + Entry.nSize = 0; + } + else + { + Entry.Type = TDirectoryListEntryType::File; + Entry.nSize = FileInfo.fsize; + } + + Entry.nLastModifedDate = FileInfo.fdate; + Entry.nLastModifedTime = FileInfo.ftime; + + Result = f_findnext(&Dir, &FileInfo); + } + + f_closedir(&Dir); + + Utility::QSort(pEntries, DirectoryCaseInsensitiveAscending, 0, nOutEntries - 1); + } + } + + return pEntries; +} + +bool CFTPWorker::System(const char* pArgs) +{ + // Some FTP clients (e.g. Directory Opus) will only attempt to parse LIST responses as IIS/DOS-style if we pretend to be Windows NT + SendStatus(TFTPStatus::SystemType, "Windows_NT"); + return true; +} + +bool CFTPWorker::Username(const char* pArgs) +{ + m_User = pArgs; + char Buffer[TextBufferSize]; + snprintf(Buffer, sizeof(Buffer), "Password required for '%s'.", static_cast(m_User)); + SendStatus(TFTPStatus::PasswordRequired, Buffer); + return true; +} + +bool CFTPWorker::Port(const char* pArgs) +{ + if (!CheckLoggedIn()) + return false; + + char Buffer[TextBufferSize]; + strncpy(Buffer, pArgs, sizeof(Buffer) - 1); + + if (m_pDataSocket != nullptr) + { + delete m_pDataSocket; + m_pDataSocket = nullptr; + } + + m_TransferMode = TTransferMode::Active; + + // TODO: PORT IP Address should match original IP address + + u8 PortBytes[6]; + char* pSavePtr; + char* pToken = strtok_r(Buffer, " ,", &pSavePtr); + bool bParseError = (pToken == nullptr); + + if (!bParseError) + { + PortBytes[0] = static_cast(atoi(pToken)); + + for (u8 i = 0; i < 5; ++i) + { + pToken = strtok_r(nullptr, " ,", &pSavePtr); + if (pToken == nullptr) + { + bParseError = true; + break; + } + + PortBytes[i + 1] = static_cast(atoi(pToken)); + } + } + + if (bParseError) + { + SendStatus(TFTPStatus::SyntaxError, "Syntax error."); + return false; + } + + m_DataSocketIPAddress.Set(PortBytes); + m_nDataSocketPort = (PortBytes[4] << 8) + PortBytes[5]; + +#ifdef FTPDAEMON_DEBUG + CString IPAddressString; + m_DataSocketIPAddress.Format(&IPAddressString); + LOGDBG("PORT set to: %s:%d", static_cast(IPAddressString), m_nDataSocketPort); +#endif + + SendStatus(TFTPStatus::Success, "Command OK."); + return true; +} + +bool CFTPWorker::Passive(const char* pArgs) +{ + if (!CheckLoggedIn()) + return false; + + if (m_pDataSocket == nullptr) + { + m_TransferMode = TTransferMode::Passive; + m_nDataSocketPort = PassivePortBase + s_nInstanceCount - 1; + + CNetSubSystem* const pNet = CNetSubSystem::Get(); + m_pDataSocket = new CSocket(pNet, IPPROTO_TCP); + + if (m_pDataSocket == nullptr) + { + SendStatus(TFTPStatus::ServiceNotAvailable, "Failed to open port for passive mode."); + return false; + } + + if (m_pDataSocket->Bind(m_nDataSocketPort) < 0) + { + SendStatus(TFTPStatus::DataConnectionFailed, "Could not bind to data port."); + delete m_pDataSocket; + m_pDataSocket = nullptr; + return false; + } + + if (m_pDataSocket->Listen() < 0) + { + SendStatus(TFTPStatus::DataConnectionFailed, "Could not listen on data port."); + delete m_pDataSocket; + m_pDataSocket = nullptr; + return false; + } + } + + u8 IPAddress[IP_ADDRESS_SIZE]; + CNetSubSystem::Get()->GetConfig()->GetIPAddress()->CopyTo(IPAddress); + + char Buffer[TextBufferSize]; + snprintf(Buffer, sizeof(Buffer), "Entering passive mode (%d,%d,%d,%d,%d,%d).", + IPAddress[0], + IPAddress[1], + IPAddress[2], + IPAddress[3], + (m_nDataSocketPort >> 8) & 0xFF, + m_nDataSocketPort & 0xFF + ); + + SendStatus(TFTPStatus::EnteringPassiveMode, Buffer); + return true; +} + +bool CFTPWorker::Password(const char* pArgs) +{ + if (m_User.GetLength() == 0) + { + SendStatus(TFTPStatus::AccountRequired, "Need account for login."); + return false; + } + + m_Password = pArgs; + + if (!CheckLoggedIn()) + return false; + + SendStatus(TFTPStatus::UserLoggedIn, "User logged in."); + return true; +} + +bool CFTPWorker::Type(const char* pArgs) +{ + if (!CheckLoggedIn()) + return false; + + if (strcasecmp(pArgs, "A") == 0) + { + m_DataType = TDataType::ASCII; + SendStatus(TFTPStatus::Success, "Type set to ASCII."); + return true; + } + + if (strcasecmp(pArgs, "I") == 0) + { + m_DataType = TDataType::Binary; + SendStatus(TFTPStatus::Success, "Type set to binary."); + return true; + } + + SendStatus(TFTPStatus::SyntaxError, "Syntax error."); + return false; +} + +bool CFTPWorker::Retrieve(const char* pArgs) +{ + if (!CheckLoggedIn()) + return false; + + FIL File; + CString Path = RealPath(pArgs); + typedef const char* LPCTSTR; + //printf("%s\n", (LPCTSTR)Path); + //printf("%s\n", exclude_filename ); + if (strcmp((LPCTSTR)Path, exclude_filename) == 0) + { + SendStatus(TFTPStatus::FileNameNotAllowed, "Reading this file is not allowed"); + return false; + } + + if (f_open(&File, Path, FA_READ) != FR_OK) + { + SendStatus(TFTPStatus::FileActionNotTaken, "Could not open file for reading."); + return false; + } + + if (!SendStatus(TFTPStatus::FileStatusOk, "Command OK.")) + return false; + + CSocket* pDataSocket = OpenDataConnection(); + if (pDataSocket == nullptr) + return false; + + size_t nSize = f_size(&File); + size_t nSent = 0; + + while (nSent < nSize) + { + UINT nBytesRead; +#ifdef FTPDAEMON_DEBUG + LOGDBG("Sending data"); +#endif + if (f_read(&File, m_DataBuffer, sizeof(m_DataBuffer), &nBytesRead) != FR_OK || pDataSocket->Send(m_DataBuffer, nBytesRead, 0) < 0) + { + delete pDataSocket; + f_close(&File); + SendStatus(TFTPStatus::ActionAborted, "File action aborted, local error."); + return false; + } + + nSent += nBytesRead; + assert(nSent <= nSize); + } + + delete pDataSocket; + f_close(&File); + SendStatus(TFTPStatus::TransferComplete, "Transfer complete."); + + return false; +} + +bool CFTPWorker::Store(const char* pArgs) +{ + if (!CheckLoggedIn()) + return false; + + FIL File; + CString Path = RealPath(pArgs); + + if (f_open(&File, Path, FA_CREATE_ALWAYS | FA_WRITE) != FR_OK) + { + SendStatus(TFTPStatus::FileActionNotTaken, "Could not open file for writing."); + return false; + } + + f_sync(&File); + + if (!SendStatus(TFTPStatus::FileStatusOk, "Command OK.")) + return false; + + CSocket* pDataSocket = OpenDataConnection(); + if (pDataSocket == nullptr) + return false; + + bool bSuccess = true; + + CTimer* const pTimer = CTimer::Get(); + unsigned int nTimeout = pTimer->GetTicks(); + + while (true) + { +#ifdef FTPDAEMON_DEBUG + LOGDBG("Waiting to receive"); +#endif + int nReceiveResult = pDataSocket->Receive(m_DataBuffer, sizeof(m_DataBuffer), MSG_DONTWAIT); + FRESULT nWriteResult; + UINT nWritten; + + if (nReceiveResult == 0) + { + if (pTimer->GetTicks() - nTimeout >= SocketTimeout * HZ) + { + LOGERR("Socket timed out"); + bSuccess = false; + break; + } + CScheduler::Get()->Yield(); + continue; + } + + // All done + if (nReceiveResult < 0) + { + LOGNOTE("Receive done, no more data"); + break; + } + +#ifdef FTPDAEMON_DEBUG + //LOGDBG("Received %d bytes", nReceiveResult); +#endif + + if ((nWriteResult = f_write(&File, m_DataBuffer, nReceiveResult, &nWritten)) != FR_OK) + { + LOGERR("Write FAILED, return code %d", nWriteResult); + bSuccess = false; + break; + } + + f_sync(&File); + CScheduler::Get()->Yield(); + + nTimeout = pTimer->GetTicks(); + } + + if (bSuccess) + SendStatus(TFTPStatus::TransferComplete, "Transfer complete."); + else + SendStatus(TFTPStatus::ActionAborted, "File action aborted, local error."); + +#ifdef FTPDAEMON_DEBUG + LOGDBG("Closing socket/file"); +#endif + delete pDataSocket; + f_close(&File); + + return true; +} + +bool CFTPWorker::Delete(const char* pArgs) +{ + if (!CheckLoggedIn()) + return false; + + CString Path = RealPath(pArgs); + + if (f_unlink(Path) != FR_OK) + SendStatus(TFTPStatus::FileActionNotTaken, "File was not deleted."); + else + SendStatus(TFTPStatus::FileActionOk, "File deleted."); + + return true; +} + +bool CFTPWorker::MakeDirectory(const char* pArgs) +{ + if (!CheckLoggedIn()) + return false; + + CString Path = RealPath(pArgs); + + if (f_mkdir(Path) != FR_OK) + SendStatus(TFTPStatus::FileActionNotTaken, "Directory creation failed."); + else + { + char Buffer[TextBufferSize]; + FatFsPathToFTPPath(Path, Buffer, sizeof(Buffer)); + strcat(Buffer, " directory created."); + SendStatus(TFTPStatus::PathCreated, Buffer); + } + + return true; +} + +bool CFTPWorker::ChangeWorkingDirectory(const char* pArgs) +{ + if (!CheckLoggedIn()) + return false; + + char Buffer[TextBufferSize]; + bool bSuccess = false; + + const bool bAbsolute = pArgs[0] == '/'; + if (bAbsolute) + { + // Root + if (pArgs[1] == '\0') + { + m_CurrentPath = ""; + bSuccess = true; + } + else + { + DIR Dir; + FTPPathToFatFsPath(pArgs, Buffer, sizeof(Buffer)); + + // f_stat() will fail if we're trying to CWD to the root of a volume, so use f_opendir() + if (f_opendir(&Dir, Buffer) == FR_OK) + { + f_closedir(&Dir); + m_CurrentPath = Buffer; + bSuccess = true; + } + } + } + else + { + const bool bAtRoot = m_CurrentPath.GetLength() == 0; + if (bAtRoot) + { + if (ValidateVolumeName(pArgs)) + { + m_CurrentPath.Format("%s:", pArgs); + bSuccess = true; + } + } + else + { + CString NewPath; + NewPath.Format("%s/%s", static_cast(m_CurrentPath), pArgs); + + if (f_stat(NewPath, nullptr) == FR_OK) + { + m_CurrentPath = NewPath; + bSuccess = true; + } + } + } + + if (bSuccess) + { + const bool bAtRoot = m_CurrentPath.GetLength() == 0; + if (bAtRoot) + strncpy(Buffer, "\"/\"", sizeof(Buffer)); + else + FatFsPathToFTPPath(m_CurrentPath, Buffer, sizeof(Buffer)); + SendStatus(TFTPStatus::FileActionOk, Buffer); + } + else + SendStatus(TFTPStatus::FileNotFound, "Directory unavailable."); + + return bSuccess; +} + +bool CFTPWorker::ChangeToParentDirectory(const char* pArgs) +{ + if (!CheckLoggedIn()) + return false; + + char Buffer[TextBufferSize]; + bool bSuccess = false; + bool bAtRoot = m_CurrentPath.GetLength() == 0; + + if (!bAtRoot) + { + DIR Dir; + FatFsParentPath(m_CurrentPath, Buffer, sizeof(Buffer)); + + bAtRoot = Buffer[0] == '\0'; + if (bAtRoot) + { + m_CurrentPath = Buffer; + bSuccess = true; + } + else if (f_opendir(&Dir, Buffer) == FR_OK) + { + f_closedir(&Dir); + m_CurrentPath = Buffer; + bSuccess = true; + } + } + + if (bSuccess) + { + bAtRoot = m_CurrentPath.GetLength() == 0; + if (bAtRoot) + strncpy(Buffer, "\"/\"", sizeof(Buffer)); + else + FatFsPathToFTPPath(m_CurrentPath, Buffer, sizeof(Buffer)); + SendStatus(TFTPStatus::FileActionOk, Buffer); + } + else + SendStatus(TFTPStatus::FileNotFound, "Directory unavailable."); + + return false; +} + +bool CFTPWorker::PrintWorkingDirectory(const char* pArgs) +{ + if (!CheckLoggedIn()) + return false; + + char Buffer[TextBufferSize]; + + const bool bAtRoot = m_CurrentPath.GetLength() == 0; + if (bAtRoot) + strncpy(Buffer, "\"/\"", sizeof(Buffer)); + else + FatFsPathToFTPPath(m_CurrentPath, Buffer, sizeof(Buffer)); + + SendStatus(TFTPStatus::PathCreated, Buffer); + + return true; +} + +bool CFTPWorker::List(const char* pArgs) +{ + if (!CheckLoggedIn()) + return false; + + if (!SendStatus(TFTPStatus::FileStatusOk, "Command OK.")) + return false; + + CSocket* pDataSocket = OpenDataConnection(); + if (pDataSocket == nullptr) + return false; + + char Buffer[TextBufferSize]; + char Date[9]; + char Time[8]; + + size_t nEntries; + const TDirectoryListEntry* pDirEntries = BuildDirectoryList(nEntries); + + if (pDirEntries) + { + for (size_t i = 0; i < nEntries; ++i) + { + const TDirectoryListEntry& Entry = pDirEntries[i]; + int nLength; + + // Mimic the Microsoft IIS LIST format + FormatLastModifiedDate(Entry.nLastModifedDate, Date, sizeof(Date)); + FormatLastModifiedTime(Entry.nLastModifedTime, Time, sizeof(Time)); + + if (Entry.Type == TDirectoryListEntryType::Directory) + nLength = snprintf(Buffer, sizeof(Buffer), "%-9s %-13s %-14s %s\r\n", Date, Time, "", Entry.Name); + else + nLength = snprintf(Buffer, sizeof(Buffer), "%-9s %-13s %14d %s\r\n", Date, Time, Entry.nSize, Entry.Name); + + if (pDataSocket->Send(Buffer, nLength, 0) < 0) + { + delete[] pDirEntries; + delete pDataSocket; + SendStatus(TFTPStatus::DataConnectionFailed, "Transfer error."); + return false; + } + } + + delete[] pDirEntries; + } + + delete pDataSocket; + SendStatus(TFTPStatus::TransferComplete, "Transfer complete."); + return true; +} + +bool CFTPWorker::ListFileNames(const char* pArgs) +{ + if (!CheckLoggedIn()) + return false; + + if (!SendStatus(TFTPStatus::FileStatusOk, "Command OK.")) + return false; + + CSocket* pDataSocket = OpenDataConnection(); + if (pDataSocket == nullptr) + return false; + + char Buffer[TextBufferSize]; + size_t nEntries; + const TDirectoryListEntry* pDirEntries = BuildDirectoryList(nEntries); + + if (pDirEntries) + { + for (size_t i = 0; i < nEntries; ++i) + { + const TDirectoryListEntry& Entry = pDirEntries[i]; + if (Entry.Type == TDirectoryListEntryType::Directory) + continue; + const int nLength = snprintf(Buffer, sizeof(Buffer), "%s\r\n", Entry.Name); + if (pDataSocket->Send(Buffer, nLength, 0) < 0) + { + delete[] pDirEntries; + delete pDataSocket; + SendStatus(TFTPStatus::DataConnectionFailed, "Transfer error."); + return false; + } + } + + delete[] pDirEntries; + } + + delete pDataSocket; + SendStatus(TFTPStatus::TransferComplete, "Transfer complete."); + return true; +} + +bool CFTPWorker::RenameFrom(const char* pArgs) +{ + if (!CheckLoggedIn()) + return false; + + m_RenameFrom = pArgs; + SendStatus(TFTPStatus::PendingFurtherInfo, "Requested file action pending further information."); + + return false; +} + +bool CFTPWorker::RenameTo(const char* pArgs) +{ + if (!CheckLoggedIn()) + return false; + + if (m_RenameFrom.GetLength() == 0) + { + SendStatus(TFTPStatus::BadCommandSequence, "Bad sequence of commands."); + return false; + } + + CString SourcePath = RealPath(m_RenameFrom); + CString DestPath = RealPath(pArgs); + + if (f_rename(SourcePath, DestPath) != FR_OK) + SendStatus(TFTPStatus::FileNameNotAllowed, "File name not allowed."); + else + SendStatus(TFTPStatus::FileActionOk, "File renamed."); + + m_RenameFrom = ""; + + return false; +} + +bool CFTPWorker::Bye(const char* pArgs) +{ + SendStatus(TFTPStatus::ClosingControl, "Goodbye."); + delete m_pControlSocket; + m_pControlSocket = nullptr; + + // Reboot the system if the user disconnects in order to apply any changes made + reboot (); + return true; +} + +bool CFTPWorker::NoOp(const char* pArgs) +{ + SendStatus(TFTPStatus::Success, "Command OK."); + return true; +} + +void CFTPWorker::FatFsPathToFTPPath(const char* pInBuffer, char* pOutBuffer, size_t nSize) +{ + assert(pOutBuffer && nSize > 2); + const char* pEnd = pOutBuffer + nSize; + const char* pInChar = pInBuffer; + char* pOutChar = pOutBuffer; + + *pOutChar++ = '"'; + *pOutChar++ = '/'; + + while (*pInChar != '\0' && pOutChar < pEnd) + { + // Kill the volume colon + if (*pInChar == ':') + { + *pOutChar++ = '/'; + ++pInChar; + + // Kill any slashes after the colon + while (*pInChar == '/') ++pInChar; + continue; + } + + // Kill duplicate slashes + if (*pInChar == '/') + { + *pOutChar++ = *pInChar++; + while (*pInChar == '/') ++pInChar; + continue; + } + + *pOutChar++ = *pInChar++; + } + + // Kill trailing slash + if (*(pOutChar - 1) == '/') + --pOutChar; + + assert(pOutChar < pEnd - 2); + *pOutChar++ = '"'; + *pOutChar++ = '\0'; +} + +void CFTPWorker::FTPPathToFatFsPath(const char* pInBuffer, char* pOutBuffer, size_t nSize) +{ + assert(pInBuffer && pOutBuffer); + const char* pEnd = pOutBuffer + nSize; + const char* pInChar = pInBuffer; + char* pOutChar = pOutBuffer; + + // Kill leading slashes + while (*pInChar == '/') ++pInChar; + + bool bGotVolume = false; + while (*pInChar != '\0' && pOutChar < pEnd) + { + // Kill the volume colon + if (!bGotVolume && *pInChar == '/') + { + bGotVolume = true; + *pOutChar++ = ':'; + ++pInChar; + + // Kill any slashes after the colon + while (*pInChar == '/') ++pInChar; + continue; + } + + // Kill duplicate slashes + if (*pInChar == '/') + { + *pOutChar++ = *pInChar++; + while (*pInChar == '/') ++pInChar; + continue; + } + + *pOutChar++ = *pInChar++; + } + + assert(pOutChar < pEnd - 2); + + // Kill trailing slash + if (*(pOutChar - 1) == '/') + --pOutChar; + + // Add volume colon + if (!bGotVolume) + *pOutChar++ = ':'; + + *pOutChar++ = '\0'; +} + +void CFTPWorker::FatFsParentPath(const char* pInBuffer, char* pOutBuffer, size_t nSize) +{ + assert(pInBuffer != nullptr && pOutBuffer != nullptr); + + size_t nLength = strlen(pInBuffer); + assert(nLength > 0 && nSize >= nLength); + + const char* pLastChar = pInBuffer + nLength - 1; + const char* pInChar = pLastChar; + + // Kill trailing slashes + while (*pInChar == '/' && pInChar > pInBuffer) --pInChar; + + // Kill subdirectory name + while (*pInChar != '/' && *pInChar != ':' && pInChar > pInBuffer) --pInChar; + + // Kill trailing slashes + while (*pInChar == '/' && pInChar > pInBuffer) --pInChar; + + // Pointer didn't move (we're already at a volume root), or we reached the start of the string (path invalid) + if (pInChar == pLastChar || pInChar == pInBuffer) + { + *pOutBuffer = '\0'; + return; + } + + // Truncate string + nLength = pInChar - pInBuffer + 1; + memcpy(pOutBuffer, pInBuffer, nLength); + pOutBuffer[nLength] = '\0'; +} + +void CFTPWorker::FormatLastModifiedDate(u16 nDate, char* pOutBuffer, size_t nSize) +{ + // 2-digit year + const u16 nYear = (1980 + (nDate >> 9)) % 100; + u16 nMonth = (nDate >> 5) & 0x0F; + u16 nDay = nDate & 0x1F; + + if (nMonth == 0) + nMonth = 1; + if (nDay == 0) + nDay = 1; + + snprintf(pOutBuffer, nSize, "%02d-%02d-%02d", nMonth, nDay, nYear); +} + +void CFTPWorker::FormatLastModifiedTime(u16 nDate, char* pOutBuffer, size_t nSize) +{ + u16 nHour = (nDate >> 11) & 0x1F; + const u16 nMinute = (nDate >> 5) & 0x3F; + const char* pSuffix = nHour < 12 ? "AM" : "PM"; + + if (nHour == 0) + nHour = 12; + else if (nHour >= 12) + nHour -= 12; + + snprintf(pOutBuffer, nSize, "%02d:%02d%s", nHour, nMinute, pSuffix); +} \ No newline at end of file diff --git a/src/net/ftpworker.h b/src/net/ftpworker.h new file mode 100644 index 0000000..62e60ed --- /dev/null +++ b/src/net/ftpworker.h @@ -0,0 +1,157 @@ +// +// ftpworker.h +// +// mt32-pi - A baremetal MIDI synthesizer for Raspberry Pi +// Copyright (C) 2020-2023 Dale Whinham +// +// This file is part of mt32-pi. +// +// mt32-pi is free software: you can redistribute it and/or modify it under the +// terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// mt32-pi is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// mt32-pi. If not, see . +// + +#ifndef _ftpworker_h +#define _ftpworker_h + +#include +#include +#include +#include + +// TODO: These may be incomplete/inaccurate +enum TFTPStatus +{ + FileStatusOk = 150, + + Success = 200, + SystemType = 215, + ReadyForNewUser = 220, + ClosingControl = 221, + TransferComplete = 226, + EnteringPassiveMode = 227, + UserLoggedIn = 230, + FileActionOk = 250, + PathCreated = 257, + + PasswordRequired = 331, + AccountRequired = 332, + PendingFurtherInfo = 350, + + ServiceNotAvailable = 421, + DataConnectionFailed = 425, + FileActionNotTaken = 450, + ActionAborted = 451, + + CommandUnrecognized = 500, + SyntaxError = 501, + CommandNotImplemented = 502, + BadCommandSequence = 503, + NotLoggedIn = 530, + FileNotFound = 550, + FileNameNotAllowed = 553, +}; + +enum class TTransferMode +{ + Active, + Passive, +}; + +enum class TDataType +{ + ASCII, + Binary, +}; + +struct TFTPCommand; +struct TDirectoryListEntry; + +class CFTPWorker : protected CTask +{ +public: + CFTPWorker(CSocket* pControlSocket, const char* pExpectedUser, const char* pExpectedPassword); + virtual ~CFTPWorker() override; + + virtual void Run() override; + + static u8 GetInstanceCount() { return s_nInstanceCount; } + +private: + CSocket* OpenDataConnection(); + + bool SendStatus(TFTPStatus StatusCode, const char* pMessage); + + bool CheckLoggedIn(); + + // Directory navigation + CString RealPath(const char* pInBuffer) const; + const TDirectoryListEntry* BuildDirectoryList(size_t& nOutEntries) const; + + // FTP command handlers + bool System(const char* pArgs); + bool Username(const char* pArgs); + bool Port(const char* pArgs); + bool Passive(const char* pArgs); + bool Password(const char* pArgs); + bool Type(const char* pArgs); + bool Retrieve(const char* pArgs); + bool Store(const char* pArgs); + bool Delete(const char* pArgs); + bool MakeDirectory(const char* pArgs); + bool ChangeWorkingDirectory(const char* pArgs); + bool ChangeToParentDirectory(const char* pArgs); + bool PrintWorkingDirectory(const char* pArgs); + bool List(const char* pArgs); + bool ListFileNames(const char* pArgs); + bool RenameFrom(const char* pArgs); + bool RenameTo(const char* pArgs); + bool Bye(const char* pArgs); + bool NoOp(const char* pArgs); + + CString m_LogName; + + // Authentication + const char* m_pExpectedUser; + const char* m_pExpectedPassword; + + // TCP sockets + CSocket* m_pControlSocket; + CSocket* m_pDataSocket; + u16 m_nDataSocketPort; + CIPAddress m_DataSocketIPAddress; + + // Command/data buffers + char m_CommandBuffer[FRAME_BUFFER_SIZE]; + u8 m_DataBuffer[FRAME_BUFFER_SIZE]; + + // Session state + CString m_User; + CString m_Password; + TDataType m_DataType; + TTransferMode m_TransferMode; + CString m_CurrentPath; + CString m_RenameFrom; + + static void FatFsPathToFTPPath(const char* pInBuffer, char* pOutBuffer, size_t nSize); + static void FTPPathToFatFsPath(const char* pInBuffer, char* pOutBuffer, size_t nSize); + + static void FatFsParentPath(const char* pInBuffer, char* pOutBuffer, size_t nSize); + + static void FormatLastModifiedDate(u16 nDate, char* pOutBuffer, size_t nSize); + static void FormatLastModifiedTime(u16 nDate, char* pOutBuffer, size_t nSize); + + static const TFTPCommand Commands[]; + static u8 s_nInstanceCount; +}; + +#endif \ No newline at end of file diff --git a/src/net/mdnspublisher.cpp b/src/net/mdnspublisher.cpp new file mode 100644 index 0000000..23052db --- /dev/null +++ b/src/net/mdnspublisher.cpp @@ -0,0 +1,345 @@ +// +// mdnspublisher.cpp +// +// Circle - A C++ bare metal environment for Raspberry Pi +// Copyright (C) 2024 R. Stange +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +#include "mdnspublisher.h" +#include +#include +#include +#include +#include +#define MDNS_HOST_GROUP {224, 0, 0, 251} +#define MDNS_PORT 5353 +#define MDNS_DOMAIN "local" +#define RR_TYPE_A 1 +#define RR_TYPE_PTR 12 +#define RR_TYPE_TXT 16 +#define RR_TYPE_SRV 33 +#define RR_CLASS_IN 1 +#define RR_CACHE_FLUSH 0x8000 +LOGMODULE ("mdnspub"); +CmDNSPublisher::CmDNSPublisher (CNetSubSystem *pNet) +: m_pNet (pNet), + m_pSocket (nullptr), + m_bRunning (FALSE), + m_pWritePtr (nullptr), + m_pDataLen (nullptr) +{ + SetName ("mdnspub"); +} +CmDNSPublisher::~CmDNSPublisher (void) +{ + assert (!m_pSocket); + m_bRunning = FALSE; +} +boolean CmDNSPublisher::PublishService (const char *pServiceName, const char *pServiceType, + u16 usServicePort, const char *ppText[]) +{ + if (!m_bRunning) + { + // Let task can run once to initialize + CScheduler::Get ()->Yield (); + if (!m_bRunning) + { + return FALSE; + } + } + assert (pServiceName); + assert (pServiceType); + TService *pService = new TService {pServiceName, pServiceType, usServicePort, 0}; + assert (pService); + if (ppText) + { + for (unsigned i = 0; i < MaxTextRecords && ppText[i]; i++) + { + pService->ppText[i] = new CString (ppText[i]); + assert (pService->ppText[i]); + pService->nTextRecords++; + } + } + m_Mutex.Acquire (); + // Insert as first element into list + TPtrListElement *pElement = m_ServiceList.GetFirst (); + if (pElement) + { + m_ServiceList.InsertBefore (pElement, pService); + } + else + { + m_ServiceList.InsertAfter (nullptr, pService); + } + m_Mutex.Release (); + LOGDBG ("Publish service %s", (const char *) pService->ServiceName); + m_Event.Set (); // Trigger resent for everything + return TRUE; +} +boolean CmDNSPublisher::UnpublishService (const char *pServiceName) +{ + if (!m_bRunning) + { + return FALSE; + } + assert (pServiceName); + m_Mutex.Acquire (); + // Find service in the list and remove it + TService *pService = nullptr; + TPtrListElement *pElement = m_ServiceList.GetFirst (); + while (pElement) + { + pService = static_cast (CPtrList::GetPtr (pElement)); + assert (pService); + if (pService->ServiceName.Compare (pServiceName) == 0) + { + m_ServiceList.Remove (pElement); + break; + } + pService = nullptr; + pElement = m_ServiceList.GetNext (pElement); + } + m_Mutex.Release (); + if (!pService) + { + return FALSE; + } + LOGDBG ("Unpublish service %s", (const char *) pService->ServiceName); + if (!SendResponse (pService, TRUE)) + { + LOGWARN ("Send failed"); + } + for (unsigned i = 0; i < pService->nTextRecords; i++) + { + delete pService->ppText[i]; + } + delete pService; + return TRUE; +} +void CmDNSPublisher::Run (void) +{ + assert (m_pNet); + assert (!m_pSocket); + m_pSocket = new CSocket (m_pNet, IPPROTO_UDP); + assert (m_pSocket); + if (m_pSocket->Bind (MDNS_PORT) < 0) + { + LOGERR ("Cannot bind to port %u", MDNS_PORT); + delete m_pSocket; + m_pSocket = nullptr; + while (1) + { + m_Event.Clear (); + m_Event.Wait (); + } + } + static const u8 mDNSIPAddress[] = MDNS_HOST_GROUP; + CIPAddress mDNSIP (mDNSIPAddress); + if (m_pSocket->Connect (mDNSIP, MDNS_PORT) < 0) + { + LOGERR ("Cannot connect to mDNS host group"); + delete m_pSocket; + m_pSocket = nullptr; + while (1) + { + m_Event.Clear (); + m_Event.Wait (); + } + } + m_bRunning = TRUE; + while (1) + { + m_Event.Clear (); + m_Event.WaitWithTimeout ((TTLShort - 10) * 1000000); + for (unsigned i = 1; i <= 3; i++) + { + m_Mutex.Acquire (); + TPtrListElement *pElement = m_ServiceList.GetFirst (); + while (pElement) + { + TService *pService = + static_cast (CPtrList::GetPtr (pElement)); + assert (pService); + if (!SendResponse (pService, FALSE)) + { + LOGWARN ("Send failed"); + } + pElement = m_ServiceList.GetNext (pElement); + } + m_Mutex.Release (); + CScheduler::Get ()->Sleep (1); + } + } +} +boolean CmDNSPublisher::SendResponse (TService *pService, boolean bDelete) +{ + assert (pService); + assert (m_pNet); + // Collect data + static const char Domain[] = "." MDNS_DOMAIN; + CString ServiceType (pService->ServiceType); + ServiceType.Append (Domain); + CString ServiceName (pService->ServiceName); + ServiceName.Append ("."); + ServiceName.Append (ServiceType); + CString Hostname (m_pNet->GetHostname ()); + Hostname.Append (Domain); + // Start writing buffer + assert (!m_pWritePtr); + m_pWritePtr = m_Buffer; + // mDNS Header + PutWord (0); // Transaction ID + PutWord (0x8400); // Message is a response, Server is an authority for the domain + PutWord (0); // Questions + PutWord (5); // Answer RRs + PutWord (0); // Authority RRs + PutWord (0); // Additional RRs + // Answer RRs + // PTR + PutDNSName ("_services._dns-sd._udp.local"); + PutWord (RR_TYPE_PTR); + PutWord (RR_CLASS_IN); + PutDWord (bDelete ? TTLDelete : TTLLong); + ReserveDataLength (); + u8 *pServiceTypePtr = m_pWritePtr; + PutDNSName (ServiceType); + SetDataLength (); + // PTR + PutCompressedString (pServiceTypePtr); + PutWord (RR_TYPE_PTR); + PutWord (RR_CLASS_IN); + PutDWord (bDelete ? TTLDelete : TTLLong); + ReserveDataLength (); + u8 *pServiceNamePtr = m_pWritePtr; + PutDNSName (ServiceName); + SetDataLength (); + // SRV + PutCompressedString (pServiceNamePtr); + PutWord (RR_TYPE_SRV); + PutWord (RR_CLASS_IN | RR_CACHE_FLUSH); + PutDWord (bDelete ? TTLDelete : TTLShort); + ReserveDataLength (); + PutWord (0); // Priority + PutWord (0); // Weight + PutWord (pService->usServicePort); + u8 *pHostnamePtr = m_pWritePtr; + PutDNSName (Hostname); + SetDataLength (); + // A + PutCompressedString (pHostnamePtr); + PutWord (RR_TYPE_A); + PutWord (RR_CLASS_IN | RR_CACHE_FLUSH); + PutDWord (TTLShort); + ReserveDataLength (); + PutIPAddress (*m_pNet->GetConfig ()->GetIPAddress ()); + SetDataLength (); + // TXT + PutCompressedString (pServiceNamePtr); + PutWord (RR_TYPE_TXT); + PutWord (RR_CLASS_IN | RR_CACHE_FLUSH); + PutDWord (bDelete ? TTLDelete : TTLLong); + ReserveDataLength (); + for (int i = pService->nTextRecords-1; i >= 0; i--) // In reverse order + { + assert (pService->ppText[i]); + PutString (*pService->ppText[i]); + } + SetDataLength (); + unsigned nMsgSize = m_pWritePtr - m_Buffer; + m_pWritePtr = nullptr; + if (nMsgSize >= MaxMessageSize) + { + return FALSE; + } + assert (m_pSocket); + return m_pSocket->Send (m_Buffer, nMsgSize, MSG_DONTWAIT) == (int) nMsgSize; +} +void CmDNSPublisher::PutByte (u8 uchValue) +{ + assert (m_pWritePtr); + if ((unsigned) (m_pWritePtr - m_Buffer) < MaxMessageSize) + { + *m_pWritePtr++ = uchValue; + } +} +void CmDNSPublisher::PutWord (u16 usValue) +{ + PutByte (usValue >> 8); + PutByte (usValue & 0xFF); +} +void CmDNSPublisher::PutDWord (u32 nValue) +{ + PutWord (nValue >> 16); + PutWord (nValue & 0xFFFF); +} +void CmDNSPublisher::PutString (const char *pValue) +{ + assert (pValue); + size_t nLen = strlen (pValue); + assert (nLen <= 255); + PutByte (nLen); + while (*pValue) + { + PutByte (static_cast (*pValue++)); + } +} +void CmDNSPublisher::PutCompressedString (const u8 *pWritePtr) +{ + assert (m_pWritePtr); + assert (pWritePtr < m_pWritePtr); + unsigned nOffset = pWritePtr - m_Buffer; + assert (nOffset < MaxMessageSize); + nOffset |= 0xC000; + PutWord (static_cast (nOffset)); +} +void CmDNSPublisher::PutDNSName (const char *pValue) +{ + char Buffer[256]; + assert (pValue); + strncpy (Buffer, pValue, sizeof Buffer); + Buffer[sizeof Buffer-1] = '\0'; + char *pSavePtr = nullptr; + char *pToken = strtok_r (Buffer, ".", &pSavePtr); + while (pToken) + { + PutString (pToken); + pToken = strtok_r (nullptr, ".", &pSavePtr); + } + PutByte (0); +} +void CmDNSPublisher::PutIPAddress (const CIPAddress &rValue) +{ + u8 Buffer[IP_ADDRESS_SIZE]; + rValue.CopyTo (Buffer); + for (unsigned i = 0; i < IP_ADDRESS_SIZE; i++) + { + PutByte (Buffer[i]); + } +} +void CmDNSPublisher::ReserveDataLength (void) +{ + assert (!m_pDataLen); + m_pDataLen = m_pWritePtr; + assert (m_pDataLen); + PutWord (0); +} +void CmDNSPublisher::SetDataLength (void) +{ + assert (m_pDataLen); + assert (m_pWritePtr); + assert (m_pWritePtr > m_pDataLen); + *reinterpret_cast (m_pDataLen) = le2be16 (m_pWritePtr - m_pDataLen - sizeof (u16)); + m_pDataLen = nullptr; +} \ No newline at end of file diff --git a/src/net/mdnspublisher.h b/src/net/mdnspublisher.h new file mode 100644 index 0000000..6b132a7 --- /dev/null +++ b/src/net/mdnspublisher.h @@ -0,0 +1,90 @@ +// +// mdnspublisher.h +// +// Circle - A C++ bare metal environment for Raspberry Pi +// Copyright (C) 2024 R. Stange +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +#ifndef _circle_net_mdnspublisher_h +#define _circle_net_mdnspublisher_h +#include +#include +#include +#include +#include +#include +#include +#include +#include +class CmDNSPublisher : public CTask /// mDNS / Bonjour client task +{ +public: + static constexpr const char *ServiceTypeAppleMIDI = "_apple-midi._udp"; +public: + /// \param pNet Pointer to the network subsystem object + CmDNSPublisher (CNetSubSystem *pNet); + ~CmDNSPublisher (void); + /// \brief Start publishing a service + /// \param pServiceName Name of the service to be published + /// \param pServiceType Type of the service to be published (e.g. ServiceTypeAppleMIDI) + /// \param usServicePort Port number of the service to be published (in host byte order) + /// \param ppText Descriptions of the service (terminated with a nullptr, or nullptr itself) + /// \return Operation successful? + boolean PublishService (const char *pServiceName, + const char *pServiceType, + u16 usServicePort, + const char *ppText[] = nullptr); + /// \brief Stop publishing a service + /// \param pServiceName Name of the service to be unpublished (same as when published) + /// \return Operation successful? + boolean UnpublishService (const char *pServiceName); + void Run (void) override; +private: + static const unsigned MaxTextRecords = 10; + static const unsigned MaxMessageSize = 1400; // safe UDP payload in an Ethernet frame + static const unsigned TTLShort = 15; // seconds + static const unsigned TTLLong = 4500; + static const unsigned TTLDelete = 0; + struct TService + { + CString ServiceName; + CString ServiceType; + u16 usServicePort; + unsigned nTextRecords; + CString *ppText[MaxTextRecords]; + }; + boolean SendResponse (TService *pService, boolean bDelete); + // Helpers for writing to buffer + void PutByte (u8 uchValue); + void PutWord (u16 usValue); + void PutDWord (u32 nValue); + void PutString (const char *pValue); + void PutCompressedString (const u8 *pWritePtr); + void PutDNSName (const char *pValue); + void PutIPAddress (const CIPAddress &rValue); + void ReserveDataLength (void); + void SetDataLength (void); +private: + CNetSubSystem *m_pNet; + CPtrList m_ServiceList; + CMutex m_Mutex; + CSocket *m_pSocket; + boolean m_bRunning; + CSynchronizationEvent m_Event; + u8 m_Buffer[MaxMessageSize]; + u8 *m_pWritePtr; + u8 *m_pDataLen; +}; +#endif \ No newline at end of file diff --git a/src/net/udpmidi.cpp b/src/net/udpmidi.cpp new file mode 100644 index 0000000..2f25eda --- /dev/null +++ b/src/net/udpmidi.cpp @@ -0,0 +1,89 @@ +// +// udpmidi.cpp +// +// mt32-pi - A baremetal MIDI synthesizer for Raspberry Pi +// Copyright (C) 2020-2023 Dale Whinham +// +// This file is part of mt32-pi. +// +// mt32-pi is free software: you can redistribute it and/or modify it under the +// terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// mt32-pi is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// mt32-pi. If not, see . +// + +#include +#include +#include +#include + +#include "udpmidi.h" + +LOGMODULE("udpmidi"); + +constexpr u16 MIDIPort = 1999; + +CUDPMIDIReceiver::CUDPMIDIReceiver(CUDPMIDIHandler* pHandler) + : CTask(TASK_STACK_SIZE, true), + m_pMIDISocket(nullptr), + m_MIDIBuffer{0}, + m_pHandler(pHandler) +{ +} + +CUDPMIDIReceiver::~CUDPMIDIReceiver() +{ + if (m_pMIDISocket) + delete m_pMIDISocket; +} + +bool CUDPMIDIReceiver::Initialize() +{ + assert(m_pMIDISocket == nullptr); + + CNetSubSystem* const pNet = CNetSubSystem::Get(); + + if ((m_pMIDISocket = new CSocket(pNet, IPPROTO_UDP)) == nullptr) + return false; + + if (m_pMIDISocket->Bind(MIDIPort) != 0) + { + LOGERR("Couldn't bind to port %d", MIDIPort); + return false; + } + + // We started as a suspended task; run now that initialization is successful + Start(); + + return true; +} + +void CUDPMIDIReceiver::Run() +{ + assert(m_pHandler != nullptr); + assert(m_pMIDISocket != nullptr); + + CScheduler* const pScheduler = CScheduler::Get(); + + while (true) + { + // Blocking call + const int nMIDIResult = m_pMIDISocket->Receive(m_MIDIBuffer, sizeof(m_MIDIBuffer), 0); + + if (nMIDIResult < 0) + LOGERR("MIDI socket receive error: %d", nMIDIResult); + else if (nMIDIResult > 0) + m_pHandler->OnUDPMIDIDataReceived(m_MIDIBuffer, nMIDIResult); + + // Allow other tasks to run + pScheduler->Yield(); + } +} \ No newline at end of file diff --git a/src/net/udpmidi.h b/src/net/udpmidi.h new file mode 100644 index 0000000..102d339 --- /dev/null +++ b/src/net/udpmidi.h @@ -0,0 +1,57 @@ +// +// udpmidi.h +// +// mt32-pi - A baremetal MIDI synthesizer for Raspberry Pi +// Copyright (C) 2020-2023 Dale Whinham +// +// This file is part of mt32-pi. +// +// mt32-pi is free software: you can redistribute it and/or modify it under the +// terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// mt32-pi is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// mt32-pi. If not, see . +// + +#ifndef _udpmidi_h +#define _udpmidi_h + +#include +#include +#include + +class CUDPMIDIHandler +{ +public: + virtual void OnUDPMIDIDataReceived(const u8* pData, size_t nSize) = 0; +}; + +class CUDPMIDIReceiver : protected CTask +{ +public: + CUDPMIDIReceiver(CUDPMIDIHandler* pHandler); + virtual ~CUDPMIDIReceiver() override; + + bool Initialize(); + + virtual void Run() override; + +private: + // UDP sockets + CSocket* m_pMIDISocket; + + // Socket receive buffer + u8 m_MIDIBuffer[FRAME_BUFFER_SIZE]; + + // Callback handler + CUDPMIDIHandler* m_pHandler; +}; + +#endif \ No newline at end of file diff --git a/src/net/utility.h b/src/net/utility.h new file mode 100644 index 0000000..3b64395 --- /dev/null +++ b/src/net/utility.h @@ -0,0 +1,193 @@ + +// +// utility.h +// +// mt32-pi - A baremetal MIDI synthesizer for Raspberry Pi +// Copyright (C) 2020-2023 Dale Whinham +// +// This file is part of mt32-pi. +// +// mt32-pi is free software: you can redistribute it and/or modify it under the +// terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// mt32-pi is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// mt32-pi. If not, see . +// + +#ifndef _utility_h +#define _utility_h + +#include +#include + +// Macro to extract the string representation of an enum +#define CONFIG_ENUM_VALUE(VALUE, STRING) VALUE, + +// Macro to extract the enum value +#define CONFIG_ENUM_STRING(VALUE, STRING) #STRING, + +// Macro to declare the enum itself +#define CONFIG_ENUM(NAME, VALUES) enum class NAME { VALUES(CONFIG_ENUM_VALUE) } + +// Macro to declare an array of string representations for an enum +#define CONFIG_ENUM_STRINGS(NAME, DATA) static const char* NAME##Strings[] = { DATA(CONFIG_ENUM_STRING) } + +namespace Utility +{ + // Templated function for clamping a value between a minimum and a maximum + template + constexpr T Clamp(const T& nValue, const T& nMin, const T& nMax) + { + return (nValue < nMin) ? nMin : (nValue > nMax) ? nMax : nValue; + } + + // Templated function for taking the minimum of two values + template + constexpr T Min(const T& nLHS, const T& nRHS) + { + return nLHS < nRHS ? nLHS : nRHS; + } + + // Templated function for taking the maximum of two values + template + constexpr T Max(const T& nLHS, const T& nRHS) + { + return nLHS > nRHS ? nLHS : nRHS; + } + + // Function for performing a linear interpolation of a value + constexpr float Lerp(float nValue, float nMinA, float nMaxA, float nMinB, float nMaxB) + { + return nMinB + (nValue - nMinA) * ((nMaxB - nMinB) / (nMaxA - nMinA)); + } + + // Return number of elements in an array + template + constexpr size_t ArraySize(const T(&)[N]) { return N; } + + // Returns whether some value is a power of 2 + template + constexpr bool IsPowerOfTwo(const T& nValue) + { + return nValue && ((nValue & (nValue - 1)) == 0); + } + + // Rounds a number to a nearest multiple; only works for integer values/multiples + template + constexpr T RoundToNearestMultiple(const T& nValue, const T& nMultiple) + { + return ((nValue + nMultiple / 2) / nMultiple) * nMultiple; + } + + // Convert between milliseconds and ticks of a 1MHz clock + template + constexpr T MillisToTicks(const T& nMillis) + { + return nMillis * 1000; + } + + template + constexpr T TicksToMillis(const T& nTicks) + { + return nTicks / 1000; + } + + // Computes the Roland checksum + constexpr u8 RolandChecksum(const u8* pData, size_t nSize) + { + u8 nSum = 0; + for (size_t i = 0; i < nSize; ++i) + nSum = (nSum + pData[i]) & 0x7F; + + return 128 - nSum; + } + + // Comparators for sorting + namespace Comparator + { + template + using TComparator = bool (*)(const T&, const T&); + + template + inline bool LessThan(const T& ObjectA, const T& ObjectB) + { + return ObjectA < ObjectB; + } + + template + inline bool GreaterThan(const T& ObjectA, const T& ObjectB) + { + return ObjectA > ObjectB; + } + + inline bool CaseInsensitiveAscending(const CString& StringA, const CString& StringB) + { + return strcasecmp(StringA, StringB) < 0; + } + } + + // Swaps two objects in-place + template + inline void Swap(T& ObjectA, T& ObjectB) + { + u8 Buffer[sizeof(T)]; + memcpy(Buffer, &ObjectA, sizeof(T)); + memcpy(&ObjectA, &ObjectB, sizeof(T)); + memcpy(&ObjectB, Buffer, sizeof(T)); + } + + namespace + { + // Quicksort partition function (private) + template + size_t Partition(T* Items, Comparator::TComparator Comparator, size_t nLow, size_t nHigh) + { + const size_t nPivotIndex = (nHigh + nLow) / 2; + T* Pivot = &Items[nPivotIndex]; + + while (true) + { + while (Comparator(Items[nLow], *Pivot)) + ++nLow; + + while (Comparator(*Pivot, Items[nHigh])) + --nHigh; + + if (nLow >= nHigh) + return nHigh; + + Swap(Items[nLow], Items[nHigh]); + + // Update pointer if pivot was swapped + if (nPivotIndex == nLow) + Pivot = &Items[nHigh]; + else if (nPivotIndex == nHigh) + Pivot = &Items[nLow]; + + ++nLow; + --nHigh; + } + } + } + + // Sorts an array in-place using the Tony Hoare Quicksort algorithm + template + void QSort(T* Items, Comparator::TComparator Comparator, size_t nLow, size_t nHigh) + { + if (nLow < nHigh) + { + size_t p = Partition(Items, Comparator, nLow, nHigh); + QSort(Items, Comparator, nLow, p); + QSort(Items, Comparator, p + 1, nHigh); + } + } +} + +#endif diff --git a/src/udpmididevice.cpp b/src/udpmididevice.cpp new file mode 100644 index 0000000..4b0d1c7 --- /dev/null +++ b/src/udpmididevice.cpp @@ -0,0 +1,90 @@ +// +// udpmididevice.cpp +// +// MiniDexed - Dexed FM synthesizer for bare metal Raspberry Pi +// Copyright (C) 2022 The MiniDexed Team +// +// Original author of this class: +// R. Stange +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// + +#include +#include +#include "udpmididevice.h" +#include + +#define VIRTUALCABLE 24 + +LOGMODULE("udpmididevice"); + +CUDPMIDIDevice::CUDPMIDIDevice (CMiniDexed *pSynthesizer, + CConfig *pConfig, CUserInterface *pUI) +: CMIDIDevice (pSynthesizer, pConfig, pUI), + m_pSynthesizer (pSynthesizer), + m_pConfig (pConfig) +{ + AddDevice ("udp"); +} + +CUDPMIDIDevice::~CUDPMIDIDevice (void) +{ + //m_pSynthesizer = 0; +} + +boolean CUDPMIDIDevice::Initialize (void) +{ + m_pAppleMIDIParticipant = new CAppleMIDIParticipant(&m_Random, this); + if (!m_pAppleMIDIParticipant->Initialize()) + { + LOGERR("Failed to init RTP listener"); + delete m_pAppleMIDIParticipant; + m_pAppleMIDIParticipant = nullptr; + } + else + LOGNOTE("RTP Listener initialized"); + m_pUDPMIDIReceiver = new CUDPMIDIReceiver(this); + if (!m_pUDPMIDIReceiver->Initialize()) + { + LOGERR("Failed to init UDP MIDI receiver"); + delete m_pUDPMIDIReceiver; + m_pUDPMIDIReceiver = nullptr; + } + else + LOGNOTE("UDP MIDI receiver initialized"); + return true; +} + +// Methods to handle MIDI events + +void CUDPMIDIDevice::OnAppleMIDIDataReceived(const u8* pData, size_t nSize) +{ + MIDIMessageHandler(pData, nSize, VIRTUALCABLE); +} + +void CUDPMIDIDevice::OnAppleMIDIConnect(const CIPAddress* pIPAddress, const char* pName) +{ + LOGNOTE("RTP Device connected"); +} + +void CUDPMIDIDevice::OnAppleMIDIDisconnect(const CIPAddress* pIPAddress, const char* pName) +{ + LOGNOTE("RTP Device disconnected"); +} + +void CUDPMIDIDevice::OnUDPMIDIDataReceived(const u8* pData, size_t nSize) +{ + MIDIMessageHandler(pData, nSize, VIRTUALCABLE); +} \ No newline at end of file diff --git a/src/udpmididevice.d b/src/udpmididevice.d new file mode 100644 index 0000000..b698e67 --- /dev/null +++ b/src/udpmididevice.d @@ -0,0 +1,182 @@ +udpmididevice.o udpmididevice.d: udpmididevice.cpp \ + ../circle-stdlib/libs/circle/include/circle/logger.h \ + ../circle-stdlib/libs/circle/include/circle/device.h \ + ../circle-stdlib/libs/circle/include/circle/ptrlist.h \ + ../circle-stdlib/libs/circle/include/circle/types.h \ + ../circle-stdlib/libs/circle/include/assert.h \ + ../circle-stdlib/libs/circle/include/circle/macros.h \ + ../circle-stdlib/libs/circle/include/circle/timer.h \ + ../circle-stdlib/libs/circle/include/circle/interrupt.h \ + ../circle-stdlib/libs/circle/include/circle/bcm2835int.h \ + ../circle-stdlib/libs/circle/include/circle/exceptionstub.h \ + ../circle-stdlib/libs/circle/include/circle/string.h \ + ../circle-stdlib/libs/circle/include/circle/stdarg.h \ + ../circle-stdlib/libs/circle/include/circle/sysconfig.h \ + ../circle-stdlib/libs/circle/include/circle/memorymap.h \ + ../circle-stdlib/libs/circle/include/circle/memorymap64.h \ + ../circle-stdlib/libs/circle/include/circle/spinlock.h \ + ../circle-stdlib/libs/circle/include/circle/synchronize.h \ + ../circle-stdlib/libs/circle/include/circle/synchronize64.h \ + ../circle-stdlib/libs/circle/include/circle/time.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/cstring \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/aarch64-none-elf/bits/c++config.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/aarch64-none-elf/bits/os_defines.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/aarch64-none-elf/bits/cpu_defines.h \ + ../circle-stdlib/install/aarch64-none-circle/include/string.h \ + ../circle-stdlib/install/aarch64-none-circle/include/_ansi.h \ + ../circle-stdlib/install/aarch64-none-circle/include/newlib.h \ + ../circle-stdlib/install/aarch64-none-circle/include/_newlib_version.h \ + ../circle-stdlib/install/aarch64-none-circle/include/sys/config.h \ + ../circle-stdlib/install/aarch64-none-circle/include/machine/ieeefp.h \ + ../circle-stdlib/install/aarch64-none-circle/include/sys/features.h \ + ../circle-stdlib/install/aarch64-none-circle/include/sys/reent.h \ + ../circle-stdlib/install/aarch64-none-circle/include/_ansi.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/lib/gcc/aarch64-none-elf/10.3.1/include/stddef.h \ + ../circle-stdlib/install/aarch64-none-circle/include/sys/_types.h \ + ../circle-stdlib/install/aarch64-none-circle/include/machine/_types.h \ + ../circle-stdlib/install/aarch64-none-circle/include/machine/_default_types.h \ + ../circle-stdlib/install/aarch64-none-circle/include/sys/lock.h \ + ../circle-stdlib/install/aarch64-none-circle/include/sys/cdefs.h \ + ../circle-stdlib/install/aarch64-none-circle/include/sys/string.h \ + udpmididevice.h mididevice.h config.h \ + ../circle-stdlib/libs/circle/include/circle/net/ipaddress.h \ + ../circle-stdlib/libs/circle/addon/fatfs/ff.h \ + ../circle-stdlib/libs/circle/addon/fatfs/ffconf.h \ + ../circle-stdlib/install/aarch64-none-circle/include/stdint.h \ + ../circle-stdlib/install/aarch64-none-circle/include/sys/_intsup.h \ + ../circle-stdlib/install/aarch64-none-circle/include/sys/_stdint.h \ + ../circle-stdlib/libs/circle/addon/Properties/propertiesfatfsfile.h \ + ../circle-stdlib/libs/circle/addon/Properties/propertiesbasefile.h \ + ../circle-stdlib/libs/circle/addon/Properties/properties.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/string \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/stringfwd.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/memoryfwd.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/char_traits.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/stl_algobase.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/functexcept.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/exception_defines.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/cpp_type_traits.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/ext/type_traits.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/ext/numeric_traits.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/stl_pair.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/move.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/type_traits \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/stl_iterator_base_types.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/stl_iterator_base_funcs.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/concept_check.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/debug/assertions.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/stl_iterator.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/ptr_traits.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/debug/debug.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/predefined_ops.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/postypes.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/cwchar \ + ../circle-stdlib/install/aarch64-none-circle/include/wchar.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/lib/gcc/aarch64-none-elf/10.3.1/include/stdarg.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/cstdint \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/allocator.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/aarch64-none-elf/bits/c++allocator.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/ext/new_allocator.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/new \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/exception \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/exception.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/exception_ptr.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/cxxabi_init_exception.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/typeinfo \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/hash_bytes.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/nested_exception.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/localefwd.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/aarch64-none-elf/bits/c++locale.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/clocale \ + ../circle-stdlib/install/aarch64-none-circle/include/locale.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/iosfwd \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/cctype \ + ../circle-stdlib/install/aarch64-none-circle/include/ctype.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/ostream_insert.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/cxxabi_forced.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/stl_function.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/backward/binders.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/range_access.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/initializer_list \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/iterator_concepts.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/concepts \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/range_cmp.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/basic_string.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/ext/atomicity.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/aarch64-none-elf/bits/gthr.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/aarch64-none-elf/bits/gthr-default.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/aarch64-none-elf/bits/atomic_word.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/ext/alloc_traits.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/alloc_traits.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/stl_construct.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/ext/string_conversions.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/cstdlib \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/stdlib.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/_ansi.h \ + ../circle-stdlib/install/aarch64-none-circle/include/machine/stdlib.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/std_abs.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/cstdio \ + ../circle-stdlib/install/aarch64-none-circle/include/stdio.h \ + ../circle-stdlib/install/aarch64-none-circle/include/sys/types.h \ + ../circle-stdlib/install/aarch64-none-circle/include/sys/_pthreadtypes.h \ + ../circle-stdlib/install/aarch64-none-circle/include/machine/types.h \ + ../circle-stdlib/install/aarch64-none-circle/include/sys/stdio.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/cerrno \ + ../circle-stdlib/install/aarch64-none-circle/include/errno.h \ + ../circle-stdlib/install/aarch64-none-circle/include/sys/errno.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/charconv.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/functional_hash.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/basic_string.tcc \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/unordered_map \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/ext/aligned_buffer.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/hashtable.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/hashtable_policy.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/tuple \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/utility \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/stl_relops.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/array \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/uses_allocator.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/invoke.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/limits \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/unordered_map.h \ + /mnt/c/Users/User/Development/MiniDexed/MiniDexed/gcc-arm-10.3-2021.07-x86_64-aarch64-none-elf/aarch64-none-elf/include/c++/10.3.1/bits/erase_if.h \ + userinterface.h uimenu.h uibuttons.h \ + ../circle-stdlib/libs/circle/include/circle/gpiopin.h midipin.h \ + ../circle-stdlib/libs/circle/addon/sensor/ky040.h \ + ../circle-stdlib/libs/circle/include/circle/gpiomanager.h \ + ../circle-stdlib/libs/circle/addon/display/hd44780device.h \ + ../circle-stdlib/libs/circle/include/circle/i2cmaster.h \ + ../circle-stdlib/libs/circle/addon/display/chardevice.h \ + ../circle-stdlib/libs/circle/addon/display/ssd1306device.h \ + ../circle-stdlib/libs/circle/addon/display/st7789device.h \ + ../circle-stdlib/libs/circle/addon/display/st7789display.h \ + ../circle-stdlib/libs/circle/include/circle/display.h \ + ../circle-stdlib/libs/circle/include/circle/spimaster.h \ + ../circle-stdlib/libs/circle/include/circle/chargenerator.h \ + ../circle-stdlib/libs/circle/include/circle/font.h \ + ../circle-stdlib/libs/circle/include/circle/util.h \ + ../circle-stdlib/libs/circle/include/circle/writebuffer.h \ + net/applemidi.h ../circle-stdlib/libs/circle/include/circle/bcmrandom.h \ + ../circle-stdlib/libs/circle/include/circle/net/socket.h \ + ../circle-stdlib/libs/circle/include/circle/net/netsocket.h \ + ../circle-stdlib/libs/circle/include/circle/net/netconfig.h \ + ../circle-stdlib/libs/circle/include/circle/net/transportlayer.h \ + ../circle-stdlib/libs/circle/include/circle/net/networklayer.h \ + ../circle-stdlib/libs/circle/include/circle/net/linklayer.h \ + ../circle-stdlib/libs/circle/include/circle/net/netdevlayer.h \ + ../circle-stdlib/libs/circle/include/circle/netdevice.h \ + ../circle-stdlib/libs/circle/include/circle/macaddress.h \ + ../circle-stdlib/libs/circle/include/circle/net/netqueue.h \ + ../circle-stdlib/libs/circle/include/circle/bcm54213.h \ + ../circle-stdlib/libs/circle/include/circle/macb.h \ + ../circle-stdlib/libs/circle/include/circle/net/arphandler.h \ + ../circle-stdlib/libs/circle/include/circle/net/icmphandler.h \ + ../circle-stdlib/libs/circle/include/circle/net/routecache.h \ + ../circle-stdlib/libs/circle/include/circle/ptrarray.h \ + ../circle-stdlib/libs/circle/include/circle/net/netconnection.h \ + ../circle-stdlib/libs/circle/include/circle/net/checksumcalculator.h \ + ../circle-stdlib/libs/circle/include/circle/net/tcprejector.h \ + ../circle-stdlib/libs/circle/include/circle/sched/task.h \ + ../circle-stdlib/libs/circle/include/circle/sched/taskswitch.h \ + ../circle-stdlib/libs/circle/include/circle/sched/synchronizationevent.h \ + net/udpmidi.h diff --git a/src/udpmididevice.h b/src/udpmididevice.h new file mode 100644 index 0000000..de50172 --- /dev/null +++ b/src/udpmididevice.h @@ -0,0 +1,55 @@ +// +// udpmididevice.h +// +// Virtual midi device for data recieved on network +// +// MiniDexed - Dexed FM synthesizer for bare metal Raspberry Pi +// Copyright (C) 2022 The MiniDexed Team +// +// Original author of this class: +// R. Stange +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +#ifndef _udpmididevice_h +#define _udpmididevice_h + +#include "mididevice.h" +#include "config.h" +#include "net/applemidi.h" +#include "net/udpmidi.h" + +class CMiniDexed; + +class CUDPMIDIDevice : CAppleMIDIHandler, CUDPMIDIHandler, public CMIDIDevice +{ +public: + CUDPMIDIDevice (CMiniDexed *pSynthesizer, CConfig *pConfig, CUserInterface *pUI); + ~CUDPMIDIDevice (void); + + boolean Initialize (void); + virtual void OnAppleMIDIDataReceived(const u8* pData, size_t nSize) override; + virtual void OnAppleMIDIConnect(const CIPAddress* pIPAddress, const char* pName) override; + virtual void OnAppleMIDIDisconnect(const CIPAddress* pIPAddress, const char* pName) override; + virtual void OnUDPMIDIDataReceived(const u8* pData, size_t nSize) override; + +private: + CMiniDexed *m_pSynthesizer; + CConfig *m_pConfig; + CBcmRandomNumberGenerator m_Random; + CAppleMIDIParticipant* m_pAppleMIDIParticipant; // AppleMIDI participant instance + CUDPMIDIReceiver* m_pUDPMIDIReceiver; +}; + +#endif diff --git a/src/udpmididevice.o b/src/udpmididevice.o new file mode 100644 index 0000000000000000000000000000000000000000..6cf8c5b9d5c17b67d41ee543c07236049f46110f GIT binary patch literal 159264 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%0Da+tueIUa4&3v_ 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 literal 0 HcmV?d00001