diff --git a/.gitmodules b/.gitmodules index 950c861..b6f5a0f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "lib/Control-Surface"] path = lib/Control-Surface url = https://github.com/tttapa/Control-Surface.git +[submodule "lib/cs-midi"] + path = lib/cs-midi + url = https://git.else-if.org/jess/cs-midi.git diff --git a/CMakeLists.txt b/CMakeLists.txt index e14db66..a6c4411 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,18 +15,30 @@ add_compile_definitions(PICO_RP2350A=0) pico_sdk_init() +add_subdirectory(lib/cs-midi) + +# cs_midi's BTstack sources need project-level btstack_config.h and lwipopts.h +target_include_directories(cs_midi PRIVATE ${CMAKE_CURRENT_LIST_DIR}) + add_executable(fractional_looper - main.c + main.cpp + src/encoder.cpp + src/spp_midi.cpp ) -target_include_directories(fractional_looper PRIVATE ${CMAKE_CURRENT_LIST_DIR}) +target_include_directories(fractional_looper PRIVATE + ${CMAKE_CURRENT_LIST_DIR} + ${CMAKE_CURRENT_LIST_DIR}/src +) target_link_libraries(fractional_looper pico_stdlib pico_cyw43_arch_lwip_threadsafe_background pico_btstack_ble + pico_btstack_classic pico_btstack_cyw43 hardware_adc + cs_midi ) pico_enable_stdio_usb(fractional_looper 1) diff --git a/Makefile b/Makefile index dd5b885..bb92bf4 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,16 @@ $(BUILD_DIR)/Makefile: CMakeLists.txt @cd $(BUILD_DIR) && PICO_SDK_PATH=$$HOME/Staging/pico-sdk cmake .. clean: + @rm -f $(BUILD_DIR)/CMakeFiles/$(TARGET).dir/main.cpp.o + @rm -f $(BUILD_DIR)/CMakeFiles/$(TARGET).dir/src/*.o + @rm -f $(BUILD_DIR)/$(TARGET).elf $(BUILD_DIR)/$(TARGET).uf2 + @rm -f $(BUILD_DIR)/$(TARGET).hex $(BUILD_DIR)/$(TARGET).bin + @rm -f $(BUILD_DIR)/$(TARGET).dis $(BUILD_DIR)/$(TARGET).map + @echo "Project objects cleaned (libraries + SDK preserved)" + +distclean: @rm -rf $(BUILD_DIR) + @echo "Full clean (rebuild everything with make)" flash: all @echo "Waiting for RP2350 volume..." @@ -18,4 +27,4 @@ flash: all @cp $(BUILD_DIR)/$(TARGET).uf2 /Volumes/RP2350/ @echo "Done." -.PHONY: all clean flash +.PHONY: all clean distclean flash diff --git a/btstack_config.h b/btstack_config.h index 4f286a0..9467c04 100644 --- a/btstack_config.h +++ b/btstack_config.h @@ -1,9 +1,16 @@ #ifndef _BTSTACK_CONFIG_H #define _BTSTACK_CONFIG_H +// BLE #define ENABLE_LE_PERIPHERAL #define ENABLE_LE_CENTRAL #define ENABLE_L2CAP_LE_CREDIT_BASED_FLOW_CONTROL_MODE + +// Classic BT +#ifndef ENABLE_CLASSIC +#define ENABLE_CLASSIC +#endif +#define ENABLE_LOG_INFO #define ENABLE_PRINTF_HEXDUMP #define HCI_OUTGOING_PRE_BUFFER_SIZE 4 @@ -12,9 +19,9 @@ #define MAX_NR_BTSTACK_LINK_KEY_DB_MEMORY_ENTRIES 2 #define MAX_NR_GATT_CLIENTS 1 -#define MAX_NR_HCI_CONNECTIONS 2 -#define MAX_NR_L2CAP_CHANNELS 4 -#define MAX_NR_L2CAP_SERVICES 3 +#define MAX_NR_HCI_CONNECTIONS 4 +#define MAX_NR_L2CAP_CHANNELS 6 +#define MAX_NR_L2CAP_SERVICES 4 #define MAX_NR_SM_LOOKUP_ENTRIES 3 #define MAX_NR_WHITELIST_ENTRIES 1 #define MAX_NR_LE_DEVICE_DB_ENTRIES 4 @@ -28,6 +35,14 @@ #define HCI_HOST_SCO_PACKET_LEN 120 #define HCI_HOST_SCO_PACKET_NUM 3 +// RFCOMM for SPP +#define MAX_NR_RFCOMM_MULTIPLEXERS 1 +#define MAX_NR_RFCOMM_SERVICES 1 +#define MAX_NR_RFCOMM_CHANNELS 1 + +// SDP +#define MAX_NR_SERVICE_RECORD_ITEMS 2 + #define NVM_NUM_DEVICE_DB_ENTRIES 16 #define NVM_NUM_LINK_KEYS 16 diff --git a/lib/cs-midi b/lib/cs-midi new file mode 160000 index 0000000..e215f76 --- /dev/null +++ b/lib/cs-midi @@ -0,0 +1 @@ +Subproject commit e215f7686c92a5588907164ac2c7e61258abc039 diff --git a/main.c b/main.c deleted file mode 100644 index 0e1ee2b..0000000 --- a/main.c +++ /dev/null @@ -1,23 +0,0 @@ -#include -#include "pico/stdlib.h" -#include "pico/cyw43_arch.h" - -int main(void) { - stdio_init_all(); - - if (cyw43_arch_init()) { - printf("CYW43 init failed\n"); - return 1; - } - - printf("FractionalLooper: CYW43 initialized\n"); - - while (1) { - cyw43_arch_gpio_put(CYW43_WL_GPIO_LED_PIN, 1); - sleep_ms(500); - cyw43_arch_gpio_put(CYW43_WL_GPIO_LED_PIN, 0); - sleep_ms(500); - } - - return 0; -} diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000..55e2347 --- /dev/null +++ b/main.cpp @@ -0,0 +1,117 @@ +#include +#include "pico/stdlib.h" +#include "pico/cyw43_arch.h" + +#include +#include "encoder.h" +#include "spp_midi.h" + +// Two CC sets, toggled by SW1 button. +// Set A: encoders CC 16-19, buttons CC 20-22 (SW2-SW4) +// Set B: encoders CC 24-27, buttons CC 28-30 (SW2-SW4) +static constexpr uint8_t SET_A_ENC_CC = 16; +static constexpr uint8_t SET_A_BTN_CC = 20; +static constexpr uint8_t SET_B_ENC_CC = 24; +static constexpr uint8_t SET_B_BTN_CC = 28; +static constexpr uint8_t TOGGLE_CC = 23; + +static uint8_t enc_cc_base = SET_A_ENC_CC; +static uint8_t btn_cc_base = SET_A_BTN_CC; +static bool set_b_active = false; + +// LED state +enum LedState { LED_IDLE_BLINK, LED_CONNECT_FLASH, LED_OFF }; +static LedState led_state = LED_IDLE_BLINK; +static uint16_t led_counter = 0; +static uint8_t led_flash_count = 0; +static bool was_connected = false; + +static void led_set(bool on) { + cyw43_arch_gpio_put(CYW43_WL_GPIO_LED_PIN, on); +} + +int main() { + stdio_init_all(); + + if (cyw43_arch_init()) { + printf("CYW43 init failed\n"); + return 1; + } + printf("FractionalLooper: CYW43 initialized\n"); + + cs::BluetoothMIDI_Interface ble; + ble.begin(); + printf("BLE MIDI started\n"); + + SPPStreamMIDI_Interface spp; + spp.begin(); + + cs::BidirectionalMIDI_PipeFactory<2> pipes; + ble | pipes | spp; + + encoders_init(); + printf("Encoders initialized\n"); + + ble.setAsDefault(); + + while (true) { + bool connected = ble.isConnected() || spp.isConnected(); + if (connected && !was_connected) { + led_state = LED_CONNECT_FLASH; + led_flash_count = 0; + led_counter = 0; + } else if (!connected && was_connected) { + led_state = LED_IDLE_BLINK; + led_counter = 0; + } + was_connected = connected; + + switch (led_state) { + case LED_IDLE_BLINK: + led_set(led_counter < 500); + if (++led_counter >= 1000) led_counter = 0; + break; + case LED_CONNECT_FLASH: + led_set(led_counter < 50); + if (++led_counter >= 100) { + led_counter = 0; + if (++led_flash_count >= 6) { + led_state = LED_OFF; + led_set(false); + } + } + break; + case LED_OFF: + break; + } + + for (int i = 0; i < NUM_ENCODERS; i++) { + int32_t delta = encoder_get_delta(i); + if (delta != 0) { + int32_t clamped = delta; + if (clamped > 63) clamped = 63; + if (clamped < -63) clamped = -63; + uint8_t val = (clamped > 0) ? (uint8_t)clamped + : (uint8_t)(128 + clamped); + ble.sendControlChange({enc_cc_base + i, cs::Channel_1}, val); + } + + if (button_pressed(i)) { + if (i == 0) { + set_b_active = !set_b_active; + enc_cc_base = set_b_active ? SET_B_ENC_CC : SET_A_ENC_CC; + btn_cc_base = set_b_active ? SET_B_BTN_CC : SET_A_BTN_CC; + ble.sendControlChange({TOGGLE_CC, cs::Channel_1}, + set_b_active ? 127 : 0); + } else { + ble.sendControlChange({btn_cc_base + (i - 1), cs::Channel_1}, 127); + } + } + } + + ble.update(); + spp.update(); + + sleep_ms(1); + } +} diff --git a/src/encoder.cpp b/src/encoder.cpp new file mode 100644 index 0000000..5bdab81 --- /dev/null +++ b/src/encoder.cpp @@ -0,0 +1,140 @@ +#include "encoder.h" + +#include "hardware/gpio.h" +#include "hardware/structs/sio.h" +#include "hardware/timer.h" +#include "pico/stdlib.h" + +#include + +// Interrupt-driven quadrature decoder using the same state machine +// as Control Surface's AHEncoder. Fires on every edge of both A and B +// pins, reads both pins directly from SIO register, and updates +// position atomically. +// +// new_B new_A old_B old_A Result +// 0 0 0 1 +1 +// 0 0 1 0 -1 +// 0 0 1 1 +2 +// 0 1 0 0 -1 +// 0 1 1 0 -2 +// 0 1 1 1 +1 +// 1 0 0 0 +1 +// 1 0 0 1 -2 +// 1 0 1 1 -1 +// 1 1 0 0 +2 +// 1 1 0 1 -1 +// 1 1 1 0 +1 + +static std::atomic enc_position[NUM_ENCODERS]; +static uint8_t enc_state[NUM_ENCODERS]; + +static inline void encoder_update(int i) { + uint32_t gpio_all = sio_hw->gpio_in; + uint8_t s = enc_state[i] & 0b11; + if (gpio_all & (1u << ENC_PIN_A[i])) s |= 4; + if (gpio_all & (1u << ENC_PIN_B[i])) s |= 8; + enc_state[i] = (s >> 2); + switch (s) { + case 1: case 7: case 8: case 14: + enc_position[i].fetch_add(1, std::memory_order_relaxed); return; + case 2: case 4: case 11: case 13: + enc_position[i].fetch_add(-1, std::memory_order_relaxed); return; + case 3: case 12: + enc_position[i].fetch_add(2, std::memory_order_relaxed); return; + case 6: case 9: + enc_position[i].fetch_add(-2, std::memory_order_relaxed); return; + } +} + +// Button state +static volatile bool btn_flags[NUM_ENCODERS]; +static volatile uint32_t btn_debounce_time[NUM_ENCODERS]; +#define DEBOUNCE_MS 25 + +// Single callback for all encoder edges and button presses +static void gpio_irq_callback(uint gpio, uint32_t events) { + for (int i = 0; i < NUM_ENCODERS; i++) { + if (gpio == ENC_PIN_A[i] || gpio == ENC_PIN_B[i]) { + encoder_update(i); + return; + } + } + if (events & GPIO_IRQ_EDGE_FALL) { + uint32_t now = to_ms_since_boot(get_absolute_time()); + for (int i = 0; i < NUM_ENCODERS; i++) { + if (gpio == BTN_PINS[i]) { + if (now - btn_debounce_time[i] > DEBOUNCE_MS) { + btn_debounce_time[i] = now; + btn_flags[i] = true; + } + return; + } + } + } +} + +void encoders_init() { + for (int i = 0; i < NUM_ENCODERS; i++) { + enc_position[i].store(0, std::memory_order_relaxed); + enc_state[i] = 0; + btn_flags[i] = false; + btn_debounce_time[i] = 0; + } + + // Set up encoder pins with pull-ups and edge interrupts + for (int i = 0; i < NUM_ENCODERS; i++) { + gpio_init(ENC_PIN_A[i]); + gpio_set_dir(ENC_PIN_A[i], GPIO_IN); + gpio_pull_up(ENC_PIN_A[i]); + + gpio_init(ENC_PIN_B[i]); + gpio_set_dir(ENC_PIN_B[i], GPIO_IN); + gpio_pull_up(ENC_PIN_B[i]); + } + + // Set up button pins + for (int i = 0; i < NUM_ENCODERS; i++) { + gpio_init(BTN_PINS[i]); + gpio_set_dir(BTN_PINS[i], GPIO_IN); + gpio_pull_up(BTN_PINS[i]); + } + + // Allow RC filters to settle + sleep_ms(2); + + // Read initial encoder states + uint32_t gpio_all = sio_hw->gpio_in; + for (int i = 0; i < NUM_ENCODERS; i++) { + uint8_t s = 0; + if (gpio_all & (1u << ENC_PIN_A[i])) s |= 1; + if (gpio_all & (1u << ENC_PIN_B[i])) s |= 2; + enc_state[i] = s; + } + + // Register single callback, enable IRQs on all encoder and button pins + gpio_set_irq_enabled_with_callback(ENC_PIN_A[0], + GPIO_IRQ_EDGE_RISE | GPIO_IRQ_EDGE_FALL, true, gpio_irq_callback); + + for (int i = 0; i < NUM_ENCODERS; i++) { + if (i > 0) + gpio_set_irq_enabled(ENC_PIN_A[i], + GPIO_IRQ_EDGE_RISE | GPIO_IRQ_EDGE_FALL, true); + gpio_set_irq_enabled(ENC_PIN_B[i], + GPIO_IRQ_EDGE_RISE | GPIO_IRQ_EDGE_FALL, true); + gpio_set_irq_enabled(BTN_PINS[i], + GPIO_IRQ_EDGE_FALL, true); + } +} + +int32_t encoder_get_delta(int index) { + return enc_position[index].exchange(0, std::memory_order_relaxed); +} + +bool button_pressed(int index) { + if (btn_flags[index]) { + btn_flags[index] = false; + return true; + } + return false; +} diff --git a/src/encoder.h b/src/encoder.h new file mode 100644 index 0000000..18aa203 --- /dev/null +++ b/src/encoder.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +#define NUM_ENCODERS 4 + +// A/B swapped on encoders 0-2 to correct rotation direction. +// Encoder 0 rewired to GPIO 17/18. Encoder 3 left as-is (already correct). +static constexpr uint8_t ENC_PIN_A[NUM_ENCODERS] = {17, 5, 8, 9}; +static constexpr uint8_t ENC_PIN_B[NUM_ENCODERS] = {18, 3, 6, 11}; +static constexpr uint8_t BTN_PINS[NUM_ENCODERS] = {1, 4, 7, 10}; + +void encoders_init(); + +// Returns accumulated delta since last call. Positive = clockwise. +int32_t encoder_get_delta(int index); + +// Returns true if button was pressed since last call (edge-triggered). +bool button_pressed(int index); diff --git a/src/encoder.pio b/src/encoder.pio new file mode 100644 index 0000000..448507a --- /dev/null +++ b/src/encoder.pio @@ -0,0 +1,47 @@ +.pio_version 0 + +; Quadrature encoder decoder for non-adjacent A/B pins. +; +; Pin layout per encoder: A = in_base+0, (switch = in_base+1), B = in_base+2. +; On each B transition, samples A and B into the ISR and pushes to RX FIFO. +; Software decodes direction from the state table. +; +; Set clock divider for ~10 kHz to filter mechanical bounce. + +.program encoder +.wrap_target + wait 1 pin 2 ; wait for B (base+2) to go high + in pins, 3 ; sample A, switch, B (3 bits from base) + push noblock + wait 0 pin 2 ; wait for B to go low + in pins, 3 ; sample again + push noblock +.wrap + +% c-sdk { +#include "hardware/clocks.h" +#include "hardware/gpio.h" + +static inline void encoder_program_init(PIO pio, uint sm, uint offset, + uint pin_a) { + // Pin layout: A = pin_a, switch = pin_a+1, B = pin_a+2 + uint pin_sw = pin_a + 1; + uint pin_b = pin_a + 2; + + pio_gpio_init(pio, pin_a); + pio_gpio_init(pio, pin_b); + gpio_pull_up(pin_a); + gpio_pull_up(pin_b); + pio_sm_set_consecutive_pindirs(pio, sm, pin_a, 3, false); + + pio_sm_config c = encoder_program_get_default_config(offset); + sm_config_set_in_pins(&c, pin_a); + sm_config_set_in_shift(&c, false, false, 32); + + float div = (float)clock_get_hz(clk_sys) / 10000.0f; + sm_config_set_clkdiv(&c, div); + + pio_sm_init(pio, sm, offset, &c); + pio_sm_set_enabled(pio, sm, true); +} +%} diff --git a/src/spp_midi.cpp b/src/spp_midi.cpp new file mode 100644 index 0000000..c3dd95b --- /dev/null +++ b/src/spp_midi.cpp @@ -0,0 +1,160 @@ +#include "spp_midi.h" + +#include +#include + +#include "btstack.h" + +#define RFCOMM_SERVER_CHANNEL 1 + +static SPPStreamMIDI_Interface *spp_instance = nullptr; +static uint16_t rfcomm_cid = 0; +static uint16_t rfcomm_mtu = 0; +static uint8_t spp_service_buffer[150]; +static btstack_packet_callback_registration_t hci_event_cb_reg; + +// Ring buffer storage +volatile uint8_t SPPStreamMIDI_Interface::rx_buf[RX_BUF_SIZE]; +volatile uint16_t SPPStreamMIDI_Interface::rx_head = 0; +volatile uint16_t SPPStreamMIDI_Interface::rx_tail = 0; + +SPPStreamMIDI_Interface *SPPStreamMIDI_Interface::instance() { + return spp_instance; +} + +void SPPStreamMIDI_Interface::push_rx_data(const uint8_t *data, uint16_t len) { + for (uint16_t i = 0; i < len; i++) { + uint16_t next = (rx_head + 1) % RX_BUF_SIZE; + if (next == rx_tail) break; // overflow — drop + rx_buf[rx_head] = data[i]; + rx_head = next; + } +} + +bool SPPStreamMIDI_Interface::RxPuller::pull(uint8_t &byte) { + if (rx_head == rx_tail) return false; + byte = rx_buf[rx_tail]; + rx_tail = (rx_tail + 1) % RX_BUF_SIZE; + return true; +} + +// BTstack packet handler for RFCOMM events +void spp_packet_handler(uint8_t packet_type, uint16_t channel, + uint8_t *packet, uint16_t size) { + (void)channel; + bd_addr_t event_addr; + + switch (packet_type) { + case HCI_EVENT_PACKET: + switch (hci_event_packet_get_type(packet)) { + case HCI_EVENT_PIN_CODE_REQUEST: + hci_event_pin_code_request_get_bd_addr(packet, event_addr); + gap_pin_code_response(event_addr, "0000"); + break; + + case HCI_EVENT_USER_CONFIRMATION_REQUEST: + hci_event_user_confirmation_request_get_bd_addr(packet, event_addr); + gap_ssp_confirmation_response(event_addr); + break; + + case RFCOMM_EVENT_INCOMING_CONNECTION: + rfcomm_cid = rfcomm_event_incoming_connection_get_rfcomm_cid(packet); + rfcomm_accept_connection(rfcomm_cid); + break; + + case RFCOMM_EVENT_CHANNEL_OPENED: + if (rfcomm_event_channel_opened_get_status(packet)) { + printf("SPP: channel open failed 0x%02x\n", + rfcomm_event_channel_opened_get_status(packet)); + rfcomm_cid = 0; + } else { + rfcomm_cid = rfcomm_event_channel_opened_get_rfcomm_cid(packet); + rfcomm_mtu = rfcomm_event_channel_opened_get_max_frame_size(packet); + if (spp_instance) spp_instance->connected = true; + printf("SPP: connected, cid 0x%02x mtu %u\n", rfcomm_cid, rfcomm_mtu); + } + break; + + case RFCOMM_EVENT_CHANNEL_CLOSED: + if (spp_instance) spp_instance->connected = false; + printf("SPP: disconnected\n"); + rfcomm_cid = 0; + gap_discoverable_control(1); + gap_connectable_control(1); + break; + + default: + break; + } + break; + + case RFCOMM_DATA_PACKET: + SPPStreamMIDI_Interface::push_rx_data(packet, size); + break; + + default: + break; + } +} + +void SPPStreamMIDI_Interface::begin() { + spp_instance = this; + + // Classic BT init + l2cap_init(); + rfcomm_init(); + rfcomm_register_service(spp_packet_handler, RFCOMM_SERVER_CHANNEL, 0xffff); + + sdp_init(); + memset(spp_service_buffer, 0, sizeof(spp_service_buffer)); + spp_create_sdp_record(spp_service_buffer, + sdp_create_service_record_handle(), + RFCOMM_SERVER_CHANNEL, + "FractionalLooper MIDI"); + sdp_register_service(spp_service_buffer); + + hci_event_cb_reg.callback = &spp_packet_handler; + hci_add_event_handler(&hci_event_cb_reg); + + gap_set_local_name("FractionalLooper 00:00:00:00:00:00"); + gap_ssp_set_io_capability(SSP_IO_CAPABILITY_DISPLAY_YES_NO); + gap_discoverable_control(1); + gap_connectable_control(1); + + printf("SPP MIDI: initialized\n"); +} + +cs::MIDIReadEvent SPPStreamMIDI_Interface::read() { + RxPuller puller; + return parser.pull(puller); +} + +void SPPStreamMIDI_Interface::send_bytes(const uint8_t *data, size_t len) { + if (rfcomm_cid == 0 || len == 0) return; + rfcomm_send(rfcomm_cid, (uint8_t *)data, (uint16_t)len); +} + +void SPPStreamMIDI_Interface::sendChannelMessageImpl(cs::ChannelMessage msg) { + uint8_t buf[3] = {msg.header, msg.data1, msg.data2}; + size_t len = msg.hasTwoDataBytes() ? 3 : 2; + send_bytes(buf, len); +} + +void SPPStreamMIDI_Interface::sendSysCommonImpl(cs::SysCommonMessage msg) { + uint8_t buf[3] = {msg.header, msg.data1, msg.data2}; + size_t len = msg.getNumberOfDataBytes() + 1; + send_bytes(buf, len); +} + +void SPPStreamMIDI_Interface::sendSysExImpl(cs::SysExMessage msg) { + send_bytes(msg.data, msg.length); +} + +void SPPStreamMIDI_Interface::sendRealTimeImpl(cs::RealTimeMessage msg) { + uint8_t buf[1] = {msg.message}; + send_bytes(buf, 1); +} + +void SPPStreamMIDI_Interface::sendNowImpl() { + // RFCOMM sends immediately, nothing to flush +} diff --git a/src/spp_midi.h b/src/spp_midi.h new file mode 100644 index 0000000..80f4426 --- /dev/null +++ b/src/spp_midi.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include + +// Classic Bluetooth SPP MIDI interface. +// Carries raw MIDI bytes over RFCOMM serial port. + +class SPPStreamMIDI_Interface : public cs::MIDI_Interface { + public: + SPPStreamMIDI_Interface() = default; + + void begin() override; + void update() override { cs::MIDI_Interface::updateIncoming(this); } + void handleStall() override { cs::MIDI_Interface::handleStall(this); } + + cs::MIDIReadEvent read(); + cs::ChannelMessage getChannelMessage() const { return parser.getChannelMessage(); } + cs::SysCommonMessage getSysCommonMessage() const { return parser.getSysCommonMessage(); } + cs::RealTimeMessage getRealTimeMessage() const { return parser.getRealTimeMessage(); } + cs::SysExMessage getSysExMessage() const { return parser.getSysExMessage(); } + + static void push_rx_data(const uint8_t *data, uint16_t len); + static SPPStreamMIDI_Interface *instance(); + bool isConnected() const { return connected; } + + protected: + void sendChannelMessageImpl(cs::ChannelMessage msg) override; + void sendSysCommonImpl(cs::SysCommonMessage msg) override; + void sendSysExImpl(cs::SysExMessage msg) override; + void sendRealTimeImpl(cs::RealTimeMessage msg) override; + void sendNowImpl() override; + + private: + cs::SerialMIDI_Parser parser; + + struct RxPuller { + bool pull(uint8_t &byte); + }; + + static constexpr size_t RX_BUF_SIZE = 256; + static volatile uint8_t rx_buf[RX_BUF_SIZE]; + static volatile uint16_t rx_head; + static volatile uint16_t rx_tail; + + bool connected = false; + void send_bytes(const uint8_t *data, size_t len); + friend void spp_packet_handler(uint8_t, uint16_t, uint8_t *, uint16_t); +};