diff --git a/Cargo.lock b/Cargo.lock
index 4b5279289df6675a9bafe867109c4ca545d16c15..d5f837259f10a0f550c6d04c148976b1f56d3efd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2854,6 +2854,7 @@ dependencies = [
  "sunset-demo-embassy-common",
  "sunset-embassy",
  "sunset-sshwire-derive",
+ "usbd-hid",
 ]
 
 [[package]]
diff --git a/embassy/demos/picow/Cargo.toml b/embassy/demos/picow/Cargo.toml
index 32406027ab3bfebb7bc56f22c074ef008dbf07f4..1335c7e94c30076ee0eb351ca62235d1863705cb 100644
--- a/embassy/demos/picow/Cargo.toml
+++ b/embassy/demos/picow/Cargo.toml
@@ -61,6 +61,8 @@ sha2 = { version = "0.10", default-features = false }
 # for defmt feature
 smoltcp = { version = "0.10", default-features = false }
 
+usbd-hid = "0.6"
+
 [features]
 default = ["cyw43", "defmt", "sunset-demo-embassy-common/defmt" ]
 defmt = ["dep:defmt", "sunset/defmt", "sunset-embassy/defmt", "smoltcp/defmt"]
diff --git a/embassy/demos/picow/src/keyboard.rs b/embassy/demos/picow/src/keyboard.rs
new file mode 100644
index 0000000000000000000000000000000000000000..6e11df943fc91a75501c6cbdc36d2ca24b15fee1
--- /dev/null
+++ b/embassy/demos/picow/src/keyboard.rs
@@ -0,0 +1,52 @@
+#[allow(unused_imports)]
+#[cfg(not(feature = "defmt"))]
+pub use log::{debug, error, info, log, trace, warn};
+
+#[allow(unused_imports)]
+#[cfg(feature = "defmt")]
+pub use defmt::{debug, error, info, panic, trace, warn};
+
+use embassy_futures::join::join;
+use embassy_usb::class::hid::{HidReaderWriter, ReportId, RequestHandler};
+use embassy_usb::control::OutResponse;
+use embassy_usb_driver::Driver;
+
+use crate::*;
+
+pub(crate) async fn run<'a, D: Driver<'a>>(
+    _global: &'static GlobalState,
+    hid: HidReaderWriter<'a, D, 1, 8>,
+) -> ! {
+    let (reader, _writer) = hid.split();
+    let keyb_fut = async {
+        loop {
+            todo!();
+        }
+    };
+
+    let handler = Handler;
+    let control_fut = reader.run(false, &handler);
+
+    join(keyb_fut, control_fut).await;
+    unreachable!()
+}
+
+
+struct Handler;
+
+impl RequestHandler for Handler {
+    fn get_report(&self, _id: ReportId, _buf: &mut [u8]) -> Option<usize> {
+        None
+    }
+
+    fn set_report(&self, _id: ReportId, _data: &[u8]) -> OutResponse {
+        OutResponse::Accepted
+    }
+
+    fn set_idle_ms(&self, _id: Option<ReportId>, _dur: u32) {
+    }
+
+    fn get_idle_ms(&self, _id: Option<ReportId>) -> Option<u32> {
+        None
+    }
+}
diff --git a/embassy/demos/picow/src/main.rs b/embassy/demos/picow/src/main.rs
index b657ac3bef523877f016c0963fdb833ab33d11ae..2398d927ff891bed821d734716f35b400fb80927 100644
--- a/embassy/demos/picow/src/main.rs
+++ b/embassy/demos/picow/src/main.rs
@@ -37,7 +37,8 @@ mod flashconfig;
 mod picowmenu;
 mod serial;
 mod takepipe;
-mod usbserial;
+mod usb;
+mod keyboard;
 #[cfg(feature = "w5500")]
 mod w5500;
 #[cfg(feature = "cyw43")]
@@ -147,7 +148,7 @@ async fn main(spawner: Spawner) {
     }
 
     // USB task requires `state`
-    spawner.spawn(usbserial::task(p.USB, state)).unwrap();
+    spawner.spawn(usb::task(p.USB, state)).unwrap();
 }
 
 // TODO: pool_size should be NUM_LISTENERS but needs a literal
diff --git a/embassy/demos/picow/src/usbserial.rs b/embassy/demos/picow/src/usb.rs
similarity index 64%
rename from embassy/demos/picow/src/usbserial.rs
rename to embassy/demos/picow/src/usb.rs
index 86bdad728e303590436047dec0b3669b79be5f3c..e158ce77dcd1ffac4aed6214fe858b7433f606e0 100644
--- a/embassy/demos/picow/src/usbserial.rs
+++ b/embassy/demos/picow/src/usb.rs
@@ -6,13 +6,15 @@ pub use log::{debug, error, info, log, trace, warn};
 #[cfg(feature = "defmt")]
 pub use defmt::{debug, error, info, panic, trace, warn};
 
-use embassy_futures::join::{join, join3};
-use embassy_rp::usb::{InterruptHandler};
+use embassy_futures::join::{join, join4};
 use embassy_rp::bind_interrupts;
 use embassy_rp::peripherals::USB;
-use embassy_usb::class::cdc_acm::{self, CdcAcmClass, State};
+use embassy_rp::usb::InterruptHandler;
+use embassy_usb::class::cdc_acm::{self, CdcAcmClass};
+use embassy_usb::class::hid::{self, HidReaderWriter};
 use embassy_usb::Builder;
 use embassy_usb_driver::Driver;
+use usbd_hid::descriptor::{KeyboardReport, SerializedDescriptor};
 
 use embedded_io_async::{Read, Write, BufRead, ErrorType};
 
@@ -29,8 +31,7 @@ bind_interrupts!(struct Irqs {
 pub(crate) async fn task(
     usb: embassy_rp::peripherals::USB,
     global: &'static GlobalState,
-) -> !
-{
+) -> ! {
     let driver = embassy_rp::usb::Driver::new(usb, Irqs);
 
     let mut config = embassy_usb::Config::new(0xf055, 0x6053);
@@ -55,8 +56,9 @@ pub(crate) async fn task(
     let mut control_buf = [0; 64];
 
     // lives longer than builder
-    let mut usb_state0 = State::new();
-    let mut usb_state2 = State::new();
+    let mut usb_state0 = cdc_acm::State::new();
+    let mut usb_state2 = cdc_acm::State::new();
+    let mut usb_state4 = hid::State::new();
 
     let mut builder = Builder::new(
         driver,
@@ -69,75 +71,97 @@ pub(crate) async fn task(
 
     // if00
     let cdc0 = CdcAcmClass::new(&mut builder, &mut usb_state0, 64);
-    let (mut cdc0_tx, mut cdc0_rx) = cdc0.split();
     // if02
     let cdc2 = CdcAcmClass::new(&mut builder, &mut usb_state2, 64);
-    let (mut cdc2_tx, mut cdc2_rx) = cdc2.split();
 
-    let mut usb = builder.build();
+    let hid_config = embassy_usb::class::hid::Config {
+        report_descriptor: KeyboardReport::desc(),
+        request_handler: None,
+        poll_ms: 20,
+        max_packet_size: 64,
+    };
+    let hid =
+        HidReaderWriter::<_, 1, 8>::new(&mut builder, &mut usb_state4, hid_config);
 
+    let mut usb = builder.build();
 
     // Run the USB device.
     let usb_fut = usb.run();
 
     // console via SSH on if00
-    let io0 = async {
-        let (mut chan_rx, mut chan_tx) = global.usb_pipe.split();
-        let chan_rx = &mut chan_rx;
-        let chan_tx = &mut chan_tx;
-        loop {
-            info!("USB waiting");
-            cdc0_rx.wait_connection().await;
-            info!("USB connected");
-            let mut cdc0_tx = CDCWrite::new(&mut cdc0_tx);
-            let mut cdc0_rx = CDCRead::new(&mut cdc0_rx);
-
-            let io_tx = io_buf_copy(&mut cdc0_rx, chan_tx);
-            let io_rx = io_copy::<64, _, _>(chan_rx, &mut cdc0_tx);
-
-            let _ = join(io_rx, io_tx).await;
-            info!("USB disconnected");
-        }
-    };
+    let io0_run = console_if00_run(&global, cdc0);
 
     // Admin menu on if02
-    let setup = async {
-        'usb: loop {
-            cdc2_rx.wait_connection().await;
-            let mut cdc2_tx = CDCWrite::new(&mut cdc2_tx);
-            let mut cdc2_rx = CDCRead::new(&mut cdc2_rx);
-
-            // wait for a keystroke before writing anything.
-            let mut c = [0u8];
-            let _ = cdc2_rx.read_exact(&mut c).await;
-            
-            let p = {
-                let c = global.config.lock().await;
-                c.admin_pw.clone()
-            };
-
-            if let Some(p) = p {
-                'pw: loop {
-                    match request_pw(&mut cdc2_tx, &mut cdc2_rx).await {
-                        Ok(pw) => {
-                            if p.check(&pw) {
-                                let _ = cdc2_tx.write_all(b"Good\r\n").await;
-                                break 'pw
-                            }
+    let io2_run = menu_if02_run(&global, cdc2);
+
+    // keyboard
+    let hid_run = keyboard::run(&global, hid);
+
+    join4(usb_fut, io0_run, io2_run, hid_run).await;
+    unreachable!()
+}
+
+async fn console_if00_run<'a, D: Driver<'a>>(
+    global: &'static GlobalState,
+    cdc: CdcAcmClass<'a, D>,
+) -> ! {
+    let (mut cdc_tx, mut cdc_rx) = cdc.split();
+    let (mut chan_rx, mut chan_tx) = global.usb_pipe.split();
+    let chan_rx = &mut chan_rx;
+    let chan_tx = &mut chan_tx;
+    loop {
+        info!("USB waiting");
+        cdc_rx.wait_connection().await;
+        info!("USB connected");
+        let mut cdc_tx = CDCWrite::new(&mut cdc_tx);
+        let mut cdc_rx = CDCRead::new(&mut cdc_rx);
+
+        let io_tx = io_buf_copy(&mut cdc_rx, chan_tx);
+        let io_rx = io_copy::<64, _, _>(chan_rx, &mut cdc_tx);
+
+        let _ = join(io_rx, io_tx).await;
+        info!("USB disconnected");
+    }
+}
+
+async fn menu_if02_run<'a, D: Driver<'a>>(
+    global: &'static GlobalState,
+    cdc: CdcAcmClass<'a, D>,
+) -> ! {
+    let (mut cdc_tx, mut cdc_rx) = cdc.split();
+    'usb: loop {
+        cdc_rx.wait_connection().await;
+        let mut cdc_tx = CDCWrite::new(&mut cdc_tx);
+        let mut cdc_rx = CDCRead::new(&mut cdc_rx);
+
+        // wait for a keystroke before writing anything.
+        let mut c = [0u8];
+        let _ = cdc_rx.read_exact(&mut c).await;
+
+        let p = {
+            let c = global.config.lock().await;
+            c.admin_pw.clone()
+        };
+
+        if let Some(p) = p {
+            'pw: loop {
+                match request_pw(&mut cdc_tx, &mut cdc_rx).await {
+                    Ok(pw) => {
+                        if p.check(&pw) {
+                            let _ = cdc_tx.write_all(b"Good\r\n").await;
+                            break 'pw;
                         }
-                        Err(_) => continue 'usb
                     }
+                    Err(_) => continue 'usb,
                 }
             }
-
-            let _ = menu(&mut cdc2_rx, &mut cdc2_tx, true, global).await;
         }
-    };
 
-    join3(usb_fut, io0, setup).await;
-    unreachable!()
+        let _ = menu(&mut cdc_rx, &mut cdc_tx, true, global).await;
+    }
 }
 
+// TODO: this could be merged into embassy?
 pub struct CDCRead<'a, 'p, D: Driver<'a>> {
     cdc: &'p mut cdc_acm::Receiver<'a, D>,
     // sufficient for max packet
@@ -164,14 +188,14 @@ impl<'a, D: Driver<'a>> Read for CDCRead<'a, '_, D> {
                 .read_packet(ret)
                 .await
                 .map_err(|_| sunset::Error::ChannelEOF)?;
-            return Ok(n)
+            return Ok(n);
         }
 
         let b = self.fill_buf().await?;
         let n = ret.len().min(b.len());
         (&mut ret[..n]).copy_from_slice(&b[..n]);
         self.consume(n);
-        return Ok(n)
+        return Ok(n);
     }
 }