{"id":3049,"date":"2026-07-07T18:28:14","date_gmt":"2026-07-07T18:28:14","guid":{"rendered":"https:\/\/brakkee.org\/site\/?p=3049"},"modified":"2026-07-07T21:58:55","modified_gmt":"2026-07-07T21:58:55","slug":"ant-to-bluetooth-low-energy-bridge","status":"publish","type":"post","link":"https:\/\/brakkee.org\/site\/2026\/07\/07\/ant-to-bluetooth-low-energy-bridge\/","title":{"rendered":"Ant+ to Bluetooth Low Energy Bridge in Go"},"content":{"rendered":"<p>As a regular Zwifter, I use a Life Fitness IC5 spinning bike (2020) that broadcasts power and cadence over ANT+.<\/p>\n<h2>The Problem<\/h2>\n<p>Modern devices only support Bluetooth, requiring an ANT+ bridge such as the North Pole Engineering CABLE or the Viiiiva Heart Rate Strap. Unfortunately, both are discontinued. Also, the <a href=\"https:\/\/single-market-economy.ec.europa.eu\/sectors\/electrical-and-electronic-engineering-industries-eei\/radio-equipment-directive-red_en\">European Radio Equipment Directive<\/a> requires encryption for wireless transmission of personal data and is helping to end ANT+. The CABLE replacements WYUR and CORD from North Pole Engineering are also unavailable.<\/p>\n<p><!--more--><\/p>\n<h2>The Solution<\/h2>\n<p>Rather than wait for my CABLE to fail, and having to replace my perfectly working spinning bike because of this, I wrote my own ANT+ to Bluetooth bridge in Go. This runs on Debian with a USB ANT+ dongle. The architecture is straightforward. An Ant module receives ANT+ events and puts them on a Channel from where the Bluetooth module reads them:<\/p>\n<p><img src=http:\/\/www.plantuml.com\/plantuml\/img\/RPB1Ri8m38RlUGgBqwQ1jErOJPFOn4u38QXsc7enhLaZYjsbWSAclVkSg20gSMaS-_FlNqvDQGk2jOXVoS4q6yirEjn2Gzyxufp6pacRbZRVC3KDF5uWc7jnIt-i24gfAcalQw4DVWt0cx1VsX-2XtlpTq6mUBe2H54Of0ujani0Dj35HlkypMsDx64mdgLt6K-bs5KKAKtqYScUs6VyMY8pLP7fuZxtyZxf2UdzvEApjMiVap4AQRj7zHs6dIydrNCYA7IcOVGSO4XWvGIB71kr5Bo3mnOAz6ZY8cIlhEOJcADhuW3OwiBnYuOPqy4xLOSQZvMgc51LuMwmYhVEUihuPZ6V3c4kBRaXf7PBj-R4F8s2BKKTON6L36KLrIE1saaUdjkBehF5v4EX-8qTPpPk9T5_XOi-bt47_WC0 alt=\"PlantUML Syntax:<br \/>\nhide footbox<br \/>\nskinparam sequenceArrowThickness 2<br \/>\nskinparam actorStyle hollow<\/p>\n<p>skinparam arrow {<br \/>\n  FontSize 10<br \/>\n}<br \/>\nskinparam note {<br \/>\n  FontSize 10<br \/>\n}<\/p>\n<p>actor &#8220;Trainer&#8221; as Trainer<br \/>\nparticipant &#8220;ANT+\\nModule&#8221; as ANT<br \/>\nparticipant &#8220;Event\\nChannel&#8221; as Channel<br \/>\nparticipant &#8220;BLE\\nModule&#8221; as BLE<br \/>\nactor &#8220;Zwift&#8221; as Zwift<\/p>\n<p>activate ANT<br \/>\nactivate Channel<br \/>\nactivate BLE<\/p>\n<p>Trainer -&gt; ANT : broadcast\\nANT+ raw data<br \/>\nnote over ANT: Parse ANT message,\\nextract payload<\/p>\n<p>ANT -&gt; Channel : write\\n(RPM, Power, Time)<br \/>\nChannel &lt;- BLE : read\\n(RPM, Power, Time)<br \/>\nBLE -> BLE: process to\\n(NREV, TREV, Power)<br \/>\nBLE -&gt; Zwift: send\\n(NREV, TREV,\\nPower)<br \/>\n\" usemap=\"#plantuml_map\"><\/p>\n<p>The key challenge: ANT+ broadcasts instantaneous RPM, while Bluetooth requires cumulative revolution counts and a last-revolution timestamp.<\/p>\n<h2>Technical Challenges<\/h2>\n<p>Three issues required custom solutions:<\/p>\n<ul>\n<li><strong>USB reliability<\/strong>: The ANT+ library&#8217;s USB handling wasn&#8217;t robust enough, so I wrote my own to support hot-plugging the dongle.<\/li>\n<li><strong>Recovery from bicycle pauzes<\/strong>: When the bike stops transmitting (e.g. during a break), the bridge now detects 10 seconds of inactivity and reinitializes automatically. Without this it did not recover.<\/li>\n<li><strong>Restarts or crashes of the bridge<\/strong>: When the bridge restarts it resets the bluetooth interface. This causes the bike to automatically reconnect. There must be a proper way to do it but life is too short to find out.<\/li>\n<li><strong>Crash robustness<\/strong>: A script runs the bridge and simply restarts it when it crashes. Crashes still can occur occasionally although haven&#8217;t seen them while cycling.<\/li>\n<li><strong>Sleep prevention<\/strong>: Wrapped the bridge in <code>systemd-inhibit<\/code> to prevent the PC from sleeping mid-ride:<br \/>\n<code>systemd-inhibit --what=idle:sleep --why=\"Do not interrupt cycling\" $( dirname $0 )\/bin\/bridge \"$@\"<\/code><\/li>\n<\/ul>\n<h2>Physical challenges<\/h2>\n<p>During initial development I was a lot on the bike trying to get the ant+ data to show. After development I was still a lot on the bike.<\/p>\n<h2>Conclusion<\/h2>\n<p>The bridge has been running reliably across sessions from 30 minutes to over 2 hours. The code is available <a href=\"https:\/\/git.wamblee.org\/public\/antplusbridge\">here<\/a> and supports ANT+ dongles that identify as &#8220;Dynastream Innovations&#8221;. It&#8217;s not perfect, but it works well \u2014 pull requests welcome.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>As a regular Zwifter, I use a Life Fitness IC5 spinning bike (2020) that broadcasts power and cadence over ANT+. The Problem Modern devices only support Bluetooth, requiring an ANT+ bridge such as the North Pole Engineering CABLE or the &hellip; <a href=\"https:\/\/brakkee.org\/site\/2026\/07\/07\/ant-to-bluetooth-low-energy-bridge\/\">Continue reading <span class=\"meta-nav\">&rarr;<\/span><\/a><\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"inline_featured_image":false,"footnotes":""},"categories":[6,10],"tags":[],"_links":{"self":[{"href":"https:\/\/brakkee.org\/site\/wp-json\/wp\/v2\/posts\/3049"}],"collection":[{"href":"https:\/\/brakkee.org\/site\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/brakkee.org\/site\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/brakkee.org\/site\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/brakkee.org\/site\/wp-json\/wp\/v2\/comments?post=3049"}],"version-history":[{"count":48,"href":"https:\/\/brakkee.org\/site\/wp-json\/wp\/v2\/posts\/3049\/revisions"}],"predecessor-version":[{"id":3097,"href":"https:\/\/brakkee.org\/site\/wp-json\/wp\/v2\/posts\/3049\/revisions\/3097"}],"wp:attachment":[{"href":"https:\/\/brakkee.org\/site\/wp-json\/wp\/v2\/media?parent=3049"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/brakkee.org\/site\/wp-json\/wp\/v2\/categories?post=3049"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/brakkee.org\/site\/wp-json\/wp\/v2\/tags?post=3049"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}