Repository: HaoboGu/rmk (language: rust)

Issue:
Combos support
Some references: 
- ZMK's combos doc: https://zmk.dev/docs/keymaps/combos
- QMK's combos doc: https://docs.qmk.fm/features/combo
- vial user manual for combos: https://get.vial.today/manual/combos.html



Discussion / hints:

Fix patch:
diff --git a/rmk-macro/src/behavior.rs b/rmk-macro/src/behavior.rs
index a5bf3e04..03069aa7 100644
--- a/rmk-macro/src/behavior.rs
+++ b/rmk-macro/src/behavior.rs
@@ -1,9 +1,10 @@
 //! Initialize behavior config boilerplate of RMK
 //!
 
-use crate::config::{OneShotConfig, TapHoldConfig, TriLayerConfig};
+use crate::config::{CombosConfig, OneShotConfig, TapHoldConfig, TriLayerConfig};
 use crate::keyboard_config::KeyboardConfig;
 use quote::quote;
+use crate::layout::parse_key;
 
 fn expand_tri_layer(tri_layer: &Option<TriLayerConfig>) -> proc_macro2::TokenStream {
     match tri_layer {
@@ -82,16 +83,52 @@ fn expand_tap_hold(tap_hold: &Option<TapHoldConfig>) -> proc_macro2::TokenStream
     }
 }
 
+fn expand_combos(combos: &Option<CombosConfig>) -> proc_macro2::TokenStream {
+    let default = quote! { ::core::default::Default::default() };
+    match combos {
+        Some(combos) => {
+            let combos_def = combos.combos.iter().map(|combo| {
+                let actions = combo.actions.iter().map(|a| parse_key(a.to_owned()));
+                let output = parse_key(combo.output.to_owned());
+                let layer = match combo.layer {
+                    Some(layer) => quote! { ::core::option::Option::Some(#layer) },
+                    None => quote! { ::core::option::Option::None },
+                };
+                quote! { ::rmk::combo::Combo::new([#(#actions),*], #output, #layer) }
+            });
+
+            let timeout = match &combos.timeout {
+                Some(t) => {
+                    let millis = t.0;
+                    quote! { timeout: ::embassy_time::Duration::from_millis(#millis), }
+                }
+                None => quote! {},
+            };
+
+            quote! {
+                ::rmk::config::CombosConfig {
+                    combos: ::rmk::heapless::Vec::from_iter([#(#combos_def),*]),
+                    #timeout
+                    ..Default::default()
+                }
+            }
+        }
+        None => default,
+    }
+}
+
 pub(crate) fn expand_behavior_config(keyboard_config: &KeyboardConfig) -> proc_macro2::TokenStream {
     let tri_layer = expand_tri_layer(&keyboard_config.behavior.tri_layer);
     let tap_hold = expand_tap_hold(&keyboard_config.behavior.tap_hold);
     let one_shot = expand_one_shot(&keyboard_config.behavior.one_shot);
+    let combos = expand_combos(&keyboard_config.behavior.combo);
 
     quote! {
         let behavior_config = ::rmk::config::BehaviorConfig {
             tri_layer: #tri_layer,
             tap_hold: #tap_hold,
             one_shot: #one_shot,
+            combo: #combos,
         };
     }
 }
diff --git a/rmk-macro/src/config/mod.rs b/rmk-macro/src/config/mod.rs
index bbfb8f6e..c22dcc87 100644
--- a/rmk-macro/src/config/mod.rs
+++ b/rmk-macro/src/config/mod.rs
@@ -140,6 +140,7 @@ pub struct BehaviorConfig {
     pub tri_layer: Option<TriLayerConfig>,
     pub tap_hold: Option<TapHoldConfig>,
     pub one_shot: Option<OneShotConfig>,
+    pub combo: Option<CombosConfig>,
 }
 
 /// Configurations for tap hold
@@ -165,6 +166,21 @@ pub struct OneShotConfig {
     pub timeout: Option<DurationMillis>,
 }
 
+/// Configurations for combos
+#[derive(Clone, Debug, Deserialize)]
+pub struct CombosConfig {
+    pub combos: Vec<ComboConfig>,
+    pub timeout: Option<DurationMillis>,
+}
+
+/// Configurations for combo
+#[derive(Clone, Debug, Deserialize)]
+pub struct ComboConfig {
+    pub actions: Vec<String>,
+    pub output: String,
+    pub layer: Option<u8>,
+}
+
 /// Configurations for split keyboards
 #[derive(Clone, Debug, Default, Deserialize)]
 pub struct SplitConfig {
@@ -223,7 +239,7 @@ fn parse_duration_millis<'de, D: de::Deserializer<'de>>(deserializer: D) -> Resu
     let unit = &input[num.len()..];
     let num: u64 = num.parse().map_err(|_| {
         de::Error::custom(format!(
-            "Invalid number \"{num}\" in [one_shot.timeout]: number part must be a u64"
+            "Invalid number \"{num}\" in duration: number part must be a u64"
         ))
     })?;
 
diff --git a/rmk-macro/src/keyboard_config.rs b/rmk-macro/src/keyboard_config.rs
index 70c40309..d3efb527 100644
--- a/rmk-macro/src/keyboard_config.rs
+++ b/rmk-macro/src/keyboard_config.rs
@@ -22,6 +22,11 @@ macro_rules! rmk_compile_error {
     };
 }
 
+// Max number of combos
+pub const COMBO_MAX_NUM: usize = 8;
+// Max size of combos
+pub const COMBO_MAX_LENGTH: usize = 4;
+
 /// Keyboard's basic info
 #[allow(unused)]
 #[derive(Clone, Debug, Deserialize)]
@@ -443,6 +448,25 @@ impl KeyboardConfig {
                 behavior.tap_hold = behavior.tap_hold.or(default.tap_hold);
                 behavior.one_shot = behavior.one_shot.or(default.one_shot);
 
+                behavior.combo = behavior.combo.or(default.combo);
+                if let Some(combo) = &behavior.combo {
+                    if combo.combos.len() > COMBO_MAX_NUM {
+                        return rmk_compile_error!(format!("keyboard.toml: number of combos is greater than [behavior.combo.max_num]"));
+                    }
+
+                    for (i, c) in combo.combos.iter().enumerate() {
+                        if c.actions.len() > COMBO_MAX_LENGTH {
+                            return rmk_compile_error!(format!("keyboard.toml: number of keys in combo #{i} is greater than [behavior.combo.max_length]"));
+                        }
+
+                        if let Some(layer) = c.layer {
+                            if layer >= layout.layers {
+                                return rmk_compile_error!(format!("keyboard.toml: layer in combo #{i} is greater than [layout.layers]"));
+                            }
+                        }
+                    }
+                }
+
                 Ok(behavior)
             }
             None => Ok(default),
diff --git a/rmk-macro/src/layout.rs b/rmk-macro/src/layout.rs
index ecac1250..b6930766 100644
--- a/rmk-macro/src/layout.rs
+++ b/rmk-macro/src/layout.rs
@@ -107,7 +107,7 @@ fn parse_modifiers(modifiers_str: &str) -> ModifierCombinationMacro {
 }
 
 /// Parse the key string at a single position
-fn parse_key(key: String) -> TokenStream2 {
+pub(crate) fn parse_key(key: String) -> TokenStream2 {
     if key.len() < 5 {
         return if key.len() > 0 && key.trim_start_matches("_").len() == 0 {
             quote! { ::rmk::a!(No) }
diff --git a/rmk/src/ble/esp/mod.rs b/rmk/src/ble/esp/mod.rs
index 07e01de6..cb718229 100644
--- a/rmk/src/ble/esp/mod.rs
+++ b/rmk/src/ble/esp/mod.rs
@@ -59,16 +59,19 @@ pub async fn initialize_esp_ble_keyboard_with_config_and_run<
     )
     .await;
 
-    let keymap = RefCell::new(KeyMap::new_from_storage(default_keymap, Some(&mut storage)).await);
+    let keymap = RefCell::new(
+        KeyMap::new_from_storage(
+            default_keymap,
+            Some(&mut storage),
+            keyboard_config.behavior_config,
+        )
+        .await,
+    );
 
     let keyboard_report_sender = KEYBOARD_REPORT_CHANNEL.sender();
     let keyboard_report_receiver = KEYBOARD_REPORT_CHANNEL.receiver();
 
-    let mut keyboard = Keyboard::new(
-        &keymap,
-        &keyboard_report_sender,
-        keyboard_config.behavior_config,
-    );
+    let mut keyboard = Keyboard::new(&keymap, &keyboard_report_sender);
     // esp32c3 doesn't have USB device, so there is no usb here
     // TODO: add usb service for other chips of esp32 which have USB device
 
diff --git a/rmk/src/ble/nrf/mod.rs b/rmk/src/ble/nrf/mod.rs
index 0cbea31a..d8ae42db 100644
--- a/rmk/src/ble/nrf/mod.rs
+++ b/rmk/src/ble/nrf/mod.rs
@@ -252,7 +252,14 @@ pub async fn initialize_nrf_ble_keyboard_and_run<
     // Flash and keymap configuration
     let flash = Flash::take(sd);
     let mut storage = Storage::new(flash, default_keymap, keyboard_config.storage_config).await;
-    let keymap = RefCell::new(KeyMap::new_from_storage(default_keymap, Some(&mut storage)).await);
+    let keymap = RefCell::new(
+        KeyMap::new_from_storage(
+            default_keymap,
+            Some(&mut storage),
+            keyboard_config.behavior_config,
+        )
+        .await,
+    );
 
     let mut buf: [u8; 128] = [0; 128];
 
@@ -304,11 +311,7 @@ pub async fn initialize_nrf_ble_keyboard_and_run<
     let keyboard_report_receiver = KEYBOARD_REPORT_CHANNEL.receiver();
 
     // Keyboard services
-    let mut keyboard = Keyboard::new(
-        &keymap,
-        &keyboard_report_sender,
-        keyboard_config.behavior_config,
-    );
+    let mut keyboard = Keyboard::new(&keymap, &keyboard_report_sender);
     #[cfg(not(feature = "_no_usb"))]
     let mut usb_device = KeyboardUsbDevice::new(usb_driver, keyboard_config.usb_config);
     let mut vial_service = VialService::new(&keymap, keyboard_config.vial_config);
diff --git a/rmk/src/combo.rs b/rmk/src/combo.rs
new file mode 100644
index 00000000..5e1d7920
--- /dev/null
+++ b/rmk/src/combo.rs
@@ -0,0 +1,86 @@
+use heapless::Vec;
+
+use crate::{action::KeyAction, event::KeyEvent};
+
+// Max number of combos
+pub(crate) const COMBO_MAX_NUM: usize = 8;
+// Max size of combos
+pub(crate) const COMBO_MAX_LENGTH: usize = 4;
+
+#[derive(Clone)]
+pub struct Combo {
+    pub(crate) actions: Vec<KeyAction, COMBO_MAX_LENGTH>,
+    pub(crate) output: KeyAction,
+    pub(crate) layer: Option<u8>,
+    state: u8,
+}
+
+impl Default for Combo {
+    fn default() -> Self {
+        Self::empty()
+    }
+}
+
+impl Combo {
+    pub fn new<I: IntoIterator<Item = KeyAction>>(
+        actions: I,
+        output: KeyAction,
+        layer: Option<u8>,
+    ) -> Self {
+        Self {
+            actions: Vec::from_iter(actions),
+            output,
+            layer,
+            state: 0,
+        }
+    }
+
+    pub fn empty() -> Self {
+        Self::new(
+            Vec::<KeyAction, COMBO_MAX_LENGTH>::new(),
+            KeyAction::No,
+            None,
+        )
+    }
+
+    pub(crate) fn update(
+        &mut self,
+        key_action: KeyAction,
+        key_event: KeyEvent,
+        active_layer: u8,
+    ) -> bool {
+        if !key_event.pressed || key_action == KeyAction::No {
+            return false;
+        }
+
+        if let Some(layer) = self.layer {
+            if layer != active_layer {
+                return false;
+            }
+        }
+
+        let action_idx = self.actions.iter().position(|&a| a == key_action);
+        if let Some(i) = action_idx {
+            self.state |= 1 << i;
+        } else if !self.done() {
+            self.reset();
+        }
+        action_idx.is_some()
+    }
+
+    pub(crate) fn done(&self) -> bool {
+        self.started() && self.keys_pressed() == self.actions.len() as u32
+    }
+
+    pub(crate) fn started(&self) -> bool {
+        self.state != 0
+    }
+
+    pub(crate) fn keys_pressed(&self) -> u32 {
+        self.state.count_ones()
+    }
+
+    pub(crate) fn reset(&mut self) {
+        self.state = 0;
+    }
+}
diff --git a/rmk/src/config/mod.rs b/rmk/src/config/mod.rs
index 56259c2f..95ade7ba 100644
--- a/rmk/src/config/mod.rs
+++ b/rmk/src/config/mod.rs
@@ -3,6 +3,7 @@ mod esp_config;
 #[cfg(feature = "_nrf_ble")]
 mod nrf_config;
 
+use ::heapless::Vec;
 #[cfg(feature = "_esp_ble")]
 pub use esp_config::BleBatteryConfig;
 #[cfg(feature = "_nrf_ble")]
@@ -11,6 +12,8 @@ pub use nrf_config::BleBatteryConfig;
 use embassy_time::Duration;
 use embedded_hal::digital::OutputPin;
 
+use crate::combo::{Combo, COMBO_MAX_NUM};
+
 /// Internal configurations for RMK keyboard.
 pub struct RmkConfig<'a, O: OutputPin> {
     pub mouse_config: MouseConfig,
@@ -46,6 +49,7 @@ pub struct BehaviorConfig {
     pub tri_layer: Option<[u8; 3]>,
     pub tap_hold: TapHoldConfig,
     pub one_shot: OneShotConfig,
+    pub combo: CombosConfig,
 }
 
 /// Configurations for tap hold behavior
@@ -80,6 +84,21 @@ impl Default for OneShotConfig {
     }
 }
 
+/// Config for combo behavior
+pub struct CombosConfig {
+    pub combos: Vec<Combo, COMBO_MAX_NUM>,
+    pub timeout: Duration,
+}
+
+impl Default for CombosConfig {
+    fn default() -> Self {
+        Self {
+            timeout: Duration::from_millis(50),
+            combos: Vec::new(),
+        }
+    }
+}
+
 /// Config for storage
 #[derive(Clone, Copy, Debug)]
 pub struct StorageConfig {
diff --git a/rmk/src/keyboard.rs b/rmk/src/keyboard.rs
index 473580ce..4abd201a 100644
--- a/rmk/src/keyboard.rs
+++ b/rmk/src/keyboard.rs
@@ -1,4 +1,4 @@
-use crate::config::BehaviorConfig;
+use crate::combo::{Combo, COMBO_MAX_LENGTH};
 use crate::event::{Event, KeyEvent};
 use crate::CONNECTION_STATE;
 use crate::{
@@ -17,7 +17,7 @@ use embassy_sync::{
     channel::{Channel, Receiver, Sender},
 };
 use embassy_time::{Instant, Timer};
-use heapless::{FnvIndexMap, Vec};
+use heapless::{Deque, FnvIndexMap, Vec};
 use usbd_hid::descriptor::KeyboardReport;
 
 pub const EVENT_CHANNEL_SIZE: usize = 32;
@@ -136,9 +136,6 @@ pub(crate) struct Keyboard<'a, const ROW: usize, const COL: usize, const NUM_LAY
     /// Record whether the keyboard is in hold-after-tap state
     hold_after_tap: [Option<KeyEvent>; 6],
 
-    /// Options for configurable action behavior
-    behavior: BehaviorConfig,
-
     /// One shot modifier state
     osm_state: OneShotState<ModifierCombination>,
 
@@ -166,6 +163,12 @@ pub(crate) struct Keyboard<'a, const ROW: usize, const COL: usize, const NUM_LAY
     /// The current distance of mouse key moving
     mouse_key_move_delta: i8,
     mouse_wheel_move_delta: i8,
+
+    /// Buffer for pressed `KeyAction` and `KeyEvents` in combos
+    combo_actions_buffer: Deque<(KeyAction, KeyEvent), COMBO_MAX_LENGTH>,
+
+    /// Used for temporarily disabling combos
+    combo_on: bool,
 }
 
 impl<'a, const ROW: usize, const COL: usize, const NUM_LAYER: usize>
@@ -174,7 +177,6 @@ impl<'a, const ROW: usize, const COL: usize, const NUM_LAYER: usize>
     pub(crate) fn new(
         keymap: &'a RefCell<KeyMap<'a, ROW, COL, NUM_LAYER>>,
         sender: &'a Sender<'a, CriticalSectionRawMutex, KeyboardReportMessage, REPORT_CHANNEL_SIZE>,
-        behavior: BehaviorConfig,
     ) -> Self {
         Keyboard {
             keymap,
@@ -190,7 +192,6 @@ impl<'a, const ROW: usize, const COL: usize, const NUM_LAYER: usize>
                 None,
             ),
             hold_after_tap: Default::default(),
-            behavior,
             osm_state: OneShotState::default(),
             osl_state: OneShotState::default(),
             unprocessed_events: Vec::new(),
@@ -209,6 +210,8 @@ impl<'a, const ROW: usize, const COL: usize, const NUM_LAYER: usize>
             last_mouse_tick: FnvIndexMap::new(),
             mouse_key_move_delta: 8,
             mouse_wheel_move_delta: 1,
+            combo_actions_buffer: Deque::new(),
+            combo_on: true,
         }
     }
 
@@ -288,11 +291,22 @@ impl<'a, const ROW: usize, const COL: usize, const NUM_LAYER: usize>
         }
 
         // Process key
-        let action = self
+        let key_action = self
             .keymap
             .borrow_mut()
             .get_action_with_layer_cache(key_event);
-        match action {
+
+        if self.combo_on {
+            if let Some(key_action) = self.process_combo(key_action, key_event).await {
+                self.process_key_action(key_action, key_event).await;
+            }
+        } else {
+            self.process_key_action(key_action, key_event).await;
+        }
+    }
+
+    async fn process_key_action(&mut self, key_action: KeyAction, key_event: KeyEvent) {
+        match key_action {
             KeyAction::No | KeyAction::Transparent => (),
             KeyAction::Single(a) => self.process_key_action_normal(a, key_event).await,
             KeyAction::WithModifier(a, m) => {
@@ -323,7 +337,7 @@ impl<'a, const ROW: usize, const COL: usize, const NUM_LAYER: usize>
         if !key_event.pressed {
             // Check key release only
             let mut is_mod = false;
-            if let KeyAction::Single(Action::Key(k)) = action {
+            if let KeyAction::Single(Action::Key(k)) = key_action {
                 if k.is_modifier() {
                     is_mod = true;
                 }
@@ -331,11 +345,74 @@ impl<'a, const ROW: usize, const COL: usize, const NUM_LAYER: usize>
             // Record the last release event
             self.last_release = (key_event, is_mod, Some(Instant::now()));
         }
+    }
 
-        // Tri Layer
-        if let Some(ref tri_layer) = self.behavior.tri_layer {
-            self.keymap.borrow_mut().update_tri_layer(tri_layer);
+    async fn process_combo(
+        &mut self,
+        key_action: KeyAction,
+        key_event: KeyEvent,
+    ) -> Option<KeyAction> {
+        let mut is_combo_action = false;
+        let current_layer = self.keymap.borrow().get_activated_layer();
+        for combo in self.keymap.borrow_mut().combos.iter_mut() {
+            is_combo_action |= combo.update(key_action, key_event, current_layer);
         }
+
+        if key_event.pressed && is_combo_action {
+            if self
+                .combo_actions_buffer
+                .push_back((key_action, key_event))
+                .is_err()
+            {
+                error!("Combo actions buffer overflowed! This is a bug and should not happen!");
+            }
+
+            let next_action = self
+                .keymap
+                .borrow_mut()
+                .combos
+                .iter()
+                .find_map(|combo| combo.done().then_some(combo.output));
+
+            if next_action.is_some() {
+                self.combo_actions_buffer.clear();
+            } else {
+                let timeout =
+                    embassy_time::Timer::after(self.keymap.borrow().behavior.combo.timeout);
+                match select(timeout, KEY_EVENT_CHANNEL.receive()).await {
+                    embassy_futures::select::Either::First(_) => self.dispatch_combos().await,
+                    embassy_futures::select::Either::Second(event) => {
+                        self.unprocessed_events.push(event).unwrap()
+                    }
+                }
+            }
+            next_action
+        } else {
+            if !key_event.pressed {
+                for combo in self.keymap.borrow_mut().combos.iter_mut() {
+                    if combo.done() && combo.actions.contains(&key_action) {
+                        combo.reset();
+                        return Some(combo.output);
+                    }
+                }
+            }
+
+            self.dispatch_combos().await;
+            Some(key_action)
+        }
+    }
+
+    async fn dispatch_combos(&mut self) {
+        while let Some((action, event)) = self.combo_actions_buffer.pop_front() {
+            self.process_key_action(action, event).await;
+        }
+
+        self.keymap
+            .borrow_mut()
+            .combos
+            .iter_mut()
+            .filter(|combo| !combo.done())
+            .for_each(Combo::reset);
     }
 
     async fn update_osm(&mut self, key_event: KeyEvent) {
@@ -490,11 +567,12 @@ impl<'a, const ROW: usize, const COL: usize, const NUM_LAYER: usize>
         hold_action: Action,
         key_event: KeyEvent,
     ) {
-        if self.behavior.tap_hold.enable_hrm {
+        if self.keymap.borrow().behavior.tap_hold.enable_hrm {
             // If HRM is enabled, check whether it's a different key is in key streak
             if let Some(last_release_time) = self.last_release.2 {
                 if key_event.pressed {
-                    if last_release_time.elapsed() < self.behavior.tap_hold.prior_idle_time
+                    if last_release_time.elapsed()
+                        < self.keymap.borrow().behavior.tap_hold.prior_idle_time
                         && !(key_event.row == self.last_release.0.row
                             && key_event.col == self.last_release.0.col)
                     {
@@ -502,7 +580,8 @@ impl<'a, const ROW: usize, const COL: usize, const NUM_LAYER: usize>
                         debug!("Key streak detected, trigger tap action");
                         self.process_key_action_tap(tap_action, key_event).await;
                         return;
-                    } else if last_release_time.elapsed() < self.behavior.tap_hold.hold_timeout
+                    } else if last_release_time.elapsed()
+                        < self.keymap.borrow().behavior.tap_hold.hold_timeout
                         && key_event.row == self.last_release.0.row
                         && key_event.col == self.last_release.0.col
                     {
@@ -524,8 +603,14 @@ impl<'a, const ROW: usize, const COL: usize, const NUM_LAYER: usize>
             // Press
             self.timer[col][row] = Some(Instant::now());
 
-            let hold_timeout =
-                embassy_time::Timer::after_millis(self.behavior.tap_hold.hold_timeout.as_millis());
+            let hold_timeout = embassy_time::Timer::after_millis(
+                self.keymap
+                    .borrow()
+                    .behavior
+                    .tap_hold
+                    .hold_timeout
+                    .as_millis(),
+            );
             match select(hold_timeout, KEY_EVENT_CHANNEL.receive()).await {
                 embassy_futures::select::Either::First(_) => {
                     // Timeout, trigger hold
@@ -603,7 +688,12 @@ impl<'a, const ROW: usize, const COL: usize, const NUM_LAYER: usize>
                 };
 
                 let wait_timeout = embassy_time::Timer::after_millis(
-                    self.behavior.tap_hold.post_wait_time.as_millis(),
+                    self.keymap
+                        .borrow()
+                        .behavior
+                        .tap_hold
+                        .post_wait_time
+                        .as_millis(),
                 );
                 match select(wait_timeout, wait_release).await {
                     embassy_futures::select::Either::First(_) => {
@@ -657,7 +747,8 @@ impl<'a, const ROW: usize, const COL: usize, const NUM_LAYER: usize>
                 OneShotState::Initial(m) | OneShotState::Single(m) => {
                     self.osm_state = OneShotState::Single(m);
 
-                    let timeout = embassy_time::Timer::after(self.behavior.one_shot.timeout);
+                    let timeout =
+                        embassy_time::Timer::after(self.keymap.borrow().behavior.one_shot.timeout);
                     match select(timeout, KEY_EVENT_CHANNEL.receive()).await {
                         embassy_futures::select::Either::First(_) => {
                             // Timeout, release modifier
@@ -708,7 +799,8 @@ impl<'a, const ROW: usize, const COL: usize, const NUM_LAYER: usize>
                 OneShotState::Initial(l) | OneShotState::Single(l) => {
                     self.osl_state = OneShotState::Single(l);
 
-                    let timeout = embassy_time::Timer::after(self.behavior.one_shot.timeout);
+                    let timeout =
+                        embassy_time::Timer::after(self.keymap.borrow().behavior.one_shot.timeout);
                     match select(timeout, KEY_EVENT_CHANNEL.receive()).await {
                         embassy_futures::select::Either::First(_) => {
                             // Timeout, deactivate layer
@@ -785,6 +877,8 @@ impl<'a, const ROW: usize, const COL: usize, const NUM_LAYER: usize>
         } else if key.is_macro() {
             // Process macro
             self.process_action_macro(key, key_event).await;
+        } else if key.is_combo() {
+            self.process_action_combo(key, key_event).await;
         } else {
             warn!("Unsupported key: {:?}", key);
         }
@@ -800,6 +894,18 @@ impl<'a, const ROW: usize, const COL: usize, const NUM_LAYER: usize>
         }
     }
 
+    /// Process combo action.
+    async fn process_action_combo(&mut self, key: KeyCode, key_event: KeyEvent) {
+        if key_event.pressed {
+            match key {
+                KeyCode::ComboOn => self.combo_on = true,
+                KeyCode::ComboOff => self.combo_on = false,
+                KeyCode::ComboToggle => self.combo_on = !self.combo_on,
+                _ => (),
+            }
+        }
+    }
+
     /// Process consumer control action. Consumer control keys are keys in hid consumer page, such as media keys.
     async fn process_action_consumer_control(&mut self, key: KeyCode, key_event: KeyEvent) {
         if key.is_consumer() {
diff --git a/rmk/src/keycode.rs b/rmk/src/keycode.rs
index 75555942..8cc86679 100644
--- a/rmk/src/keycode.rs
+++ b/rmk/src/keycode.rs
@@ -995,6 +995,11 @@ impl KeyCode {
         KeyCode::Bootloader <= self && self <= KeyCode::AltRepeatKey
     }
 
+    /// Returns `true` if the keycode is a combo keycode
+    pub(crate) fn is_combo(self) -> bool {
+        KeyCode::ComboOn <= self && self <= KeyCode::ComboToggle
+    }
+
     /// Returns `true` if the keycode is a kb keycode
     pub(crate) fn is_kb(self) -> bool {
         KeyCode::Kb0 <= self && self <= KeyCode::Kb31
diff --git a/rmk/src/keymap.rs b/rmk/src/keymap.rs
index 6bdbc82b..6c25d1ba 100644
--- a/rmk/src/keymap.rs
+++ b/rmk/src/keymap.rs
@@ -1,5 +1,7 @@
 use crate::{
     action::KeyAction,
+    combo::{Combo, COMBO_MAX_NUM},
+    config::BehaviorConfig,
     event::KeyEvent,
     keyboard_macro::{MacroOperation, MACRO_SPACE_SIZE},
     keycode::KeyCode,
@@ -28,50 +30,63 @@ pub(crate) struct KeyMap<'a, const ROW: usize, const COL: usize, const NUM_LAYER
     layer_cache: [[u8; COL]; ROW],
     /// Macro cache
     pub(crate) macro_cache: [u8; MACRO_SPACE_SIZE],
+    /// Combos
+    pub(crate) combos: [Combo; COMBO_MAX_NUM],
+    /// Options for configurable action behavior
+    pub(crate) behavior: BehaviorConfig,
 }
 
 impl<'a, const ROW: usize, const COL: usize, const NUM_LAYER: usize>
     KeyMap<'a, ROW, COL, NUM_LAYER>
 {
-    pub(crate) async fn new(action_map: &'a mut [[[KeyAction; COL]; ROW]; NUM_LAYER]) -> Self {
+    pub(crate) async fn new(
+        action_map: &'a mut [[[KeyAction; COL]; ROW]; NUM_LAYER],
+        behavior: BehaviorConfig,
+    ) -> Self {
+        let mut combos: [Combo; COMBO_MAX_NUM] = Default::default();
+        for (i, combo) in behavior.combo.combos.iter().enumerate() {
+            combos[i] = combo.clone();
+        }
         KeyMap {
             layers: action_map,
             layer_state: [false; NUM_LAYER],
             default_layer: 0,
             layer_cache: [[0; COL]; ROW],
             macro_cache: [0; MACRO_SPACE_SIZE],
+            combos,
+            behavior,
         }
     }
 
     pub(crate) async fn new_from_storage<F: NorFlash>(
         action_map: &'a mut [[[KeyAction; COL]; ROW]; NUM_LAYER],
         storage: Option<&mut Storage<F, ROW, COL, NUM_LAYER>>,
+        behavior: BehaviorConfig,
     ) -> Self {
         // If the storage is initialized, read keymap from storage
         let mut macro_cache = [0; MACRO_SPACE_SIZE];
+        let mut combos: [Combo; COMBO_MAX_NUM] = Default::default();
+        for (i, combo) in behavior.combo.combos.iter().enumerate() {
+            combos[i] = combo.clone();
+        }
         if let Some(storage) = storage {
-            // Read keymap to `action_map`
-            if storage.read_keymap(action_map).await.is_err() {
-                error!("Keymap reading aborted by an error, clearing the storage...");
-                // Dont sent flash message here, since the storage task is not running yet
+            if {
+                Ok(())
+                    // Read keymap to `action_map`
+                    .and(storage.read_keymap(action_map).await)
+                    // Read combo cache
+                    .and(storage.read_macro_cache(&mut macro_cache).await)
+                    // Read macro cache
+                    .and(storage.read_combos(&mut combos).await)
+            }
+            .is_err()
+            {
+                error!("Failed to read from storage, clearing...");
                 sequential_storage::erase_all(&mut storage.flash, storage.storage_range.clone())
                     .await
                     .ok();
 
                 reboot_keyboard();
-            } else {
-                // Read macro cache
-                if storage.read_macro_cache(&mut macro_cache).await.is_err() {
-                    error!("Wrong macro cache, clearing the storage...");
-                    sequential_storage::erase_all(
-                        &mut storage.flash,
-                        storage.storage_range.clone(),
-                    )
-                    .await
-                    .ok();
-
-                    reboot_keyboard();
-                }
             }
         }
 
@@ -81,6 +96,8 @@ impl<'a, const ROW: usize, const COL: usize, const NUM_LAYER: usize>
             default_layer: 0,
             layer_cache: [[0; COL]; ROW],
             macro_cache,
+            combos,
+            behavior,
         }
     }
 
@@ -229,7 +246,7 @@ impl<'a, const ROW: usize, const COL: usize, const NUM_LAYER: usize>
         KeyAction::No
     }
 
-    fn get_activated_layer(&self) -> u8 {
+    pub(crate) fn get_activated_layer(&self) -> u8 {
         for (layer_idx, _) in self.layers.iter().enumerate().rev() {
             if self.layer_state[layer_idx] || layer_idx as u8 == self.default_layer {
                 return layer_idx as u8;
@@ -254,10 +271,12 @@ impl<'a, const ROW: usize, const COL: usize, const NUM_LAYER: usize>
         self.layer_cache[row][col] = layer_num;
     }
 
-    /// Update given Tri Layer state
-    pub(crate) fn update_tri_layer(&mut self, tri_layer: &[u8; 3]) {
-        self.layer_state[tri_layer[2] as usize] =
-            self.layer_state[tri_layer[0] as usize] && self.layer_state[tri_layer[1] as usize];
+    /// Update Tri Layer state
+    fn update_tri_layer(&mut self) {
+        if let Some(ref tri_layer) = self.behavior.tri_layer {
+            self.layer_state[tri_layer[2] as usize] =
+                self.layer_state[tri_layer[0] as usize] && self.layer_state[tri_layer[1] as usize];
+        }
     }
 
     /// Activate given layer
@@ -270,6 +289,7 @@ impl<'a, const ROW: usize, const COL: usize, const NUM_LAYER: usize>
             return;
         }
         self.layer_state[layer_num as usize] = true;
+        self.update_tri_layer();
     }
 
     /// Deactivate given layer
@@ -282,6 +302,7 @@ impl<'a, const ROW: usize, const COL: usize, const NUM_LAYER: usize>
             return;
         }
         self.layer_state[layer_num as usize] = false;
+        self.update_tri_layer();
     }
 
     /// Toggle given layer
diff --git a/rmk/src/lib.rs b/rmk/src/lib.rs
index 2ff32235..3405744c 100644
--- a/rmk/src/lib.rs
+++ b/rmk/src/lib.rs
@@ -47,6 +47,7 @@ use embedded_hal_async::digital::Wait;
 use embedded_storage::nor_flash::NorFlash;
 pub use flash::EmptyFlashWrapper;
 use futures::pin_mut;
+pub use heapless;
 use keyboard::{communication_task, Keyboard, KeyboardReportMessage, KEYBOARD_REPORT_CHANNEL};
 pub use keyboard::{EVENT_CHANNEL, EVENT_CHANNEL_SIZE, REPORT_CHANNEL_SIZE};
 use keymap::KeyMap;
@@ -60,6 +61,7 @@ use {embedded_storage_async::nor_flash::NorFlash as AsyncNorFlash, storage::Stor
 pub mod action;
 #[cfg(feature = "_ble")]
 pub mod ble;
+pub mod combo;
 pub mod config;
 pub mod debounce;
 pub mod direct_pin;
@@ -291,22 +293,27 @@ pub async fn initialize_usb_keyboard_and_run<
     #[cfg(any(feature = "_nrf_ble", not(feature = "_no_external_storage")))]
     let (mut storage, keymap) = {
         let mut s = Storage::new(flash, default_keymap, keyboard_config.storage_config).await;
-        let keymap = RefCell::new(KeyMap::new_from_storage(default_keymap, Some(&mut s)).await);
+        let keymap = RefCell::new(
+            KeyMap::new_from_storage(
+                default_keymap,
+                Some(&mut s),
+                keyboard_config.behavior_config,
+            )
+            .await,
+        );
         (s, keymap)
     };
     #[cfg(all(not(feature = "_nrf_ble"), feature = "_no_external_storage"))]
-    let keymap = RefCell::new(KeyMap::<ROW, COL, NUM_LAYER>::new(default_keymap).await);
+    let keymap = RefCell::new(
+        KeyMap::<ROW, COL, NUM_LAYER>::new(default_keymap, keyboard_config.behavior_config).await,
+    );
 
     let keyboard_report_sender = KEYBOARD_REPORT_CHANNEL.sender();
     let keyboard_report_receiver = KEYBOARD_REPORT_CHANNEL.receiver();
 
     // Create keyboard services and devices
     let (mut keyboard, mut usb_device, mut vial_service, mut light_service) = (
-        Keyboard::new(
-            &keymap,
-            &keyboard_report_sender,
-            keyboard_config.behavior_config,
-        ),
+        Keyboard::new(&keymap, &keyboard_report_sender),
         KeyboardUsbDevice::new(usb_driver, keyboard_config.usb_config),
         VialService::new(&keymap, keyboard_config.vial_config),
         LightService::from_config(keyboard_config.light_config),
diff --git a/rmk/src/split/central.rs b/rmk/src/split/central.rs
index 1ae87598..c3f3b6ae 100644
--- a/rmk/src/split/central.rs
+++ b/rmk/src/split/central.rs
@@ -302,6 +302,7 @@ pub async fn initialize_usb_split_central_and_run<
             KeyMap::<TOTAL_ROW, TOTAL_COL, NUM_LAYER>::new_from_storage(
                 default_keymap,
                 Some(&mut s),
+                keyboard_config.behavior_config,
             )
             .await,
         );
@@ -309,18 +310,20 @@ pub async fn initialize_usb_split_central_and_run<
     };
 
     #[cfg(all(not(feature = "_nrf_ble"), feature = "_no_external_storage"))]
-    let keymap = RefCell::new(KeyMap::<TOTAL_ROW, TOTAL_COL, NUM_LAYER>::new(default_keymap).await);
+    let keymap = RefCell::new(
+        KeyMap::<TOTAL_ROW, TOTAL_COL, NUM_LAYER>::new(
+            default_keymap,
+            keyboard_config.behavior_config,
+        )
+        .await,
+    );
 
     let keyboard_report_sender = KEYBOARD_REPORT_CHANNEL.sender();
     let keyboard_report_receiver = KEYBOARD_REPORT_CHANNEL.receiver();
 
     // Create keyboard services and devices
     let (mut keyboard, mut usb_device, mut vial_service, mut light_service) = (
-        Keyboard::new(
-            &keymap,
-            &keyboard_report_sender,
-            keyboard_config.behavior_config,
-        ),
+        Keyboard::new(&keymap, &keyboard_report_sender),
         KeyboardUsbDevice::new(usb_driver, keyboard_config.usb_config),
         VialService::new(&keymap, keyboard_config.vial_config),
         LightService::from_config(keyboard_config.light_config),
diff --git a/rmk/src/storage/mod.rs b/rmk/src/storage/mod.rs
index 3a80bd50..9c1e1d68 100644
--- a/rmk/src/storage/mod.rs
+++ b/rmk/src/storage/mod.rs
@@ -1,7 +1,10 @@
 mod eeconfig;
 pub mod nor_flash;
 
-use crate::config::StorageConfig;
+use crate::{
+    combo::{Combo, COMBO_MAX_LENGTH},
+    config::StorageConfig,
+};
 use byteorder::{BigEndian, ByteOrder};
 use core::fmt::Debug;
 use core::ops::Range;
@@ -9,6 +12,7 @@ use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
 use embassy_sync::channel::Channel;
 use embedded_storage::nor_flash::NorFlash;
 use embedded_storage_async::nor_flash::NorFlash as AsyncNorFlash;
+use heapless::Vec;
 use sequential_storage::{
     cache::NoCache,
     map::{fetch_all_items, fetch_item, store_item, SerializationError, Value},
@@ -57,6 +61,8 @@ pub(crate) enum FlashOperationMessage {
     },
     // Current saved connection type
     ConnectionType(u8),
+    // Write combo
+    WriteCombo(ComboData),
 }
 
 #[repr(u32)]
@@ -68,6 +74,7 @@ pub(crate) enum StorageKeys {
     LayoutConfig,
     KeymapKeys,
     MacroData,
+    ComboData,
     ConnectionType,
     #[cfg(feature = "_nrf_ble")]
     ActiveBleProfile = 0xEE,
@@ -85,6 +92,7 @@ impl StorageKeys {
             4 => Some(StorageKeys::LayoutConfig),
             5 => Some(StorageKeys::KeymapKeys),
             6 => Some(StorageKeys::MacroData),
+            7 => Some(StorageKeys::ComboData),
             #[cfg(feature = "_nrf_ble")]
             0xEF => Some(StorageKeys::BleBondInfo),
             _ => None,
@@ -99,6 +107,7 @@ pub(crate) enum StorageData {
     KeymapConfig(EeKeymapConfig),
     KeymapKey(KeymapKey),
     MacroData([u8; MACRO_SPACE_SIZE]),
+    ComboData(ComboData),
     ConnectionType(u8),
     #[cfg(feature = "_nrf_ble")]
     BondInfo(BondInfo),
@@ -118,6 +127,10 @@ pub(crate) fn get_keymap_key<const ROW: usize, const COL: usize, const NUM_LAYER
     (0x1000 + layer * COL * ROW + row * COL + col) as u32
 }
 
+pub(crate) fn get_combo_key(idx: usize) -> u32 {
+    (0x3000 + idx) as u32
+}
+
 impl Value<'_> for StorageData {
     fn serialize_into(&self, buffer: &mut [u8]) -> Result<usize, SerializationError> {
         if buffer.len() < 6 {
@@ -162,6 +175,20 @@ impl Value<'_> for StorageData {
                 buffer[1..MACRO_SPACE_SIZE + 1].copy_from_slice(d);
                 Ok(MACRO_SPACE_SIZE + 1)
             }
+            StorageData::ComboData(combo) => {
+                if buffer.len() < 11 {
+                    return Err(SerializationError::BufferTooSmall);
+                }
+                buffer[0] = StorageKeys::ComboData as u8;
+                for i in 0..4 {
+                    BigEndian::write_u16(
+                        &mut buffer[1 + i * 2..3 + i * 2],
+                        to_via_keycode(combo.actions[i]),
+                    );
+                }
+                BigEndian::write_u16(&mut buffer[9..11], to_via_keycode(combo.output));
+                Ok(11)
+            }
             StorageData::ConnectionType(ty) => {
                 buffer[0] = StorageKeys::ConnectionType as u8;
                 buffer[1] = *ty;
@@ -245,6 +272,22 @@ impl Value<'_> for StorageData {
                     buf.copy_from_slice(&buffer[1..MACRO_SPACE_SIZE + 1]);
                     Ok(StorageData::MacroData(buf))
                 }
+                StorageKeys::ComboData => {
+                    if buffer.len() < 11 {
+                        return Err(SerializationError::InvalidData);
+                    }
+                    let mut actions = [KeyAction::No; 4];
+                    for i in 0..4 {
+                        actions[i] =
+                            from_via_keycode(BigEndian::read_u16(&buffer[1 + i * 2..3 + i * 2]));
+                    }
+                    let output = from_via_keycode(BigEndian::read_u16(&buffer[9..11]));
+                    Ok(StorageData::ComboData(ComboData {
+                        idx: 0,
+                        actions,
+                        output,
+                    }))
+                }
                 StorageKeys::ConnectionType => Ok(StorageData::ConnectionType(buffer[1])),
                 #[cfg(feature = "_nrf_ble")]
                 StorageKeys::BleBondInfo => {
@@ -274,6 +317,9 @@ impl StorageData {
                 panic!("To get storage key for KeymapKey, use `get_keymap_key` instead");
             }
             StorageData::MacroData(_) => StorageKeys::MacroData as u32,
+            StorageData::ComboData(_) => {
+                panic!("To get combo key for ComboData, use `get_combo_key` instead");
+            }
             StorageData::ConnectionType(_) => StorageKeys::ConnectionType as u32,
             #[cfg(feature = "_nrf_ble")]
             StorageData::BondInfo(b) => get_bond_info_key(b.slot_num),
@@ -304,6 +350,14 @@ pub(crate) struct KeymapKey {
     action: KeyAction,
 }
 
+#[derive(Clone, Copy, Debug)]
+#[cfg_attr(feature = "defmt", derive(defmt::Format))]
+pub(crate) struct ComboData {
+    pub(crate) idx: usize,
+    pub(crate) actions: [KeyAction; COMBO_MAX_LENGTH],
+    pub(crate) output: KeyAction,
+}
+
 pub(crate) struct Storage<
     F: AsyncNorFlash,
     const ROW: usize,
@@ -497,6 +551,18 @@ impl<F: AsyncNorFlash, const ROW: usize, const COL: usize, const NUM_LAYER: usiz
                     )
                     .await
                 }
+                FlashOperationMessage::WriteCombo(combo) => {
+                    let key = get_combo_key(combo.idx);
+                    store_item(
+                        &mut self.flash,
+                        self.storage_range.clone(),
+                        &mut storage_cache,
+                        &mut self.buffer,
+                        &key,
+                        &StorageData::ComboData(combo),
+                    )
+                    .await
+                }
                 FlashOperationMessage::ConnectionType(ty) => {
                     store_item(
                         &mut self.flash,
@@ -612,6 +678,31 @@ impl<F: AsyncNorFlash, const ROW: usize, const COL: usize, const NUM_LAYER: usiz
         Ok(())
     }
 
+    pub(crate) async fn read_combos(&mut self, combos: &mut [Combo]) -> Result<(), ()> {
+        for i in 0..combos.len() {
+            let key = get_combo_key(i);
+            let read_data = fetch_item::<u32, StorageData, _>(
+                &mut self.flash,
+                self.storage_range.clone(),
+                &mut NoCache::new(),
+                &mut self.buffer,
+                &key,
+            )
+            .await
+            .map_err(|e| print_storage_error::<F>(e))?;
+
+            if let Some(StorageData::ComboData(combo)) = read_data {
+                let mut actions = Vec::<_, 4>::new();
+                for &action in combo.actions.iter().filter(|&&a| a != KeyAction::No) {
+                    let _ = actions.push(action);
+                }
+                combos[i] = Combo::new(actions, combo.output, combos[i].layer);
+            }
+        }
+
+        Ok(())
+    }
+
     async fn initialize_storage_with_config(
         &mut self,
         keymap: &[[[KeyAction; COL]; ROW]; NUM_LAYER],
diff --git a/rmk/src/via/keycode_convert.rs b/rmk/src/via/keycode_convert.rs
index fa4ff86a..f01d1468 100644
--- a/rmk/src/via/keycode_convert.rs
+++ b/rmk/src/via/keycode_convert.rs
@@ -15,6 +15,8 @@ pub(crate) fn to_via_keycode(key_action: KeyAction) -> u16 {
                     k as u16 & 0xFF | 0x7700
                 } else if k.is_user() {
                     k as u16 & 0xF | 0x7E00
+                } else if k.is_combo() {
+                    k as u16 & 0xF | 0x7C50
                 } else {
                     k as u16
                 }
@@ -153,6 +155,7 @@ pub(crate) fn from_via_keycode(via_keycode: u16) -> KeyAction {
             KeyAction::No
         }
         0x7700..=0x770F => {
+            // Macro
             let keycode = via_keycode & 0xFF | 0x500;
             KeyAction::Single(Action::Key(KeyCode::from_primitive(keycode)))
         }
@@ -161,6 +164,11 @@ pub(crate) fn from_via_keycode(via_keycode: u16) -> KeyAction {
             warn!("Backlight and RGB configuration key not supported");
             KeyAction::No
         }
+        0x7C50..=0x7C52 => {
+            // Combo
+            let keycode = via_keycode & 0xFF | 0x750;
+            KeyAction::Single(Action::Key(KeyCode::from_primitive(keycode)))
+        }
         0x7C00..=0x7C5F => {
             // TODO: Reset/GESC/Space Cadet/Haptic/Auto shift(AS)/Dynamic macro
             // - [GESC](https://docs.qmk.fm/#/feature_grave_esc)
@@ -319,6 +327,13 @@ mod test {
             ),
             from_via_keycode(via_keycode)
         );
+
+        // ComboOff
+        let via_keycode = 0x7C51;
+        assert_eq!(
+            KeyAction::Single(Action::Key(KeyCode::ComboOff)),
+            from_via_keycode(via_keycode)
+        );
     }
 
     #[test]
@@ -408,5 +423,9 @@ mod test {
             ModifierCombination::new_from(false, false, true, true, true),
         );
         assert_eq!(0x2704, to_via_keycode(a));
+
+        // ComboOff
+        let a = KeyAction::Single(Action::Key(KeyCode::ComboOff));
+        assert_eq!(0x7C51, to_via_keycode(a));
     }
 }
diff --git a/rmk/src/via/process.rs b/rmk/src/via/process.rs
index a63cba12..02b82c6e 100644
--- a/rmk/src/via/process.rs
+++ b/rmk/src/via/process.rs
@@ -1,6 +1,6 @@
 use super::{protocol::*, vial::process_vial};
-use crate::config::VialConfig;
 use crate::{
+    config::VialConfig,
     hid::{HidError, HidReaderWriterWrapper},
     keyboard_macro::{MACRO_SPACE_SIZE, NUM_MACRO},
     keymap::KeyMap,
@@ -313,12 +313,15 @@ impl<'a, const ROW: usize, const COL: usize, const NUM_LAYER: usize>
             ViaCommand::DynamicKeymapSetEncoder => {
                 warn!("Keymap set encoder -- not supported");
             }
-            ViaCommand::Vial => process_vial(
-                report,
-                self.vial_config.vial_keyboard_id,
-                self.vial_config.vial_keyboard_def,
-                keymap,
-            ),
+            ViaCommand::Vial => {
+                process_vial(
+                    report,
+                    self.vial_config.vial_keyboard_id,
+                    self.vial_config.vial_keyboard_def,
+                    keymap,
+                )
+                .await
+            }
             ViaCommand::Unhandled => {
                 info!("Unknown cmd: {}", report.output_data);
                 report.input_data[0] = ViaCommand::Unhandled as u8
diff --git a/rmk/src/via/vial.rs b/rmk/src/via/vial.rs
index 2f0b9453..7e49b2aa 100644
--- a/rmk/src/via/vial.rs
+++ b/rmk/src/via/vial.rs
@@ -3,12 +3,20 @@ use core::cell::RefCell;
 use byteorder::{ByteOrder, LittleEndian};
 use num_enum::FromPrimitive;
 
-use crate::{keymap::KeyMap, usb::descriptor::ViaReport};
+use crate::{
+    action::KeyAction,
+    combo::{Combo, COMBO_MAX_NUM},
+    keymap::KeyMap,
+    storage::{ComboData, FlashOperationMessage, FLASH_CHANNEL},
+    usb::descriptor::ViaReport,
+    via::keycode_convert::{from_via_keycode, to_via_keycode},
+};
 
+/// Vial communication commands. Check [vial-qmk/quantum/vial.h`](https://github.com/vial-kb/vial-qmk/blob/20d61fcb373354dc17d6ecad8f8176be469743da/quantum/vial.h#L36)
 #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, FromPrimitive)]
 #[cfg_attr(feature = "defmt", derive(defmt::Format))]
 #[repr(u8)]
-enum VialCommand {
+pub(crate) enum VialCommand {
     GetKeyboardId = 0x00,
     GetSize = 0x01,
     GetKeyboardDef = 0x02,
@@ -27,15 +35,31 @@ enum VialCommand {
     Unhandled = 0xFF,
 }
 
+/// Vial dynamic commands. Check [vial-qmk/quantum/vial.h`](https://github.com/vial-kb/vial-qmk/blob/20d61fcb373354dc17d6ecad8f8176be469743da/quantum/vial.h#L53)
+#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, FromPrimitive)]
+#[repr(u8)]
+pub(crate) enum VialDynamic {
+    DynamicVialGetNumberOfEntries = 0x00,
+    DynamicVialTapDanceGet = 0x01,
+    DynamicVialTapDanceSet = 0x02,
+    DynamicVialComboGet = 0x03,
+    DynamicVialComboSet = 0x04,
+    DynamicVialKeyOverrideGet = 0x05,
+    DynamicVialKeyOverrideSet = 0x06,
+    #[num_enum(default)]
+    Unhandled = 0xFF,
+}
+
 const VIAL_PROTOCOL_VERSION: u32 = 6;
 const VIAL_EP_SIZE: usize = 32;
+const VIAL_COMBO_MAX_LENGTH: usize = 4;
 
 /// Note: vial uses litte endian, while via uses big endian
-pub(crate) fn process_vial<'a, const ROW: usize, const COL: usize, const NUM_LAYER: usize>(
+pub(crate) async fn process_vial<'a, const ROW: usize, const COL: usize, const NUM_LAYER: usize>(
     report: &mut ViaReport,
     vial_keyboard_Id: &[u8],
     vial_keyboard_def: &[u8],
-    _keymap: &RefCell<KeyMap<'a, ROW, COL, NUM_LAYER>>,
+    keymap: &RefCell<KeyMap<'a, ROW, COL, NUM_LAYER>>,
 ) {
     // report.output_data[0] == 0xFE -> vial commands
     let vial_command = VialCommand::from_primitive(report.output_data[1]);
@@ -86,7 +110,95 @@ pub(crate) fn process_vial<'a, const ROW: usize, const COL: usize, const NUM_LAY
             report.input_data.fill(0xFF);
         }
         VialCommand::DynamicEntryOp => {
-            report.input_data.fill(0x00);
+            let vial_dynamic = VialDynamic::from_primitive(report.output_data[2]);
+            match vial_dynamic {
+                VialDynamic::DynamicVialGetNumberOfEntries => {
+                    debug!("DynamicEntryOp - DynamicVialGetNumberOfEntries");
+                    // TODO: Support dynamic tap dance
+                    report.input_data[0] = 0; // Tap dance entries
+                    report.input_data[1] = 8; // Combo entries
+                                              // TODO: Support dynamic key override
+                    report.input_data[2] = 0; // Key override entries
+                }
+                VialDynamic::DynamicVialTapDanceGet => {
+                    warn!("DynamicEntryOp - DynamicVialTapDanceGet -- to be implemented");
+                    report.input_data.fill(0x00);
+                }
+                VialDynamic::DynamicVialTapDanceSet => {
+                    warn!("DynamicEntryOp - DynamicVialTapDanceSet -- to be implemented");
+                    report.input_data.fill(0x00);
+                }
+                VialDynamic::DynamicVialComboGet => {
+                    debug!("DynamicEntryOp - DynamicVialComboGet");
+                    report.input_data[0] = 0; // Index 0 is the return code, 0 means success
+
+                    let combo_idx = report.output_data[3] as usize;
+                    let combos = &keymap.borrow().combos;
+                    if let Some((_, combo)) = vial_combo(combos, combo_idx) {
+                        for i in 0..4 {
+                            LittleEndian::write_u16(
+                                &mut report.input_data[1 + i * 2..3 + i * 2],
+                                to_via_keycode(*combo.actions.get(i).unwrap_or(&KeyAction::No)),
+                            );
+                        }
+                        LittleEndian::write_u16(
+                            &mut report.input_data[9..11],
+                            to_via_keycode(combo.output),
+                        );
+                    } else {
+                        report.input_data[1..11].fill(0);
+                    }
+                }
+                VialDynamic::DynamicVialComboSet => {
+                    debug!("DynamicEntryOp - DynamicVialComboSet");
+                    report.input_data[0] = 0; // Index 0 is the return code, 0 means success
+
+                    let combo_idx = report.output_data[3] as usize;
+                    let combos = &mut keymap.borrow_mut().combos;
+                    let Some((real_idx, combo)) = vial_combo_mut(combos, combo_idx) else {
+                        return;
+                    };
+
+                    let mut actions = heapless::Vec::new();
+                    for i in 0..4 {
+                        let action = from_via_keycode(LittleEndian::read_u16(
+                            &report.output_data[4 + i * 2..6 + i * 2],
+                        ));
+                        if action != KeyAction::No {
+                            let _ = actions.push(action);
+                        }
+                    }
+                    let output =
+                        from_via_keycode(LittleEndian::read_u16(&report.output_data[12..14]));
+
+                    combo.actions = actions;
+                    combo.output = output;
+
+                    let mut actions = [KeyAction::No; 4];
+                    for (i, &action) in combo.actions.iter().enumerate() {
+                        actions[i] = action;
+                    }
+                    FLASH_CHANNEL
+                        .send(FlashOperationMessage::WriteCombo(ComboData {
+                            idx: real_idx,
+                            actions,
+                            output,
+                        }))
+                        .await;
+                }
+                VialDynamic::DynamicVialKeyOverrideGet => {
+                    warn!("DynamicEntryOp - DynamicVialKeyOverrideGet -- to be implemented");
+                    report.input_data.fill(0x00);
+                }
+                VialDynamic::DynamicVialKeyOverrideSet => {
+                    warn!("DynamicEntryOp - DynamicVialKeyOverrideSet -- to be implemented");
+                    report.input_data.fill(0x00);
+                }
+                VialDynamic::Unhandled => {
+                    warn!("DynamicEntryOp - Unhandled -- subcommand not recognized");
+                    report.input_data.fill(0x00);
+                }
+            }
         }
         VialCommand::GetEncoder => {
             let layer = report.output_data[2];
@@ -141,3 +253,21 @@ pub(crate) fn process_vial<'a, const ROW: usize, const COL: usize, const NUM_LAY
         _ => (),
     }
 }
+
+fn vial_combo(combos: &[Combo; COMBO_MAX_NUM], idx: usize) -> Option<(usize, &Combo)> {
+    combos
+        .iter()
+        .enumerate()
+        .filter(|(_, combo)| combo.actions.len() <= VIAL_COMBO_MAX_LENGTH)
+        .enumerate()
+        .find_map(|(i, combo)| (i == idx).then_some(combo))
+}
+
+fn vial_combo_mut(combos: &mut [Combo; COMBO_MAX_NUM], idx: usize) -> Option<(usize, &mut Combo)> {
+    combos
+        .iter_mut()
+        .enumerate()
+        .filter(|(_, combo)| combo.actions.len() <= VIAL_COMBO_MAX_LENGTH)
+        .enumerate()
+        .find_map(|(i, combo)| (i == idx).then_some(combo))
+}


Test patch: