Skip to content

iTWT (Individual Target Wake Time) support - #5960

Open
main-- wants to merge 7 commits into
esp-rs:mainfrom
main--:feat/itwt-support
Open

iTWT (Individual Target Wake Time) support#5960
main-- wants to merge 7 commits into
esp-rs:mainfrom
main--:feat/itwt-support

Conversation

@main--

@main-- main-- commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Submission Checklist 📝

  • I have updated existing examples or added new ones (if applicable).
  • I have used cargo xtask fmt-packages command to ensure that all changed code is formatted correctly.
  • I have added changelog entries and/or migration guide notes in the sections below, or I will ask a maintainer to add the skip-changelog or manual-changelog label as appropriate.
  • My changes are in accordance to the esp-rs developer guidelines

Extra:

Pull Request Details 📖

Description

Add individual Target Wake Time (iTWT) support for Wi-Fi 6 (802.11ax) chips (ESP32-C5, C6, C61), enabling significant power savings for periodic traffic patterns. After negotiating iTWT with the AP, the entire model will go to sleep and only wake up during the negotiated time windows.

Depends on esp-rs/esp-wifi-sys#505.

Testing

Tested on ESP32-C6.

Measured power consumption in an example that sends a 160 byte UDP packet every 20ms (simulating a VoIP use case):

  • Baseline: 63 mA
  • With Power Save Maximum: 63 mA (no impact because send interval prevents the modem from going to sleep)
  • With iTWT (2ms wakeup every 20ms): 32 mA (modem sleeps 90% of the time, can be optimized further)

Changelog

esp-radio

  • Added: wifi::twt module with iTWT configuration types and constants
  • Added: WifiController methods for iTWT setup, teardown, suspend, probing, and configuration (Wi-Fi 6 chips only)
  • Added: Structured event data for all TWT-related WiFi events
  • Added: TwtFull, TwtSetupTimeout, TwtSetupTxFail, TwtSetupRejected, and TwtSetupFailed variants added to WifiError
  • Changed: wifi_clock_disable OS adapter callback now keeps clocks on during modem-sleep, matching ESP-IDF behavior

esp-phy

  • No changelog necessary.

While Wi-Fi is active, ESP-IDF blocks wifi_clock_disable calls from the blob: https://github.com/espressif/esp-idf/blob/release/v5.5/components/esp_hw_support/modem_clock.c#L77-L85

This is necessary because otherwise hardware-provided timers such as TWT do not fire.
@bjoernQ

bjoernQ commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Depends on esp-rs/esp-wifi-sys#505.

The additional bindings PR got merged so this should become buildable by changing the git revision of the esp-wifi-sys dependencies

@main--
main-- force-pushed the feat/itwt-support branch 4 times, most recently from dde6d48 to eb5d2d3 Compare July 24, 2026 09:00
main-- added 3 commits July 24, 2026 11:19
Add individual Target Wake Time (iTWT) support for Wi-Fi 6 (802.11ax)
chips (ESP32-C5, C6, C61), enabling significant power savings for
periodic traffic patterns.

Examples:
- embassy_twt: comprehensively demonstrates usage of iTWT APIs
- embassy_twt_udp: voice-over-WiFi (160B UDP on 20ms TWT wakeup)
@main--
main-- force-pushed the feat/itwt-support branch from eb5d2d3 to f1dbfa1 Compare July 24, 2026 09:19
Comment thread esp-radio/src/wifi/mod.rs Outdated
@bjoernQ

bjoernQ commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

This is an exciting addition - however the udp example doesn't seem to work for me - maybe "I'm holding it wrong"

I see

Requesting iTWT setup...
iTWT setup OK: ITwtSetupConfig { setup_cmd: Accept, trigger: true, flow_type: Announced, flow_id: Flow1, wake_interval: Duration(20000 µs), wake_duration: Duration(2048 µs), timeout: Duration(0 µs) }
#1: sent 160B voice frame (type=Individual, flow=Flow1)

And nothing more - nothing is received. But the setup looks good, doesn't it?
(The router is a Fritz!Box 5090, tested on ESP32-C6)

@main--

main-- commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Oh no! Here is what I get with a Fritz!Box 7682 and my ESP32-C6:

Requesting iTWT setup...
iTWT setup OK: ITwtSetupConfig { setup_cmd: Accept, trigger: true, flow_type: Announced, flow_id: Flow1, wake_interval: Duration(20000 µs), wake_duration: Duration(2048 µs), timeout: Duration(0 µs) }
#1: sent 160B voice frame (type=Individual, flow=Flow1)
#51: sent 160B voice frame (type=Individual, flow=Flow1)
#101: sent 160B voice frame (type=Individual, flow=Flow1)
#151: sent 160B voice frame (type=Individual, flow=Flow1)
#201: sent 160B voice frame (type=Individual, flow=Flow1)

and so on

I can think of a few possibilities:

  • I have already reported some iTWT-related bugs to AVM, so perhaps the 5090 has some issue preventing this from working
    • seems unlikely, and according to espressif even if the AP fails to send triggers in trigger mode after some timeout the chip should wake up anyways
  • I also found a bug in the wifi blob (Individual Target Wake Time (iTWT) downlink broken (IDFGH-17670) espressif/esp-idf#18623), but it should not prevent this example from running because it only concerns receiving
    • unless maybe you got unlucky and got deauthenticated immediately after starting due to this bug (normally it takes 15-30mins for me)
    • or maybe there is some other bug that you're running into
  • you can try other config options, e.g. .with_trigger(false) and see if that maybe helps

Since you are also using a C6 it shouldn't be a hardware difference, but I originally saw the same problem and it was fixed by 734971d.

And move twt-specific stuff outof the general wifi module
@bjoernQ

bjoernQ commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Did you configure anything special on your Fritz!Box 7682 ? Are you using WPA2+WPA3 (I enabled WPA2 only - not sure if that might make a difference)

(Ah - there was a typo - mine is a 5590 not 5090)

@bjoernQ

bjoernQ commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

When changing wifi_clock_disable to an empty stub I receive at least 4 packets before it stops doing anything 🤷‍♂️

@main--

main-- commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

I am using WPA2+WPA3, and all the settings on default as far as I can see. But in theory, the fact that you are getting a positive setup response should already be a definitive proof that your Fritz!Box can do iTWT and accepts your parameters. Also in my tests esp-hal doesn't even do WPA3 so that's another reason why it shouldn't matter.

If you're hanging on the call to wait_for_next_twt_wakeup, the only reasons I can think of are:

  • some bug in esp-hal is causing the wakeup event to be lost
  • some bug in the wifi blob is causing it to lose the wakeup event
  • some bug in the hardware is causing it not to wake up in the first place

I'm very confused because none of those sound plausible to me.

When changing wifi_clock_disable to an empty stub I receive at least 4 packets before it stops doing anything 🤷‍♂️

By receive you mean receiving the UDP packets on another host, or observing the wakeup events in the logs?

Maybe if you listen in monitor mode you can see what's really going on (maybe deauthentication, maybe AP is tearing down the TWT agreement, etc)

@bjoernQ

bjoernQ commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Yes for now we can't support WPA3 and I agree that accepting the iTWT setup should tell it should work 🤷‍♂️

By receive you mean receiving the UDP packets on another host, or observing the wakeup events in the logs?

Yes - seeing the packets getting received on the other host

@main--

main-- commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Given that I'm not able to reproduce the issue, I have no idea how to proceed here. Do you have the same issue with esp-idf?

@bjoernQ

bjoernQ commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Maybe the esp-idf example also doesn't work correctly - I'm not sure how it should look like if it's working, can you paste your output of that example here?

I asked a colleague to test the PR but also without luck - I'm still trying to check / fix my setup but the esp-idf example output might be helpful for me

@main--

main-- commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Right, the IDF example does not post events or send anything out of the box. Here are my changes that makes it similar to the udp example here:

Diff
diff --git a/examples/wifi/itwt/main/itwt_main.c b/examples/wifi/itwt/main/itwt_main.c
index ae4914067cb..ee417d87eb8 100644
--- a/examples/wifi/itwt/main/itwt_main.c
+++ b/examples/wifi/itwt/main/itwt_main.c
@@ -18,6 +18,9 @@
    start esp32c6 and when it connected to AP it will setup itwt.
 */
 #include <netdb.h>
+#include <inttypes.h>
+#include <sys/socket.h>
+#include <arpa/inet.h>
 #include "freertos/FreeRTOS.h"
 #include "freertos/event_groups.h"
 #include "esp_wifi.h"
@@ -37,6 +40,10 @@
  *******************************************************/
 static const char *TAG = "itwt";
 
+/* UDP target for TWT wakeup packets */
+#define TWT_WAKEUP_UDP_TARGET_IP   "255.255.255.255"
+#define TWT_WAKEUP_UDP_TARGET_PORT 12345
+
 /*******************************************************
  *                Structures
  *******************************************************/
@@ -72,6 +79,10 @@ const int CONNECTED_BIT = BIT0;
 const int DISCONNECTED_BIT = BIT1;
 EventGroupHandle_t wifi_event_group;
 
+static int twt_udp_sock = -1;
+static struct sockaddr_in twt_udp_dest;
+static uint32_t twt_wakeup_counter = 0;
+
 /*******************************************************
  *                Function Declarations
  *******************************************************/
@@ -112,12 +123,44 @@ static const char *itwt_probe_status_to_str(wifi_itwt_probe_status_t status)
     }
 }
 
+static void twt_wakeup_handler(void *arg, esp_event_base_t event_base,
+                               int32_t event_id, void *event_data)
+{
+return;
+    if (twt_udp_sock < 0) {
+        return;
+    }
+    twt_wakeup_counter++;
+    int ret = sendto(twt_udp_sock, &twt_wakeup_counter, sizeof(twt_wakeup_counter), 0,
+                     (struct sockaddr *)&twt_udp_dest, sizeof(twt_udp_dest));
+    if (ret < 0) {
+        ESP_LOGD(TAG, "<TWT_WAKEUP> UDP send failed: errno %d", errno);
+    } else {
+        ESP_LOGI(TAG, "<TWT_WAKEUP> sent UDP pkt #%"PRIu32, twt_wakeup_counter);
+    }
+}
+
 static void got_ip_handler(void *arg, esp_event_base_t event_base,
                            int32_t event_id, void *event_data)
 {
     xEventGroupClearBits(wifi_event_group, DISCONNECTED_BIT);
     xEventGroupSetBits(wifi_event_group, CONNECTED_BIT);
 
+    /* Create UDP socket for TWT wakeup packets */
+    if (twt_udp_sock < 0) {
+        twt_udp_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
+        if (twt_udp_sock < 0) {
+            ESP_LOGE(TAG, "Failed to create UDP socket: errno %d", errno);
+        } else {
+            memset(&twt_udp_dest, 0, sizeof(twt_udp_dest));
+            twt_udp_dest.sin_family = AF_INET;
+            twt_udp_dest.sin_port = htons(TWT_WAKEUP_UDP_TARGET_PORT);
+            inet_aton(TWT_WAKEUP_UDP_TARGET_IP, &twt_udp_dest.sin_addr);
+            ESP_LOGI(TAG, "TWT wakeup UDP socket created, target %s:%d",
+                     TWT_WAKEUP_UDP_TARGET_IP, TWT_WAKEUP_UDP_TARGET_PORT);
+        }
+    }
+
     /* setup a trigger-based announce individual TWT agreement. */
     wifi_phy_mode_t phymode;
     wifi_config_t sta_cfg = { 0, };
@@ -260,6 +303,11 @@ static void wifi_itwt(void)
                     &itwt_probe_handler,
                     NULL,
                     NULL));
+    ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT,
+                    WIFI_EVENT_TWT_WAKEUP,
+                    &twt_wakeup_handler,
+                    NULL,
+                    NULL));
 
     wifi_config_t wifi_config = {
         .sta = {
@@ -271,7 +319,7 @@ static void wifi_itwt(void)
     ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
 
     wifi_twt_config_t wifi_twt_config = {
-        .post_wakeup_event = false,
+        .post_wakeup_event = true,
         .twt_enable_keep_alive = keep_alive_enabled,
     };
     ESP_ERROR_CHECK(esp_wifi_sta_twt_config(&wifi_twt_config));

@bjoernQ

bjoernQ commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Ok this at least indicates something bad I guess

I (5610) esp_netif_handlers: sta ip: 192.168.178.86, mask: 255.255.255.0, gw: 192.168.178.1
I (5611) itwt: TWT wakeup UDP socket created, target 255.255.255.255:12345
E (10654) itwt: <WIFI_EVENT_ITWT_SETUP>twt_id:0, timeout of receiving twt setup response frame`

@main--

main-- commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

It's confusing that IDF gives you a timeout during setup when you were getting a success response before. Are you running the same wifi blob in both cases?

@bjoernQ

bjoernQ commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Yes - it's run from the same commit I took the blobs from

Maybe I should retry the Rust example since my router did an auto-update since then - but I don't expect much to have changed

@bjoernQ

bjoernQ commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Aha - so maybe the update did something - now I also get an error for the Rust example:

Wifi connected: ConnectedInfo { ssid: "FRITZ!Box 5590 YH", bssid: [x, x, x, x, x, x], channel: 6, authmode: Wpa2Personal, aid: 6 }
Got IP: 192.168.178.86/24
Requesting iTWT setup...
iTWT setup FAILED: Failed(ITwtSetupFailedInfo { config: ITwtSetupConfig { setup_cmd: Request, trigger: true, flow_type: Announced, flow_id: Flow0, wake_interval: Duration(20000 µs), wake_duration: Duration(2048 µs), timeout: Duration(0 µs) }, status: 12312, reason: 0 })

@bjoernQ

bjoernQ commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Interestingly - at least with new firmware - I see that with "WPA2" only enabled, by default PMF is disabled, enabling it doesn't help.

"WPA2+WPA3" enabled PMF automatically (and there is no option to disable it anymore)

I also tried a few other config changes which made kind of sense but none of them made a difference.

At least ESP-IDF and the Rust implementation show the same behavior

@main--

main-- commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

This is strange, your Fritz!Box claims to support WiFi 6, so the iTWT setup request should definitely not go unanswered. But if you're getting the same issue with IDF then it looks like at least it's not caused by any problem with the code in this PR. 🤷

Comment thread esp-radio/src/wifi/mod.rs
.subscriber()
.expect("Unable to subscribe to events - consider increasing the internal event channel subscriber count");

let twt_id = NEXT_TWT_ID.fetch_add(1, Ordering::Relaxed);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can overflow and start handing out already in-use IDs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To the best of my knowledge (unfortunately there is no clear documentation on this, but it does not exist on air so it's definitely local to the wifi blob) this ID is only used to match a setup request with the corresponding response event when it comes back from the wifi blob. Then this ID is meaningless and the flow will only be identified by its FlowId.

For a spec-compliant AP I don't think we can send 65536 setup requests before receiving a reply. If I have an AP which does not send any setup replies and I pick a high enough timeout then this might overflow fast enough to in-use IDs (although before this happens the Wifi Blob may even OOM from having too many in-flight setup requests).

And don't forget that there can only be 8 flows in total, so an application will never attempt to allocate sixty-thousand of them in an instant unless it already is in some serious unrecoverable error state.

@bugadani

Copy link
Copy Markdown
Contributor

We shouldn't let this PR rot away, even if we can't test it ourselves. Although I'm slightly worried about useability - is this really something we expect the user to manage? If it is, it is, but in that case the documentation could be a bit more detailed, a bit more guide-like.

Comment thread esp-radio/src/wifi/twt.rs
/// ```
#[derive(BuilderLite, Clone, Copy, PartialEq, Eq, Hash)]
#[instability::unstable]
pub struct ITwtSetupConfig {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Config objects should not have public methods, they should be exclusively constructible via BuilderLite, so that we don't stabilise their field layout. Even if the type is currently unstable, this must change eventually.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also I find the name "setup config" weirdly redundant. Can this just be called ITwtConfig?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was an attempt to balance between two conflicting interests:

  • either we could expose exponent+mantissa and interval+unit as-is (like IDF does) and have the user handle the maths
  • or we could only expose a much nicer "porcelain" interface (see with_wake_interval below) that simply takes a Duration and converts it

Now the problem with the second approach is that for a given Duration value there will often be multiple possible raw representations, and one of the Fritz!Box bugs I ran into resulted in a case where the user might have to specify raw values in order to work around the AP bug for certain Duration values. This is why the struct does expose an entirely builder-based interface (and this is what one is supposed to reach for) but gives the option to use struct syntax if one really must specify raw values instead.

Having said that, there is always the obvious alternative of naming the other builders something like with_raw_wake_interval_exponent so if you think that's a better solution then I'm happy to change it.

Regarding the name, I just used the same name as in IDF but of course we can change it.

Comment thread esp-radio/src/wifi/twt.rs
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[instability::unstable]
pub enum ITwtTeardownStatus {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer (just to end up with idiomatic Rust) using error enums and Result.

Comment thread esp-radio/src/wifi/mod.rs
if negotiated_config.twt_id != twt_id {
continue;
}
if status == 1 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That 1 should be a named constant since non-zero usually means failure.

Comment thread esp-radio/src/wifi/mod.rs Outdated
Comment thread esp-radio/src/wifi/mod.rs Outdated
@bjoernQ

bjoernQ commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

We shouldn't let this PR rot away, even if we can't test it ourselves.

Agreed - it somehow fell off my radar.

Given iTWT is apparently something which is not as widely supported as I assumed, maybe it would be good to make the example show a more "defensive" way to deal with that situation (i.e. printing a warning message and work without iTWT) - at least that might help users in dealing with the situation.

I also wonder if we could get away with only one example - I see how embassy_twt shows a more complex scenario so maybe it's worth to have two examples? Just wondering

@bugadani

Copy link
Copy Markdown
Contributor

My phone is supposedly a WiFi 6 access point, but the embassy_twt example just crashes miserably, which is a blocker for this PR:

[    0.230] Starting wifi
[    0.281] Connecting...
[    3.654] Wifi connected: ConnectedInfo { ssid: "DaniDroid", bssid: [98, 40, 83, 150, 71, 188], channel: 11, authmode: Wpa2Personal, aid: 1 }
[    3.668]
=== Setting up flow A (500ms interval, 65ms wake) ===
WARN - unsafe { crate::sys::include::esp_wifi_sta_itwt_setup(&mut raw) } returned an error: 262 (262). If this error is unmapped, please open an issue at <https://github.com/esp-rs/esp-hal/issues>.


====================== PANIC ======================
panicked at C:\_Espressif\worktrees\esp-hal\gusty-beam\esp-hal\esp-radio\src\wifi\mod.rs:957:18:
Unknown error code: 262

@Frostie314159

Copy link
Copy Markdown
Contributor

If you need some testing on this PR lmk. I should be able to do some packet capturing, and hopefully diagnosing the issue.

@MabezDev

Copy link
Copy Markdown
Member

If you need some testing on this PR lmk. I should be able to do some packet capturing, and hopefully diagnosing the issue.

Given that no one in our team can get this working, we'd be most grateful 🙏

@Frostie314159

Copy link
Copy Markdown
Contributor
image

Now I've got some spec to read on the 1h30 train journey I'll be on tomorrow.

@main--

main-- commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

make the example show a more "defensive" way to deal with that situation

I like the idea. The problem is, I don't have a lot of hardware to test with so I can only test the failure scenarios that I can personally reproduce. For example, I have yet to see any actual negotiation or rejection response - all I ever get from my router is Accept. 🤷

My phone is supposedly a WiFi 6 access point, but the embassy_twt example just crashes miserably, which is a blocker for this PR

It is likely that your phone does not support iTWT, which at least explains the ESP_ERR_NOT_SUPPORTED return value. Of course I will fix the crash.

I'm also happy to take a look at pcap files, just make sure to set he_sniffer_params on Intel NICs.

@main--
main-- force-pushed the feat/itwt-support branch from cf0a394 to 105c7eb Compare August 14, 2026 20:21
@Frostie314159

Copy link
Copy Markdown
Contributor

I have to admit, that I haven't been able to do much in the way of testing, mainly because my Vodafone Wi-Fi 6 router is apparently not capable of iTWT and rejects all request.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants