Finally, a smartwatch firmware for the 90s: as subtle as a retro watch, but with modern conveniences.

ur, named after an archaic Norwegian word for a timepiece, runs on the PineTime and syncs with Gadgetbridge on Android.

Try it in your browser Download

The simulator runs the firmware's own code, compiled to WebAssembly: tap, swipe, and press its button as you would the watch's.

The clock face in the Arcade theme, white and red on black: a note for music playing, and 27° along the top, with the battery; then the time, with the date and the seconds under it; the heart rate; and ten blocks for the day's steps, six of them red, then ×1K: 6,000 steps so far, towards 10,000; a short red bar on the top edge means unseen notifications The same clock face in the Parchment theme, for the day: brown on cream The Last night screen: 7:20 asleep, from 23:20 to 06:55, and a bar of deep, REM, and light sleep with each one's time The Alarms screen, in the Handheld theme: smart wake on, for 06:30 on weekdays and 08:30 at weekends, then two alarms and two timers A glance while the phone navigates: the time, a right-turn arrow, 150 m, and the street

Swiping around the clock face, each screen sweeping in as a wave of red dithered blocks: right to Today's steps and heart rate, right again to last night's sleep, back to the clock, left to the alarms, back, and down to the latest notification A notification arriving with the screen off: the dim screen shows SIGNAL in red over a dashed rule, and the sender's name, JANE SMITH, large and centred in white; a tap wakes it to the clock face, with the notification along the bottom for a moment A glance: from a dark screen, the time shows large and dim, 17 above 45, for five seconds, then the screen goes dark again An alarm ringing at 07:30, the screen saying TAP: SNOOZE, BUTTON: STOP; a tap snoozes it, and the clock face shows when it rings again, 07:39

It does what you'd expect of a smartwatch (time, notifications, steps, heart rate, alarms, timers, music control, "find my watch"), and these on top:

Caveats: ur is experimental and personal: it's tested on two watches, and heart rate and sleep are still being tuned against a Garmin, so treat sleep stages as a rough guide. Steps come from the accelerometer's own counter, which skips walks of under about 8 steps and much stop-and-go walking indoors: a day around the house reads lower than a phone or a Garmin, though long walks count within a few percent. It needs an Android phone with Gadgetbridge, which can't install updates on a Bangle.js, so those go through another app or a computer. Once it has switched off (a flat battery, or storage mode), the watch doesn't know the time, or ring alarms, until the phone reconnects.

How it was built: in Rust, on embassy. It was written largely by Claude (Anthropic's model), steered and tested on real hardware by a human. That's me, Jon, 👋, but yes, this is very much LLM-written, Jon-guided! Although I'm also quite happy with what I've now landed on, and am daily driving this in real life :)

Using it

In Gadgetbridge, add the watch "Bangle.js ur", choose Pair, and type the passkey the watch shows when Android asks. Gadgetbridge then sets the time and forwards notifications on its own, and shows the battery. It fetches activity (steps, heart rate, sleep) only when you pull down on its device list, or tap Synchronize (the circular arrow) on the watch's card, or, with Settings → Automations → "Auto fetch activity data" on, when you unlock the phone (that needs a screen lock; "Minimum time between fetches" spaces them out). It passes steps, heart rate and sleep on to Health Connect if you like (its "Sync after device sync"): after the first sync since the watch started, after the one that brings a night, then after a sync at most every 2 hours, which ends with a short "End : Fetching sports details" message. A night reaches Gadgetbridge in one piece, once its sleep stages are settled: about 47 minutes after you get up and walk about (or take the watch off), or 2¼ hours if you don't (details). For the themes to follow your sun rather than Oslo's, the phone has to share its location: see C03. A few Gadgetbridge defaults need changing: see docs/gadgetbridge.md.

The clock face shows the time, date, battery, heart rate, the next alarm or timer, and the room's temperature: a nearby room sensor's, to the tenth (21.4°), or else, to the degree (21°), estimated from the watch's own on the wrist, or the watch's own off it. Steps fill ten blocks of 1,000 (×1K) towards 10,000, then of 10,000 (×10K); Today has the exact count, since midnight in the phone's time zone (after a flight across midnight, the new zone's). A bar along the top means unseen notifications; a note in the top left, that music is playing; a faded Bluetooth rune struck through in red there, that the phone isn't connected. The battery's percentage climbs on the charger no faster than the cell fills, and reads 100% only once charging is done, so it doesn't drop when the watch comes off; 0% is where it switches itself off.

On the charger, the screen lights up as soon as the watch makes contact (dimly in a quiet night), so you see at once that it charges. Every 5 minutes there, outside a quiet night and not over anything on screen, a dim glance shows the charge and how long until full (FULL IN 0:40, a rough guess that errs long). Once the charge is done, the watch buzzes twice and shows FULL, big, for a few seconds (the button puts it away), so you know it can come off: once a charge, after any alarm, call or update on screen has finished; in a quiet night it waits, dark and silent, for the first time you wake the screen after the night. A watch set on the charger full says nothing. The heart rate isn't measured there (the clock face says OFF, and its LED stays dark); taken off, the watch measures again two minutes later, or after 10 seconds if you look at the clock face.

Notifications show the app and who it's from (the sender, or the title without one), never what it says, and without emoji (nor the :shortcodes: Gadgetbridge sends in their place, here or in a caller's name or what's playing). One that comes with the screen on shows along the bottom for 4 seconds; with the screen off, it gets the whole screen, dimly, the name centred, and large if it's short.

Screens: swipe right for Today (steps and heart rate by the hour), then Last night; left for Alarms, the alarms set in Gadgetbridge, the settings (LOOK, then BUTTON GRID), then Info; down for the last three notifications; up for the button grid (if on), Music, and Air (while a room sensor is near: its CO₂, temperature, humidity and pressure, or those it measures). A short bar on an edge means there's more that way.

Touch and button:

On the settings' LOOK, tap THEME for the next colour scheme, FORCE to show the day's schemes (DAY) or the night's (NIGHT) whatever the sun says, or follow the sun (NONE), and LIGHT to switch brightness from AUTO to SOFT, a step dimmer, for a longer battery. With one scheme for the time of day, THEME is dim and can't change, nor FORCE with schemes of one side only: a tap says why (FAQ D01, D02). On BUTTON GRID, the next, tap BUTTONS to turn on the button grid, GRID for its layout (six buttons, or two layers), and BTHOME KEY (while presses go over BTHome) for the key Home Assistant asks for (see Home Assistant). To set an alarm or timer, tap it, then swipe the hours and minutes. Alarms set in Gadgetbridge are read-only on the watch; a one-off one switches itself off once it has rung, without Gadgetbridge hearing of it, so switch it off and on there to set it again. Alarms follow the phone's time zone and summer time, from when Gadgetbridge sends the change (at once, if connected); the night summer time starts or ends, each rings once, one in the skipped hour as the clock jumps. An alarm that comes round while another rings (smart wake too) rings as soon as that one is done.

Gadgetbridge forwards directions from Google Maps and OsmAnd by default; turn CoMaps on in its settings, under "Navigation apps".

Installing

Download ur-<version>-dfu.zip from the latest release: the same package installs ur over InfiniTime and updates it afterwards. ur-logging-<version>-dfu.zip is the same firmware keeping a log in the watch's flash, for reporting problems; it costs a little battery, and CONTRIBUTING.md explains fetching the log.

Send the zip over Bluetooth with either of these:

Themes

How the watch looks is its theme: Retro, the one in the screenshots, with colour schemes for the night and for the day. A build can have other themes instead, or as well: LOOK's THEME row then steps through every scheme built in. The themes there are, with what they look like, are in src/themes/. A release has a package of each other theme, ur-<theme>-<version>-dfu.zip, installed as above (one with colour schemes for only night or only day shows them by day and by night alike); to build any other mix, see CONTRIBUTING.md, and to make a theme of your own, AUTHORING-THEMES.md.

The first install, over InfiniTime

  1. On InfiniTime 1.16 or later, turn on the OTA switch in Settings. If you're sending with the nRF app and Gadgetbridge is connected to InfiniTime, disconnect it there: InfiniTime takes one connection at a time, so the app wouldn't find the watch.
  2. Send the zip. If the nRF app stalls at 14% with "GATT CONN TIMEOUT" (InfiniTime's side of the transfer), send it from a computer instead.
  3. The watch shows the PINE64 logo for about a minute while the bootloader swaps the images. Leave it be until ur's clock face appears.
  4. In Gadgetbridge, remove the InfiniTime device, and forget the watch in Android's Bluetooth settings too. Then add "Bangle.js ur" in Gadgetbridge, choose Pair, and type the passkey the watch shows. Within seconds, Gadgetbridge shows the battery, and firmware "ur" with its version; if not, disconnect and connect again there (see C05). The watch keeps ur once the phone has set the time; until then, a reset puts InfiniTime back.

Updating

Send a newer zip; Gadgetbridge can stay connected. The watch resets into the new version, and keeps it once Gadgetbridge has synced the time again; a reset before that goes back to the old one. Today's steps, and the activity the phone hasn't fetched, carry over.

To roll back by hand, hold the button for about 7 seconds, until the watch resets into the bootloader, and keep holding: release when the logo turns blue for the previous firmware, or red for PINE64's recovery firmware, a minimal InfiniTime to reinstall from.

Verifying

Each release file carries a GitHub attestation. To check that a download is exactly what this repository's release workflow built, with the GitHub CLI:

gh attestation verify ur-<version>-dfu.zip --repo jonhoo/ur

Releases are also reproducible: CONTRIBUTING.md shows how to rebuild one and compare.

FAQ

When the watch points you here, it shows an entry's code: a letter for its area (B the button grid, C connectivity, U updates, P power, S sensors, D display and touch, A alarms, N notifications, E errors), and a number.

B01: Home Assistant fires two events for each grid press

With GRID on 2 LAYERS, that's by design: see B04.

B02: Home Assistant has no trigger for a grid gesture

It lists a BTHome device's events only once it has seen each: tap, double-tap and hold (for 1.5 s, for "Long press" too) each grid button once (with two layers, any one). "Hold release" needs Home Assistant 2026.11 or later; older versions ignore it.

B03: Home Assistant asks for the watch's key again

The watch made a new key: it lost track of how many presses it had encrypted with the old one, and reusing that count would let anyone listening decode them. Give Home Assistant the new key from Settings → BTHOME KEY, as when you set it up.

B04: With two layers, presses reach Home Assistant as dimmers

The watch shows *B04 by GRID when it's on 2 LAYERS and BUTTONS on BTHOME or BOTH. Two layers make codes 1–99 (none ends in 0: the first button is the tens digit, the second the ones), and BTHome can't send each as a button of its own. Its buttons carry no number, so Home Assistant tells them apart only by their place in the advertisement, and an encrypted advertisement has room for about 15 bytes of data: six buttons, not one per code. So each press fires two events instead: a button event for the gesture (press, double_press, hold_press, long_press if held on to 1.5 s, then hold_release on lifting after a hold), and a dimmer event, which can carry a number, turning by the code: dimmer_1 right for a tap, dimmer_2 right for a double tap, dimmer_3 right for a hold, and dimmer_3 left for a long press (none for a release). To act on one code, trigger on its dimmer and direction (rotate_right or rotate_left: without it, a hold's trigger fires for its long press too), in YAML (a Manual event trigger in the automation editor). For 2, then 3, double-tapped:

triggers:
  - trigger: event
    event_type: bthome_ble_event
    event_data:
      address: "E5:12:34:56:78:9A"   # from BTHOME KEY
      event_class: dimmer_2          # double tap
      event_type: rotate_right
      event_properties: { steps: 23 }

Or skip the YAML: the two-layer blueprint matches codes and gestures for you. docs/buttons.md covers both ways in full.

With 6 BUTTONS instead, each button is its own BTHome button (button_1 to button_6), with a trigger in the automation editor for each button and gesture, no YAML needed. See docs/buttons.md.

B05: The watch says "BTHOME NOT SENT"

The watch couldn't send that grid press to Home Assistant: both its Bluetooth connections were in use, usually the phone plus a computer (fetching the field log, or updating it). Disconnect the computer, and press again.

C01: The watch says "NOT PAIRED"

A phone or computer connected without pairing, and ur only talks to devices paired with its passkey. Remove the watch from Gadgetbridge, if it's there, and forget it in Android's Bluetooth settings (Android may still hold InfiniTime's pairing). Then add it in Gadgetbridge, choose Pair, and type the 6-digit passkey the watch shows.

The watch disconnects a device that hasn't started pairing within 40 seconds, so it can't hold a connection the phone needs, and says "NOT PAIRED" at most every five minutes, however often the device comes back. Info's PHONE row shows it whenever one is connected. If it isn't yours (a phone that paired with the watch under InfiniTime, say), forget the watch on that device to stop it.

C02: The watch says "PAIRING LOST"

The watch remembers pairing with this phone, but the phone has forgotten it, for instance after the watch was removed from Android's Bluetooth settings. Remove the watch from both Android's Bluetooth settings and Gadgetbridge, then add it again in Gadgetbridge and type the passkey.

C03: The watch never gets a location from the phone

Without one, the themes and brightness follow the sun in Oslo. The watch asks Gadgetbridge for the phone's location when it connects with none from the last day, or after two hours apart (at most once an hour), when the time zone changes, and when it first hears a room sensor or stops hearing one (at most four times a day), and keeps the last one. Gadgetbridge only passes on fresh locations, and only with all of these on:

  1. In the watch's settings in Gadgetbridge: "Use phone GPS data", with "Only use network to determine location" off (with it on, no location ever arrived in testing).
  2. In Gadgetbridge's own settings, under Location: "Keep location updated".
  3. In Android's settings for Gadgetbridge: location permission "Allow all the time", and location itself on.

Phones without Google Play services may have no network location, so nothing arrives indoors until the watch happens to ask while the phone is outside.

C04: The watch says "INTENTS OFF"

Gadgetbridge didn't pass a button grid press on to other apps: turn on "Allow Intents" in the watch's settings in Gadgetbridge.

C05: Gadgetbridge shows no battery, or firmware "N/A"

Nothing the watch sends reaches Gadgetbridge: it most likely asked for it before the link was paired, was refused, and carried on regardless. The watch notices within about 10 seconds and disconnects once, and Gadgetbridge reconnects and asks again. If that didn't help:

  1. In Gadgetbridge, disconnect the watch and connect it again.
  2. Check its device type in Gadgetbridge is Bangle.js. An entry left from InfiniTime for the same watch never works: remove it.
  3. Remove the watch from Gadgetbridge and Android's Bluetooth settings, and add it again as in C01.

Steps and sleep come separately, when Gadgetbridge fetches them: see Using it.

D01: The watch says "ONLY ONE THEME"

The firmware on the watch has only one colour scheme for this time of day (or for the side FORCE pins), so THEME has nothing to switch to. For more, install a release's ur-<version>-dfu.zip, which has Retro's, several for each side, or another theme's package, ur-<theme>-<version>-dfu.zip (see Installing). Or build one with every theme in it (--features themes-all), or the themes you like (see CONTRIBUTING.md). An update keeps the scheme you had for each side, if the new firmware has it.

D02: The watch says "NO DAY THEMES" or "NO NIGHT THEMES"

The firmware on the watch has colour schemes for one side of the day only (a theme with night schemes only, say), so it shows them by day and by night alike, and FORCE has nothing to pick between. That's how such a theme is: for schemes of both sides, install a release's ur-<version>-dfu.zip, which has Retro's, or another theme's package with both (a release's notes say which have one side only; see Installing), or build one with themes of both sides (see CONTRIBUTING.md).

N01: Notifications don't buzz, and the screen stays dim

The watch thinks you're asleep: it's a quiet night. It ends with the morning's first alarm, or once you've been up for 22 minutes (7 after a nap, which is sleep between 09:00 and 20:00). If you're up before either, double-press the button.

N02: The watch says "TEXT AS BITMAPS"

Gadgetbridge's "Text as Bitmaps" is on: it sends what the watch's font can't show as pictures, which ur shows as ?. Switch it off in the watch's settings in Gadgetbridge, under Notifications.

P01: How do I put the watch away for a while?

Use storage mode: long-press the Info screen (swipe left from the clock, past Alarms, any alarms from Gadgetbridge, and the settings), then tap to confirm. The watch saves the activity the phone hasn't fetched and switches itself off, using roughly 40% of a full charge in six weeks. Press the button or plug in the charger to wake it; the phone sets the time when it reconnects, and gets the saved activity with its next sync.

U01: A phone update fails at "Enabling bootloader" or with "GATT CONN TIMEOUT"

The nRF Device Firmware Update app only works over a paired link: if the phone hasn't paired with the watch, the app tries a different kind of update, and fails. Pair first (see C01 and C02), then send the update again.

U02: The watch says "UPDATE REFUSED"

The update arrived, but the watch checked it before installing it and found something wrong: it was damaged or cut short on the way, or it isn't firmware for this watch (a zip for another device, say). The watch keeps the firmware it has. Send the zip again; if it's refused again, download it again, and verify it.

U03: The phone has to pair again after every update

The Android nRF Device Firmware Update app makes the phone forget its pairing with the watch when an update finishes, unless Keep bond is on in the app's Settings (under "Legacy DFU"); it's off by default. The watch still remembers the phone, so it says "PAIRING LOST". Pair again as in C02, and turn on Keep bond before the next update.

Contributing

CONTRIBUTING.md covers building, iterating on a dev kit, fetching logs from a sealed watch, and making releases.

License

Licensed under either of

at your option.

The firmware images also contain third-party code under its own terms:

Contribution

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.