Engineering · IoT & connected hardware

Streaming live tractor telemetry into an Ionic app over Bluetooth LE

A farmer stands next to a tractor in a field with no cell signal at all, opens an app on their phone, and watches the engine's live RPM sweep across a gauge. Here's how we built that - the Capacitor BLE plumbing, the NgZone trap, and the server-side signal dictionary that lets one app speak to hundreds of tractor variants.

The product requirement

Our team builds and maintains the mySAME / myDeutz-Fahr farmer app for SDF Group - the Italian manufacturer behind the Deutz-Fahr, SAME, and Lamborghini Trattori tractor brands. It's a white-label Ionic + Capacitor app we've written about before in the mySAME case study: one codebase, multiple brands, thirteen European markets.

Newer tractors carry a Bluetooth Telematics Module (BTM) - a small unit on the machine that listens to the tractor's internal CAN bus and re-broadcasts engine signals over Bluetooth Low Energy. The requirement was direct: when a farmer walks up to their tractor, the app should connect to the BTM and show a live dashboard - engine speed on an RPM gauge, torque, coolant temperature, instant fuel consumption, accumulated engine hours, and the machine's position on a map.

Two constraints made it interesting. First, fields do not have Wi-Fi, and often no mobile coverage either - so the live path had to be phone-to-tractor, with no server in the loop. Second, SDF ships a lot of different tractor models, and as we'll see, they do not all speak the same dialect.

BLE in Capacitor: what you actually get

Capacitor doesn't ship Bluetooth support in its core - and that's fine, because BLE is exactly the kind of thing that belongs in a plugin. For standard GATT work (scan, connect, read characteristics, subscribe to notifications), the community plugin ecosystem is mature. But the BTM doesn't expose bare GATT characteristics you'd talk to by UUID - it speaks a proprietary protocol on top of BLE, and the module's manufacturer provides a native SDK for it.

So our integration is a vendor Bluetooth SDK behind a custom Capacitor plugin. The plugin is deliberately thin: it initializes the SDK with a developer key, runs device discovery, connects, and exposes a subscribe/unsubscribe pair per named property. Everything above that line is TypeScript.

On the app side, we wrapped the plugin's callback API in an Angular service that turns each operation into an Observable or Promise. Discovery is a stream of found devices; a property subscription is a stream of { propertyName, propertyValue } events. This wrapping matters more than it sounds: the rest of the app never touches the plugin directly, which means we could swap the vendor SDK - or mock it entirely for development on a desk with no tractor nearby - without touching a single page component.

Once connected, notifications arrive several times per second per property. Fast enough that a gauge needle feels alive; slow enough that a phone handles it without breaking a sweat.

The real problem: hundreds of tractor variants, one app

Here's the part that turned a Bluetooth feature into an architecture decision. The signals the BTM re-broadcasts are derived from the tractor's CAN bus - and different tractor models, generations, and engine configurations expose the same physical quantity under different signal identifiers. Engine speed on one model is not the same property name as engine speed on another. Coolant temperature might live under one identifier on a 2019 series and a completely different one on its successor.

The naive fix is a lookup table in the app: if model X, subscribe to signal A; if model Y, signal B. With three models, that's a switch statement. With SDF's catalogue - three brands, dozens of series, hundreds of product variants, and new ones launching every year - it's a maintenance disaster. Every new tractor model would require an app release on both stores, and a farmer with an outdated app would connect to their brand-new machine and see empty gauges.

We've seen this exact shape before in other hardware fleets: any time an app has to talk to a family of devices that evolved over years, the device-to-capability mapping is the thing that changes most often - and it's the thing you least want compiled into the binary.

The server-side signal dictionary

Our answer was to move the mapping out of the app entirely and treat it as data: a signal dictionary that lives on the backend. Concretely, it's a MongoDB collection keyed by product_code, where each document maps one tractor variant to the five signal names its BTM exposes:

{
  "product_code": "DF6165.4",
  "engine_speed": "ENG_SPEED_CAN1",
  "engine_torque": "ACT_ENG_TORQUE",
  "engine_coolant_temp": "COOLANT_TEMP_A",
  "fuel_rate": "FUEL_RATE_INST",
  "engine_hours": "TOTAL_ENG_HOURS"
}

(Illustrative values - the shape is what matters.) The app never sees product codes directly; farmers register machines by VIN. So resolution is a two-hop lookup the backend performs in one request:

GET /api/v1/telemetry-signals/{vin}

VIN ──► vin registry ──► product_code ──► signal dictionary
                                              │
        { engine_speed: "ENG_SPEED_CAN1", ... } ◄┘

Before the dashboard opens, the app calls this endpoint for the selected tractor, receives the five signal names, and subscribes to exactly those properties over BLE. Each incoming notification carries its property name, so routing a value to the right gauge is a straight comparison against the resolved names - no model logic anywhere in the client.

The second half of the win is who maintains the dictionary. The collection is editable from the back-office CMS we built for SDF's team - including a bulk CSV import and an update-by-commercial-family operation, so "set the coolant temperature signal for this entire series" is one form submission that fans out across every product code in the family. When a new tractor model ships, someone who has never opened an IDE adds its signal mapping, and every installed copy of the app supports the new machine immediately. Zero app releases. In the years this system has been live, the dictionary has absorbed new models routinely without a single client-side change.

The lesson we keep reusing: ship the mapping as data, not code. Any time device-specific knowledge is hardcoded in an app, every new device costs you a release, a review cycle, and a long tail of users on old versions. Put that knowledge behind an API keyed by whatever identifies the device, cache it on the client, and the app becomes a generic renderer that never needs to know what a "model" is.

Getting live data onto the screen

The dashboard itself is an Ionic Angular page with two needle gauges (engine speed and instant consumption, rendered by a lightweight gauge-chart library), numeric readouts for torque, coolant temperature, and engine hours, and a map. Wiring BLE streams to it surfaced the classic Ionic + Angular trap: the data arrives and the UI doesn't move.

Native plugin callbacks fire outside Angular's zone. Angular's change detection only runs for work it knows about - and a callback marshalled in from native Bluetooth code isn't on that list. So values update in the service, console logs show fresh data every 200 ms, and the template sits frozen at zero. The fix is one deliberate re-entry point:

private onPropertyValue(event: PropertyEvent) {
  this.zone.run(() => {
    if (event.propertyName === this.engineSpeedSignal) {
      this.speedGauge.updateNeedle(this.toPercent(event.propertyValue));
    } else if (event.propertyName === this.torqueSignal) {
      this.engineTorque = Number(event.propertyValue);
    }
    // ...coolant temp, fuel rate, engine hours
  });
}

All five property streams funnel through this single handler, so NgZone.run() appears exactly once instead of being sprinkled through the codebase. Notice the comparisons: this.engineSpeedSignal and friends are the values that came back from the dictionary lookup - this is where the server-side mapping and the BLE stream meet.

A few practical notes from tuning this in the field:

  • Update the needle, not the chart. Gauge libraries usually offer a cheap "move the needle" call alongside a full re-render. At several updates per second, the difference is a smooth sweep versus a slideshow on mid-range Android phones.
  • Deduplicate at the service layer. Engine hours change once every few minutes; there's no reason to trigger change detection for a repeated value. A distinctUntilChanged on each stream keeps the zone quiet.
  • Unsubscribe symmetrically. When the farmer leaves the dashboard, we explicitly unsubscribe from all five properties before disconnecting. Orphaned BLE subscriptions are a reliable source of "it worked yesterday" bugs - the module keeps notifying, the OS keeps waking your app, and the next connection behaves strangely.

Field conditions: offline, reconnection, flaky radios

Everything above has to survive an environment that's hostile to both radios involved. Our working assumptions:

  • Assume no internet at the point of use. The dictionary lookup happens when the farmer registers or selects the tractor - typically at home or in a connected farmyard - and the resolved signal names are cached on the device. In the field, the app needs only the cache and the BLE link. Live telemetry is device-to-device by design; there is no cloud round-trip to fail.
  • Treat the BLE connection as temporary. A tractor is a large moving chunk of metal; the farmer walks around it; the phone goes in a pocket. Connections drop and that's normal, so the app treats "connected" as a state to re-enter, not an event that happens once - discovery restarts, and subscriptions are re-established from the same cached signal list.
  • Show state honestly. A gauge silently holding its last value is worse than one that greys out. The connection state drives the UI, so a dropped link looks dropped instead of freezing at the last reading.

What this pattern generalizes to

Strip away the tractors and this is a template for any fleet of heterogeneous devices behind one app - EV chargers, medical devices, industrial sensors, dev-kits across hardware revisions:

  1. A thin native bridge. Wrap the vendor SDK or BLE stack in the smallest possible plugin surface; keep protocol-agnostic logic in the web layer where it's testable.
  2. A server-side capability dictionary. Map device identity to whatever varies per model - signal names, characteristic UUIDs, feature flags, calibration constants - and serve it from an API that non-engineers can edit.
  3. A resolve-then-subscribe client. The app resolves identity to capabilities once, caches the result for offline use, and renders whatever it's told about.
  4. One zone re-entry point where native callbacks meet the UI framework.

It's the same separation of concerns we applied in the cloud-to-printer IoT platform we engineered, at a different scale: keep the device-specific knowledge where it's cheapest to change. And because the mySAME app has lived through Cordova-to-Capacitor and multiple Angular major upgrades - a story we tell in our guide to modernizing legacy Ionic apps - we can add that the thin-plugin approach is also what made those migrations survivable: the Bluetooth integration crossed each upgrade almost untouched.

Frequently asked questions

Can a Capacitor app read live data over Bluetooth LE?

Yes. Capacitor apps can subscribe to BLE characteristic notifications through a plugin - either a community BLE plugin or, when the hardware ships with a vendor SDK, a custom plugin that bridges that SDK to JavaScript. In our tractor telemetry app, live engine values arrive as BLE notifications several times per second and drive gauges directly, with no server in the loop.

How do you handle different devices exposing different BLE characteristics?

Don't hardcode the mapping in the app. We keep a server-side signal dictionary - a database collection keyed by product code that maps each device model to the signal names it exposes for engine hours, engine speed, coolant temperature, torque, and fuel rate. The app resolves the identifier (VIN, serial, model) to a product code, fetches the signal names once, caches them, and subscribes to exactly those properties. Supporting a new model means adding a row of data, not shipping an app release.

Why does BLE data not update the UI in an Ionic Angular app?

Because BLE callbacks from native plugins fire outside Angular's zone, so change detection never runs and bound values appear frozen even though data is arriving. The fix is to re-enter the zone: wrap the value handling in NgZone.run() (or signals/manual change detection) so Angular re-renders. We wrap the single handler that fans values out to the gauges, which keeps the fix in one place.

Do you need a native plugin for BLE in Ionic?

For real hardware work, yes - the web Bluetooth API is not reliable inside mobile webviews. Community plugins like @capacitor-community/bluetooth-le cover standard GATT work well. If the device speaks a proprietary protocol on top of BLE, wrap the manufacturer's native SDK in a thin custom Capacitor plugin and keep all business logic in TypeScript, where it is testable and portable.

Work with Lumetha

Building a product that talks to hardware?

Lumetha builds connected-hardware products - BLE integrations, telemetry dashboards, device fleets behind one app - as a dedicated engineering pod. Every engagement starts with a two-week paid pilot on your real roadmap, so you risk two weeks, not a contract.

Book a 30-minute call →

Related: more of our IoT work - the architecture of a cloud-to-printer platform · the mySAME / myDeutz-Fahr white-label platform case study · modernizing a legacy Ionic + Capacitor app without a rewrite · hire a dedicated development team