MentraOS OEM Display Protocol

This is the firmware-side contract an OEM implements so that MentraOS โ€” running on the userโ€™s phone โ€” can drive your glasses. The phone is the brain: it owns all application logic and screen layout and streams your glasses commands that say what to draw. Your glasses are a thin client for the live display, plus a small firmware-resident home screen they render on their own while disconnected. Control messages between the phone and the glasses are Protocol Buffers; audio and images stream as raw binary for throughput. Every BLE packet begins with a 1-byte control header that names the payload type. We assume one thing of your firmware: a render stack capable of the draw primitives below. If you already ship a chunked image transport, you may reuse it for bitmap pixel data rather than implementing a new one.

Contents

Foundations Display Input, Audio & Notifications Device & Implementation

Foundations

๐Ÿ” Transport

Communication is standard BLE GATT. The phone is the central; the glasses are the peripheral and send notifications on the RX characteristic.

Packet types

Every packetโ€™s first byte is a control header naming the payload:

Protobuf control messages

A protobuf packet is 0x02 followed by the protobuf-encoded bytes:
No length header is needed; the BLE characteristic defines packet length. All control messages are PhoneToGlasses or GlassesToPhone (a oneof over every command/event). If the glasses receive a field or message type they do not recognize, they ignore it rather than fail โ€” this is what lets the protocol grow without breaking older firmware.

MTU

Standard BLE MTU is 23 bytes (20 payload); an extended MTU is negotiated up to 512. Image and audio chunks are sized to fit the negotiated MTU minus their headers; an audio frame must fit a single packet.

๐Ÿงญ Capability Descriptor

The phone needs each deviceโ€™s parameters to lay out frames correctly: screen size, intensity depth, available fonts, and max image size. The glasses report them in response to request_glasses_info:

๐Ÿ”  Fonts

The phone wraps and lays out all text, so it must measure each line against the metrics of the font the glasses will actually render. The glasses report their fonts in the capability descriptor; the phone picks a font_code for every text draw and measures against the matching fontโ€™s metrics, which MentraOS holds. An OEM ships at least one font covering the required languages below; the runtime then wraps text identically to what the glasses render. Required glyph coverage: Latin (incl. extended Latin for European languages such as Spanish, French, German, Italian, Portuguese, Dutch, Polish, Turkish, Vietnamese) and CJK โ€” Chinese (Simplified and Traditional), Japanese, and Korean. Recommended: other scripts โ€” Cyrillic (Russian, Ukrainian), Arabic, Hebrew, Devanagari (Hindi), Bengali, and Thai. Use the Mentra-provided IBM Plex Sans build, or share your own font (Mentra loads its metrics into the runtime).

Runtime font upload (optional)

If supports_font_upload is true, the phone can push a font to the glasses at runtime. A font is just another binary asset, so it reuses the cached-bitmap transfer path (preload_image) rather than a new one: the glyph-table bytes stream over the 0xB0 channel against a stream_id and are acked with image_transfer_complete, exactly like a preloaded image. The only extra is a control message that says โ€œthe blob arriving on this stream_id is a font โ€” register it under this font_codeโ€:
Once the transfer completes, the firmware stores the font in one of the max_uploaded_fonts slots and addresses it by font_code exactly like a resident font.

โœ… Acknowledgements & Forward Compatibility

Many commands are fire-and-forget. A single generic result message lets the phone confirm that state-changing commands landed and detect features a given firmware doesnโ€™t implement. Every PhoneToGlasses carries a msg_id; the glasses echo it in a result:
The glasses should ack state-changing commands โ€” especially commit, SetHomeScreen, and asset uploads, where the phone must know the change was persisted โ€” rather than stay silent. High-frequency, fire-and-forget commands (individual draw_*) need not be acked. Forward compatibility: unknown fields and message types are ignored, never fatal. When the phone sends a feature the firmware doesnโ€™t implement, the firmware ignores the effect and returns UNSUPPORTED, so the phone can adapt rather than guess.

Display

๐ŸŽž๏ธ The Drawing Model

The phone draws by sending draw commands that queue on the glasses, then a commit that paints them all at once. Nothing appears until commit. Two examples show the whole model. Draw a fresh frame โ€” lead with clear, then the ops, then commit:
Change one element โ€” give the element an id when you draw it, then update that id later. No clear, so everything else stays:
The rules behind those two examples:
  • Everything queues; nothing reaches the panel except via commit. clear_display, every draw_*, and update accumulate in a pending batch. commit flushes the batch atomically โ€” the wearer never sees a half-applied batch, and a commit is the only thing that changes the screen.
  • Draw order is layer order. Commands paint in the order sent; later commands paint over earlier ones. To put text on a filled box, send the rectangle first, the text second.
  • clear_display is the only thing that wipes. Queue it to start a fresh frame; it drops the standing scene โ€” including all retained elements, so their ids become free โ€” and the commit begins from blank. A batch with no clear_display amends the standing scene rather than replacing it.
  • Retained elements and partial repaint. A draw_* op may carry an optional id (1-based; absent = unaddressed), making it a retained element the firmware keeps so the phone can change it later with update (see Updating one element). A commit whose batch is only updates repaints just those elementsโ€™ regions, leaving the rest of the panel untouched โ€” the flicker-free path for changing one field on a busy screen. Any other batch (a clear_display, or any non-id draw_*) is a full frame and repaints the whole panel.
  • Intensity, not RGB. The displays are monochrome. Every drawable carries an intensity from 0 (off) to 15 (brightest); 0 means โ€œnot drawn.โ€
  • Bitmaps composite; unset pixels are transparent. Only lit pixels paint. An unset pixel emits no light, leaves what is beneath untouched, and lets the real world show through โ€” so a bitmap layers cleanly over text and shapes without a hard erase.
Pacing is the phoneโ€™s job, not the firmwareโ€™s. The firmware does not implement flow control or backpressure on the display channel. The phone paces its own sends and coalesces redundant frames per device; the glasses simply render what arrives.

๐Ÿ–ฅ๏ธ Display Commands

All display commands are PhoneToGlasses protobuf messages, carry a msg_id, and queue into the pending batch โ€” they take effect on the next commit.

Text

Text is drawn in UTF-8 with (x, y) as the top-left corner of a single line. The phone handles all line breaking and wrapping โ€” it measures against the font metrics (see Fonts) and sends each line as its own display_text. The glasses never compute multi-line layout.

Shapes

Every draw_* op carries an optional id (as in display_text above) to retain it as an addressable element. These same op messages are also the building blocks of the stored home screen. The op messages:
id is 1-based; 0 or absent means the op is a one-shot paint, not retained. The firmware can hold up to max_addressable_elements retained elements (see Capability Descriptor); creating more returns NO_MEMORY. The field member is ignored on the live display โ€” the phone draws live values itself, and the firmware never repaints the live screen on its own. It is honored only inside a stored home-screen frame, where the firmware renders standalone (see The Home Screen).

Commit and clear

Both queue into the pending batch and take effect on commit (see The Drawing Model).

Updating one element

update replaces the content of a retained element (one whose draw_* carried an id). It queues like any draw op and takes effect on the next commit; a commit whose batch is only updates repaints just those elementsโ€™ regions, so changing one field on a busy screen does not flicker the rest of the panel. update carries a full replacement op. It is an upsert: if id exists, its content is replaced; if it does not (e.g. the scene was wiped by a clear), the op is created as a new retained element. The replacement op must be the same type as the element it replaces (you cannot turn a DrawText element into a DrawCircle). Its coordinates may differ from the original, so an update can move the element โ€” the firmware clears the elementโ€™s old region and paints it at the new position, all within the partial repaint.
To change several elements together without tearing, queue several updates and a single commit โ€” they all repaint in one atomic frame. For example, to show an image with a label and then change only the label โ€” without re-sending the image:

Bitmaps

A bitmap is sent with two messages: a protobuf display_image that places it in the draw order and declares its geometry, and the pixel bytes streamed on the binary channel. The bitmap takes its place in the draw order the instant the protobuf message is queued; the pixels are held against its stream_id until commit, where they render as part of the frame.
The pixels follow on the binary channel, one chunk per packet:
  • stream_id: 2 bytes, matching the display_image message.
  • chunk_index: 0โ€“255.
  • chunk_data: raw pixel bytes, โ‰ค MTU โˆ’ 4.
When every chunk of a bitmap has arrived, the glasses confirm the transfer:
Or, if chunks are missing:
The phone re-sends only the missing chunks and waits again, repeating until acknowledged or a per-chunk timeout. The phone waits for status: OK before it sends commit. Multiple bitmaps may be in flight in one frame; stream_id routes each chunk to the right bitmap.

Cached bitmaps

A bitmap can be preloaded once and then drawn by id without re-sending the pixels. preload_image uses the same binary chunking as display_image but stores the image under an image_id instead of drawing it. The imageโ€™s dimensions and encoding live only here, at preload time:
The pixel chunks transfer as above; completion is reported with image_transfer_complete against stream_id: "003B". Once cached, draw it by id at a position โ€” no dimensions, since the firmware already knows the imageโ€™s size from preload (this enqueues into the pending frame like any draw op):
Evict a cached bitmap when it is no longer needed:

Display power and geometry

Brightness


๐Ÿ  The Home Screen

While the phone is connected, MentraOS draws every screen โ€” including any menus or app launchers โ€” with the ordinary draw commands above, reacting to button_events. The firmware has no resident menus or navigation of its own. The home screen exists for one reason: the glasses must not go dead during a momentary BLE drop. It is a single firmware-resident screen the glasses can render on their own, showing glanceable info, so a disconnect is invisible to the wearer.

Display states

  • Idle: the display is off. Nothing is shown until the wearer wakes it (head-up gesture, or a tap โ€” whatever the device supports).
  • On wake while connected: the phone is driving โ€” it draws whatever it wants.
  • On wake while disconnected: the firmware shows the cached home screen.

Uploading the home screen

The phone uploads one home-screen frame, built from the same draw ops as the live display (above). The firmware persists it in flash and re-renders it whenever it needs to show the home screen offline.
Most content is baked in as literal text โ€” weather, notification count, calendar, anything phone-sourced. It cannot change on the glasses, so when it changes the phone simply re-uploads the frame; there is nothing for the firmware to track. The exception is the field placeholder, which is honored here (and only here). A few values change on the glasses while disconnected because only the glasses know them โ€” and field lets the firmware fill them from its own state as it renders: TIME/DATE from its RTC, BATTERY from its gauge, LINK_STATUS as an offline glyph when the link is down. A DrawText op with field set renders the firmwareโ€™s live value instead of its literal text, and the firmware keeps it current (the clock ticks, the battery updates) for as long as the home screen is shown. SetHomeScreen is acked with command_result. ids carry no meaning here โ€” the home screen is a stored frame, not a live scene; to change it, re-upload.

Setting the clock

The firmware ticks TIME/DATE from its own RTC, so it stays correct while disconnected. The phone syncs the clock on connect and periodically (for drift and timezone):
format_24h is a MentraOS user setting; the phone pushes it here and the firmware persists the last value and renders TIME accordingly. The firmware does not expose its own 12/24-hour menu.

Input, Audio & Notifications

๐ŸŽฎ Input & Sensors

Buttons

Triggered by a hardware button tap or hold:

Head gestures and position

When a subscribed gesture fires:
Head tilt angle (degrees), and the head-up detection threshold:

IMU

The glasses report:
IMU streaming runs at a configurable 10โ€“100 Hz.

๐Ÿ”‰ Audio & Microphone

Audio is a binary stream on the 0xA0 channel, controlled by protobuf messages. Frames are LC3, sized to the negotiated MTU; the 1-byte stream_id distinguishes streams (e.g. microphone vs TTS).

Microphone

While the mic is enabled, the glasses stream LC3 frames continuously:

Voice Activity Detection (VAD)

When voice activity starts or stops, the glasses emit:

๐Ÿ”” Notifications

The glasses firmware does very little for notifications. MentraOS owns all notification logic - what to show, how, and when - and renders notifications with the ordinary draw commands described above. The firmware never decides what is notification-worthy and never stores notification UI of its own. Android. The glasses are not involved at all. The phone reads notifications through its own OS-level listener and draws whatever it wants on the glasses. There is nothing to implement on the firmware side. iOS. iOS does not expose notifications to a companion app the way Android does; instead a BLE accessory reads them directly over Appleโ€™s ANCS (Apple Notification Center Service). So on iOS the glasses do one thing: act as an ANCS consumer and relay each notification to the phone.
  • After bonding, the glasses subscribe to ANCS on the iPhone (the iPhone is the Notification Provider; the glasses are the Notification Consumer). This is standard ANCS โ€” most BLE SoC SDKs ship an ANCS client.
  • ANCS delivers every notification from the iPhone to the accessory; there is no per-app filter in the protocol itself. Each notification carries the source appโ€™s bundle id as an attribute. The glasses do not filter โ€” they relay everything, and MentraOS applies the userโ€™s per-app preferences on the phone.
  • For each notification, the glasses forward it to the phone and otherwise do nothing with it:
MentraOS takes it from there โ€” it decides whether to show the notification and, if so, draws it with the normal display commands. The firmwareโ€™s entire responsibility is: subscribe to ANCS, relay, done.

Device & Implementation

๐Ÿงฐ Device & System

Battery and charging

When the glasses detect they are charging, they emit asynchronously:

Heartbeat

The glasses send a periodic ping to verify the link is alive; the phone replies:

Pairing, disconnect, restart, factory reset


๐Ÿ’พ Implementation Notes

Protobuf on the MCU

nanopb (~10 KB footprint, static allocation, no dynamic memory) is the recommended library. A minimal receive path:
Schema conventions: field numbers never change; new fields are optional; oneof discriminates message type; repeated carries arrays (like missing_chunks).

Error handling

  • Invalid message: if protobuf decode fails, ignore the message.
  • Unknown fields/messages: ignore (forward compatibility).
  • Resource limits: return a command_result error for operations that exceed memory/display limits.
  • Timeouts: image transfers ~5 s per chunk; command responses ~1 s.
  • Connection loss: clean up pending operations and reset state.

Timing and buffers

  • Audio: 10 ms LC3 frame intervals; buffer 3โ€“5 frames for jitter.
  • Display: aim for < 50 ms latency for responsive UI.
  • IMU streaming: configurable 10โ€“100 Hz.
  • Image chunk buffer: typically 12 chunks (configurable, reported in Features).
  • Command queue: support at least 10 pending commands.

Security

  • Implement pairing/bonding and encryption at the BLE level.
  • Validate all input parameters; enforce maximum sizes for strings and arrays.
  • Rate-limit to prevent abuse via excessive commands.

๐Ÿ“Œ What a Complete Device Implements

  • Display & input: the transport, the drawing model (queue โ†’ atomic commit; clear to start fresh; retained idโ€™d elements with update for flicker-free partial repaint), draw primitives, image transfer and cached bitmaps, brightness, button/IMU/head events.
  • Capability descriptor & fonts: report geometry, intensity depth, and the resident fonts so the phone can measure and wrap text.
  • Acknowledgements: echo command_result for state-changing commands; honor forward-compatibility (ignore unknowns, return UNSUPPORTED).
  • Notifications: on iOS, subscribe to ANCS and relay each notification to the phone as ancs_notification; on Android, nothing. MentraOS draws notifications itself.
  • Home screen: accept a phone-uploaded home-screen design (SetHomeScreen), render it on wake while disconnected, fill the TIME/DATE/BATTERY/LINK_STATUS fields from its own state, and tick the clock from SyncClock. Display is off when idle.
Optional refinements (none change the contract above): saved-bitmap transforms (scale/rotate a cached image), 4-bit grayscale bitmaps, and curved/arc line support.
OEM Firmware Integration Specification - MentraOS