From d7e75477e860d90fbbbbaa62bc07fe85a9948f76 Mon Sep 17 00:00:00 2001 From: Titouan Christophe Date: Thu, 31 Jul 2025 11:47:56 +0200 Subject: [PATCH 1/7] audio: midi: improve API documentation Improve the API documentation for Universal MIDI Packets definitions: - Add link to the reference document, and make it referenceable in doxygen - Use BIT_MASK macro instead of hexadecimal litterals to better convey the length of various fields within UMP packets - Add more cross-references where possible - Use @remark when applicable Signed-off-by: Titouan Christophe --- include/zephyr/audio/midi.h | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/include/zephyr/audio/midi.h b/include/zephyr/audio/midi.h index d564531ecad24..acc7d51338fd6 100644 --- a/include/zephyr/audio/midi.h +++ b/include/zephyr/audio/midi.h @@ -19,11 +19,22 @@ extern "C" { * @ingroup audio_interface * @since 4.1 * @version 0.1.0 - * @see ump112: "Universal MIDI Packet (UMP) Format and MIDI 2.0 Protocol" - * Document version 1.1.2 * @{ */ +/** + * @defgroup ump112 Universal MIDI Packet (UMP) Format and MIDI 2.0 Protocol + * @ingroup midi_ump + * @{ + * @details Definitions based on the following document + * + * Universal MIDI Packet (UMP) Format and MIDI 2.0 Protocol + * With MIDI 1.0 Protocol in UMP Format - Document version 1.1.2 + * (MIDI Association Document: M2-104-UM) + * + * @} + */ + /** * @brief Universal MIDI Packet container */ @@ -59,6 +70,7 @@ struct midi_ump { /** * @brief Message Type field of a Universal MIDI Packet * @param[in] ump Universal MIDI Packet + * @see midi_ump_mt */ #define UMP_MT(ump) \ ((ump).data[0] >> 28) @@ -86,14 +98,15 @@ struct midi_ump { * @param[in] ump Universal MIDI Packet */ #define UMP_GROUP(ump) \ - (((ump).data[0] >> 24) & 0x0f) + (((ump).data[0] >> 24) & BIT_MASK(4)) /** * @brief Status byte of a MIDI channel voice or system message * @param[in] ump Universal MIDI Packet (containing a MIDI1 event) + * @see midi_ump_sys */ #define UMP_MIDI_STATUS(ump) \ - (((ump).data[0] >> 16) & 0xff) + (((ump).data[0] >> 16) & BIT_MASK(8)) /** * @brief Command of a MIDI channel voice message * @param[in] ump Universal MIDI Packet (containing a MIDI event) @@ -106,19 +119,19 @@ struct midi_ump { * @param[in] ump Universal MIDI Packet (containing a MIDI event) */ #define UMP_MIDI_CHANNEL(ump) \ - (UMP_MIDI_STATUS(ump) & 0x0f) + (UMP_MIDI_STATUS(ump) & BIT_MASK(4)) /** * @brief First parameter of a MIDI1 channel voice or system message * @param[in] ump Universal MIDI Packet (containing a MIDI1 message) */ #define UMP_MIDI1_P1(ump) \ - (((ump).data[0] >> 8) & 0x7f) + (((ump).data[0] >> 8) & BIT_MASK(7)) /** * @brief Second parameter of a MIDI1 channel voice or system message * @param[in] ump Universal MIDI Packet (containing a MIDI1 message) */ #define UMP_MIDI1_P2(ump) \ - ((ump).data[0] & 0x7f) + ((ump).data[0] & BIT_MASK(7)) /** * @brief Initialize a UMP with a MIDI1 channel voice message @@ -143,9 +156,8 @@ struct midi_ump { * @defgroup midi_ump_cmd MIDI commands * @ingroup midi_ump * @see ump112: 7.3 MIDI 1.0 Channel Voice Messages - * - * When UMP_MT(x)=UMP_MT_MIDI1_CHANNEL_VOICE or UMP_MT_MIDI2_CHANNEL_VOICE, then - * UMP_MIDI_COMMAND(x) may be one of: + * @remark When UMP_MT(x)=UMP_MT_MIDI1_CHANNEL_VOICE or UMP_MT_MIDI2_CHANNEL_VOICE, + * then UMP_MIDI_COMMAND(x) may be one of: * @{ */ #define UMP_MIDI_NOTE_OFF 0x8 /**< Note Off (p1=note number, p2=velocity) */ @@ -180,7 +192,8 @@ struct midi_ump { * @ingroup midi_ump * @see ump112: 7.6 System Common and System Real Time Messages * - * When UMP_MT(x)=UMP_MT_SYS_RT_COMMON, UMP_MIDI_STATUS(x) may be one of: + * @remark When UMP_MT(x)=UMP_MT_SYS_RT_COMMON, + * then UMP_MIDI_STATUS(x) may be one of: * @{ */ #define UMP_SYS_MIDI_TIME_CODE 0xf1 /**< MIDI Time Code (no param) */ From ece75f98fb216196c9513302a089074c523c997e Mon Sep 17 00:00:00 2001 From: Titouan Christophe Date: Thu, 31 Jul 2025 11:51:40 +0200 Subject: [PATCH 2/7] lib: midi2: new UMP Stream responder library Add a new top-level, transport independent library to respond to UMP Stream Discovery messages. This allows MIDI2.0 clients to discover UMP endpoints hosted on Zephyr over the UMP protocol. The endpoint specification can be gathered from the device tree, so that the same information used to generate USB descriptors in usb-midi2.0 can be delivered over UMP Stream. Signed-off-by: Titouan Christophe --- doc/zephyr.doxyfile.in | 1 + dts/bindings/usb/zephyr,midi2-device.yaml | 14 ++ include/zephyr/audio/midi.h | 148 ++++++++++- lib/CMakeLists.txt | 1 + lib/Kconfig | 2 + lib/midi2/CMakeLists.txt | 8 + lib/midi2/Kconfig | 13 + lib/midi2/ump_stream_responder.c | 284 ++++++++++++++++++++++ lib/midi2/ump_stream_responder.h | 121 +++++++++ 9 files changed, 591 insertions(+), 1 deletion(-) create mode 100644 lib/midi2/CMakeLists.txt create mode 100644 lib/midi2/Kconfig create mode 100644 lib/midi2/ump_stream_responder.c create mode 100644 lib/midi2/ump_stream_responder.h diff --git a/doc/zephyr.doxyfile.in b/doc/zephyr.doxyfile.in index 1dbc5ecd0d2b6..cea0300cfb0ab 100644 --- a/doc/zephyr.doxyfile.in +++ b/doc/zephyr.doxyfile.in @@ -1017,6 +1017,7 @@ INPUT = @ZEPHYR_BASE@/doc/_doxygen/mainpage.md \ @ZEPHYR_BASE@/include/zephyr/sys/atomic.h \ @ZEPHYR_BASE@/include/ \ @ZEPHYR_BASE@/lib/libc/minimal/include/ \ + @ZEPHYR_BASE@/lib/midi2/ \ @ZEPHYR_BASE@/subsys/testsuite/include/ \ @ZEPHYR_BASE@/subsys/testsuite/ztest/include/ \ @ZEPHYR_BASE@/subsys/secure_storage/include/ \ diff --git a/dts/bindings/usb/zephyr,midi2-device.yaml b/dts/bindings/usb/zephyr,midi2-device.yaml index 851916e7e8775..1113c1886efa7 100644 --- a/dts/bindings/usb/zephyr,midi2-device.yaml +++ b/dts/bindings/usb/zephyr,midi2-device.yaml @@ -14,6 +14,10 @@ properties: type: int const: 1 + label: + type: string + description: Name of the UMP (MIDI 2.0) endpoint + child-binding: description: | MIDI2 Group terminal block. @@ -21,6 +25,10 @@ child-binding: device exchange Universal MIDI Packets with the host. properties: + label: + type: string + description: Name of the corresponding UMP Function block + reg: type: array required: true @@ -49,3 +57,9 @@ child-binding: - "output-only" description: | Type (data direction) of Group Terminals in this Block. + + serial-31250bps: + type: boolean + description: | + This represent a physical MIDI1 serial port, which is limited + to a transmission speed of 31.25kb/s. diff --git a/include/zephyr/audio/midi.h b/include/zephyr/audio/midi.h index acc7d51338fd6..5e59dd444a1b8 100644 --- a/include/zephyr/audio/midi.h +++ b/include/zephyr/audio/midi.h @@ -63,7 +63,10 @@ struct midi_ump { #define UMP_MT_DATA_128 0x05 /** Flex Data Messages */ #define UMP_MT_FLEX_DATA 0x0d -/** UMP Stream Message */ +/** + * UMP Stream Message + * @see midi_ump_stream + */ #define UMP_MT_UMP_STREAM 0x0f /** @} */ @@ -208,6 +211,149 @@ struct midi_ump { #define UMP_SYS_RESET 0xff /**< Reset (no param) */ /** @} */ + +/** + * @defgroup midi_ump_stream UMP Stream specific fields + * @ingroup midi_ump + * @see ump112: 7.1 UMP Stream Messages + * + * @{ + */ + +/** + * @brief Format of a UMP Stream message + * @param[in] ump Universal MIDI Packet (containing a UMP Stream message) + * @see midi_ump_stream_format + */ +#define UMP_STREAM_FORMAT(ump) \ + (((ump).data[0] >> 26) & BIT_MASK(2)) + +/** + * @defgroup midi_ump_stream_format UMP Stream format + * @ingroup midi_ump_stream + * @see ump112: 7.1 UMP Stream Messages: Format + * @remark When UMP_MT(x)=UMP_MT_UMP_STREAM, + * then UMP_STREAM_FORMAT(x) may be one of: + * @{ + */ + +/** Complete message in one UMP */ +#define UMP_STREAM_FORMAT_COMPLETE 0x00 +/** Start of a message which spans two or more UMPs */ +#define UMP_STREAM_FORMAT_START 0x01 +/** Continuing a message which spans three or more UMPs. + * There might be multiple Continue UMPs in a single message + */ +#define UMP_STREAM_FORMAT_CONTINUE 0x02 +/** End of message which spans two or more UMPs */ +#define UMP_STREAM_FORMAT_END 0x03 + +/** @} */ + +/** + * @brief Status field of a UMP Stream message + * @param[in] ump Universal MIDI Packet (containing a UMP Stream message) + * @see midi_ump_stream_status + */ +#define UMP_STREAM_STATUS(ump) \ + (((ump).data[0] >> 16) & BIT_MASK(10)) + +/** + * @defgroup midi_ump_stream_status UMP Stream status + * @ingroup midi_ump_stream + * @see ump112: 7.1 UMP Stream Messages + * @remark When UMP_MT(x)=UMP_MT_UMP_STREAM, + * then UMP_STREAM_STATUS(x) may be one of: + * @{ + */ + +/** Endpoint Discovery Message */ +#define UMP_STREAM_STATUS_EP_DISCOVERY 0x00 +/** Endpoint Info Notification Message */ +#define UMP_STREAM_STATUS_EP_INFO 0x01 +/** Device Identity Notification Message */ +#define UMP_STREAM_STATUS_DEVICE_IDENT 0x02 +/** Endpoint Name Notification */ +#define UMP_STREAM_STATUS_EP_NAME 0x03 +/** Product Instance Id Notification Message */ +#define UMP_STREAM_STATUS_PROD_ID 0x04 +/** Stream Configuration Request Message */ +#define UMP_STREAM_STATUS_CONF_REQ 0x05 +/** Stream Configuration Notification Message */ +#define UMP_STREAM_STATUS_CONF_NOTIF 0x06 +/** Function Block Discovery Message */ +#define UMP_STREAM_STATUS_FB_DISCOVERY 0x10 +/** Function Block Info Notification */ +#define UMP_STREAM_STATUS_FB_INFO 0x11 +/** Function Block Name Notification */ +#define UMP_STREAM_STATUS_FB_NAME 0x12 +/** @} */ + +/** + * @brief Filter bitmap of an Endpoint Discovery message + * @param[in] ump Universal MIDI Packet (containing an Endpoint Discovery message) + * @see ump112: 7.1.1 Endpoint Discovery Message + * @see midi_ump_ep_disc + */ +#define UMP_STREAM_EP_DISCOVERY_FILTER(ump) \ + ((ump).data[1] & BIT_MASK(8)) + +/** + * @defgroup midi_ump_ep_disc UMP Stream endpoint discovery message filter bits + * @ingroup midi_ump_stream + * @see ump112: 7.1.1 Fig. 12: Endpoint Discovery Message Filter Bitmap Field + * @remark When UMP_MT(x)=UMP_MT_UMP_STREAM and + * UMP_STREAM_STATUS(x)=UMP_STREAM_STATUS_EP_DISCOVERY, + * then UMP_STREAM_EP_DISCOVERY_FILTER(x) may be an ORed combination of: + * @{ + */ + +/** Requesting an Endpoint Info Notification */ +#define UMP_EP_DISC_FILTER_EP_INFO BIT(0) +/** Requesting a Device Identity Notification */ +#define UMP_EP_DISC_FILTER_DEVICE_ID BIT(1) +/** Requesting an Endpoint Name Notification */ +#define UMP_EP_DISC_FILTER_EP_NAME BIT(2) +/** Requesting a Product Instance Id Notification */ +#define UMP_EP_DISC_FILTER_PRODUCT_ID BIT(3) +/** Requesting a Stream Configuration Notification */ +#define UMP_EP_DISC_FILTER_STREAM_CFG BIT(4) +/** @} */ + +/** + * @brief Filter bitmap of a Function Block Discovery message + * @param[in] ump Universal MIDI Packet (containing a Function Block Discovery message) + * @see ump112: 7.1.7 Function Block Discovery Message + * @see midi_ump_fb_disc + */ +#define UMP_STREAM_FB_DISCOVERY_FILTER(ump) \ + ((ump).data[0] & BIT_MASK(8)) + +/** + * @brief Block number requested in a Function Block Discovery message + * @param[in] ump Universal MIDI Packet (containing a Function Block Discovery message) + * @see ump112: 7.1.7 Function Block Discovery Message + */ +#define UMP_STREAM_FB_DISCOVERY_NUM(ump) \ + (((ump).data[0] >> 8) & BIT_MASK(8)) + +/** + * @defgroup midi_ump_fb_disc UMP Stream Function Block discovery message filter bits + * @ingroup midi_ump_stream + * @see ump112: 7.1.7 Fig. 21: Function Block Discovery Filter Bitmap Field Format + * @remark When UMP_MT(x)=UMP_MT_UMP_STREAM and + * UMP_STREAM_STATUS(x)=UMP_STREAM_STATUS_FB_DISCOVERY, + * then UMP_STREAM_FB_DISCOVERY_FILTER(x) may be an ORed combination of: + * @{ + */ +/** Requesting a Function Block Info Notification */ +#define UMP_FB_DISC_FILTER_INFO BIT(0) +/** Requesting a Function Block Name Notification */ +#define UMP_FB_DISC_FILTER_NAME BIT(1) +/** @} */ + +/** @} */ + /** @} */ #ifdef __cplusplus diff --git a/lib/CMakeLists.txt b/lib/CMakeLists.txt index fad0e383c9061..f960141b7af76 100644 --- a/lib/CMakeLists.txt +++ b/lib/CMakeLists.txt @@ -11,6 +11,7 @@ add_subdirectory_ifdef(CONFIG_CPP cpp) add_subdirectory(hash) add_subdirectory(heap) add_subdirectory(mem_blocks) +add_subdirectory(midi2) add_subdirectory_ifdef(CONFIG_NET_BUF net_buf) add_subdirectory(os) add_subdirectory(utils) diff --git a/lib/Kconfig b/lib/Kconfig index 149c1317e499e..7f2a5b2a15be1 100644 --- a/lib/Kconfig +++ b/lib/Kconfig @@ -15,6 +15,8 @@ source "lib/heap/Kconfig" source "lib/mem_blocks/Kconfig" +source "lib/midi2/Kconfig" + source "lib/net_buf/Kconfig" source "lib/os/Kconfig" diff --git a/lib/midi2/CMakeLists.txt b/lib/midi2/CMakeLists.txt new file mode 100644 index 0000000000000..49a11c2ed8552 --- /dev/null +++ b/lib/midi2/CMakeLists.txt @@ -0,0 +1,8 @@ +# Copyright (c) 2025 Titouan Christophe +# SPDX-License-Identifier: Apache-2.0 + +zephyr_include_directories(.) + +if(CONFIG_MIDI2_UMP_STREAM_RESPONDER) + zephyr_sources(ump_stream_responder.c) +endif() diff --git a/lib/midi2/Kconfig b/lib/midi2/Kconfig new file mode 100644 index 0000000000000..d4ab789c30304 --- /dev/null +++ b/lib/midi2/Kconfig @@ -0,0 +1,13 @@ +# Copyright (c) 2025 Titouan Christophe +# SPDX-License-Identifier: Apache-2.0 + +menu "MIDI2" + +config MIDI2_UMP_STREAM_RESPONDER + bool "MIDI2 UMP Stream responder" + help + Library to respond to UMP Stream discovery messages, as specified + in "Universal MIDI Packet (UMP) Format and MIDI 2.0 Protocol" + version 1.1.2 section 7.1: "UMP Stream Messages" + +endmenu diff --git a/lib/midi2/ump_stream_responder.c b/lib/midi2/ump_stream_responder.c new file mode 100644 index 0000000000000..7ffffbf873255 --- /dev/null +++ b/lib/midi2/ump_stream_responder.c @@ -0,0 +1,284 @@ +/* + * Copyright (c) 2025 Titouan Christophe + * SPDX-License-Identifier: Apache-2.0 + */ + +#include +#include +#include +#include + +#include "ump_stream_responder.h" + +#define BIT_IF(cond, n) ((cond) ? BIT(n) : 0) + +/** + * @brief MIDI-CI version identifier for UMP v1.1 devices + * @see ump112: 7.1.8 FB Info Notification > MIDI-CI Message Version/Format + */ +#define MIDI_CI_VERSION_FORMAT_UMP_1_1 0x01 + +static inline bool ep_has_midi1(const struct ump_endpoint_dt_spec *ep) +{ + for (size_t i = 0; i < ep->n_blocks; i++) { + if (ep->blocks[i].is_midi1) { + return true; + } + } + return false; +} + +static inline bool ep_has_midi2(const struct ump_endpoint_dt_spec *ep) +{ + for (size_t i = 0; i < ep->n_blocks; i++) { + if (!ep->blocks[i].is_midi1) { + return true; + } + } + return false; +} + +/** + * @brief Build an Endpoint Info Notification Universal MIDI Packet + * @see ump112: 7.1.2 Endpoint Info Notification Message + */ +static inline struct midi_ump make_endpoint_info(const struct ump_endpoint_dt_spec *ep) +{ + struct midi_ump res; + + res.data[0] = (UMP_MT_UMP_STREAM << 28) + | (UMP_STREAM_STATUS_EP_INFO << 16) + | 0x0101; /* UMP version 1.1 */ + + res.data[1] = BIT(31) /* Static function blocks */ + | ((ep->n_blocks) << 24) + | BIT_IF(ep_has_midi2(ep), 9) + | BIT_IF(ep_has_midi1(ep), 8); + + return res; +} + +/** + * @brief Build a Function Block Info Notification Universal MIDI Packet + * @see ump112: 7.1.8 Function Block Info Notification + */ +static inline struct midi_ump make_function_block_info(const struct ump_endpoint_dt_spec *ep, + size_t block_num) +{ + const struct ump_block_dt_spec *block = &ep->blocks[block_num]; + struct midi_ump res; + uint8_t midi1_mode = block->is_31250bps ? 2 : block->is_midi1 ? 1 : 0; + + res.data[0] = (UMP_MT_UMP_STREAM << 28) + | (UMP_STREAM_STATUS_FB_INFO << 16) + | BIT(15) /* Block is active */ + | (block_num << 8) + | BIT_IF(block->is_output, 5) /* UI hint Sender */ + | BIT_IF(block->is_input, 4) /* UI hint Receiver */ + | (midi1_mode << 2) + | BIT_IF(block->is_output, 1) /* Function block is output */ + | BIT_IF(block->is_input, 0); /* Function block is input */ + + res.data[1] = (block->first_group << 24) + | (block->groups_spanned << 16) + | (MIDI_CI_VERSION_FORMAT_UMP_1_1 << 8) /* MIDI-CI for UMP v1.1 */ + | 0xff; /* At most 255 simultaneous Sysex streams */ + + return res; +} + +/** + * @brief Copy an ASCII string into a Universal MIDI Packet while leaving + * some most significant bytes untouched, such that the caller can + * set this prefix. + * @param ump The ump into which the string is copied + * @param[in] offset Number of bytes from the most-significant side to leave free + * @param[in] src The source string + * @param[in] len The length of the source string + * @return The number of bytes copied + */ +static inline size_t fill_str(struct midi_ump *ump, size_t offset, + const char *src, size_t len) +{ + size_t i, j; + + if (offset >= sizeof(struct midi_ump)) { + return 0; + } + + for (i = 0; i < len && (j = i + offset) < sizeof(struct midi_ump); i++) { + ump->data[j / 4] |= src[i] << (8 * (3 - (j % 4))); + } + + return i; +} + +/** + * @brief Send a string as UMP Stream, possibly splitting into multiple + * packets if the string length is larger than 1 UMP + * @param[in] cfg The responder configuration + * @param[in] string The string to send + * @param[in] prefix The fixed prefix of UMP packets to send + * @param[in] offset The offset the strings starts in the packet, in bytes + * + * @return The number of packets sent + */ +static inline int send_string(const struct ump_stream_responder_cfg *cfg, + const char *string, uint32_t prefix, size_t offset) +{ + struct midi_ump reply; + size_t stringlen = strlen(string); + size_t strwidth = sizeof(reply) - offset; + uint8_t format; + size_t i = 0; + int res = 0; + + while (i < stringlen) { + memset(&reply, 0, sizeof(reply)); + format = (i == 0) + ? (stringlen - i <= strwidth) + ? UMP_STREAM_FORMAT_COMPLETE + : UMP_STREAM_FORMAT_START + : (stringlen - i > strwidth) + ? UMP_STREAM_FORMAT_CONTINUE + : UMP_STREAM_FORMAT_END; + + reply.data[0] = (UMP_MT_UMP_STREAM << 28) + | (format << 26) + | prefix; + + i += fill_str(&reply, offset, &string[i], stringlen - i); + cfg->send(cfg->dev, reply); + res++; + } + + return res; +} + +/** + * @brief Handle Endpoint Discovery messages + * @param[in] cfg The responder configuration + * @param[in] pkt The discovery packet to handle + * @return The number of UMP sent as reply + */ +static inline int ump_ep_discover(const struct ump_stream_responder_cfg *cfg, + const struct midi_ump pkt) +{ + int res = 0; + uint8_t filter = UMP_STREAM_EP_DISCOVERY_FILTER(pkt); + + /* Request for Endpoint Info Notification */ + if ((filter & UMP_EP_DISC_FILTER_EP_INFO) != 0U) { + cfg->send(cfg->dev, make_endpoint_info(cfg->ep_spec)); + res++; + } + + /* Request for Endpoint Name Notification */ + if ((filter & UMP_EP_DISC_FILTER_EP_NAME) != 0U && cfg->ep_spec->name != NULL) { + res += send_string(cfg, cfg->ep_spec->name, + UMP_STREAM_STATUS_EP_NAME << 16, 2); + } + + /* Request for Product Instance ID */ + if ((filter & UMP_EP_DISC_FILTER_PRODUCT_ID) != 0U && IS_ENABLED(CONFIG_HWINFO)) { + res += send_string(cfg, ump_product_instance_id(), + UMP_STREAM_STATUS_PROD_ID << 16, 2); + } + + return res; +} + +static inline int ump_fb_discover_block(const struct ump_stream_responder_cfg *cfg, + size_t block_num, uint8_t filter) +{ + int res = 0; + const struct ump_block_dt_spec *blk = &cfg->ep_spec->blocks[block_num]; + + if ((filter & UMP_FB_DISC_FILTER_INFO) != 0U) { + cfg->send(cfg->dev, make_function_block_info(cfg->ep_spec, block_num)); + res++; + } + + if ((filter & UMP_FB_DISC_FILTER_NAME) != 0U && blk->name != NULL) { + res += send_string(cfg, blk->name, + (UMP_STREAM_STATUS_FB_NAME << 16) | (block_num << 8), 3); + } + + return res; +} + +/** + * @brief Handle Function Block Discovery messages + * @param[in] cfg The responder configuration + * @param[in] pkt The discovery packet to handle + * @return The number of UMP sent as reply + */ +static inline int ump_fb_discover(const struct ump_stream_responder_cfg *cfg, + const struct midi_ump pkt) +{ + int res = 0; + uint8_t block_num = UMP_STREAM_FB_DISCOVERY_NUM(pkt); + uint8_t filter = UMP_STREAM_FB_DISCOVERY_FILTER(pkt); + + if (block_num < cfg->ep_spec->n_blocks) { + res += ump_fb_discover_block(cfg, block_num, filter); + } else if (block_num == 0xff) { + /* Requesting information for all blocks at once */ + for (block_num = 0; block_num < cfg->ep_spec->n_blocks; block_num++) { + res += ump_fb_discover_block(cfg, block_num, filter); + } + } + + return res; +} + +const char *ump_product_instance_id(void) +{ + static char product_id[43] = ""; + static const char hex[] = "0123456789ABCDEF"; + + if (IS_ENABLED(CONFIG_HWINFO) && product_id[0] == '\0') { + uint8_t devid[sizeof(product_id) / 2]; + ssize_t len = hwinfo_get_device_id(devid, sizeof(devid)); + + if (len == -ENOSYS && hwinfo_get_device_eui64(devid) == 0) { + /* device id unavailable, but there is an eui64, + * which has a fixed length of 8 + */ + len = 8; + } else if (len < 0) { + /* Other hwinfo driver error; mark as empty */ + len = 0; + } + + /* Convert to hex string */ + for (ssize_t i = 0; i < len; i++) { + product_id[2 * i] = hex[devid[i] >> 4]; + product_id[2 * i + 1] = hex[devid[i] & 0xf]; + } + product_id[2*len] = '\0'; + } + + return product_id; +} + +int ump_stream_respond(const struct ump_stream_responder_cfg *cfg, + const struct midi_ump pkt) +{ + if (cfg->send == NULL) { + return -EINVAL; + } + + if (UMP_MT(pkt) != UMP_MT_UMP_STREAM) { + return 0; + } + + switch (UMP_STREAM_STATUS(pkt)) { + case UMP_STREAM_STATUS_EP_DISCOVERY: + return ump_ep_discover(cfg, pkt); + case UMP_STREAM_STATUS_FB_DISCOVERY: + return ump_fb_discover(cfg, pkt); + } + + return 0; +} diff --git a/lib/midi2/ump_stream_responder.h b/lib/midi2/ump_stream_responder.h new file mode 100644 index 0000000000000..420e206de38f3 --- /dev/null +++ b/lib/midi2/ump_stream_responder.h @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2025 Titouan Christophe + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef ZEPHYR_LIB_MIDI2_UMP_STREAM_RESPONDER_H_ +#define ZEPHYR_LIB_MIDI2_UMP_STREAM_RESPONDER_H_ + +/** + * @brief Respond to UMP Stream message Endpoint or Function Block discovery + * @defgroup ump_stream_responder UMP Stream Responder + * @ingroup midi_ump + * @since 4.3 + * @version 0.1.0 + * @see ump112 7.1: UMP Stream Messages + * @{ + */ + +#include +#include + +/** + * @brief UMP Function Block specification + * @see ump112: 6: Function Blocks + */ +struct ump_block_dt_spec { + /** Name of this function block, or NULL if unnamed */ + const char *name; + /** Number of the first UMP group in this block */ + uint8_t first_group; + /** Number of (contiguous) UMP groups spanned by this block */ + uint8_t groups_spanned; + /** True if this function block is an input */ + bool is_input; + /** True if this function block is an output */ + bool is_output; + /** True if this function block carries MIDI1 data only */ + bool is_midi1; + /** True if this function block is physically wired to a (MIDI1) + * serial interface, where data is transmitted at the standard + * baud rate of 31250 b/s + */ + bool is_31250bps; +}; + +/** + * @brief UMP endpoint specification + */ +struct ump_endpoint_dt_spec { + /** Name of this endpoint, or NULL if unnamed */ + const char *name; + /** Number of function blocks in this endpoint */ + size_t n_blocks; + /** Function blocks in this endpoint */ + struct ump_block_dt_spec blocks[]; +}; + +/** + * @brief Configuration for the UMP Stream responder + */ +struct ump_stream_responder_cfg { + /** The device to send reply packets */ + void *dev; + /** The function to call to send a reply packet */ + void (*send)(void *dev, const struct midi_ump ump); + /** The UMP endpoint specification */ + const struct ump_endpoint_dt_spec *ep_spec; +}; + +/** + * @brief Get a Universal MIDI Packet endpoint function block from its + * device-tree representation + * @param _node The device tree node representing the midi2 block + */ +#define UMP_BLOCK_DT_SPEC_GET(_node) \ +{ \ + .name = DT_PROP_OR(_node, label, NULL), \ + .first_group = DT_REG_ADDR(_node), \ + .groups_spanned = DT_REG_SIZE(_node), \ + .is_input = !DT_ENUM_HAS_VALUE(_node, terminal_type, output_only), \ + .is_output = !DT_ENUM_HAS_VALUE(_node, terminal_type, input_only), \ + .is_midi1 = !DT_ENUM_HAS_VALUE(_node, protocol, midi2), \ + .is_31250bps = DT_PROP(_node, serial_31250bps), \ +} + +#define UMP_BLOCK_SEP_IF_OKAY(_node) \ + COND_CODE_1(DT_NODE_HAS_STATUS_OKAY(_node), \ + (UMP_BLOCK_DT_SPEC_GET(_node),), \ + ()) + +/** + * @brief Get a Universal MIDI Packet endpoint description from + * the device-tree representation of a midi2 device + * @param _node The device tree node representing a midi2 device + */ +#define UMP_ENDPOINT_DT_SPEC_GET(_node) \ +{ \ + .name = DT_PROP_OR(_node, label, NULL), \ + .n_blocks = DT_FOREACH_CHILD_SEP(_node, DT_NODE_HAS_STATUS_OKAY, (+)), \ + .blocks = {DT_FOREACH_CHILD(_node, UMP_BLOCK_SEP_IF_OKAY)}, \ +} + +/** + * @brief Respond to an UMP Stream message + * @param[in] cfg The responder configuration + * @param[in] pkt The message to respond to + * @return The number of UMP packets sent as reply, + * or -errno in case of error + */ +int ump_stream_respond(const struct ump_stream_responder_cfg *cfg, + const struct midi_ump pkt); + +/** + * @return The UMP Product Instance ID of this device, based on the device + * hwinfo if available, otherwise an empty string + */ +const char *ump_product_instance_id(void); + +/** @} */ + +#endif From 126a2b0f19e5a3de35e8b7fb3288aaf11322a4db Mon Sep 17 00:00:00 2001 From: Titouan Christophe Date: Fri, 1 Aug 2025 09:57:02 +0200 Subject: [PATCH 3/7] usb: device_next: usbd_midi: mark UMP group as 31.25kb/s from devicetree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since a new device-tree property has been introduced to describe if a UMP group is limited to 31.25kb/s (like a physical MIDI DIN-5 port), this can be used in the USB UMP group terminal block descriptors, as specified in the USB-MIDI2.0 reference document [1], (in 5.4.2.1 Group Terminal Block Descriptor): Maximum Output Bandwidth Capability in 4KB/second. 0x0000 – 0xFFFF. Bandwidth is total for this Block, shared among all OUT Group Terminals. 0x0000 = Unknown or Not Fixed. 0x0001 = Rounded version of 31.25kb/s [1] https://www.usb.org/sites/default/files/USB%20MIDI%20v2_0.pdf Signed-off-by: Titouan Christophe --- subsys/usb/device_next/class/usbd_midi2.c | 26 +++++++++++------------ 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/subsys/usb/device_next/class/usbd_midi2.c b/subsys/usb/device_next/class/usbd_midi2.c index 1afab63b4d92f..78b8fa4ccc41c 100644 --- a/subsys/usb/device_next/class/usbd_midi2.c +++ b/subsys/usb/device_next/class/usbd_midi2.c @@ -555,19 +555,19 @@ void usbd_midi_set_ops(const struct device *dev, const struct usbd_midi_ops *ops #define USBD_MIDI_VALIDATE_INSTANCE(n) \ DT_INST_FOREACH_CHILD(n, USBD_MIDI_VALIDATE_GRPTRM_BLOCK) -#define USBD_MIDI2_INIT_GRPTRM_BLOCK_DESCRIPTOR(node) \ - { \ - .bLength = sizeof(struct usb_midi_grptrm_block_descriptor), \ - .bDescriptorType = CS_GR_TRM_BLOCK, \ - .bDescriptorSubtype = GR_TRM_BLOCK, \ - .bGrpTrmBlkID = GRPTRM_BLOCK_ID(node), \ - .bGrpTrmBlkType = GRPTRM_BLOCK_TYPE(node), \ - .nGroupTrm = DT_REG_ADDR(node), \ - .nNumGroupTrm = DT_REG_SIZE(node), \ - .iBlockItem = 0, \ - .bMIDIProtocol = GRPTRM_PROTOCOL(node), \ - .wMaxInputBandwidth = 0x0000, \ - .wMaxOutputBandwidth = 0x0000, \ +#define USBD_MIDI2_INIT_GRPTRM_BLOCK_DESCRIPTOR(node) \ + { \ + .bLength = sizeof(struct usb_midi_grptrm_block_descriptor), \ + .bDescriptorType = CS_GR_TRM_BLOCK, \ + .bDescriptorSubtype = GR_TRM_BLOCK, \ + .bGrpTrmBlkID = GRPTRM_BLOCK_ID(node), \ + .bGrpTrmBlkType = GRPTRM_BLOCK_TYPE(node), \ + .nGroupTrm = DT_REG_ADDR(node), \ + .nNumGroupTrm = DT_REG_SIZE(node), \ + .iBlockItem = 0, \ + .bMIDIProtocol = GRPTRM_PROTOCOL(node), \ + .wMaxInputBandwidth = sys_cpu_to_le16(DT_PROP(node, serial_31250bps)), \ + .wMaxOutputBandwidth = sys_cpu_to_le16(DT_PROP(node, serial_31250bps)), \ } #define USBD_MIDI2_GRPTRM_TOTAL_LEN(n) \ From 713831fe0caeb6282e3ef3d9e999b6eee71c53ea Mon Sep 17 00:00:00 2001 From: Titouan Christophe Date: Thu, 31 Jul 2025 11:53:03 +0200 Subject: [PATCH 4/7] samples: subsys: usb: midi: demonstrate usage of UMP Stream responder Update the USB-MIDI2.0 sample to use the newly introduced UMP Stream Responder library. This allows the host to discover the topology and function block names (if supported by the host OS). Signed-off-by: Titouan Christophe --- samples/subsys/usb/midi/app.overlay | 2 ++ samples/subsys/usb/midi/prj.conf | 2 ++ samples/subsys/usb/midi/src/main.c | 24 +++++++++++++++++++++--- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/samples/subsys/usb/midi/app.overlay b/samples/subsys/usb/midi/app.overlay index 7d8ba72ef5cb3..5bf8eef1835c8 100644 --- a/samples/subsys/usb/midi/app.overlay +++ b/samples/subsys/usb/midi/app.overlay @@ -10,10 +10,12 @@ status = "okay"; #address-cells = <1>; #size-cells = <1>; + label = "Zephyr USB-MIDI Sample"; midi_in_out@0 { reg = <0 1>; protocol = "midi1-up-to-128b"; + label = "MIDI-IN-OUT"; }; }; }; diff --git a/samples/subsys/usb/midi/prj.conf b/samples/subsys/usb/midi/prj.conf index 02e2d52c7edd3..ed1e8d7d6988a 100644 --- a/samples/subsys/usb/midi/prj.conf +++ b/samples/subsys/usb/midi/prj.conf @@ -10,3 +10,5 @@ CONFIG_INPUT=y CONFIG_LOG=y CONFIG_USBD_LOG_LEVEL_WRN=y CONFIG_UDC_DRIVER_LOG_LEVEL_WRN=y + +CONFIG_MIDI2_UMP_STREAM_RESPONDER=y diff --git a/samples/subsys/usb/midi/src/main.c b/samples/subsys/usb/midi/src/main.c index 4581d1f01835b..cd1b045c2a2ef 100644 --- a/samples/subsys/usb/midi/src/main.c +++ b/samples/subsys/usb/midi/src/main.c @@ -14,10 +14,14 @@ #include #include +#include + #include LOG_MODULE_REGISTER(sample_usb_midi, LOG_LEVEL_INF); -static const struct device *const midi = DEVICE_DT_GET(DT_NODELABEL(usb_midi)); +#define USB_MIDI_DT_NODE DT_NODELABEL(usb_midi) + +static const struct device *const midi = DEVICE_DT_GET(USB_MIDI_DT_NODE); static struct gpio_dt_spec led = GPIO_DT_SPEC_GET_OR(DT_ALIAS(led0), gpios, {0}); @@ -38,15 +42,29 @@ static void key_press(struct input_event *evt, void *user_data) } INPUT_CALLBACK_DEFINE(NULL, key_press, NULL); +static const struct ump_endpoint_dt_spec ump_ep_dt = + UMP_ENDPOINT_DT_SPEC_GET(USB_MIDI_DT_NODE); + +const struct ump_stream_responder_cfg responder_cfg = { + .dev = midi, + .send = (void (*)(void *, const struct midi_ump)) usbd_midi_send, + .ep_spec = &ump_ep_dt, +}; + static void on_midi_packet(const struct device *dev, const struct midi_ump ump) { LOG_INF("Received MIDI packet (MT=%X)", UMP_MT(ump)); - /* Only send MIDI1 channel voice messages back to the host */ - if (UMP_MT(ump) == UMP_MT_MIDI1_CHANNEL_VOICE) { + switch (UMP_MT(ump)) { + case UMP_MT_MIDI1_CHANNEL_VOICE: + /* Only send MIDI1 channel voice messages back to the host */ LOG_INF("Send back MIDI1 message %02X %02X %02X", UMP_MIDI_STATUS(ump), UMP_MIDI1_P1(ump), UMP_MIDI1_P2(ump)); usbd_midi_send(dev, ump); + break; + case UMP_MT_UMP_STREAM: + ump_stream_respond(&responder_cfg, ump); + break; } } From 2f5763edcf5977087d178d3cc3930f461d1e06d6 Mon Sep 17 00:00:00 2001 From: Titouan Christophe Date: Thu, 31 Jul 2025 12:03:44 +0200 Subject: [PATCH 5/7] net: lib: midi2: new Network MIDI 2.0 host stack Add a new network protocol for MIDI2.0 over the network, using UDP sockets. This allows Zephyr to host a UMP endpoint on the network, which can be invited by UMP clients to exchange MIDI2.0 data. Signed-off-by: Titouan Christophe --- include/zephyr/net/midi2.h | 260 +++++++++ subsys/net/lib/CMakeLists.txt | 1 + subsys/net/lib/Kconfig | 2 + subsys/net/lib/midi2/CMakeLists.txt | 6 + subsys/net/lib/midi2/Kconfig | 36 ++ subsys/net/lib/midi2/netmidi2.c | 826 ++++++++++++++++++++++++++++ 6 files changed, 1131 insertions(+) create mode 100644 include/zephyr/net/midi2.h create mode 100644 subsys/net/lib/midi2/CMakeLists.txt create mode 100644 subsys/net/lib/midi2/Kconfig create mode 100644 subsys/net/lib/midi2/netmidi2.c diff --git a/include/zephyr/net/midi2.h b/include/zephyr/net/midi2.h new file mode 100644 index 0000000000000..4af8edc32582a --- /dev/null +++ b/include/zephyr/net/midi2.h @@ -0,0 +1,260 @@ +/* + * Copyright (c) 2025 Titouan Christophe + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef ZEPHYR_INCLUDE_NET_MIDI2_H_ +#define ZEPHYR_INCLUDE_NET_MIDI2_H_ + +/** + * @defgroup net_midi2 Network MIDI 2.0 + * @since 4.3 + * @version 0.1.0 + * @ingroup networking + * @{ + */ + +/** + * @defgroup netmidi10 User Datagram Protocol for Universal MIDI Packets + * @ingroup net_midi2 + * @{ + * @details Definitions based on the following document + * + * User Datagram Protocol for Universal MIDI Packets + * Network MIDI 2.0 (UDP) Transport Specification - Document version 1.0 + * (MIDI Association Document: M2-124-UM) + * + * @} + */ + + +#include +#include +#include +#include + +/** + * Size, in bytes, of the nonce sent to the client for authentication + * @see netmidi10: 6.7 Invitation Reply: Authentication Required + */ +#define NETMIDI2_NONCE_SIZE 16 + +/** + * @brief Statically declare a Network (UDP) MIDI 2.0 endpoint host + * @param _var_name The name of the variable holding the server data + * @param _ep_name The UMP endpoint name + * @param _piid The UMP Product Instance ID. If NULL, + * HWINFO device ID will be used at runtime. + * @param _port The UDP port to listen to, or 0 for automatic assignment + */ +#define NETMIDI2_EP_DEFINE(_var_name, _ep_name, _piid, _port) \ + static struct netmidi2_ep _var_name = { \ + .name = (_ep_name), \ + .piid = (_piid), \ + .addr4.sin_port = (_port), \ + .auth_type = NETMIDI2_AUTH_NONE, \ + } + +/** + * @brief Statically declare a Network (UDP) MIDI 2.0 endpoint host, + * with a predefined shared secret key for authentication + * @param _var_name The name of the variable holding the server data + * @param _ep_name The UMP endpoint name + * @param _piid The UMP Product Instance ID. If NULL, + * HWINFO device ID will be used at runtime. + * @param _port The UDP port to listen to, or 0 for automatic assignment + * @param _secret The shared secret key clients must provide to connect + */ +#define NETMIDI2_EP_DEFINE_WITH_AUTH(_var_name, _ep_name, _piid, _port, _secret) \ + static struct netmidi2_ep _var_name = { \ + .name = (_ep_name), \ + .piid = (_piid), \ + .addr4.sin_port = (_port), \ + .auth_type = NETMIDI2_AUTH_SHARED_SECRET, \ + .shared_auth_secret = (_secret), \ + } + +/** + * @brief Statically declare a Network (UDP) MIDI 2.0 endpoint host, + * with a predefined list of users/passwords for authentication + * @param _var_name The name of the variable holding the server data + * @param _ep_name The UMP endpoint name + * @param _piid The UMP Product Instance ID. If NULL, + * HWINFO device ID will be used at runtime. + * @param _port The UDP port to listen to, or 0 for automatic assignment + * @param ... The username/password pairs (struct netmidi2_user) + * + * Example usage: + * @code + * NETMIDI2_EP_DEFINE_WITH_USERS(my_server, "endpoint", NULL, 0, + * {.name="user1", .password="passwd1"}, + * {.name="user2", .password="passwd2"}) + * @endcode + */ +#define NETMIDI2_EP_DEFINE_WITH_USERS(_var_name, _ep_name, _piid, _port, ...) \ + static const struct netmidi2_userlist users_of_##_var_name = { \ + .n_users = ARRAY_SIZE(((struct netmidi2_user []) { __VA_ARGS__ })), \ + .users = { __VA_ARGS__ }, \ + }; \ + static struct netmidi2_ep _var_name = { \ + .name = (_ep_name), \ + .piid = (_piid), \ + .addr4.sin_port = (_port), \ + .auth_type = NETMIDI2_AUTH_USER_PASSWORD, \ + .userlist = &users_of_##_var_name, \ + } + +struct netmidi2_ep; + +/** + * @brief A username/password pair for user-based authentication + */ +struct netmidi2_user { + /** The user name for authentication */ + const char *name; + /** The password for authentication */ + const char *password; +}; + +/** + * @brief A list of users for user-based authentication + */ +struct netmidi2_userlist { + /** Number of users in the list */ + size_t n_users; + /** The user/password pairs */ + const struct netmidi2_user users[]; +}; + +/** + * @brief A Network MIDI2 session, representing a connection to a peer + */ +struct netmidi2_session { + /** + * State of this session + * @see netmidi10: 6.1 Session States + */ + enum { + /** The session is not in use */ + NETMIDI2_SESSION_NOT_INITIALIZED = 0, + /** Device may be aware of each other (e.g. through mDNS), + * however neither device has sent an Invitation. + * The two Devices are not in a Session. + */ + NETMIDI2_SESSION_IDLE, + /** Client has sent Invitation, however the Host has not + * accepted the Invitation. A Bye Command has not been + * sent or received. + */ + NETMIDI2_SESSION_PENDING_INVITATION, + /** Host has replied with Invitation Reply: Authentication + * Required or Invitation Reply: User Authentication Required. + * No timeout has yet occurred. This state is different from + * Idle, because the Client and Host need to store the Nonce. + */ + NETMIDI2_SESSION_AUTH_REQUIRED, + /** Invitation accepted, UMP Commands can be exchanged. */ + NETMIDI2_SESSION_ESTABLISHED, + /** One Device has sent the Session Reset Command and is + * waiting for Session Reset Reply, and a timeout has not + * yet occurred. + */ + NETMIDI2_SESSION_PENDING_RESET, + /** one Endpoint has sent Bye and is waiting for Bye Reply, + * and a timeout has not yet occurred. + */ + NETMIDI2_SESSION_PENDING_BYE, + } state; + /** Sequence number of the next universal MIDI packet to send */ + uint16_t tx_ump_seq; + /** Sequence number of the next universal MIDI packet to receive */ + uint16_t rx_ump_seq; + /** Remote address of the peer */ + struct sockaddr_storage addr; + /** Length of the peer's remote address */ + socklen_t addr_len; + /** The Network MIDI2 endpoint to which this session belongs */ + struct netmidi2_ep *ep; +#if defined(CONFIG_NETMIDI2_HOST_AUTH) || defined(__DOXYGEN__) + /** The username to which this session belongs */ + const struct netmidi2_user *user; + /** The crypto nonce used to authorize this session */ + char nonce[NETMIDI2_NONCE_SIZE]; +#endif + /** The transmission buffer for that peer */ + struct net_buf *tx_buf; + /** The transmission work for that peer */ + struct k_work tx_work; +}; + +/** + * @brief Type of authentication in Network MIDI2 + */ +enum netmidi2_auth_type { + /** No authentication required */ + NETMIDI2_AUTH_NONE, + /** Authentication with a shared secret key */ + NETMIDI2_AUTH_SHARED_SECRET, + /** Authentication with username and password */ + NETMIDI2_AUTH_USER_PASSWORD, +}; + +/** + * @brief A Network MIDI2.0 Endpoint + */ +struct netmidi2_ep { + /** The endpoint name */ + const char *name; + /** The endpoint product instance id */ + const char *piid; + /** The local endpoint address */ + union { + struct sockaddr addr; + struct sockaddr_in addr4; + struct sockaddr_in6 addr6; + }; + /** The listening socket wrapped in a poll descriptor */ + struct pollfd pollsock; + /** The function to call when data is received from a client */ + void (*rx_packet_cb)(struct netmidi2_session *session, + const struct midi_ump ump); + /** List of peers to this endpoint */ + struct netmidi2_session peers[CONFIG_NETMIDI2_HOST_MAX_CLIENTS]; + /** The type of authentication required to establish a session + * with this host endpoint + */ + enum netmidi2_auth_type auth_type; +#if defined(CONFIG_NETMIDI2_HOST_AUTH) || defined(__DOXYGEN__) + union { + /** A shared authentication key */ + const char *shared_auth_secret; + /** A list of users/passwords */ + const struct netmidi2_userlist *userlist; + }; +#endif +}; + +/** + * @brief Start hosting a network (UDP) Universal MIDI Packet endpoint + * @param ep The network endpoint to start + * @return 0 on success, -errno on error + */ +int netmidi2_host_ep_start(struct netmidi2_ep *ep); + +/** + * @brief Send a Universal MIDI Packet to all clients connected to the endpoint + * @param ep The endpoint + * @param[in] ump The packet to send + */ +void netmidi2_broadcast(struct netmidi2_ep *ep, const struct midi_ump ump); + +/** + * @brief Send a Universal MIDI Packet to a single client + * @param sess The session identifying the single client + * @param[in] ump The packet to send + */ +void netmidi2_send(struct netmidi2_session *sess, const struct midi_ump ump); + +/** @} */ + +#endif diff --git a/subsys/net/lib/CMakeLists.txt b/subsys/net/lib/CMakeLists.txt index a2b11f44b0bb1..7b7be67015daa 100644 --- a/subsys/net/lib/CMakeLists.txt +++ b/subsys/net/lib/CMakeLists.txt @@ -21,6 +21,7 @@ add_subdirectory_ifdef(CONFIG_NET_DHCPV6 dhcpv6) add_subdirectory_ifdef(CONFIG_PROMETHEUS prometheus) add_subdirectory_ifdef(CONFIG_WIFI_CREDENTIALS wifi_credentials) add_subdirectory_ifdef(CONFIG_OCPP ocpp) +add_subdirectory_ifdef(CONFIG_NETMIDI2_HOST midi2) if (CONFIG_NET_DHCPV4 OR CONFIG_NET_DHCPV4_SERVER) add_subdirectory(dhcpv4) diff --git a/subsys/net/lib/Kconfig b/subsys/net/lib/Kconfig index 9ffb49fbb68e1..4249ca650f75c 100644 --- a/subsys/net/lib/Kconfig +++ b/subsys/net/lib/Kconfig @@ -9,6 +9,8 @@ source "subsys/net/lib/dns/Kconfig" source "subsys/net/lib/latmon/Kconfig" +source "subsys/net/lib/midi2/Kconfig" + source "subsys/net/lib/mqtt/Kconfig" source "subsys/net/lib/mqtt_sn/Kconfig" diff --git a/subsys/net/lib/midi2/CMakeLists.txt b/subsys/net/lib/midi2/CMakeLists.txt new file mode 100644 index 0000000000000..f86aa960b7ec4 --- /dev/null +++ b/subsys/net/lib/midi2/CMakeLists.txt @@ -0,0 +1,6 @@ +# Copyright (c) 2025 Titouan Christophe +# SPDX-License-Identifier: Apache-2.0 + +zephyr_library() + +zephyr_library_sources(netmidi2.c) diff --git a/subsys/net/lib/midi2/Kconfig b/subsys/net/lib/midi2/Kconfig new file mode 100644 index 0000000000000..8ebe7141e6567 --- /dev/null +++ b/subsys/net/lib/midi2/Kconfig @@ -0,0 +1,36 @@ +# Copyright (c) 2025 Titouan Christophe +# SPDX-License-Identifier: Apache-2.0 + +config NETMIDI2_HOST + bool "Network MIDI2 (UDP) host [EXPERIMENTAL]" + select NET_UDP + select NET_SOCKETS + select NET_SOCKETS_SERVICE + depends on NET_IPV4 || NET_IPV6 + imply NET_IPV4_MAPPING_TO_IPV6 if NET_IPV4 && NET_IPV6 + help + Host library of User Datagram Protocol for Universal MIDI Packets. + Provides the following features: + - Exposing an UMP endpoint over UDP + - Accepting inbound client invitations + - Sending/Receiving Universal MIDI packets + Following "Network MIDI 2.0 (UDP) Transport Specification" v1.0 + +if NETMIDI2_HOST +config NETMIDI2_HOST_MAX_CLIENTS + int "Maximum number of clients supported by the Network MIDI2 host" + default 5 + +config NETMIDI2_HOST_AUTH + bool "Support for authentication (shared key or user/password)" + select MBEDTLS + select MBEDTLS_CIPHER_CCM_ENABLED + select CRYPTO + select CRYPTO_MBEDTLS_SHIM + +module=NET_MIDI2 +module-dep=NET_LOG +module-str=Log level for network MIDI2 +module-help=Enables midi2 debug messages. +source "subsys/net/Kconfig.template.log_config.net" +endif diff --git a/subsys/net/lib/midi2/netmidi2.c b/subsys/net/lib/midi2/netmidi2.c new file mode 100644 index 0000000000000..4ab8ec6a3a636 --- /dev/null +++ b/subsys/net/lib/midi2/netmidi2.c @@ -0,0 +1,826 @@ +/* + * Copyright (c) 2025 Titouan Christophe + * SPDX-License-Identifier: Apache-2.0 + */ + +#include + +#include +#include + +#if defined(CONFIG_MIDI2_UMP_STREAM_RESPONDER) +#include +#endif + +#include +LOG_MODULE_REGISTER(net_midi2, CONFIG_NET_MIDI2_LOG_LEVEL); + +#define NETMIDI2_BUFSIZE 256 + +NET_BUF_POOL_DEFINE(netmidi2_pool, 2 + CONFIG_NETMIDI2_HOST_MAX_CLIENTS, + NETMIDI2_BUFSIZE, 0, NULL); + +/** + * Size, in bytes, of the digest sent by the client to authenticate (sha256) + */ +#define NETMIDI2_DIGEST_SIZE 32 + +/* See netmidi10: 5.5: Command Codes and Packet Types */ +#define COMMAND_INVITATION 0x01 +#define COMMAND_INVITATION_WITH_AUTH 0x02 +#define COMMAND_INVITATION_WITH_USER_AUTH 0x03 +#define COMMAND_INVITATION_REPLY_ACCEPTED 0x10 +#define COMMAND_INVITATION_REPLY_PENDING 0x11 +#define COMMAND_INVITATION_REPLY_AUTH_REQUIRED 0x12 +#define COMMAND_INVITATION_REPLY_USER_AUTH_REQUIRED 0x13 +#define COMMAND_PING 0x20 +#define COMMAND_PING_REPLY 0x21 +#define COMMAND_RETRANSMIT_REQUEST 0x80 +#define COMMAND_RETRANSMIT_ERROR 0x81 +#define COMMAND_SESSION_RESET 0x82 +#define COMMAND_SESSION_RESET_REPLY 0x83 +#define COMMAND_NAK 0x8F +#define COMMAND_BYE 0xF0 +#define COMMAND_BYE_REPLY 0xF1 +#define COMMAND_UMP_DATA 0xFF + +/* See netmidi10: 6.4 / Table 11: Capabilities for Invitation */ +#define CLIENT_CAP_INV_WITH_AUTH BIT(0) +#define CLIENT_CAP_INV_WITH_USER_AUTH BIT(1) + +/* See netmidi10: 6.7 / Table 15: Values for Authentication State */ +#define AUTH_STATE_FIRST_REQUEST 0x00 +#define AUTH_STATE_INCORRECT_DIGEST 0x01 + +/* See netmidi10: 6.15 / Table 25: List of NAK Reasons */ +#define NAK_OTHER 0x00 +#define NAK_COMMAND_NOT_SUPPORTED 0x01 +#define NAK_COMMAND_NOT_EXPECTED 0x02 +#define NAK_COMMAND_MALFORMED 0x03 +#define NAK_BAD_PING_REPLY 0x20 + +/* Logging macros that include the peer's address:port */ +#define SESS_LOG_DBG(_s, _fmt, ...) SESS_LOG(DBG, _s, _fmt, ##__VA_ARGS__) +#define SESS_LOG_INF(_s, _fmt, ...) SESS_LOG(INFO, _s, _fmt, ##__VA_ARGS__) +#define SESS_LOG_WRN(_s, _fmt, ...) SESS_LOG(WARN, _s, _fmt, ##__VA_ARGS__) +#define SESS_LOG_ERR(_s, _fmt, ...) SESS_LOG(ERR, _s, _fmt, ##__VA_ARGS__) + +#define SESS_LOG(_lvl, _s, _fmt, ...) \ + do { \ + const struct sockaddr *addr = net_sad(&(_s)->addr); \ + const struct sockaddr_in6 *addr6 = net_sin6(addr); \ + char __pn[INET6_ADDRSTRLEN]; \ + net_addr_ntop(addr->sa_family, &addr6->sin6_addr, __pn, sizeof(__pn)); \ + NET_##_lvl("%s:%d " _fmt, __pn, addr6->sin6_port, ##__VA_ARGS__); \ + } while (0) + +#define SESSION_HAS_STATE(session, expected_state) \ + ((session) && (session)->state == expected_state) + +#if defined(CONFIG_NETMIDI2_HOST_AUTH) +#include +#include + +static inline const struct netmidi2_user *netmidi2_find_user(const struct netmidi2_ep *ep, + const char *name, size_t namelen) +{ + if (ep->auth_type != NETMIDI2_AUTH_USER_PASSWORD) { + return NULL; + } + + for (size_t i = 0; i < ep->userlist->n_users; i++) { + if (strncmp(ep->userlist->users[i].name, name, namelen) == 0) { + return &ep->userlist->users[i]; + } + } + + return NULL; +} + +static bool netmidi2_auth_session(const struct netmidi2_session *sess, + struct net_buf *buf, size_t payload_len) +{ + const struct device *hasher = device_get_binding(CONFIG_CRYPTO_MBEDTLS_SHIM_DRV_NAME); + const uint8_t *auth_digest = buf->data; + const struct netmidi2_user *user; + uint8_t output[NETMIDI2_DIGEST_SIZE]; + struct hash_ctx ctx = {.flags = crypto_query_hwcaps(hasher)}; + struct hash_pkt hash = {.out_buf = output, .ctx = &ctx}; + int ret; + + if (hasher == NULL) { + SESS_LOG_ERR(sess, "mbedtls crypto pseudo-device unavailable"); + return false; + } + + if (buf->len < NETMIDI2_DIGEST_SIZE || payload_len < NETMIDI2_DIGEST_SIZE) { + SESS_LOG_ERR(sess, "Incomplete authentication digest"); + return false; + } + + /* Pull authentication digest from the command packet */ + net_buf_pull(buf, NETMIDI2_DIGEST_SIZE); + + ret = hash_begin_session(hasher, &ctx, CRYPTO_HASH_ALGO_SHA256); + if (ret != 0) { + SESS_LOG_ERR(sess, "Unable to begin hash session"); + return false; + } + + /* 1. Start hashing with the session nonce */ + hash.in_buf = (uint8_t *) sess->nonce; + hash.in_len = NETMIDI2_NONCE_SIZE; + ret = hash_update(&ctx, &hash); + if (ret != 0) { + SESS_LOG_ERR(sess, "Unable to hash nonce"); + goto end; + } + + if (sess->ep->auth_type == NETMIDI2_AUTH_SHARED_SECRET) { + /* 2. Finalize hashing with the shared secret */ + hash.in_buf = (uint8_t *) sess->ep->shared_auth_secret; + hash.in_len = strlen(sess->ep->shared_auth_secret); + ret = hash_compute(&ctx, &hash); + if (ret != 0) { + SESS_LOG_ERR(sess, "Unable to hash shared secret"); + goto end; + } + } else if (sess->ep->auth_type == NETMIDI2_AUTH_USER_PASSWORD) { + user = netmidi2_find_user(sess->ep, buf->data, + payload_len - NETMIDI2_DIGEST_SIZE); + if (user == NULL) { + LOG_ERR("No matching user found"); + goto end; + } + + /* Remove username from buffer */ + net_buf_pull(buf, payload_len - NETMIDI2_DIGEST_SIZE); + + /* 2. Continue hashing with the username */ + hash.in_buf = (uint8_t *) user->name; + hash.in_len = strlen(user->name); + ret = hash_update(&ctx, &hash); + if (ret != 0) { + SESS_LOG_ERR(sess, "Unable to hash username"); + goto end; + } + + /* 3. Finalize hashing with the password */ + hash.in_buf = (uint8_t *) user->password; + hash.in_len = strlen(user->password); + ret = hash_compute(&ctx, &hash); + if (ret != 0) { + SESS_LOG_ERR(sess, "Unable to hash password"); + goto end; + } + } + +end: + hash_free_session(hasher, &ctx); + return ret == 0 && memcmp(hash.out_buf, auth_digest, NETMIDI2_DIGEST_SIZE) == 0; +} +#endif /* CONFIG_NETMIDI2_HOST_AUTH */ + +static inline void netmidi2_free_session(struct netmidi2_session *session) +{ + SESS_LOG_INF(session, "Free client session"); + + k_work_cancel(&session->tx_work); + if (session->tx_buf != NULL) { + net_buf_unref(session->tx_buf); + } + memset(session, 0, sizeof(*session) - sizeof(struct k_work)); +} + +static inline struct netmidi2_session *netmidi2_match_session(struct netmidi2_ep *ep, + struct sockaddr *peer_addr, + socklen_t peer_addr_len) +{ + for (size_t i = 0; i < CONFIG_NETMIDI2_HOST_MAX_CLIENTS; i++) { + if (ep->peers[i].addr_len == peer_addr_len && + memcmp(&ep->peers[i].addr, peer_addr, peer_addr_len) == 0) { + LOG_DBG("Found matching client session %d", i); + return &ep->peers[i]; + } + } + + return NULL; +} + +static inline void netmidi2_free_inactive_sessions(struct netmidi2_ep *ep) +{ + struct netmidi2_session *sess; + const uint8_t bye_timeout[] = {COMMAND_BYE, 0, 0x04, 0}; + + for (size_t i = 0; i < CONFIG_NETMIDI2_HOST_MAX_CLIENTS; i++) { + sess = &ep->peers[i]; + if (sess->state != NETMIDI2_SESSION_IDLE && + sess->state != NETMIDI2_SESSION_ESTABLISHED) { + SESS_LOG_WRN(sess, "Cleanup inactive session"); + zsock_sendto(ep->pollsock.fd, bye_timeout, sizeof(bye_timeout), + 0, net_sad(&sess->addr), sess->addr_len); + netmidi2_free_session(sess); + } + } +} + +static inline struct netmidi2_session *netmidi2_try_alloc_session(struct netmidi2_ep *ep, + struct sockaddr *peer_addr, + socklen_t peer_addr_len) +{ + struct netmidi2_session *sess; + + for (size_t i = 0; i < CONFIG_NETMIDI2_HOST_MAX_CLIENTS; i++) { + sess = &ep->peers[i]; + if (sess->state == NETMIDI2_SESSION_NOT_INITIALIZED) { + sess->state = NETMIDI2_SESSION_IDLE; + sess->addr_len = peer_addr_len; + sess->ep = ep; + memcpy(&sess->addr, peer_addr, peer_addr_len); + SESS_LOG_INF(sess, "new client session (%d)", i); + return sess; + } + } + + return NULL; +} + +static inline struct netmidi2_session *netmidi2_alloc_session(struct netmidi2_ep *ep, + struct sockaddr *peer_addr, + socklen_t peer_addr_len) +{ + struct netmidi2_session *session; + + session = netmidi2_try_alloc_session(ep, peer_addr, peer_addr_len); + if (session == NULL) { + netmidi2_free_inactive_sessions(ep); + session = netmidi2_try_alloc_session(ep, peer_addr, peer_addr_len); + } + + if (session == NULL) { + LOG_ERR("No available client session"); + } + + return session; +} + +/** + * @brief Perform transmission work for an endpoint peer's session + * @param work The work item (must be contained in a netmidi2_session) + */ +static void netmidi2_session_tx_work(struct k_work *work) +{ + struct netmidi2_session *session = CONTAINER_OF(work, struct netmidi2_session, tx_work); + struct net_buf *buf = session->tx_buf; + + session->tx_buf = NULL; + + zsock_sendto(session->ep->pollsock.fd, buf->data, buf->len, 0, + net_sad(&session->addr), session->addr_len); + net_buf_unref(buf); +} + +static inline const char *netmidi2_ep_get_name(const struct netmidi2_ep *ep) +{ + return (ep->name == NULL) ? "" : ep->name; +} + +#if defined(CONFIG_MIDI2_UMP_STREAM_RESPONDER) +#define DEFAULT_PIID ump_product_instance_id() +#else +#define DEFAULT_PIID "" +#endif + +static inline const char *netmidi2_ep_get_piid(const struct netmidi2_ep *ep) +{ + return (ep->piid == NULL) ? DEFAULT_PIID : ep->piid; +} + +/** + * @brief Write the header for a CommandPacket into a session tx buffer + * @param sess The peer's session + * @param[in] command_code The command code + * @param[in] command_specific_data The command specific data + * @param[in] payload_len_words Payload length, in 32b words + * @return 0 on success, -errno on error + */ +static inline int sess_buf_add_header(struct netmidi2_session *sess, + const uint8_t command_code, + const uint16_t command_specific_data, + const uint8_t payload_len_words) +{ + if (sess->tx_buf == NULL) { + /* If no current tx buffer, allocate a new one */ + sess->tx_buf = net_buf_alloc(&netmidi2_pool, K_FOREVER); + if (sess->tx_buf == NULL) { + SESS_LOG_ERR(sess, "Unable to allocate Tx buffer"); + return -ENOBUFS; + } + /* Prefix with Network MIDI2.0 UDP header */ + net_buf_add_mem(sess->tx_buf, "MIDI", 4); + } + + if (net_buf_tailroom(sess->tx_buf) < 4*(1 + payload_len_words)) { + SESS_LOG_WRN(sess, "Not enough room in Tx buffer"); + return -ENOMEM; + } + + net_buf_add_u8(sess->tx_buf, command_code); + net_buf_add_u8(sess->tx_buf, payload_len_words); + net_buf_add_be16(sess->tx_buf, command_specific_data); + + return 0; +} + +/** + * @brief Write some bytes into a session tx buffer, and add padding + * zeros at the tail to stay aligned on 4 bytes + * @param session The session to write to + * @param[in] mem The memory to be copied + * @param[in] size The size in bytes of the memory to be copied + */ +static inline void sess_buf_add_mem_padded(struct netmidi2_session *session, + const uint8_t *mem, size_t size) +{ + if (session->tx_buf == NULL) { + return; + } + + net_buf_add_mem(session->tx_buf, mem, size); + + switch (size % 4) { + case 1: + net_buf_add_be24(session->tx_buf, 0); + break; + case 2: + net_buf_add_be16(session->tx_buf, 0); + break; + case 3: + net_buf_add_u8(session->tx_buf, 0); + break; + } +} + +/** + * @brief Send a Command Packet (from words) to a client session + * @remark The Command Packet is appended to the session's tx buffer, + * and transmission is scheduled, so the command packet is not + * transmitted immediately, and may be sent along with others in + * a single Network MIDI2.0 UDP packet. + * @param sess The recipient session + * @param[in] command_code The command code + * @param[in] command_specific_data The command specific data + * @param[in] payload The command payload as words (4B) + * @param[in] payload_len_words Payload length, in words (4B) + * @return 0 on success, -errno otherwise + * + * @see netmidi10: 5.4 Command Packet Header and Payload + */ +static int netmidi2_session_sendcmd(struct netmidi2_session *sess, + const uint8_t command_code, + const uint16_t command_specific_data, + const uint32_t *payload, + const uint8_t payload_len_words) +{ + int ret = sess_buf_add_header(sess, command_code, + command_specific_data, + payload_len_words); + if (ret != 0) { + return ret; + } + + for (size_t i = 0; i < payload_len_words; i++) { + net_buf_add_be32(sess->tx_buf, payload[i]); + } + k_work_submit(&sess->tx_work); + return 0; +} + +/** + * @brief Immediately send a Command Packet to a remote without client session + * @param[in] ep The emitting UMP endpoint + * @param[in] peer_addr The recipient's address + * @param[in] peer_addr_len The recipient's address length + * @param[in] command_specific_data The command specific data + * @param[in] payload The command payload + * @param[in] payload_len_words Payload length, in words (4B) + */ +static int netmidi2_quick_reply(struct netmidi2_ep *ep, + const struct sockaddr *peer_addr, + const socklen_t peer_addr_len, + const uint8_t command_code, + const uint16_t command_specific_data, + const uint32_t *payload, + const uint8_t payload_len_words) +{ + NET_BUF_SIMPLE_DEFINE(txbuf, 28); + + if (4 * (1 + payload_len_words) > txbuf.size) { + return -ENOBUFS; + } + + /* Network MIDI2.0 UDP header */ + net_buf_simple_add_mem(&txbuf, "MIDI", 4); + /* Command packet header */ + net_buf_simple_add_u8(&txbuf, command_code); + net_buf_simple_add_u8(&txbuf, payload_len_words); + net_buf_simple_add_be16(&txbuf, command_specific_data); + + /* Payload */ + for (size_t i = 0; i < payload_len_words; i++) { + net_buf_simple_add_be32(&txbuf, payload[i]); + } + + zsock_sendto(ep->pollsock.fd, txbuf.data, txbuf.len, 0, + peer_addr, peer_addr_len); + return 0; +} + +/** + * @brief Quickly send a NAK message to a remote without client session + * @param[in] ep The endpoint sending the NAK + * @param[in] peer_addr The peer address + * @param[in] peer_addr_len The peer address length + * @param[in] nak_reason The NAK reason + * @param[in] nakd_cmd_header The command packet header this NAK is replying to + */ +static inline int netmidi2_quick_nak(struct netmidi2_ep *ep, + const struct sockaddr *peer_addr, + const socklen_t peer_addr_len, + const uint8_t nak_reason, + const uint32_t nakd_cmd_header) +{ + return netmidi2_quick_reply(ep, peer_addr, peer_addr_len, + COMMAND_NAK, nak_reason << 8, + &nakd_cmd_header, 1); +} + +/** + * @brief Send a message "Invitation reply: ..." to a client. + * The type of Invitation Reply command code depends on the + * session state + * @param session The session to which the message shall be send + * @return 0 on success, -errno on error + * + * @see netmidi10: 6.5 Invitation Reply: Accepted + */ +static int netmidi2_send_invitation_reply(struct netmidi2_session *session, + uint8_t authentication_state) +{ + int ret; + uint8_t command_code; + + const char *name = netmidi2_ep_get_name(session->ep); + const char *piid = netmidi2_ep_get_piid(session->ep); + const size_t namelen = strlen(name); + const size_t piidlen = strlen(piid); + const size_t namelen_words = DIV_ROUND_UP(namelen, 4); + const size_t piidlen_words = DIV_ROUND_UP(piidlen, 4); + const uint16_t specific_data = (namelen_words << 8) | authentication_state; + + size_t total_words = namelen_words + piidlen_words; + + if (session->state == NETMIDI2_SESSION_ESTABLISHED) { + command_code = COMMAND_INVITATION_REPLY_ACCEPTED; + } + +#if defined(CONFIG_NETMIDI2_HOST_AUTH) + else if (session->state == NETMIDI2_SESSION_AUTH_REQUIRED) { + total_words += DIV_ROUND_UP(NETMIDI2_NONCE_SIZE, 4); + + if (session->ep->auth_type == NETMIDI2_AUTH_SHARED_SECRET) { + command_code = COMMAND_INVITATION_REPLY_AUTH_REQUIRED; + } else if (session->ep->auth_type == NETMIDI2_AUTH_USER_PASSWORD) { + command_code = COMMAND_INVITATION_REPLY_USER_AUTH_REQUIRED; + } else { + return -EINVAL; + } + } +#endif /* CONFIG_NETMIDI2_HOST_AUTH */ + + else { + return -EINVAL; + } + + ret = sess_buf_add_header(session, command_code, specific_data, total_words); + if (ret != 0) { + return ret; + } + +#if defined(CONFIG_NETMIDI2_HOST_AUTH) + if (session->state == NETMIDI2_SESSION_AUTH_REQUIRED) { + sys_rand_get(session->nonce, NETMIDI2_NONCE_SIZE); + sess_buf_add_mem_padded(session, session->nonce, NETMIDI2_NONCE_SIZE); + } +#endif /* CONFIG_NETMIDI2_HOST_AUTH */ + + sess_buf_add_mem_padded(session, name, namelen); + sess_buf_add_mem_padded(session, piid, piidlen); + k_work_submit(&session->tx_work); + return 0; +} + +/** + * @brief Consume the leading Command Packet from a buffer that contains + * a Network MIDI 2.0 UDP packet + * @param ep The endpoint that receives the packet + * @param peer_addr The network address of the sender + * @param[in] peer_addr_len The length of the sender's network address + * @param rx The received buffer + * @return non-zero if an error occurred, and the rx buffer is left in an + * unspecified state. 0 on success, in which case the head of the + * rx buffer is at the next Command Packet (or empty) + */ +static int netmidi2_dispatch_cmdpkt(struct netmidi2_ep *ep, + struct sockaddr *peer_addr, + socklen_t peer_addr_len, + struct net_buf *rx) +{ + struct midi_ump ump; + size_t payload_len; + uint32_t cmd_header; + uint8_t cmd_code, payload_len_words; + uint16_t cmd_data; + struct netmidi2_session *session; + + if (rx->len < 4) { + LOG_ERR("Incomplete UDP MIDI command packet header"); + return -1; + } + + cmd_header = net_buf_pull_be32(rx); + cmd_code = cmd_header >> 24; + payload_len_words = (cmd_header >> 16) & 0xff; + payload_len = 4 * payload_len_words; + cmd_data = cmd_header & 0xffff; + + if (payload_len > rx->len) { + netmidi2_quick_nak(ep, peer_addr, peer_addr_len, + NAK_COMMAND_MALFORMED, cmd_header); + LOG_ERR("Incomplete UDP MIDI command packet payload"); + return -1; + } + + switch (cmd_code) { + case COMMAND_PING: + /* See netmidi10: 6.13 Ping */ + if (payload_len_words != 1) { + netmidi2_quick_nak(ep, peer_addr, peer_addr_len, + NAK_COMMAND_MALFORMED, cmd_header); + LOG_ERR("Invalid payload length for PING packet"); + return -1; + } + /* Simple reply with 1 word from the PING request */ + netmidi2_quick_reply(ep, peer_addr, peer_addr_len, + COMMAND_PING_REPLY, 0, + (uint32_t [1]) {net_buf_pull_be32(rx)}, 1); + return 0; + + case COMMAND_INVITATION: + /* See netmidi10: 6.13 Ping + * We currently don't care about the peer's name or product + * instance id. Simply pull the entire payload at once. + */ + net_buf_pull(rx, payload_len); + + session = netmidi2_alloc_session(ep, peer_addr, peer_addr_len); + if (session == NULL) { + return -1; + } + + if (ep->auth_type == NETMIDI2_AUTH_NONE) { + session->state = NETMIDI2_SESSION_ESTABLISHED; + netmidi2_send_invitation_reply(session, AUTH_STATE_FIRST_REQUEST); + } + +#if !CONFIG_NETMIDI2_HOST_AUTH + return 0; +#else + /* See netmidi10: 6.7 Invitation Reply: Authentication Required */ + else { + session->state = NETMIDI2_SESSION_AUTH_REQUIRED; + netmidi2_send_invitation_reply(session, AUTH_STATE_FIRST_REQUEST); + } + + return 0; + + case COMMAND_INVITATION_WITH_AUTH: + case COMMAND_INVITATION_WITH_USER_AUTH: + /* See netmidi10: 6.9 - 6.10 Invitation with (User) Authentication */ + session = netmidi2_match_session(ep, peer_addr, peer_addr_len); + if (!SESSION_HAS_STATE(session, NETMIDI2_SESSION_AUTH_REQUIRED)) { + netmidi2_quick_nak(ep, peer_addr, peer_addr_len, + NAK_COMMAND_NOT_EXPECTED, cmd_header); + LOG_WRN("No session to authenticate found"); + return -1; + } + + if (!netmidi2_auth_session(session, rx, payload_len)) { + SESS_LOG_WRN(session, "Invalid auth digest"); + netmidi2_send_invitation_reply(session, AUTH_STATE_INCORRECT_DIGEST); + return -1; + } + + session->state = NETMIDI2_SESSION_ESTABLISHED; + netmidi2_send_invitation_reply(session, AUTH_STATE_FIRST_REQUEST); + return 0; +#endif /* CONFIG_NETMIDI2_HOST_AUTH */ + + case COMMAND_BYE: + session = netmidi2_match_session(ep, peer_addr, peer_addr_len); + if (session == NULL) { + netmidi2_quick_nak(ep, peer_addr, peer_addr_len, + NAK_COMMAND_NOT_EXPECTED, cmd_header); + LOG_WRN("Receiving BYE without session"); + return -1; + } + net_buf_pull(rx, payload_len); + netmidi2_quick_reply(ep, peer_addr, peer_addr_len, + COMMAND_BYE_REPLY, 0, NULL, 0); + netmidi2_free_session(session); + return 0; + + case COMMAND_UMP_DATA: + session = netmidi2_match_session(ep, peer_addr, peer_addr_len); + if (!SESSION_HAS_STATE(session, NETMIDI2_SESSION_ESTABLISHED)) { + netmidi2_quick_nak(ep, peer_addr, peer_addr_len, + NAK_COMMAND_NOT_EXPECTED, cmd_header); + LOG_WRN("Receiving UMP data without established session"); + return -1; + } + + if (session->rx_ump_seq == cmd_data) { + session->rx_ump_seq++; + } else { + SESS_LOG_WRN(session, "UMP Rx sequence mismatch (got %d, expected %d)", + cmd_data, session->rx_ump_seq); + session->rx_ump_seq = 1 + cmd_data; + } + + if (payload_len_words < 1 || payload_len_words > 4) { + netmidi2_quick_nak(ep, peer_addr, peer_addr_len, + NAK_COMMAND_MALFORMED, cmd_header); + SESS_LOG_ERR(session, "Invalid UMP length"); + return -1; + } + + for (size_t i = 0; i < payload_len_words; i++) { + ump.data[i] = net_buf_pull_be32(rx); + } + + if (UMP_NUM_WORDS(ump) != payload_len_words) { + netmidi2_quick_nak(ep, peer_addr, peer_addr_len, + NAK_COMMAND_MALFORMED, cmd_header); + SESS_LOG_ERR(session, "Invalid UMP payload size for its message type"); + return -1; + } + + if (ep->rx_packet_cb != NULL) { + ep->rx_packet_cb(session, ump); + } + return 0; + + case COMMAND_SESSION_RESET: + session = netmidi2_match_session(ep, peer_addr, peer_addr_len); + if (!SESSION_HAS_STATE(session, NETMIDI2_SESSION_ESTABLISHED)) { + LOG_WRN("Receiving session reset without established session"); + netmidi2_quick_nak(ep, peer_addr, peer_addr_len, + NAK_COMMAND_NOT_EXPECTED, cmd_header); + return -1; + } + + session->tx_ump_seq = 0; + session->rx_ump_seq = 0; + SESS_LOG_INF(session, "Reset session"); + netmidi2_session_sendcmd(session, COMMAND_SESSION_RESET_REPLY, 0, NULL, 0); + return 0; + + default: + LOG_WRN("Unknown command code %02X", cmd_code); + net_buf_pull(rx, payload_len); + netmidi2_quick_nak(ep, peer_addr, peer_addr_len, + NAK_COMMAND_NOT_SUPPORTED, cmd_header); + return 0; + } + return 0; +} + +/** + * @brief Service handler: receive a Network MIDI 2.0 UDP packet, + * and dispatch all the command packets that it contains + * @param pev the service event that triggers this handler + */ +static void netmidi2_service_handler(struct net_socket_service_event *pev) +{ + int ret; + struct netmidi2_ep *ep = pev->user_data; + struct pollfd *pfd = &pev->event; + struct sockaddr peer_addr; + socklen_t peer_addr_len = sizeof(peer_addr); + struct net_buf *rxbuf; + + rxbuf = net_buf_alloc(&netmidi2_pool, K_FOREVER); + if (rxbuf == NULL) { + NET_ERR("Cannot allocate Rx buf"); + return; + } + + ret = zsock_recvfrom(pfd->fd, rxbuf->data, rxbuf->size, 0, + &peer_addr, &peer_addr_len); + if (ret < 0) { + LOG_ERR("Rx error: %d (%d)", ret, errno); + goto end; + } + rxbuf->len = ret; + + NET_HEXDUMP_DBG(rxbuf->data, rxbuf->len, "Received UDP packet"); + + /* Check for magic header */ + if (rxbuf->len < 4 || memcmp(rxbuf->data, "MIDI", 4) != 0) { + LOG_WRN("Not a MIDI packet"); + goto end; + } + + net_buf_pull(rxbuf, 4); + + /* Parse contained command packets */ + ret = 0; + while (ret == 0 && rxbuf->len >= 4) { + ret = netmidi2_dispatch_cmdpkt(ep, &peer_addr, peer_addr_len, rxbuf); + } + +end: + net_buf_unref(rxbuf); +} + +NET_SOCKET_SERVICE_SYNC_DEFINE_STATIC(netmidi2_service, netmidi2_service_handler, 1); + +int netmidi2_host_ep_start(struct netmidi2_ep *ep) +{ + socklen_t addr_len = 0; + int ret, sock, af; + +#if defined(CONFIG_NET_IPV6) + af = AF_INET6; + addr_len = sizeof(struct sockaddr_in6); +#else + af = AF_INET; + addr_len = sizeof(struct sockaddr_in); +#endif + + ep->addr.sa_family = af; + sock = zsock_socket(af, SOCK_DGRAM, IPPROTO_UDP); + if (sock < 0) { + LOG_ERR("Unable to create socket: %d", errno); + return -ENOMEM; + } + +#if defined(CONFIG_NET_IPV6) && defined(CONFIG_NET_IPV4) + socklen_t optlen = sizeof(int); + int opt = 0; + + /* Enable sharing of IPv4 and IPv6 on same socket */ + ret = zsock_setsockopt(sock, IPPROTO_IPV6, IPV6_V6ONLY, &opt, optlen); + if (ret < 0) { + LOG_WRN("Cannot turn off IPV6_V6ONLY option"); + } +#endif + + ret = zsock_bind(sock, &ep->addr, addr_len); + if (ret < 0) { + zsock_close(sock); + LOG_ERR("Failed to bind UDP socket: %d", errno); + return -EIO; + } + + for (size_t i = 0; i < CONFIG_NETMIDI2_HOST_MAX_CLIENTS; i++) { + k_work_init(&ep->peers[i].tx_work, netmidi2_session_tx_work); + } + + ep->pollsock.fd = sock; + ep->pollsock.events = POLLIN; + ret = net_socket_service_register(&netmidi2_service, &ep->pollsock, 1, ep); + if (ret < 0) { + zsock_close(sock); + LOG_ERR("Failed to register service: %d", ret); + return -EIO; + } + + LOG_INF("Started UDP-MIDI2 server (%d)", ntohs(ep->addr4.sin_port)); + return 0; +} + +void netmidi2_broadcast(struct netmidi2_ep *ep, const struct midi_ump ump) +{ + for (size_t i = 0; i < CONFIG_NETMIDI2_HOST_MAX_CLIENTS; i++) { + if (ep->peers[i].state == NETMIDI2_SESSION_ESTABLISHED) { + netmidi2_send(&ep->peers[i], ump); + } + } +} + +void netmidi2_send(struct netmidi2_session *sess, const struct midi_ump ump) +{ + netmidi2_session_sendcmd(sess, COMMAND_UMP_DATA, sess->tx_ump_seq++, + ump.data, UMP_NUM_WORDS(ump)); +} From e67977d08101002349a7993ff912f402be2861f6 Mon Sep 17 00:00:00 2001 From: Titouan Christophe Date: Thu, 31 Jul 2025 12:04:10 +0200 Subject: [PATCH 6/7] boards: shields: olimex_shield_midi: new shield Add a new very simple shield that provides MIDI DIN-5 IN/OUT/THROUGH, as well as 2 leds. Other features of the shields (capacitive sensors) are currently not reified in Zephyr. While there is no further device on the shield itself (everything goes through the Arduino header directly), this allows for meaningful device-tree naming when using the shield. Signed-off-by: Titouan Christophe --- .../shields/olimex_shield_midi/Kconfig.shield | 5 ++ .../shields/olimex_shield_midi/doc/index.rst | 44 ++++++++++++++++++ .../doc/olimex_shield_midi.jpg | Bin 0 -> 61929 bytes .../olimex_shield_midi.overlay | 20 ++++++++ boards/shields/olimex_shield_midi/shield.yml | 7 +++ 5 files changed, 76 insertions(+) create mode 100644 boards/shields/olimex_shield_midi/Kconfig.shield create mode 100644 boards/shields/olimex_shield_midi/doc/index.rst create mode 100644 boards/shields/olimex_shield_midi/doc/olimex_shield_midi.jpg create mode 100644 boards/shields/olimex_shield_midi/olimex_shield_midi.overlay create mode 100644 boards/shields/olimex_shield_midi/shield.yml diff --git a/boards/shields/olimex_shield_midi/Kconfig.shield b/boards/shields/olimex_shield_midi/Kconfig.shield new file mode 100644 index 0000000000000..c9aa1a04d95f2 --- /dev/null +++ b/boards/shields/olimex_shield_midi/Kconfig.shield @@ -0,0 +1,5 @@ +# Copyright (c) 2025 Titouan Christophe +# SPDX-License-Identifier: Apache-2.0 + +config SHIELD_OLIMEX_SHIELD_MIDI + def_bool $(shields_list_contains,olimex_shield_midi) diff --git a/boards/shields/olimex_shield_midi/doc/index.rst b/boards/shields/olimex_shield_midi/doc/index.rst new file mode 100644 index 0000000000000..e4884c3258418 --- /dev/null +++ b/boards/shields/olimex_shield_midi/doc/index.rst @@ -0,0 +1,44 @@ +.. _olimex_shield_midi: + +Olimex SHIELD-MIDI +################## + +Overview +******** + +This is a MIDI shield which allows Arduino like boards to receive and send MIDI +messages. The shield allows direct wiring of up to 5 piezzo sensors and a +keyboard (with buttons and serial resistors) making it ideal for drums projects. + +.. figure:: olimex_shield_midi.jpg + :align: center + :alt: Olimex SHIELD-MIDI + +* MIDI-IN, MIDI-OUT and MIDI-THRU connectors +* 5 Piezzo sensors for drum implementation +* Keyboard for piano implementation +* Works with both 3.3V and 5V Arduino-like boards + +More information on the `SHIELD-MIDI website`_. + +Peripherals +*********** + +The following peripherals are available in Zephyr: + +- MIDI IN/OUT: on ``midi_serial`` (set at 31.25kb/s) +- leds: ``midi_green_led``, ``midi_red_led`` + +Programming +*********** + +Set ``--shield olimex_shield_midi`` when you invoke ``west build``. For example: + +.. zephyr-app-commands:: + :zephyr-app: samples/net/midi2 + :board: nucleo_f429zi + :shield: olimex_shield_midi + :goals: build + +.. _SHIELD-MIDI website: + https://www.olimex.com/Products/Duino/Shields/SHIELD-MIDI/open-source-hardware diff --git a/boards/shields/olimex_shield_midi/doc/olimex_shield_midi.jpg b/boards/shields/olimex_shield_midi/doc/olimex_shield_midi.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1c892eddbd451662c19601c6916bea8973d3058d GIT binary patch literal 61929 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

uT1hWb=H7HCG%j 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 DKC-2 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 literal 0 HcmV?d00001 diff --git a/boards/shields/olimex_shield_midi/olimex_shield_midi.overlay b/boards/shields/olimex_shield_midi/olimex_shield_midi.overlay new file mode 100644 index 0000000000000..3779724366b4c --- /dev/null +++ b/boards/shields/olimex_shield_midi/olimex_shield_midi.overlay @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Titouan Christophe + * SPDX-License-Identifier: Apache-2.0 + */ + +/ { + leds { + midi_green_led: midi-green-led { + gpios = <&arduino_header 13 GPIO_ACTIVE_HIGH>; + }; + + midi_red_led: midi-red-led { + gpios = <&arduino_header 12 GPIO_ACTIVE_HIGH>; + }; + }; +}; + +midi_serial: &arduino_serial { + current-speed = <31250>; +}; diff --git a/boards/shields/olimex_shield_midi/shield.yml b/boards/shields/olimex_shield_midi/shield.yml new file mode 100644 index 0000000000000..6bb238be27973 --- /dev/null +++ b/boards/shields/olimex_shield_midi/shield.yml @@ -0,0 +1,7 @@ +shield: + name: olimex_shield_midi + full_name: Olimex SHIELD-MIDI + vendor: olimex + supported_features: + - led + - uart From 2ba64b2d83da1282be21c98fa90d3ce25178366e Mon Sep 17 00:00:00 2001 From: Titouan Christophe Date: Thu, 31 Jul 2025 12:04:29 +0200 Subject: [PATCH 7/7] samples: net: midi2: new sample Add a new sample to demonstrate usage of the newly introduced Network MIDI 2.0 host stack. Signed-off-by: Titouan Christophe --- samples/net/midi2/CMakeLists.txt | 12 +++ samples/net/midi2/Kconfig | 37 ++++++++ samples/net/midi2/README.rst | 75 ++++++++++++++++ samples/net/midi2/app.overlay | 35 ++++++++ samples/net/midi2/prj.conf | 19 ++++ samples/net/midi2/sample.yaml | 20 +++++ samples/net/midi2/src/main.c | 143 +++++++++++++++++++++++++++++++ subsys/net/lib/midi2/Kconfig | 1 + 8 files changed, 342 insertions(+) create mode 100644 samples/net/midi2/CMakeLists.txt create mode 100644 samples/net/midi2/Kconfig create mode 100644 samples/net/midi2/README.rst create mode 100644 samples/net/midi2/app.overlay create mode 100644 samples/net/midi2/prj.conf create mode 100644 samples/net/midi2/sample.yaml create mode 100644 samples/net/midi2/src/main.c diff --git a/samples/net/midi2/CMakeLists.txt b/samples/net/midi2/CMakeLists.txt new file mode 100644 index 0000000000000..189b31f8f60c2 --- /dev/null +++ b/samples/net/midi2/CMakeLists.txt @@ -0,0 +1,12 @@ +# Copyright (c) 2025 Titouan Christophe +# SPDX-License-Identifier: Apache-2.0 + +cmake_minimum_required(VERSION 3.20.0) + +find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE}) +project(netmidi2) + +FILE(GLOB app_sources src/*.c) +target_sources(app PRIVATE ${app_sources}) + +include(${ZEPHYR_BASE}/samples/net/common/common.cmake) diff --git a/samples/net/midi2/Kconfig b/samples/net/midi2/Kconfig new file mode 100644 index 0000000000000..07a128aa09ddd --- /dev/null +++ b/samples/net/midi2/Kconfig @@ -0,0 +1,37 @@ +# Copyright (c) 2025 Titouan Christophe +# SPDX-License-Identifier: Apache-2.0 + +menu "Networking MIDI2 sample application" + +choice NET_SAMPLE_MIDI2_AUTH_TYPE + prompt "UMP Endpoint authentication method" + default NET_SAMPLE_MIDI2_AUTH_NONE + +config NET_SAMPLE_MIDI2_AUTH_NONE + bool "No authentication" + +config NET_SAMPLE_MIDI2_AUTH_SHARED_SECRET + bool "Authentication with shared secret" + select NETMIDI2_HOST_AUTH + +config NET_SAMPLE_MIDI2_AUTH_USER_PASSWORD + bool "Authentication with username+password" + select NETMIDI2_HOST_AUTH + +endchoice + +config NET_SAMPLE_MIDI2_SHARED_SECRET + string "Shared secret" + depends on NET_SAMPLE_MIDI2_AUTH_SHARED_SECRET + +config NET_SAMPLE_MIDI2_USERNAME + string "Username" + depends on NET_SAMPLE_MIDI2_AUTH_USER_PASSWORD + +config NET_SAMPLE_MIDI2_PASSWORD + string "Password" + depends on NET_SAMPLE_MIDI2_AUTH_USER_PASSWORD + +endmenu + +source "Kconfig.zephyr" diff --git a/samples/net/midi2/README.rst b/samples/net/midi2/README.rst new file mode 100644 index 0000000000000..ddb28304bbf48 --- /dev/null +++ b/samples/net/midi2/README.rst @@ -0,0 +1,75 @@ +.. zephyr:code-sample:: netmidi2 + :name: MIDI2 network transport + :relevant-api: midi_ump net_midi2 + + Exchange Universal MIDI Packets over the Network MIDI 2.0 protocol + +Overview +******** + +This sample demonstrates usage of the Network MIDI 2.0 stack: + +* start a UMP Endpoint host reachable on the network +* respond to UMP Stream discovery messages, so that clients can discover the + topology described in the device tree +* if ``midi_serial`` port is defined in the device tree, + send MIDI1 data from UMP group 9 there +* if ``midi_green_led`` node is defined in the device tree, + light up the led when sending data on the serial port + +Requirements +************ + +This sample requires a board with IP networking support. To perform anything +useful against the running sample, you will also need a Network MIDI2.0 client +to connect to the target, for example `pymidi2`_ + +Building and Running +******************** + +The easiest way to try out this sample without any hardware is using +``native_sim``. See :ref:`native_sim ethernet driver ` to setup +networking on your computer accordingly + +.. zephyr-app-commands:: + :zephyr-app: samples/net/midi2/ + :board: native_sim + :goals: run + +The Network MIDI 2.0 endpoint should now be reachable on the network: + +.. code-block:: console + + $ pymidi2 find + Zephyr-UDP-MIDI2 (udp://192.0.2.1:45486) + - Block #0 [io : Recv/Send] 'Synthesizer' UMP groups {0, 1, 2, 3} [MIDI1 + MIDI2] + - Block #1 [i- : Recv ] 'Keyboard' UMP groups {8} [MIDI1 only] + - Block #2 [-o : Send] 'External output (MIDI DIN-5)' UMP groups {9} [MIDI1 31.25kb/s] + + +Furthermore, this sample pairs well with the :ref:`olimex_shield_midi`, +that conveniently defines the device tree nodes for the external MIDI OUT +and its led. For example, using this shield on the ST Nucleo F429zi: + +.. zephyr-app-commands:: + :zephyr-app: samples/net/midi2 + :board: nucleo_f429zi + :shield: olimex_shield_midi + :goals: build flash + +Using authentication +******************** + +To enable shared secret authentication to connect to the UMP endpoint host, +enable :kconfig:option:`CONFIG_NET_SAMPLE_MIDI2_AUTH_SHARED_SECRET`, +and then configure the desired shared secret in +:kconfig:option:`CONFIG_NET_SAMPLE_MIDI2_SHARED_SECRET` + +To enable user/password authentication instead, enable +:kconfig:option:`CONFIG_NET_SAMPLE_MIDI2_AUTH_USER_PASSWORD`, and then +configure the desired username/password in +:kconfig:option:`CONFIG_NET_SAMPLE_MIDI2_USERNAME` +and :kconfig:option:`CONFIG_NET_SAMPLE_MIDI2_PASSWORD` + +.. _pymidi2: + https://github.com/titouanc/pymidi2 diff --git a/samples/net/midi2/app.overlay b/samples/net/midi2/app.overlay new file mode 100644 index 0000000000000..bc9b9d29b3a78 --- /dev/null +++ b/samples/net/midi2/app.overlay @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Titouan Christophe + * SPDX-License-Identifier: Apache-2.0 + */ + +/ { + midi2: zephyr-midi2 { + compatible = "zephyr,midi2-device"; + status = "okay"; + #address-cells = <1>; + #size-cells = <1>; + label = "Zephyr-UDP-MIDI2"; + + midi2@0 { + reg = <0 4>; + label = "Synthesizer"; + protocol = "midi2"; + }; + + midi1@8 { + reg = <8 1>; + protocol = "midi1-up-to-64b"; + terminal-type = "input-only"; + label = "Keyboard"; + }; + + ext_midi_out: midi1@9 { + reg = <9 1>; + protocol = "midi1-up-to-64b"; + terminal-type = "output-only"; + label = "External output (MIDI DIN-5)"; + serial-31250bps; + }; + }; +}; diff --git a/samples/net/midi2/prj.conf b/samples/net/midi2/prj.conf new file mode 100644 index 0000000000000..49dcaadd77bde --- /dev/null +++ b/samples/net/midi2/prj.conf @@ -0,0 +1,19 @@ +CONFIG_NETWORKING=y +CONFIG_ZVFS_POLL_MAX=4 +CONFIG_NET_IF_MCAST_IPV6_ADDR_COUNT=5 +CONFIG_NET_CONFIG_SETTINGS=y +CONFIG_NET_CONFIG_NEED_IPV6=y +CONFIG_NET_CONFIG_MY_IPV6_ADDR="2001:db8::1" +CONFIG_NET_CONFIG_NEED_IPV4=y +CONFIG_NET_CONFIG_MY_IPV4_ADDR="192.0.2.1" + +CONFIG_NET_HOSTNAME_ENABLE=y +CONFIG_MDNS_RESPONDER=y +CONFIG_DNS_SD=y + +CONFIG_NETMIDI2_HOST=y +CONFIG_MIDI2_UMP_STREAM_RESPONDER=y + +CONFIG_LOG=y +CONFIG_NET_LOG=y +CONFIG_TEST_RANDOM_GENERATOR=y diff --git a/samples/net/midi2/sample.yaml b/samples/net/midi2/sample.yaml new file mode 100644 index 0000000000000..89d4220802c8c --- /dev/null +++ b/samples/net/midi2/sample.yaml @@ -0,0 +1,20 @@ +common: + harness: net + depends_on: netif + tags: + - net + - midi2 +sample: + name: Network MIDI2.0 sample application + description: Demonstrates usage of the Network MIDI2.0 (udp) host endpoint +tests: + sample.net.midi2.host: {} + sample.net.midi2.host_auth_shared: + extra_configs: + - CONFIG_NET_SAMPLE_MIDI2_AUTH_SHARED_SECRET=y + - CONFIG_NET_SAMPLE_MIDI2_SHARED_SECRET="some-secret" + sample.net.midi2.host_auth_user: + extra_configs: + - CONFIG_NET_SAMPLE_MIDI2_AUTH_USER_PASSWORD=y + - CONFIG_NET_SAMPLE_MIDI2_USERNAME="user" + - CONFIG_NET_SAMPLE_MIDI2_PASSWORD="passwd" diff --git a/samples/net/midi2/src/main.c b/samples/net/midi2/src/main.c new file mode 100644 index 0000000000000..401671e7d7b0c --- /dev/null +++ b/samples/net/midi2/src/main.c @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2025 Titouan Christophe + * SPDX-License-Identifier: Apache-2.0 + */ + +#include +#include +#include + +#include +#include +#include + +#include + +#include +LOG_MODULE_REGISTER(net_midi2_sample, LOG_LEVEL_DBG); + +#define ACT_LED_NODE DT_NODELABEL(midi_green_led) +#define SERIAL_NODE DT_NODELABEL(midi_serial) + +#if !DT_NODE_EXISTS(ACT_LED_NODE) +#define CONFIGURE_LED() +#define SET_LED(_state) +#else /* DT_NODE_EXISTS(ACT_LED_NODE) */ +#include + +static const struct gpio_dt_spec act_led = GPIO_DT_SPEC_GET(ACT_LED_NODE, gpios); + +#define CONFIGURE_LED() gpio_pin_configure_dt(&act_led, GPIO_OUTPUT_INACTIVE) +#define SET_LED(_state) gpio_pin_set_dt(&act_led, (_state)) +#endif /* DT_NODE_EXISTS(ACT_LED_NODE) */ + + +#if !DT_NODE_EXISTS(SERIAL_NODE) +#define send_external_midi1(...) +#else /* DT_NODE_EXISTS(SERIAL_NODE) */ +#include + +static const struct device *const uart_dev = DEVICE_DT_GET(SERIAL_NODE); + +static inline void send_external_midi1(const struct midi_ump ump) +{ + /* Only send MIDI events aimed at the external output */ + if (UMP_GROUP(ump) != DT_REG_ADDR(DT_NODELABEL(ext_midi_out))) { + return; + } + + switch (UMP_MIDI_COMMAND(ump)) { + case UMP_MIDI_PROGRAM_CHANGE: + SET_LED(1); + uart_poll_out(uart_dev, UMP_MIDI_STATUS(ump)); + uart_poll_out(uart_dev, UMP_MIDI1_P1(ump)); + SET_LED(0); + break; + + case UMP_MIDI_NOTE_OFF: + case UMP_MIDI_NOTE_ON: + case UMP_MIDI_AFTERTOUCH: + case UMP_MIDI_CONTROL_CHANGE: + case UMP_MIDI_PITCH_BEND: + SET_LED(1); + uart_poll_out(uart_dev, UMP_MIDI_STATUS(ump)); + uart_poll_out(uart_dev, UMP_MIDI1_P1(ump)); + uart_poll_out(uart_dev, UMP_MIDI1_P2(ump)); + SET_LED(0); + break; + } +} +#endif /* DT_NODE_EXISTS(SERIAL_NODE) */ + + +static const struct ump_endpoint_dt_spec ump_ep_dt = + UMP_ENDPOINT_DT_SPEC_GET(DT_NODELABEL(midi2)); + +static inline void handle_ump_stream(struct netmidi2_session *session, + const struct midi_ump ump) +{ + const struct ump_stream_responder_cfg responder_cfg = { + .dev = session, + .send = (void (*)(void *, const struct midi_ump)) netmidi2_send, + .ep_spec = &ump_ep_dt, + }; + ump_stream_respond(&responder_cfg, ump); +} + +static void netmidi2_callback(struct netmidi2_session *session, + const struct midi_ump ump) +{ + switch (UMP_MT(ump)) { + case UMP_MT_MIDI1_CHANNEL_VOICE: + send_external_midi1(ump); + break; + case UMP_MT_UMP_STREAM: + handle_ump_stream(session, ump); + break; + } +} + +#if defined(CONFIG_NET_SAMPLE_MIDI2_AUTH_NONE) +/* Simple Network MIDI 2.0 endpoint without authentication */ +NETMIDI2_EP_DEFINE(midi_server, ump_ep_dt.name, NULL, 0); + +#elif defined(CONFIG_NET_SAMPLE_MIDI2_AUTH_SHARED_SECRET) +/* Network MIDI 2.0 endpoint with shared secret authentication */ +BUILD_ASSERT( + sizeof(CONFIG_NET_SAMPLE_MIDI2_SHARED_SECRET) > 1, + "CONFIG_NET_SAMPLE_MIDI2_SHARED_SECRET must be not empty" +); + +NETMIDI2_EP_DEFINE_WITH_AUTH(midi_server, ump_ep_dt.name, NULL, 0, + CONFIG_NET_SAMPLE_MIDI2_SHARED_SECRET); + +#elif defined(CONFIG_NET_SAMPLE_MIDI2_AUTH_USER_PASSWORD) +/* Network MIDI 2.0 endpoint with a single user/password*/ +BUILD_ASSERT( + sizeof(CONFIG_NET_SAMPLE_MIDI2_USERNAME) > 1, + "CONFIG_NET_SAMPLE_MIDI2_USERNAME must be not empty" +); +BUILD_ASSERT( + sizeof(CONFIG_NET_SAMPLE_MIDI2_PASSWORD) > 1, + "CONFIG_NET_SAMPLE_MIDI2_PASSWORD must be not empty" +); + +NETMIDI2_EP_DEFINE_WITH_USERS(midi_server, ump_ep_dt.name, NULL, 0, + {.name = CONFIG_NET_SAMPLE_MIDI2_USERNAME, + .password = CONFIG_NET_SAMPLE_MIDI2_PASSWORD}); + +#endif + +DNS_SD_REGISTER_SERVICE(midi_dns, CONFIG_NET_HOSTNAME "-" CONFIG_BOARD, + "_midi2", "_udp", "local", DNS_SD_EMPTY_TXT, + &midi_server.addr4.sin_port); + +int main(void) +{ + CONFIGURE_LED(); + + midi_server.rx_packet_cb = netmidi2_callback; + netmidi2_host_ep_start(&midi_server); + + return 0; +} diff --git a/subsys/net/lib/midi2/Kconfig b/subsys/net/lib/midi2/Kconfig index 8ebe7141e6567..c797d82ff1f5c 100644 --- a/subsys/net/lib/midi2/Kconfig +++ b/subsys/net/lib/midi2/Kconfig @@ -3,6 +3,7 @@ config NETMIDI2_HOST bool "Network MIDI2 (UDP) host [EXPERIMENTAL]" + select EXPERIMENTAL select NET_UDP select NET_SOCKETS select NET_SOCKETS_SERVICE