# Mohsin Mohammed > Founding Engineer at Omi AI. Flutter & Full Stack Developer passionate about AI, mobile development, and open source. Currently building the future of wearable AI. Hi, I'm Mohsin πŸ‘‹ This is the personal site of **Mohsin Mohammed** at [mohsin.xyz](https://mohsin.xyz). I'm a Founding Engineer at [Omi AI](https://omi.me), building the future of wearable AI. I've shipped products used by **500,000+ users**, architected systems that reduced response times by **75%**, and created a Flutter course with **14,000+ students** (4.9β˜… rating). I love reverse-engineering protocols, optimizing performance, and turning complex problems into elegant solutions. When I'm not coding, you'll find me writing about my engineering adventures on my blog. ## About I'm a Founding Engineer at [Omi AI](https://omi.me), building the future of wearable AI. I've shipped products used by **500,000+ users**, architected systems that reduced response times by **75%**, and created a Flutter course with **14,000+ students** (4.9β˜… rating). I love reverse-engineering protocols, optimizing performance, and turning complex problems into elegant solutions. When I'm not coding, you'll find me writing about my engineering adventures on my blog. ## Work Experience ### Omi AI β€” Founding Engineer (Jun 2024 - Present) Building the future of wearable AI as one of the first engineers. Built the macOS app with a custom audio pipeline that records conversations without virtual audio drivers. Integrated third-party wearables enabling anyone with any device to use Omi. Architected the App Store infrastructure powering 1M+ app uses with 4,000+ third-party apps. And much more. ### Google Summer of Code (AOSSIE) β€” Flutter Developer (Jun 2024 - Aug 2024) Selected from 43,000+ applicants worldwide to build an AR-integrated social platform. Shipped Desktop and Web versions using BLoC with Domain-Driven Design. Built a fully responsive UI that scales from mobile to 4K displays. ### Cattleguru β€” Software Engineer (May 2023 - June 2024) Led product development directly with the founding team. Cut API response times by 75% (from 12s to 3s) with Redis caching. Migrated backend from Spring Boot to Go, slashing memory usage from 400MB to 20MB. Owned both Flutter apps serving 5,000+ daily users. ### RentOk (EazyPG) β€” Software Engineer (Feb 2022 - Aug 2022) Rebuilt the consumer app from native to Flutter, achieving 90% faster iOS performance. Designed and shipped v2.0 UI serving 100,000+ users. Integrated payment systems and Firebase services that became core infrastructure. ## Education ### Nawab Shah Alam Khan College of Engineering and Technology Bachelor of Engineering in Information Technology (2019 - 2023) ## Skills Flutter, Dart, Go, Python, Swift, TypeScript, JavaScript, C++, Java, HTML/CSS, Firebase, Docker, Redis, MongoDB, OpenAI, LangChain, NextJS, FastAPI, Spring Boot ## Contact - Email: mohsin.lp710@gmail.com - GitHub: https://github.com/mdmohsin7 - LinkedIn: https://linkedin.com/in/mohsinxyz - X: https://x.com/mohsinxyz_ ## Developer resources - [Mohsin Mohammed developer resources](https://mohsin.xyz/developers.md): machine-readable endpoints, markdown negotiation, and profiles ## Sitemap - [Home](https://mohsin.xyz/index.md) - [Blog](https://mohsin.xyz/blog.md) - [Mohsin Mohammed developer resources](https://mohsin.xyz/developers.md) - [llms.txt](https://mohsin.xyz/llms.txt) - [Markdown sitemap](https://mohsin.xyz/sitemap.md) - [XML sitemap](https://mohsin.xyz/sitemap.xml) --- # Mohsin Mohammed developer resources > Machine-readable entry points for Mohsin Mohammed (https://mohsin.xyz). This is a personal site, not a hosted API product β€” there is no public OpenAPI spec, auth API, webhook catalog, or MCP server. Use these URLs when an agent searches for **Mohsin Mohammed** developer resources, markdown, or site structure. ## Content negotiation Pages at https://mohsin.xyz speak [acceptmarkdown.com](https://acceptmarkdown.com) content negotiation. - Send `Accept: text/markdown` to the canonical HTML URL - Or fetch the `.md` sibling (`/index.md`, `/blog.md`, `/blog/{slug}.md`, `/developers.md`) - Markdown responses use `Content-Type: text/markdown; charset=utf-8` and `Vary: Accept, Accept-Encoding` ## Discoverable files - [llms.txt](https://mohsin.xyz/llms.txt): llmstxt.org index of Mohsin Mohammed pages - [llms-full.txt](https://mohsin.xyz/llms-full.txt): full homepage plus blog posts in one markdown file - [Markdown sitemap](https://mohsin.xyz/sitemap.md): headings and links for every public page - [XML sitemap](https://mohsin.xyz/sitemap.xml): crawler sitemap - [robots.txt](https://mohsin.xyz/robots.txt): crawl rules (AI bots allowed) - [Home markdown](https://mohsin.xyz/index.md): Mohsin Mohammed profile - [Blog markdown](https://mohsin.xyz/blog.md): post index ## Profiles - [GitHub (mdmohsin7)](https://github.com/mdmohsin7): source and open source - [LinkedIn](https://linkedin.com/in/mohsinxyz): Mohsin Mohammed - [X](https://x.com/mohsinxyz_): Mohsin Mohammed ## How to fetch ```bash curl -sI -H "Accept: text/markdown" https://mohsin.xyz/ curl -s https://mohsin.xyz/llms.txt curl -s https://mohsin.xyz/developers.md ``` ## Sitemap - [Home](https://mohsin.xyz/index.md) - [Blog](https://mohsin.xyz/blog.md) - [Mohsin Mohammed developer resources](https://mohsin.xyz/developers.md) - [llms.txt](https://mohsin.xyz/llms.txt) - [Markdown sitemap](https://mohsin.xyz/sitemap.md) - [XML sitemap](https://mohsin.xyz/sitemap.xml) --- # Mohsin Mohammed Blog > Engineering writing by Mohsin Mohammed on Bluetooth, Flutter, wearables, and systems work. Posts are also available as Markdown via `Accept: text/markdown` on the HTML URL, or at the `.md` sibling. - [Our BLE Connection Was a Ghost. I Rebuilt It From Scratch.](https://mohsin.xyz/blog/our-ble-connection-was-a-ghost.md): How I rebuilt Omi's Bluetooth stack natively, killed off ghost connections, and stopped polling the radio every 15 seconds. - [The Protocol Nobody Uses](https://mohsin.xyz/blog/the-protocol-nobody-uses.md): Why GATT is the wrong abstraction for BLE audio, and what L2CAP CoC gives you instead. - [The Pendant That Refused to Die](https://mohsin.xyz/blog/the-pendant-that-refused-to-die.md): I paid 300 dollars for a wearable that went silent days before Meta acquired its maker. Reverse-engineering the BLE protocol and bringing it back to life. - [How I got into GSoC 2024](https://mohsin.xyz/blog/how-i-got-into-gsoc-2024.md): My path to GSoC 2024: how I picked an org, wrote the proposal, and what I'd tell anyone applying. - [Building Scalable Apps for Low Connectivity Areas](https://mohsin.xyz/blog/building-apps-for-low-connectivity-areas.md): Lessons from building an e-commerce app for rural users on patchy 2G/3G connections: payload budgets, offline-first state, and the choices that actually moved retention in Northern India. - [Setting Up Core Lightning Node on an Ampere VM (OCI): A Comprehensive Guide](https://mohsin.xyz/blog/setting-up-core-lightning-node-on-an-ampere-vm.md): How to run a Core Lightning node on a free ARM VM without downloading the full Bitcoin blockchain. ## Sitemap - [Home](https://mohsin.xyz/index.md) - [Blog](https://mohsin.xyz/blog.md) - [Mohsin Mohammed developer resources](https://mohsin.xyz/developers.md) - [llms.txt](https://mohsin.xyz/llms.txt) - [Markdown sitemap](https://mohsin.xyz/sitemap.md) - [XML sitemap](https://mohsin.xyz/sitemap.xml) --- --- title: "Building Scalable Apps for Low Connectivity Areas" publishedAt: "2024-06-14" summary: "Lessons from building an e-commerce app for rural users on patchy 2G/3G connections: payload budgets, offline-first state, and the choices that actually moved retention in Northern India." image: "https://mohsin.xyz/blog/building-apps-for-low-connectivity-areas/1cc95c52-74dd-4cf9-bb20-ca25c02d8396.webp" --- ## Introduction The majority of Cattleguru's audience resides in villages and rural areas in Northern India. While all major cities already have 5G and many tier 2 cities are on 4G, rural areas still lag behind. Creating an e-commerce app for these regions is quite challenging due to limited connectivity. Additionally, people often avoid updating the app because of limited internet data. This makes it difficult to make online-only apps. Furthermore, the loading time for fetching data from the backend and displaying it in the app can be significant, sometimes causing users to wait for more than 10-15 seconds to perform any operation. ## Background We needed a solution to: - Reduce latency for all sorts of operations originating from our Apps - Keep App Data in Sync with Remote Database Initially, we didn't use any specific solution; the app simply fetched data from Firestore and displayed it. Given that Firestore caches data locally, we didn't consider alternatives until we observed real-world app usage. We noticed that users weren't using the app frequently; instead, they placed orders through our salesmen. This was because most of the time, they saw the app taking anywhere between 20-30 seconds to successfully place an order. Hence, they found it easier to just call the salesmen and place the order. As we expanded into more remote villages, our delivery partners couldn't effectively use the internal team app to deliver orders due to low connectivity in those remote villages. Therefore, we needed a more robust and efficient solution to improve operations. ## Approaches There are various approaches we could have taken to resolve our issues. One approach was using CRDTs (Conflict-Free Replicated Data Type). This would have made sense if our apps were write-heavy and collaborative. While we considered it initially, we discarded it because our apps are mostly read-heavy, and only the internal team app is somewhat write-heavy. You might wonder why we didn't use CRDTs for the team app since it is write-heavy. Although it is both read and write-heavy, there are other approaches with less overhead compared to CRDTs, which make more sense because we wouldn't fully utilize CRDTs even if we implemented them. Another approach was to make our apps offline-first with sync services like [Electric SQL](https://electric-sql.com/) or [PowerSync](https://www.powersync.com/). However, the problem here is that we don't use Postgres (yet), so migrating from Firestore to PostgreSQL would be a time taking task. Additionally, Electric SQL is not yet stable and has many limitations at present, while PowerSync isn't open source yet. Another approach is to cache whatever we can and move all the time-consuming operations to the cloud (backend). We chose this approach. After quite some research, we decided to follow a three-layered caching strategy coupled with feature flags and message queues for our systems. ## The Chosen Approach If you want to reduce the latency of fetching frequently accessed and infrequently changed data, caching is one of the best solutions with very little overhead. That's exactly what we did by adding caching at various levels. We have a client-side database, an in-memory (non-persistent) database at the server level, and an adjacent Redis instance. A call is made to Firestore only if the data does not exist at the previous three levels. Our SKUs don't change frequently, and if there is a change, it's mostly in the price. So, it made sense for us to cache the product data on the client side for the long term. Whenever the app opens, it makes one API call to fetch the last cache update time and feature flags update time and compares it locally. To minimize the amount of data sent to the client, we only return the cache keys and feature flag keys that have changed. This might not seem like much, but it helps save a few bytes of data. When it comes to our internal team app, the order data changes every time a new order is received. Sometimes we don't receive orders for hours. Therefore, it doesn't make sense to have a fixed expiry for the cache while updating it at specific intervals, especially since we don't receive orders between 1 AM and 6 AM at all. Instead, we decided to use a cache with no expiry. Rather than updating both the in-memory cache and Redis with every new order, we opted to use Cron Jobs to update them at regular intervals. The Cron Jobs are scheduled to run only during the working hours of our teams. For those curious, we use [Upstash](https://upstash.com/) Redis and QStash for this purpose. ![](https://mohsin.xyz/blog/building-apps-for-low-connectivity-areas/8b258f1c-a1c9-4425-8277-41734720f80d.webp) To minimize the amount of data written by every write operation of the team app, we redesigned the feature flows to reduce as much data as possible while ensuring enough data is captured. Since we already know who our delivery partners are and the customers who placed the orders, we eliminated all unnecessary fields being transferred to the backend, using only one field as a strong identifier of the user. One very common problem with having caches at multiple levels is keeping all of them in sync with the main database. This is a big issue if the data changes frequently. But given the data that is cached locally on the client side doesn't change that frequently, it isn't that big of an issue for us. We use flags to keep track of what data was changed and when it changed. It helps us in keeping the data between the client and the backend in sync. The table below shows how much the response time has improved, thanks to the approaches we implemented. | Metric | Response Time Now (ms) | Response Time Before (ms) | | --- | --- | --- | | MAX | 5000 | 30000 | | 99TH PERC | 4975 | 29600 | | 90TH PERC | 4750 | 26000 | | 50TH PERC | 3750 | 5000 | --- --- title: "How I got into GSoC 2024" publishedAt: "2024-12-31" summary: "My path to GSoC 2024: how I picked an org, wrote the proposal, and what I'd tell anyone applying." image: "https://mohsin.xyz/blog/how-i-got-into-gsoc-2024/6d087f4d-80ec-4c32-ba4d-d3bd249b53da.webp" --- ## **What is GSoC?** If you are reading this, then chances are you already know what Google Summer of Code is and how it works. You might want to check out their [official site](https://summerofcode.withgoogle.com/) for the perfect explanation if you don't. In brief, GSoC is an open-source program powered by Google that connects organizations to students and professionals to work on their projects over the summer. This is an amazing opportunity for students as they are exposed to real-world code and also get first-hand experience working on production-level projects. And for working professionals, it is an opportunity to dive into the world of open source by making meaningful contributions to open-source projects. GSoC is not an internship but more of a program to bring people into open-source development. You are, of course, paid decently for your work, which is an additional perk. The stipend varies from country to country and is listed on their website. There are three types of projects - small (~90 hours), medium (~175 hours), and large (~350 hours), categorized based on the time span contributors need to dedicate to that project. ## **The Journey** Back in January 2024, I was preparing for Summer of Bitcoin. I didn’t know that GSoC was now open to everyone (it was not restricted to just students) and I wasn’t planning to do my master's either, so I did not think about it at all. This was my last chance for Summer of Bitcoin, and I really wanted to be a part of it. Fast forward to March, and I was trying to work on the Summer of Bitcoin assessment that had to be done as part of the proposal submission. I came across a post about GSoC, looked it up, and thought, why not give it a chance one last time? I had a few contacts who had been part of GSoC in the past. I reached out to them with a few questions and stuff. It was around March 20, and there were only about 12 days left to submit the proposal. The SoB deadline was already approaching, but the assessment wasn’t easy, so I thought of doing it later (spoiler: I did not do it at all). I knew by now that many people would have contributed and become comfortable with the projects and the community. I did not have any advantage over them (except that maybe I had some industry experience). I was planning to apply for projects under the CCExtractor organization, so I completed their required introductory tasks like [creating a Grafana dashboard for MyFitnessPal data](https://github.com/mdmohsin7/myfitnesspal-grafana) and creating a [macOS release for CCExtractor](https://github.com/CCExtractor/ccextractorfluttergui/pull/65). But I still was not confident that I would make the cut, so I started looking for other organizations and came across [AOSSIE](https://aossie.org/). AOSSIE had many projects, but I found one project particularly interesting because no one was able to run it correctly at all since the codebase was very old, and it was a native Android app with a Flutter module. That project was [Monumento](https://github.com/AOSSIE-Org/Monumento). I spent the next 2-3 days trying to migrate it to a complete Flutter app, fixing many native Android issues, and also the AR functionality. In about 3 days, I had the app fully functional and working with my Firebase. Then an organization admin gave me access to their Firebase account to set this project correctly to use their account with all the data already there. The deadline was approaching, and I had about 3-4 days to create a proposal, get it reviewed by a mentor, and submit it. I spent the entire day going through many old proposals to understand how everyone did it and what makes a proposal stand out. The very next day, I started working on it and made my first draft. I sent it to my project mentor, and he gave some feedback. I quickly started making changes based on his feedback. His feedback was basically to propose some new features alongside the UI redesign, so I spent some time creating MVPs of the features I was planning and also almost finished the UI design in Figma. I spent quite a lot of time in the last 2-3 days to make my proposal stand out from the crowd. I submitted it on the deadline day, waited for a month, and when the results came, I was ready to accept my rejection (like in previous years), but it was a selection. It was one of the happiest days for me. ## **Everyone’s Journey is Different** I've read many blogs where people share their GSoC selection stories, and most of them mention starting their preparation in December of the previous year. While that's the ideal way to begin, don't lose hope if you're starting a bit late. This was the only reason for me to write this blog and share my story. Remember, you haven't lost until the results are announced. If you quit early, you've lost before the results even come out. Believe in yourself, and you’ll succeed! :) ## **Some Tips** - Don't do it just for the money :) - Your proposal determines your outcome, so make it as detailed as possible and get it reviewed early. - It's never too early or too late to start. You can even begin just a day before the deadline (but please don't). - Don't focus on too many projects. Limit yourself to a maximum of 2-3 projects so you can dedicate time to each. - Quality over quantity. Focus on making meaningful contributions. --- --- title: "Our BLE Connection Was a Ghost. I Rebuilt It From Scratch." publishedAt: "2026-06-01" summary: "How I rebuilt Omi's Bluetooth stack natively, killed off ghost connections, and stopped polling the radio every 15 seconds." image: "https://mohsin.xyz/blog/our-ble-connection-was-a-ghost/cover.webp" --- For months, the same support ticket kept landing in our queue. > "My Omi shows connected but nothing's happening." The app said connected. The device thought it was connected. But audio wasn't streaming. We called these ghost connections. They're the kind of bug that quietly destroys a hardware product. Intermittent, hard to reproduce, masked by working most of the time. [Omi](https://omi.me) is a wearable that streams audio over Bluetooth Low Energy (BLE) to a Flutter app for real-time transcription. If the connection drops, you lose a sentence. If it ghost-connects, you lose an entire conversation and the user doesn't notice until they check the app later. Over the months our team chased these, the workarounds in our Flutter BLE stack accumulated. We added retry logic. Timers. Guard rails. Debouncers and mutexes. The workarounds bought us time. None of them was going to make ghost connections actually go away, because the library we'd built on was generic by design and our use case had outgrown what generic could do. This is the story of the rewrite. I tore out our Bluetooth transport, plugged a new native implementation into a pattern we already had, and let the OS do what it was designed to do. ![Crashes by day, before and after we shipped the Native BLE Transporter](https://mohsin.xyz/blog/our-ble-connection-was-a-ghost/crash-trend.webp) *Crashes by day, before and after we shipped the Native BLE Transporter. We'll get to how this happened.* --- ## How we got here: workarounds for a use case that had outgrown its library We started with [flutter_blue_plus](https://pub.dev/packages/flutter_blue_plus), a popular Flutter BLE plugin. It worked fine for the basics: scan, connect, read, write, subscribe. Nothing wrong with the plugin itself. The problems came from everything we built around it as the use case became more specific. ### The 15-second timer When a device disconnects, you need to reconnect. flutter_blue_plus does reconnection, but it doesn't expose enough control over how it happens for an always-on streaming use case β€” so we'd added a `Timer.periodic` that fires every 15 seconds: ```dart _reconnectionTimer = Timer.periodic( Duration(seconds: 15), (t) async { if (!isConnected && connectedDevice == null) { await scanAndConnectToDevice(); } }, ); ``` Every 15 seconds the app did a full BLE scan. Radio on, scanning for peripherals, checking if our device was around. Even when the user's phone was in their pocket and the device was sitting on their wrist, perfectly in range. ### Three ways to reconnect `scanAndConnectToDevice()` had become a choose-your-own-adventure: ```dart Future _scanConnectDevice() async { // Path 1: Maybe we're already connected and don't know it? var device = await _getConnectedDevice(); if (device != null) return device; // Path 2: Force reconnect through the service layer await ServiceManager.instance().device.ensureConnection(id, force: true); await Future.delayed(const Duration(seconds: 2)); // hope this is enough device = await _getConnectedDevice(); if (device != null) return device; // Path 3: Full discovery scan await ServiceManager.instance().device.discover(desirableDeviceId: id); await Future.delayed(const Duration(seconds: 2)); // hope again return connectedDevice; } ``` Three paths. Two hardcoded 2-second sleeps. A `_getConnectedDevice()` check that queried the plugin directly because we didn't trust our own state. ### Four versions of the truth Connection state lived in four places at the same time: - `DeviceProvider.isConnected` β€” the UI layer's opinion - `DeviceConnection._connectionState` β€” the service layer's opinion - `BleTransport._state` β€” the transport layer's opinion - `BluetoothDevice.isConnected` β€” flutter_blue_plus's opinion They could (and did) disagree. When the timer fired, it would check `isConnected` at the provider level, which might say "no" while the transport layer was mid-reconnect. So it would start a second connection attempt. Now you have two GATT connections to the same device β€” one real, one zombie. (GATT is the protocol your app uses to talk to a BLE device. You can think of it as "the open channel.") Because the state was unreliable, we added debouncers β€” a 500ms delay before we believe a disconnect, 100ms before we believe a connect. ### A foreground service hack On Android, the app needs to stay alive in the background to maintain the BLE connection. We were using a location foreground service (service type `connectedDevice|location`) to keep the process alive. This was a blunt instrument: we were telling Android "I need connected device access AND location" when really we just needed BLE not to die. By the time it was clear this had hit its ceiling, we had no OS-level reconnection β€” only a stack of Dart-side patches around a library doing what it was designed to do. --- ## What the OS gives you for free Here's what made native the right call: both iOS and Android already give you everything you need to handle long-lived device connections properly. The primitives exist, they're well-designed for this case, and they run at the chipset level so they cost almost nothing. They just live below the layer where a cross-platform library can reach. ### iOS β€” CoreBluetooth - `centralManager.connect(peripheral)` with no timeout is a *passive chipset-level scan*. Zero CPU, zero radio cost. The BLE controller hardware watches for the device's advertisement and connects when it sees it. You don't poll. You don't scan. You say "connect to this when you see it" and the chip does the rest. - State restoration (`willRestoreState`) means if iOS kills your app for memory, it remembers your pending connections. When the device appears, iOS relaunches your app and hands you back the connected peripheral. You wake up already connected. ### Android β€” CompanionDeviceManager + autoConnect - `connectGatt(autoConnect=true)` tells the Android BLE controller to passively scan for the device. Like iOS, this happens in hardware with no CPU cost. - `CompanionDeviceManager` lets you register a device as a companion. The OS fires `onDeviceAppeared` / `onDeviceDisappeared` callbacks without you scanning. It survives app death. It survives reboots. - A foreground service with `FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE` gives you proper OS-level lifecycle management instead of the location hack. None of this is exposed by a Dart BLE plugin, and that isn't really anyone's fault. Every app using BLE has different needs β€” transactional reads, long-lived streaming, scanning beacons, scanning for ranges of devices β€” and a single cross-platform library can't surface every platform-specific primitive for every shape of use case. At a certain level of specificity, you're on your own with the platform. --- ## The seam that made the rewrite possible Before this rewrite, the BLE stack was already split into three layers: **Discoverer β†’ Connector β†’ Transporter**. - **Discoverer** finds devices. - **Connector** owns the business logic for a specific device. - **Transporter** moves bytes over whatever protocol β€” BLE, WiFi, USB, classic Bluetooth. ``` App β†’ Discoverer β†’ Connector β†’ Transporter β†’ Device ``` I'd drawn that split for a different problem: supporting multiple wearable devices without rewriting the app each time. But because business logic and transport were already separated, the BLE rewrite was contained to one layer. The new **Native BLE Transporter** β€” Swift and Kotlin owning the connection lifecycle β€” slotted in behind the same interface as the old Dart one. The Connector didn't know it had changed. Without that seam, this rewrite would have cascaded through every layer of the app. --- ## The Native BLE Transporter The core design decision was simple: **native owns the connection lifecycle. Dart just reacts to events.** I used [Pigeon](https://pub.dev/packages/pigeon) (Flutter's code-gen tool for type-safe platform channels) to define the contract: ```dart // Dart tells native what to do (commands) @HostApi() abstract class BleHostApi { void manageDevice(String uuid, bool requiresBond); // "own this device" void unmanageDevice(String uuid); // "stop owning it" void startScan(); void stopScan(); // ...GATT operations (read, write, subscribe) } // Native tells Dart what happened (events) @FlutterApi() abstract class BleFlutterApi { void onDeviceReady(String uuid, List services); // "connected, here are the services" void onPeripheralDisconnected(String uuid, String? error); // "lost it" void onCharacteristicValueUpdated(...); // "got data" } ``` That's the entire boundary between Connector and Transporter. Dart sends intent, native fires events back. All the polling, scanning, and timer work we'd accumulated on the Dart side is gone β€” none of it has to exist once the OS itself is the thing managing connection state. ### Connecting: one call, then wait The Dart side became trivially simple: ```dart Future connect() async { _deviceReadyCompleter = Completer>(); _hostApi.manageDevice(_peripheralUuid, requiresBond); _services = await _deviceReadyCompleter!.future.timeout(Duration(seconds: 60)); } ``` A single Pigeon call kicks off the whole sequence. Native walks through connect, service discovery, MTU negotiation, and bonding if needed, then fires `onDeviceReady` once everything is in place. ### Reconnecting: native handles it When the device disconnects, native doesn't tell Dart "figure it out." It tells Dart "I lost the device, I'm working on it." On iOS: ```swift func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { if !manualDisconnect { centralManager.connect(peripheral) // chipset-level passive reconnect } } ``` On Android, the foreground service schedules a retry: ```kotlin private fun handleRetryLogic(deviceId: String) { handler.postDelayed({ connectToDevice(deviceId, autoConnect = true) // passive BLE controller scan }, 3000) } ``` When native reconnects, it fires `onDeviceReady` again. The Dart side notices there's no pending completer (this isn't a fresh connect) and re-subscribes to the characteristics it was listening to: ```dart void _handleDeviceReady(List services) { if (_deviceReadyCompleter != null && !_deviceReadyCompleter!.isCompleted) { _deviceReadyCompleter!.complete(services); // initial connect } else { _resubscribeAfterReconnect(services); // native auto-reconnected } } ``` The Transporter remembers which characteristics were active before the disconnect and re-subscribes automatically. From the app's perspective, the connection healed itself. ### The full flow ``` User taps Connect β†’ Dart: manageDevice(uuid) [one Pigeon call] β†’ Native: connectGatt / CBCentralManager.connect β†’ Native: discover services, negotiate MTU, bond if needed β†’ Native: onDeviceReady(uuid, services) β†’ Dart: transport ready, app streams audio Device goes out of range β†’ Native: onPeripheralDisconnected β†’ Native: auto-schedules reconnect (iOS: chipset passive watch Android: 3s delay + autoConnect) β†’ Dart: UI shows "disconnected" Device comes back in range β†’ Native: connects automatically (no scan, no poll) β†’ Native: rediscovers services β†’ Native: onDeviceReady(uuid, services) β†’ Dart: re-subscribes to characteristics β†’ Dart: UI shows "connected", audio resumes ``` No timers. No polling. No three-path reconnection. No debouncers. No ghost connections. --- ## The landmines Going native wasn't all clean architecture diagrams. Here are the things that bit me. ### Android forgets your device after a Bluetooth toggle Toggle Bluetooth off and back on, and Android clears its BLE device cache for unbonded devices. Our device uses a static random BLE address (not public). After the toggle, Android defaults to `ADDRESS_TYPE_PUBLIC` and silently fails to connect. The fix is a single API call β€” `getRemoteLeDevice(address, ADDRESS_TYPE_RANDOM)` on API 34+ β€” but finding it took hours of "why does toggling Bluetooth break everything?" ### Stale GATT callbacks create phantom connections When a connection drops and you create a new `BluetoothGatt`, the old one's callbacks can still fire. If you don't reject them, you process disconnect events from connection attempt #1 while connection attempt #2 is happily connected. A ghost connection, this time from the native side. I track a `currentGattHash` β€” each connection attempt gets a fresh `BluetoothGattCallback` with a unique hash. Callbacks from old hashes are silently dropped. ### Single owner or chaos The most important architectural decision: one entity owns the connection. On Android, `OmiBleForegroundService` is the single owner. On iOS, it's `OmiBleManager`. Dart code can't reach GATT directly, and the Connector has no access to the Transporter's internals β€” the layers are sealed by design. When we'd had four actors all managing connection state β€” Dart provider, Dart service, native BleManager, native ForegroundService β€” they created zombie GATT objects. Connections that existed in one layer's state but not another's. The single-owner model eliminated this entire class of bug. --- ## The results ![Xcode Energy Diagnostics before and after the rewrite](https://mohsin.xyz/blog/our-ble-connection-was-a-ghost/energy-diagnostics.webp) *Xcode Energy Diagnostics, before and after.* I profiled the app with Xcode's Energy Diagnostics before and after. **Before:** - Energy impact: **Very High** - Overhead: 44.2% β€” the Dart↔native bridging cost on every BLE event - Network: 44.7% β€” active radio scanning every 15 seconds (this shows as "network" in the profiler) - CPU: 10.6% β€” Dart processing connection state, timers, debouncers **After:** - Energy impact: **Low** - CPU: 98.6% of a *much smaller total* β€” the app doing its actual work - Network: 0% - Overhead: 0% The Overhead and Network categories β€” 89% of our energy budget β€” went to zero. Crashes collapsed in the same window. ![User feedback after the rollout: "Disconnects while the phone is in range have dropped off considerably with the last couple updates."](https://mohsin.xyz/blog/our-ble-connection-was-a-ghost/user-feedback.webp) *A user reporting back after the rollout.* I wasn't trying to fix battery. I was trying to fix ghost connections. The battery improvement was a side effect of not fighting the OS anymore. All the polling, bridging, and state coordination we'd been doing in Dart was work the OS could already do at the chipset level. Once we moved the connection lifecycle to native, that work didn't have to exist on our side anymore. We didn't optimize anything. We just stopped doing it. --- ## The cost Going native means maintaining BLE code on iOS and Android. Two languages, more debugging surface, platform-specific quirks to track. For us, the trade was clearly worth it. The Dart side ended up simpler too β€” the Connector that replaced the old provider does less, because most of the work it used to do is now happening in native code where it belongs. The ghost connections are gone. Not because we fixed them. Because we stopped creating them. --- --- title: "Setting Up Core Lightning Node on an Ampere VM (OCI): A Comprehensive Guide" publishedAt: "2024-02-10" summary: "How to run a Core Lightning node on a free ARM VM without downloading the full Bitcoin blockchain." image: "https://mohsin.xyz/blog/setting-up-core-lightning-node-on-an-ampere-vm/4de46860-ce3a-431f-928a-11ac30d2e9e2.webp" --- Core Lightning (previously c-lightning) is a lightweight, highly customizable, and standard-compliant implementation of the Lightning Network protocol. While the project offers extensive documentation, there is a scarcity of articles guiding beginners through the setup process. In this tutorial, I will outline the steps to successfully run a Core Lightning Node on an Ampere (Arm-based) VM with 8GB RAM and 4 OCPUs. This guide is tailored for developers aiming to set up a Lightning Node for development purposes. **Note:** You will have to be on the "Pay as you go" plan of Oracle Cloud Infrastructure (OCI) for you to be able to create an Ampere VM (with upto 24GB RAM and 4OCPUs for free). ### **VM Setup** Spin up an Ampere VM with at least 4GB of RAM (I opted for 8GB) through OCI's instance creation UI. Make sure to select Ubuntu 20.04 or later as the base image. ![OCI Instance Creation](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3y9foarht2n0r89v2m34.png) **Important:** Make sure you download the private as well as the public keys (you won't be able to SSH into the VM later on if you don't have the private key). The newly created instance should be up and running in a minute or two. ### **SSH into VM** Open a new terminal on your local machine and change the permissions for the private key by running the following command: ```bash chmod 400 /path/to/key/key_name.key ``` After changing the permissions, SSH into the VM by running the following command: ```bash ssh username@ -i /path/to/key/key_name.key ``` You can find the username and public IP of your VM in the VM Instance details page on OCI. ![OCI VM Instance Details](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cgasea37g82osyk3u09q.png) ### **Installing Core Lightning (Approaches)** There are three ways to install Core Lightning 1. Installing pre-compiled binaries 2. Using Docker 3. Building Binaries from source Since we are on an Arm based VM, we won't be able to follow the first approach because Core Lightning only provides pre-compiled binaries for AMD-based Fedora and Ubuntu distributions, which can be found [here](https://github.com/ElementsProject/lightning/releases). We will be following the third approach, which involves compiling the binary from source. ### **Installing Dependencies** The following commands will download all the required dependencies: ```basic sudo apt-get update sudo apt-get install -y \ autoconf automake build-essential git libtool libsqlite3-dev \ python3 python3-pip net-tools zlib1g-dev libsodium-dev gettext pip3 install --upgrade pip pip3 install --user poetry ``` ### **Installing Bitcoin** We will not be running our own Bitcoin Core node due to storage constraints (it requires around 400GB of storage). Instead, we will install it minimally: ```bash sudo apt-get install snapd sudo snap install bitcoin-core # Snap does some weird things with binary names; you'll # want to add a link to them so everything works as expected sudo ln -s /snap/bitcoin-core/current/bin/bitcoin{d,-cli} /usr/local/bin/ ``` ### **Setting up Core Lightning** The process isn't really different from what is in the documentation. So I have copied and pasted the required commands to be followed. ```bash git clone https://github.com/ElementsProject/lightning.git cd lightning git checkout v23.11.2 sudo apt-get install -y valgrind libpq-dev shellcheck cppcheck \ libsecp256k1-dev jq lowdown ``` ### **Building Core Lightning** ```bash pip3 install --upgrade pip pip3 install mako pip3 install -r plugins/clnrest/requirements.txt pip3 install grpcio-tools ./configure make sudo make install ``` By this point, you should have the lightning and Bitcoin binaries set up correctly. If you attempt to run the command `lightningd`, you'll probably get some error stating that there's no Bitcoin node running. To fix this, we have to connect to a Bitcoin node. For this, we can either run our own Bitcoin node (which we already have installed) or use a third-party plugin. Since running our own node requires an upwards of around 400GB of storage, we will use a third-party plugin. There are various plugins; for this tutorial, we will go with [sauron](https://github.com/lightningd/plugins/tree/master/sauron). You can check the rest of the plugins [here](https://docs.corelightning.org/docs/bitcoin-core#connecting-to-bitcoin-core-remotely). ### **Setting Up Sauron** Core Lightning supports a plugin manager called `reckless`, which simplifies the installation and uninstallation of plugins with a single command. To install Sauron, execute the following command: ```bash reckless install sauron ``` After running the above command, the sauron plugin will be downloaded in the `~/.lightning/reckless/sauron` directory. > Reckless currently supports python plugins only ### **Running Core Lightning Node** To run the node on the testnet, execute the following command: ```bash lightningd --testnet --disable-plugin bcli --plugin --plugin ~/.lightning/reckless/sauron/sauron.py --sauron-api-endpoint https://blockstream.info/testnet/api/ ``` Congratulations! You now have a successfully running Lightning node with a third-party plugin acting as a Bitcoin node. You can interact with your node using `lightning-cli`. Open a new terminal, SSH into the VM again, and run the following command to confirm whether you are able to interact with your node or not: ```bash lightning-cli --testnet getinfo ``` You can check the various commands provided by `lightning-cli`[here](https://docs.corelightning.org/reference/get_list_methods_resource#:~:text=JSON%2DRPC%20API%20REFERENCE). --- --- title: "The Pendant That Refused to Die" publishedAt: "2025-12-22" summary: "I paid 300 dollars for a wearable that went silent days before Meta acquired its maker. Reverse-engineering the BLE protocol and bringing it back to life." image: "https://mohsin.xyz/blog/the-pendant-that-refused-to-die/77d5b73f-25e7-498f-b099-585323af6e30.webp" --- I paid 300 dollars for a paperweight. Well, not intentionally. A few days before the Meta acquisition announcement, I ordered a Limitless Pendant. Payment processed. Shipping confirmed. Then, before it ever arrived, everything changed. The company announced geoblocking in multiple countries, major compliance changes, a halt on hardware sales, and a shutdown timeline for the app. My pendant was still somewhere over the Atlantic, already obsolete. For most people, that would have been the end of the story. Eat the cost. Move on. Maybe write an angry tweet. But I work at Omi. We had been adding support for every major wearable audio device on the market. Limitless was the last holdout. Overnight, thousands of users in geoblocked countries were searching for alternatives. Many did not want their voice data feeding a new ecosystem. Many already owned hardware they trusted and did not want to throw away. If I could crack the protocol, those users would get a new home. And my not yet arrived paperweight would get a second life. There was just one problem. I did not have the device. ## Working Blind What I did have was a friend in London. He owned a Limitless Pendant, still had full access to the official app, and happened to be on a business trip. When this all started, he was in the back of an Uber, on the way to the airport, heading back to the United States. When I asked if he could run a few scripts, he did not hesitate. He opened his laptop in the car. Over the next 24 hours, he became my Bluetooth lab, my QA team, and my reality check. Everything I learned came through him. I would write scripts, send them over, he would run them, and I would stare at logs trying to reconstruct what was happening five thousand miles away. I had reverse engineered enough BLE audio devices to know where to start. I wrote a small Python script using `bleak` to scan everything the pendant advertised: services, characteristics, properties. He ran it from the Uber. The logs came back clean. ```text Service: 632de001-604c-446b-a80f-7963e950f3fb Characteristic: 632de002-604c-446b-a80f-7963e950f3fb Properties: ['write', 'write-without-response'] Characteristic: 632de003-604c-446b-a80f-7963e950f3fb Properties: ['notify'] ``` Three sequential UUIDs. Classic BLE architecture. `...02` for sending commands to the device. `...03` for receiving data back. Nothing exotic. This was workable. I wrote another script to connect and capture every packet the pendant emitted. Between traffic lights, airport Wi-Fi, and boarding announcements, he kept running commands. After a lot of back and forth, reconnects, Bluetooth restarts, and button presses, we got stable connections. But no audio. The pendant just sat there. LED dark. Completely silent. ## The Missing Piece I tried everything. Different command sequences. Different timing. Different connection orders. Nothing worked. So I stopped trying to talk to the pendant and started listening to what the official app was saying to it. I walked my friend through enabling developer options on his Android phone. He used the pendant normally while capturing HCI logs, the raw Bluetooth traffic between phone and device. He did this several times, restarting between runs. By then, he was at the airport. By the time his flight boarded, I had a stack of packet captures in my inbox. I loaded them into Wireshark and compared sessions side by side. Hex dumps blur together after a while. Then something stood out. ![](https://mohsin.xyz/blog/the-pendant-that-refused-to-die/ad1c42fc-427d-4e20-bd23-e23e4ee04f62.webp) One packet appeared in every capture. Same structure. Same position in the handshake. Every time. ```plaintext 32 07 08 c1 97 c6 c2 af 33 ``` I did not immediately recognize the format. I dumped a batch of packets into Claude and asked what it could identify. Those look like protobuf field tags. Of course. I decoded it by hand. - `32` means field 6, length delimited - `07` means seven bytes follow - `08` means nested field 1, varint - `c1 97 c6 c2 af 33` is a varint encoded value The decoded value was **1765102684161**. A Unix timestamp in milliseconds. December 7, 2025. The app was telling the pendant what time it was. That was the missing piece. The pendant timestamps all audio internally. Without knowing the current time, it literally cannot record. It was not broken. It was not locked down. It was waiting. I wrote the encoder in about thirty seconds. ```python def encode_set_current_time(timestamp_ms: int) -> bytes: time_varint = bytes([0x08]) + encode_varint(timestamp_ms) return bytes([0x32, len(time_varint)]) + time_varint ``` I sent the updated script. By then, he was taxiing for takeoff. He ran it again after reconnecting. Then he pressed the button. The LED turned on. For the first time, without the official app, the device was recording. ## Cracking the Stream Recording and getting usable audio are different problems. Packets were flowing now, even as his plane climbed out of London. The data looked like noise. I needed to identify the codec. I made the obvious guess first. Opus at 16 kHz. Almost everyone uses it for voice. Sure enough, certain bytes repeated at consistent offsets: `0xb8`, `0x78`, `0xf8`. I pulled up RFC 6716, the Opus specification. These were TOC bytes. Table of Contents. They encode frame configuration, mono or stereo, and frame count. `0xb8` decodes to config 23, mono, single frame. Exactly what you would expect from a wearable microphone. ![](https://mohsin.xyz/blog/the-pendant-that-refused-to-die/8231ddab-1671-482b-b6a7-d13db0919cb5.webp) The pendant was encoding 20 ms Opus frames and wrapping each one in a protobuf message. I extracted a few dozen frames, stitched them together, wrote them to an Ogg file, and hit play. My friend’s voice came through the speakers. ![](https://mohsin.xyz/blog/the-pendant-that-refused-to-die/6deffe68-285b-4db9-9844-418adba2773a.webp) Spectrogram of the first successful extraction. That's speech. A little crackly at the transitions, but unmistakably real. Recorded on a device I had never touched, using a protocol I had learned existed only hours earlier. ## Ship It For anyone keeping score, the full stack looked like this. ```text β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Application Layer (Opus audio frames) β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ Message Layer (Protobuf fields) β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ Fragment Layer (sequence and count) β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ BLE GATT (TX: ...02 / RX: ...03) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` Once the Python proof of concept worked, I ported everything to Dart for our Flutter app. I pushed a build to TestFlight and sent it to a handful of Limitless users who had reached out. It works. My recordings show up. I can export them. It is real. By the time my collaborator landed in the United States, the protocol was cracked. We pushed it live to the App Store the next day. ![](https://mohsin.xyz/blog/the-pendant-that-refused-to-die/bc84a9c3-9a5b-42a0-a7e4-bb4d17a5a9c0.webp) Omi traffic after Limitless support went live. No encryption was bypassed. No proprietary code was decompiled. The protocol uses standard BLE, standard Protobuf, and standard Opus. All open specifications. This was pattern recognition, persistence, and one very patient collaborator with a device I could not touch. My pendant finally arrived three days later. By then, it already worked with Omi. Screenshots and code samples have been simplified for clarity. The actual implementation is in [the PR.](https://github.com/BasedHardware/omi/pull/3641) --- --- title: "The Protocol Nobody Uses" publishedAt: "2026-01-02" summary: "Why GATT is the wrong abstraction for BLE audio, and what L2CAP CoC gives you instead." image: "https://mohsin.xyz/blog/the-protocol-nobody-uses/f9b1589a-2a52-4768-a71a-ebee2273cd52.webp" --- I was staring at a packet capture, trying to figure out why my script wasn't receiving audio data. The device was clearly working. LED on. App showing audio levels. But when I subscribed to the audio characteristicβ€”the standard approach for every BLE audio device I'd ever worked withβ€”nothing came through. So, I opened Wireshark as usual and examined the raw packets. The GATT traffic appeared normal. There was service discovery, characteristic reads, and control commands being exchanged. Then, I noticed something unusual: audio-sized packets were moving through a channel I didn't recognize. CID `0x0041`. That's not GATT. GATT runs on CID `0x0004`, a fixed channel for the ATT protocol. What I was looking at was a dynamic channel, an L2CAP Connection-Oriented Channel. I'd read about this in the Bluetooth spec sometime ago and promptly forgotten about it, because I'd never seen it used in the real world. Someone was actually doing it differently. ## How Bluetooth LE Actually Works Before I explain why this matters, let me walk you through how BLE moves data. If you've worked with BLE before, you probably interact with GATT: services, characteristics, notifications. But GATT is just the top layer of a stack, and understanding the layers below it explains why there's a better way. ### The Stack ```plaintext β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Application β”‚ ← Your code β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ GATT (Generic Attribute) β”‚ ← Services & characteristics β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ ATT (Attribute Protocol) β”‚ ← Read/write/notify operations β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ L2CAP β”‚ ← Packet framing & channels β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ Link Layer β”‚ ← Radio packets β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ Physical Layer β”‚ ← 2.4 GHz radio β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` **Physical Layer**: The actual radio, broadcasting at 2.4 GHz. **Link Layer**: Handles the raw radio packets. This is where Bluetooth defines how devices advertise, connect, and exchange data over the air. The original BLE spec (4.0) limited each packet to 27 bytes of payload. Bluetooth 4.2 introduced Data Length Extension (DLE), allowing up to 251 bytes. **L2CAP (Logical Link Control and Adaptation Protocol)**: Think of this as the postal service. It takes data from higher layers, adds a 4-byte header with length and channel ID, and hands it to the Link Layer. It can also segment large messages across multiple packets and reassemble them on the other side. **ATT (Attribute Protocol)**: Defines a simple database of "attributes"β€”small pieces of data identified by handles (like memory addresses). ATT provides operations to read, write, and get notifications about these attributes. **GATT (Generic Attribute Profile)**: Builds on ATT to create a hierarchical structure. Attributes are grouped into "characteristics" (a value plus metadata), which are grouped into "services" (a collection of related characteristics). This is the layer most developers interact with. ![](https://mohsin.xyz/blog/the-protocol-nobody-uses/9621ead7-9116-4b50-908a-1077c96478b9.webp) ### How Data Flows in GATT When your fitness tracker sends your heart rate to your phone, here's what happens: 1. Your phone discovers the Heart Rate Service (UUID `0x180D`) 2. Inside that service, it finds the Heart Rate Measurement characteristic 3. Your phone enables notifications by writing to a special descriptor (CCCD) 4. The tracker sends notifications whenever your heart rate changes 5. Each notification travels: GATT β†’ ATT β†’ L2CAP β†’ Link Layer β†’ Radio β†’ Phone The key point is that GATT and ATT always operate on a fixed L2CAP channel (CID `0x0004`). Every service, characteristic, and notification is sent through this single channel. This works great for small, infrequent data like heart rate or temperature readings. It gets awkward for streams. ## The Problem with GATT for Streaming Let's do the math on what happens when you push audio through GATT. ### Packet Size Constraints The Link Layer defines how much data fits in a single over-the-air packet: | BLE Version | Link Layer Payload | L2CAP Header | Available for ATT | | --- | --- | --- | --- | | 4.0 / 4.1 | 27 bytes | 4 bytes | 23 bytes | | 4.2+ (DLE) | 251 bytes | 4 bytes | 247 bytes | That 23-byte or 247-byte figure is your ATT\_MTU, the maximum size of an ATT operation. But wait, there's more overhead. A GATT notification needs: - 1 byte for the ATT opcode (notification = `0x1B`) - 2 bytes for the attribute handle So your actual payload per notification is ATT\_MTU minus 3: | Scenario | ATT\_MTU | Notification Payload | | --- | --- | --- | | Default (no DLE) | 23 bytes | **20 bytes** | | With DLE | 247 bytes | **244 bytes** | ![](https://mohsin.xyz/blog/the-protocol-nobody-uses/84d0ad6f-bc91-463f-845e-9dfeffa354db.webp) ### What This Means for Audio Say you're streaming 16-bit audio at 16 kHz mono. That's 32 KB per second of raw PCM data. **Without DLE (20-byte notifications)**: - 32,000 Γ· 20 = 1,600 notifications per second - Each notification has 7 bytes of overhead (L2CAP + ATT headers) - You're sending 11,200 bytes of overhead per second just in headers **With DLE (244-byte notifications)**: - 32,000 Γ· 244 = ~131 notifications per second - Much more reasonable, but still constrained Most devices use compressed audio (Opus, AAC) at lower bitrates, so the math isn't quite this brutal. But the fundamental problem remains. ### The Bigger Problem: No Flow Control Here's what really hurts: GATT notifications are fire-and-forget. The server sends a notification. The client either receives it or doesn't. There's no acknowledgment, no backpressure, no way for the client to say "slow down, I'm busy." If the client's Bluetooth stack gets temporarily overwhelmed due to a CPU spike, garbage collection, or another app using Bluetooth, packets can just vanish. The server keeps sending without realizing, and your audio experiences glitches. For a heart rate notification every second, this rarely matters. For 131 audio packets per second, it's a real problem. ## The Alternative: L2CAP Connection-Oriented Channels Remember that L2CAP layer sitting below ATT? It turns out you can use it directly, bypassing GATT entirely. L2CAP Connection-Oriented Channels (CoC) were added in Bluetooth 4.1. Instead of shoving everything through the fixed ATT channel, you open a dedicated channel for your data stream. ### How It Works ```plaintext Central (Phone) Peripheral (Device) β”‚ β”‚ │══ LE Credit Based Connection ═══════►│ β”‚ PSM: 0x0080 β”‚ β”‚ MTU: 2048 β”‚ β”‚ MPS: 247 β”‚ β”‚ Initial Credits: 10 β”‚ β”‚ β”‚ │◄═════ Connection Response ═══════════│ β”‚ Assigned CID: 0x0041 β”‚ β”‚ Credits: 10 β”‚ β”‚ β”‚ │◄══════════ Data ════════════════════ β”‚ │◄══════════ Data ════════════════════ β”‚ │◄══════════ Data ════════════════════ β”‚ β”‚ β”‚ │═══════ More Credits ═══════════════► β”‚ β”‚ (flow control) β”‚ β”‚ β”‚ ``` Let me break down the terminology: **PSM (Protocol/Service Multiplexer)**: Like a port number in TCP. Identifies what protocol or service this channel is for. Values `0x0001`–`0x007F` are reserved by the Bluetooth SIG. Values `0x0080`–`0x00FF` are for custom applications. **CID (Channel Identifier)**: A unique ID for this specific channel on this specific connection. Dynamic channels use CIDs from `0x0040` to `0x007F`. **MTU (Maximum Transmission Unit)**: The largest "message" (SDU: Service Data Unit) you can send. The spec allows up to 65,535 bytes, though memory constraints usually limit this to a few KB. **MPS (Maximum PDU Size)**: The largest single packet (PDU: Protocol Data Unit) on this channel. L2CAP will automatically segment larger SDUs into MPS-sized chunks. **Credits**: Here's the magic. Each credit allows the sender to transmit one PDU. When you run out of credits, you stop sending. The receiver grants more credits when it's ready for more data. ### Credit-Based Flow Control This is the key difference from GATT. ```plaintext Initial state: Device has 10 credits from Phone Device sends audio packet #1 β†’ Credits remaining: 9 Device sends audio packet #2 β†’ Credits remaining: 8 Device sends audio packet #3 β†’ Credits remaining: 7 ... Device sends audio packet #10 β†’ Credits remaining: 0 Device must wait... Phone finishes processing, sends 8 more credits Device receives credits β†’ Credits available: 8 Device resumes sending ``` If the phone's app is busy, it doesn't grant more credits. The device waits instead of flooding packets into the void. When the phone catches up, it grants credits and data flows again. No dropped packets. No glitches from buffer overflow. The sender always knows the receiver is ready. ## Side-by-Side Comparison Let me put the two approaches next to each other: ### GATT Notification (with DLE) ```plaintext β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ L2CAP Header β”‚ ATT Header β”‚ Payload β”‚ β”‚ (4 bytes) β”‚ (3 bytes) β”‚ (up to 244 bytes) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ Channel: Fixed (CID 0x0004) Max payload: 244 bytes per notification Max characteristic value: 512 bytes total Flow control: None Discovery required: Yes (services, characteristics, CCCD) ``` ### L2CAP CoC K-frame (with DLE) ```plaintext β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ L2CAP Header β”‚ Payload β”‚ β”‚ (4 bytes) β”‚ (up to 247 bytes) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ (First frame of SDU includes 2-byte SDU length field) Channel: Dynamic (CID 0x0040–0x007F) Max payload: 247 bytes per PDU, up to 65,535 bytes per SDU Flow control: Credit-based Discovery required: No (just need to know the PSM) ``` The per-packet efficiency is similar, both are around 97% payload. The wins for CoC are: 1. Flow control prevents data loss under load 2. Larger logical units (64KB SDU vs 512-byte characteristic) 3. No GATT overhead (service discovery, handles, CCCDs) 4. Symmetric bidirectional (both sides equally efficient) ## The Ecosystem Gap When I tried to work with L2CAP CoC from my usual tools, I ran into a wall. ```python # bleak - the standard Python BLE library # L2CAP CoC support: None # GitHub issue #598: closed as wontfix ``` Bleak is a GATT client. It doesn't expose L2CAP directly, and the maintainers have decided that's outside scope. The pattern repeats across cross-platform tools: | Platform / Library | GATT | L2CAP CoC | | --- | --- | --- | | iOS (CoreBluetooth) | βœ… | βœ… (since iOS 11) | | Android | βœ… | βœ… (since API 29) | | Web Bluetooth | βœ… | ❌ | | Python (bleak) | βœ… | ❌ | | Flutter (flutter\_blue\_plus) | βœ… | ❌ | | React Native (ble-plx) | βœ… | Native modules only | The native SDKs support it. The cross-platform libraries don't. If you're building with Flutter or React Native; which many teams choose for faster iteration, you'd need to drop into Swift and Kotlin separately. That's not a dealbreaker for everyone, but it explains why most tutorials, Stack Overflow answers, and sample code stick to GATT. ### The Documentation Gap Search "BLE GATT tutorial" and you'll find hundreds of results. Nordic, TI, Espressif, Silicon Labsβ€”every chip vendor publishes getting-started guides with working sample projects. Search "BLE L2CAP CoC tutorial" and you get the Bluetooth Core Specification and a handful of sparse API references. When I needed to understand the credit-based flow control details, I ended up reading the spec. It's not that the information doesn't exist, it's that nobody has packaged it into the kind of step-by-step guides that make GATT feel approachable. ## When CoC Makes Sense Despite the tooling gap, L2CAP CoC is worth considering for specific use cases: **Streaming where reliability matters**: If dropped packets mean audible glitches or corrupted data, credit-based flow control prevents the silent failures that GATT notifications allow. **Bulk transfers**: Firmware updates, file sync, log downloads. The 512-byte characteristic limit in GATT requires chunking logic. CoC can send larger SDUs natively. **Controlled ecosystems**: If you build both the firmware and the appβ€”and don't need third-party integrationsβ€”the compatibility concerns shrink. You're writing native code anyway. **Bidirectional real-time data**: Control systems where both directions need equal efficiency and guaranteed delivery. A clean architecture separates concerns: ```plaintext GATT (control plane) β”œβ”€β”€ Device configuration β”œβ”€β”€ Status queries β”œβ”€β”€ PSM advertisement └── Standard services (Device Info, Battery) L2CAP CoC (data plane) β”œβ”€β”€ Audio streaming β”œβ”€β”€ File transfer └── High-frequency sensor data ``` Use the database for database things. Use the pipe for pipe things. ## LE Audio Changes the Equation Bluetooth LE Audio is now shipping on recent devices; AirPods Pro (2nd gen), iPhone 14 and later, Samsung Galaxy S23 series, Pixel 7 and up. The LC3 codec and isochronous channels provide a standardized path for audio streaming that's built into the spec. For new products targeting current hardware, LE Audio is the right answer. It handles the codec, the transport, and the synchronization. You don't need to roll your own. But LE Audio requires Bluetooth 5.2+ hardware on both ends. The installed base of older devicesβ€”phones from 2020, fitness trackers, smart home gadgetsβ€”won't get LE Audio support through software updates. That tail is long. If you're building for the current generation, use LE Audio. If you need to support older devices, or you're working on something LE Audio doesn't cover (non-audio bulk data, custom protocols), L2CAP CoC remains the better-than-GATT option that most developers don't know exists. ## Quick Reference ### Key Numbers | Parameter | Default (BLE 4.0/4.1) | With DLE (BLE 4.2+) | | --- | --- | --- | | Link Layer payload | 27 bytes | 251 bytes | | L2CAP header | 4 bytes | 4 bytes | | ATT\_MTU | 23 bytes | 247 bytes (optimal) | | GATT notification payload | 20 bytes | 244 bytes | | Max characteristic value | 512 bytes | 512 bytes | | L2CAP CoC SDU max | 65,535 bytes | 65,535 bytes | ### Platform Support | Platform | GATT | L2CAP CoC | Since | | --- | --- | --- | --- | | iOS (CoreBluetooth) | βœ… | βœ… | iOS 11 (2017) | | Android | βœ… | βœ… | API 29 (2019) | | Web Bluetooth | βœ… | ❌ | β€” | | Python (bleak) | βœ… | ❌ | β€” | | Flutter | βœ… | ⚠️ Native only | β€” | | React Native | βœ… | ⚠️ Native only | β€” | ### L2CAP CoC Terminology | Term | Meaning | | --- | --- | | PSM | Protocol/Service Multiplexer - like a port number | | CID | Channel Identifier - unique ID for this channel | | MTU | Maximum SDU size (logical message) | | MPS | Maximum PDU size (single packet) | | SDU | Service Data Unit - your actual data | | PDU | Protocol Data Unit - one L2CAP packet | | Credits | Flow control tokens - one credit = one PDU allowed |