diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml
new file mode 100644
index 000000000..2cd3ba5d7
--- /dev/null
+++ b/.github/workflows/rust.yml
@@ -0,0 +1,29 @@
+name: Rust
+
+on:
+  push:
+    branches: [master]
+  pull_request:
+    branches: [master]
+  merge_group:
+
+env:
+  CARGO_TERM_COLOR: always
+
+jobs:
+  build-nightly:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v2
+      - uses: actions/cache@v2
+        with:
+          path: |
+            ~/.cargo/registry
+            ~/.cargo/git
+            target
+          key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
+
+      - name: Check fmt
+        run: cargo fmt -- --check
+      - name: Build
+        run: ./ci.sh
diff --git a/Cargo.toml b/Cargo.toml
index 3bdeb0cfb..a307a6cc3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -26,3 +26,9 @@ futures = { version = "0.3.17", default-features = false, features = ["async-awa
 
 embedded-hal-1 = { package = "embedded-hal", version = "1.0.0-alpha.9" }
 num_enum = { version = "0.5.7", default-features = false }
+
+[patch.crates-io]
+embassy-time = { git = "https://github.com/embassy-rs/embassy", rev = "e3f8020c3bdf726dfa451b5b190f27191507a18f" }
+embassy-futures = { git = "https://github.com/embassy-rs/embassy", rev = "e3f8020c3bdf726dfa451b5b190f27191507a18f" }
+embassy-sync = { git = "https://github.com/embassy-rs/embassy", rev = "e3f8020c3bdf726dfa451b5b190f27191507a18f" }
+embassy-net-driver-channel = { git = "https://github.com/embassy-rs/embassy", rev = "e3f8020c3bdf726dfa451b5b190f27191507a18f" }
diff --git a/ci.sh b/ci.sh
new file mode 100755
index 000000000..1b33564fb
--- /dev/null
+++ b/ci.sh
@@ -0,0 +1,18 @@
+#!/bin/bash
+
+set -euxo pipefail
+
+# build examples
+#==================
+
+(cd examples/rpi-pico-w; WIFI_NETWORK=foo WIFI_PASSWORD=bar cargo build --release)
+
+
+# build with log/defmt combinations
+#=====================================
+
+cargo build --target thumbv6m-none-eabi --features ''
+cargo build --target thumbv6m-none-eabi --features 'log'
+cargo build --target thumbv6m-none-eabi --features 'defmt'
+cargo build --target thumbv6m-none-eabi --features 'log,firmware-logs'
+cargo build --target thumbv6m-none-eabi --features 'defmt,firmware-logs'
diff --git a/src/bus.rs b/src/bus.rs
index f77b890df..7700a832a 100644
--- a/src/bus.rs
+++ b/src/bus.rs
@@ -49,30 +49,30 @@ where
 
         while self
             .read32_swapped(REG_BUS_TEST_RO)
-            .inspect(|v| defmt::trace!("{:#x}", v))
+            .inspect(|v| trace!("{:#x}", v))
             .await
             != FEEDBEAD
         {}
 
         self.write32_swapped(REG_BUS_TEST_RW, TEST_PATTERN).await;
         let val = self.read32_swapped(REG_BUS_TEST_RW).await;
-        defmt::trace!("{:#x}", val);
+        trace!("{:#x}", val);
         assert_eq!(val, TEST_PATTERN);
 
         let val = self.read32_swapped(REG_BUS_CTRL).await;
-        defmt::trace!("{:#010b}", (val & 0xff));
+        trace!("{:#010b}", (val & 0xff));
 
         // 32-bit word length, little endian (which is the default endianess).
         self.write32_swapped(REG_BUS_CTRL, WORD_LENGTH_32 | HIGH_SPEED).await;
 
         let val = self.read8(FUNC_BUS, REG_BUS_CTRL).await;
-        defmt::trace!("{:#b}", val);
+        trace!("{:#b}", val);
 
         let val = self.read32(FUNC_BUS, REG_BUS_TEST_RO).await;
-        defmt::trace!("{:#x}", val);
+        trace!("{:#x}", val);
         assert_eq!(val, FEEDBEAD);
         let val = self.read32(FUNC_BUS, REG_BUS_TEST_RW).await;
-        defmt::trace!("{:#x}", val);
+        trace!("{:#x}", val);
         assert_eq!(val, TEST_PATTERN);
     }
 
diff --git a/src/control.rs b/src/control.rs
index 7f1c9fe86..8bfa033ba 100644
--- a/src/control.rs
+++ b/src/control.rs
@@ -9,6 +9,7 @@ use embassy_time::{Duration, Timer};
 pub use crate::bus::SpiBusCyw43;
 use crate::consts::*;
 use crate::events::{Event, EventQueue};
+use crate::fmt::Bytes;
 use crate::structs::*;
 use crate::{countries, IoctlState, IoctlType, PowerManagementMode};
 
@@ -75,7 +76,7 @@ impl<'a> Control<'a> {
         // read MAC addr.
         let mut mac_addr = [0; 6];
         assert_eq!(self.get_iovar("cur_etheraddr", &mut mac_addr).await, 6);
-        info!("mac addr: {:02x}", mac_addr);
+        info!("mac addr: {:02x}", Bytes(&mac_addr));
 
         let country = countries::WORLD_WIDE_XX;
         let country_info = CountryInfo {
@@ -205,7 +206,7 @@ impl<'a> Control<'a> {
             let msg = subscriber.next_message_pure().await;
             if msg.event_type == Event::AUTH && msg.status != 0 {
                 // retry
-                defmt::warn!("JOIN failed with status={}", msg.status);
+                warn!("JOIN failed with status={}", msg.status);
                 self.ioctl(IoctlType::Set, 26, 0, &mut i.to_bytes()).await;
             } else if msg.event_type == Event::JOIN && msg.status == 0 {
                 // successful join
@@ -241,7 +242,7 @@ impl<'a> Control<'a> {
     }
 
     async fn set_iovar(&mut self, name: &str, val: &[u8]) {
-        info!("set {} = {:02x}", name, val);
+        info!("set {} = {:02x}", name, Bytes(val));
 
         let mut buf = [0; 64];
         buf[..name.len()].copy_from_slice(name.as_bytes());
diff --git a/src/events.rs b/src/events.rs
index 9e6bb9625..b9c8cca6b 100644
--- a/src/events.rs
+++ b/src/events.rs
@@ -6,7 +6,7 @@ use core::num;
 use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
 use embassy_sync::pubsub::{PubSubChannel, Publisher, Subscriber};
 
-#[derive(Clone, Copy, PartialEq, Eq, num_enum::FromPrimitive)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, num_enum::FromPrimitive)]
 #[cfg_attr(feature = "defmt", derive(defmt::Format))]
 #[repr(u8)]
 pub enum Event {
diff --git a/src/fmt.rs b/src/fmt.rs
index f8bb0a035..5730447b3 100644
--- a/src/fmt.rs
+++ b/src/fmt.rs
@@ -1,6 +1,8 @@
 #![macro_use]
 #![allow(unused_macros)]
 
+use core::fmt::{Debug, Display, LowerHex};
+
 #[cfg(all(feature = "defmt", feature = "log"))]
 compile_error!("You may not enable both `defmt` and `log` features.");
 
@@ -226,3 +228,30 @@ impl<T, E> Try for Result<T, E> {
         self
     }
 }
+
+pub struct Bytes<'a>(pub &'a [u8]);
+
+impl<'a> Debug for Bytes<'a> {
+    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+        write!(f, "{:#02x?}", self.0)
+    }
+}
+
+impl<'a> Display for Bytes<'a> {
+    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+        write!(f, "{:#02x?}", self.0)
+    }
+}
+
+impl<'a> LowerHex for Bytes<'a> {
+    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+        write!(f, "{:#02x?}", self.0)
+    }
+}
+
+#[cfg(feature = "defmt")]
+impl<'a> defmt::Format for Bytes<'a> {
+    fn format(&self, fmt: defmt::Formatter) {
+        defmt::write!(fmt, "{:02x}", self.0)
+    }
+}
diff --git a/src/runner.rs b/src/runner.rs
index 5d840bc59..9945af3fc 100644
--- a/src/runner.rs
+++ b/src/runner.rs
@@ -11,6 +11,7 @@ use crate::bus::Bus;
 pub use crate::bus::SpiBusCyw43;
 use crate::consts::*;
 use crate::events::{EventQueue, EventStatus};
+use crate::fmt::Bytes;
 use crate::nvram::NVRAM;
 use crate::structs::*;
 use crate::{events, Core, IoctlState, IoctlType, CHIP, MTU};
@@ -23,6 +24,7 @@ struct LogState {
     buf_count: usize,
 }
 
+#[cfg(feature = "firmware-logs")]
 impl Default for LogState {
     fn default() -> Self {
         Self {
@@ -175,7 +177,6 @@ where
         let mut shared = [0; SharedMemData::SIZE];
         self.bus.bp_read(shared_addr, &mut shared).await;
         let shared = SharedMemData::from_bytes(&shared);
-        info!("shared: {:08x}", shared);
 
         self.log.addr = shared.console_addr + 8;
     }
@@ -238,7 +239,7 @@ where
                     warn!("TX stalled");
                 } else {
                     if let Some(packet) = self.ch.try_tx_buf() {
-                        trace!("tx pkt {:02x}", &packet[..packet.len().min(48)]);
+                        trace!("tx pkt {:02x}", Bytes(&packet[..packet.len().min(48)]));
 
                         let mut buf = [0; 512];
                         let buf8 = slice8_mut(&mut buf);
@@ -275,7 +276,7 @@ where
 
                         let total_len = (total_len + 3) & !3; // round up to 4byte
 
-                        trace!("    {:02x}", &buf8[..total_len.min(48)]);
+                        trace!("    {:02x}", Bytes(&buf8[..total_len.min(48)]));
 
                         self.bus.wlan_write(&buf[..(total_len / 4)]).await;
                         self.ch.tx_done();
@@ -295,7 +296,7 @@ where
                 if status & STATUS_F2_PKT_AVAILABLE != 0 {
                     let len = (status & STATUS_F2_PKT_LEN_MASK) >> STATUS_F2_PKT_LEN_SHIFT;
                     self.bus.wlan_read(&mut buf, len).await;
-                    trace!("rx {:02x}", &slice8_mut(&mut buf)[..(len as usize).min(48)]);
+                    trace!("rx {:02x}", Bytes(&slice8_mut(&mut buf)[..(len as usize).min(48)]));
                     self.rx(&slice8_mut(&mut buf)[..len as usize]);
                 }
             }
@@ -343,11 +344,11 @@ where
                     if cdc_header.id == self.ioctl_id {
                         if cdc_header.status != 0 {
                             // TODO: propagate error instead
-                            panic!("IOCTL error {=i32}", cdc_header.status as i32);
+                            panic!("IOCTL error {}", cdc_header.status as i32);
                         }
 
                         let resp_len = cdc_header.len as usize;
-                        info!("IOCTL Response: {:02x}", &payload[CdcHeader::SIZE..][..resp_len]);
+                        info!("IOCTL Response: {:02x}", Bytes(&payload[CdcHeader::SIZE..][..resp_len]));
 
                         (unsafe { &mut *buf }[..resp_len]).copy_from_slice(&payload[CdcHeader::SIZE..][..resp_len]);
                         self.ioctl_state.set(IoctlState::Done { resp_len });
@@ -365,7 +366,7 @@ where
                     return;
                 }
                 let bcd_packet = &payload[packet_start..];
-                trace!("    {:02x}", &bcd_packet[..(bcd_packet.len() as usize).min(36)]);
+                trace!("    {:02x}", Bytes(&bcd_packet[..(bcd_packet.len() as usize).min(36)]));
 
                 let mut event_packet = EventPacket::from_bytes(&bcd_packet[..EventPacket::SIZE].try_into().unwrap());
                 event_packet.byteswap();
@@ -382,7 +383,8 @@ where
                 if event_packet.hdr.oui != BROADCOM_OUI {
                     warn!(
                         "unexpected ethernet OUI {:02x}, expected Broadcom OUI {:02x}",
-                        event_packet.hdr.oui, BROADCOM_OUI
+                        Bytes(&event_packet.hdr.oui),
+                        Bytes(BROADCOM_OUI)
                     );
                     return;
                 }
@@ -405,7 +407,12 @@ where
 
                 let evt_type = events::Event::from(event_packet.msg.event_type as u8);
                 let evt_data = &bcd_packet[EventMessage::SIZE..][..event_packet.msg.datalen as usize];
-                debug!("=== EVENT {}: {} {:02x}", evt_type, event_packet.msg, evt_data);
+                debug!(
+                    "=== EVENT {:?}: {:?} {:02x}",
+                    evt_type,
+                    event_packet.msg,
+                    Bytes(evt_data)
+                );
 
                 if evt_type == events::Event::AUTH || evt_type == events::Event::JOIN {
                     self.events.publish_immediate(EventStatus {
@@ -424,7 +431,7 @@ where
                     return;
                 }
                 let packet = &payload[packet_start..];
-                trace!("rx pkt {:02x}", &packet[..(packet.len() as usize).min(48)]);
+                trace!("rx pkt {:02x}", Bytes(&packet[..(packet.len() as usize).min(48)]));
 
                 match self.ch.try_rx_buf() {
                     Some(buf) => {
@@ -490,7 +497,7 @@ where
 
         let total_len = (total_len + 3) & !3; // round up to 4byte
 
-        trace!("    {:02x}", &buf8[..total_len.min(48)]);
+        trace!("    {:02x}", Bytes(&buf8[..total_len.min(48)]));
 
         self.bus.wlan_write(&buf[..total_len / 4]).await;
     }
diff --git a/src/structs.rs b/src/structs.rs
index 41a340661..e16808f30 100644
--- a/src/structs.rs
+++ b/src/structs.rs
@@ -44,7 +44,7 @@ pub struct SharedMemLog {
 }
 impl_bytes!(SharedMemLog);
 
-#[derive(Clone, Copy)]
+#[derive(Debug, Clone, Copy)]
 #[cfg_attr(feature = "defmt", derive(defmt::Format))]
 #[repr(C)]
 pub struct SdpcmHeader {
@@ -67,7 +67,7 @@ pub struct SdpcmHeader {
 }
 impl_bytes!(SdpcmHeader);
 
-#[derive(Clone, Copy)]
+#[derive(Debug, Clone, Copy)]
 #[cfg_attr(feature = "defmt", derive(defmt::Format))]
 #[repr(C)]
 pub struct CdcHeader {
@@ -82,7 +82,7 @@ impl_bytes!(CdcHeader);
 pub const BDC_VERSION: u8 = 2;
 pub const BDC_VERSION_SHIFT: u8 = 4;
 
-#[derive(Clone, Copy)]
+#[derive(Debug, Clone, Copy)]
 #[cfg_attr(feature = "defmt", derive(defmt::Format))]
 #[repr(C)]
 pub struct BcdHeader {
@@ -129,7 +129,7 @@ impl EventHeader {
     }
 }
 
-#[derive(Clone, Copy)]
+#[derive(Debug, Clone, Copy)]
 #[cfg_attr(feature = "defmt", derive(defmt::Format))]
 #[repr(C)]
 pub struct EventMessage {