An Android alarm clock, timer, stopwatch and world clock that works completely offline — and, if you want it to, tells Home Assistant about your alarms so you can automate around them.
It comes in two parts:
- The Android app. A full alarm clock in its own right. It never needs Home Assistant, a network connection, or an account to work.
- The Home Assistant integration (
custom_components/ha_alarmclock/). Optional. Add it and your alarms show up in Home Assistant as real entities you can automate on — turn the lights on when the alarm rings, warm the house half an hour before it, dismiss it from a wall tablet, or ask Assist to set one.
The two talk directly to each other over your own Home Assistant's API, using a long-lived access token you generate yourself. There's no MQTT broker to run, no cloud service in the middle, and nothing leaves your network.
- Alarms with repeat days, per-alarm labels, ringtone, vibration, and a volume fade-in that ramps up over the first 45 seconds instead of jolting you awake.
- A full-screen ringing screen over the lock screen, with Dismiss/Snooze buttons big enough to hit by feel without your glasses on.
- An "alarm in an hour" notification with a live countdown and a Skip button, so you can call off tomorrow's alarm the moment you realise you don't need it.
- Timers with pause/resume/cancel straight from the notification, and a stopwatch with laps.
- A world clock tab — search time zones by country name ("Lebanon" finds
Asia/Beirut), reorder them how you like, all offline. - Four home-screen widgets: analog and digital, each with an opaque and a transparent variant, showing your next alarm. Colors are configurable.
Each phone that syncs shows up as its own HA device, with:
| Entity | Type | What it does |
|---|---|---|
| Ringing | binary_sensor |
On while an alarm is actually ringing. Attributes: alarm_id, label, time |
| Next alarm | sensor (timestamp) |
When the soonest enabled alarm across the whole phone will go off. Attributes: alarm_id, label |
| One per alarm | switch |
Turn that alarm on/off from HA. Attributes: time, repeat, next_trigger, snoozed_until |
| One per alarm | sensor (timestamp) |
When that specific alarm next fires |
| One per timer | sensor (timestamp) |
When that timer runs out |
| Snooze / Dismiss | button |
Act on whatever is ringing right now |
Plus a ha_alarmclock.create_alarm service (fields: time, and optionally label,
repeat, device_id) for creating alarms from automations, scripts, or an LLM agent —
and built-in Assist sentences so "set an alarm for 7am" works out of the box with the
keyword pipeline, no LLM needed.
State is pushed to Home Assistant the moment it changes — there is no polling anywhere in this integration.
Download the APK from Releases and install it. Android 8.0 (API 26) or newer.
You can stop here. Everything in "On the phone" above works with no further setup.
Via HACS (recommended): HACS → three-dot menu → Custom repositories → add
https://github.com/TargetCrafter/HA-Alarmclock with category Integration → install
it → restart Home Assistant.
Manually: copy custom_components/ha_alarmclock/ into your HA config's
custom_components/ directory and restart Home Assistant.
Then: Settings → Devices & Services → Add Integration → search "HA Alarm Clock". There's nothing to fill in — it just needs adding once, even if you have several phones.
- In Home Assistant, click your name (bottom left) → Security tab → create a Long-Lived Access Token and copy it.
- In the app: three-dot menu → Settings → enter your Home Assistant URL (e.g.
https://homeassistant.local:8123) and paste the token → turn on sync.
Your phone appears in Home Assistant as a device the first time it pushes. Its entities
are named after the phone, so sensor.pixel_8_alarm_clock_next_alarm and friends.
With more than one phone synced, create_alarm needs its device_id field to say which
one to use; with a single phone it's picked automatically.
Android's battery optimization can kill a background app before its alarm fires, and it's the single most common reason an alarm clock app silently doesn't go off. The app asks you to exempt it on launch, and Settings has an Alarm reliability section showing anything still outstanding. It's worth doing — see Alarm reliability safeguards for what's going on underneath.
Entity ids below are for a phone that shows up as "Pixel 8 Alarm Clock" — substitute your own.
# Ramp the bedroom lights up when the alarm actually starts ringing
- alias: Sunrise on alarm
trigger:
- platform: state
entity_id: binary_sensor.pixel_8_alarm_clock_ringing
to: "on"
action:
- service: light.turn_on
target:
entity_id: light.bedroom
data:
brightness_pct: 100
transition: 60
# Warm the house up half an hour before whatever alarm is next
- alias: Preheat before alarm
trigger:
- platform: time
at:
entity_id: sensor.pixel_8_alarm_clock_next_alarm
offset: "-00:30:00"
action:
- service: climate.set_temperature
target:
entity_id: climate.living_room
data:
temperature: 20
# Dismiss the alarm with a physical bedside button
- alias: Bedside button dismisses alarm
trigger:
- platform: state
entity_id: binary_sensor.bedside_button
to: "on"
condition:
- condition: state
entity_id: binary_sensor.pixel_8_alarm_clock_ringing
state: "on"
action:
- service: button.press
target:
entity_id: button.pixel_8_alarm_clock_dismiss(The offset: option on an entity-based time trigger needs a reasonably recent Home
Assistant; on older versions, use a template trigger instead.)
Nothing about the app depends on the integration. Alarms are stored on the phone and scheduled by Android itself, so they fire on time whether or not Home Assistant is reachable, running, or ever configured. Leaving sync off simply means no sync service and no notification for it.
The rest of this README is design and implementation notes — how it's built and why, rather than how to use it.
- Alarms are 100% local. They're stored in a Room database and scheduled with
AlarmManager.setAlarmClock()— the same API the stock Clock app uses, so alarms fire exactly on time, survive Doze, and don't need the "schedule exact alarms" permission dance. If Home Assistant is unreachable, alarms still ring. - Timers are 100% local too. A running timer's single
AlarmManagertrigger (setExactAndAllowWhileIdle) is what actually fires it — no foreground service has to stay alive just to count down. The "time remaining" notification usesNotificationCompat.setUsesChronometer, which the system ticks on its own; a foreground service (TimerService) only exists for the ringing phase once a timer actually hits zero, mirroringAlarmRingServicebut simpler (no fade-in/snooze). Pause/Resume/Cancel are available right from that notification, not just in-app. - Home Assistant sync is optional and additive, talking directly to a
HA Alarm Clockcustom integration (seecustom_components/ha_alarmclock/) using a normal Home Assistant long-lived access token — the same credential type used by any other HA API client, generated once from your HA user profile. No MQTT broker, no separate webhook secret.- Phone → HA (state): a foreground service (
HaSyncService) POSTs alarm/timer/ ringing state to the integration's/api/ha_alarmclock/syncendpoint whenever it changes — purely push-based, no polling, ever. The integration turns this into entities: oneswitchand next-triggersensorper alarm, one trigger-timesensorper timer, abinary_sensorfor whether an alarm is ringing, and a device-widesensorfor the soonest alarm across all of them — all grouped under one HA device per phone. A timer's remaining time isn't exposed to HA at all — it changed too often to push at a reasonable rate without turning this into a polling integration, and simply wasn't a useful HA-side signal once considered (only its trigger time is genuinely useful for automations). - Per-alarm and per-timer entities are keyed by a reused "slot" number, not the
phone's own alarm/timer id. The phone's Room database hands out ever-increasing
ids, but HA entities are keyed by a small stable slot (
alarm_1,alarm_2, ...;timer_1,timer_2, ... independently) thatstore.py'sAlarmClockStore._reassign_slotsreuses for the next new alarm/timer once a slot's occupant is deleted — so the entity'sunique_id/name update in place instead of a new entity being minted, the same way editing an alarm's time updates its existing entity. Deleting an alarm/timer just makes its slot's entities go unavailable until something new claims that slot; the number of orphaned/unavailable entities is bounded by the highest number of alarms/timers you've ever had at once, not the total number ever created. - HA → phone (commands): the same service also opens Home Assistant's built-in
WebSocket API (
/api/websocket) and subscribes to a customha_alarmclock_commandevent, which the integration fires when you flip a switch, press one of theSnooze/Dismissbutton entities it creates, or call theha_alarmclock.create_alarmservice. The connection reconnects with backoff if it drops. - Assist can create alarms. The integration registers a
ha_alarmclock.create_alarmservice (callable from automations/scripts, or exposed as a tool to an LLM-based conversation agent) and shipscustom_sentences/en/ha_alarmclock.yamlso the built-in keyword-based Assist pipeline understands sentences like "set an alarm for 7am" or "wake me up at 6:30 for gym" out of the box — no LLM required. Either path fires the samecreate_alarmcommand over the WebSocket channel, and the phone handles it inHaSyncService's command collector. Repeat days are only settable via the service'srepeatfield (voice only ever creates a one-off alarm — free-form day parsing from speech was judged too fragile to ship). A request with no label (the common case — plain "set an alarm for 7am" never sets one) updates the most recently created still-unnamed alarm's time/repeat instead of inserting a new one each time, so repeatedly asking Assist to set an alarm doesn't pile up a new entity every time — it just keeps moving the same "unnamed" alarm around. A request with a label always creates a fresh alarm, and existing labeled alarms are never touched by this at all — only ever the label-less ones compete for reuse with each other.
- Phone → HA (state): a foreground service (
Why the interface is put together the way it is — the reasoning behind decisions that aren't obvious from using it.
- Clock, Alarms, Timers, and Stopwatch are separate bottom-nav tabs (Clock first),
swipeable with a
HorizontalPageras well as tappable, with matching filled/outlined icon states (filled = selected, outlined = not, consistently across all four) so the bottom nav reads as one style rather than a mismatched one. The app reopens on whichever tab you had open last (TabPreferencesStore), not always the first one. - The top app bar, its settings entry point, and the "add" floating action button are
shared chrome owned by
MainActivity'sTabsScreen, not by each tab screen. They sit in oneScaffoldaround theHorizontalPager, so they stay fixed in place while swiping between tabs — only the title text and the FAB's label track the current tab (e.g. "Add alarm" vs "Add timer" vs "Add time zone"; the Stopwatch tab has no "add" concept, so the FAB just doesn't render there —Tab.addLabelResis nullable and the FAB lambda skips rendering when it's null, rather than repurposing the FAB for something unrelated). The settings entry point is a three-dot (Icons.Filled.MoreVert) button, not a gear. Each tab screen itself is now just its content (list/column) plus its own dialogs, takingshowAddDialog/onAddDialogDismiss(or the alarm list'sshowAddSheet/onAddSheetDismiss) from the parent instead of owning a boolean of its own — keyed by which page requested it, so a swipe away from the tab that opened it can't leak "show add" into whatever tab lands underneath. - Every floating action button in the app uses the same icon+text
ExtendedFloatingActionButtonstyle — the shared Add FAB, Settings' Save, and the alarm editor sheet's Save — instead of some being icon-only. - The Clock tab shows the live local time (with seconds), switchable between analog
and digital in Settings, plus an addable/removable list of other timezones —
app-local only, never synced to HA. Adding one searches by country/region name as well
as raw zone id — typing "Lebanon" finds
Asia/Beiruteven though the id itself has no "Lebanon" in it.rememberZoneDirectory()inClockScreen.ktbuilds this by pairing everyjava.time.ZoneIdwith the display name of whichever countryandroid.icu.util.TimeZone.getAvailableIDs(countryCode)associates it with (iteratingLocale.getISOCountries()), entirely offline and on-device — no geocoding API involved. The search matches either field, and each result's country name (when it has one — fixed-offset/Etc/...zones and UTC don't) shows as a subtitle so a country-name search with several matches (e.g. "United States") is easy to tell apart. Each row also has leading up/down arrows (WorldClockStore.moveUp/moveDown, disabled at the top/bottom of the list) to reorder the list — plain tap targets rather than drag-and-drop, since there's no way to compile-check a hand-rolled drag gesture in this sandbox and a couple of taps is little extra friction for what's normally a short list. World clock rows stay plain digital text (HH:mm+ a "tomorrow"/"yesterday" note when the date differs) regardless of that setting; only the big local-time display switches style. The analog face is drawn live with Compose'sCanvas(ui/clock/AnalogClockFace.kt), styled to match the home-screen widget's face (see below): a filled disc (defaultMaterialTheme.colorScheme.surfaceContainerHigh, theme-adaptive rather than the widget's fixed color) with a bezel ring, uniform dimmed tick marks with the four quarter ones drawn longer, and a red second hand with a two-tone white-halo/red-center pivot dot — the one place in the app seconds are shown continuously, and the only part of the face that isn't theme-driven, deliberately, to read like a real clock's second hand. - The Stopwatch tab counts up in
MM:SS.mmm, with the time display pinned at the top and Start/Pause(Resume) plus a Lap/Reset button — full-width 96dp-tall buttons (StopwatchButtoninStopwatchScreen.kt), the same height as the fullscreen ringing screen's Dismiss/Snooze — pinned at the very bottom instead, within easy one-handed reach; the lap list in between expands withModifier.weight(1f)to fill whatever space is left, which is what pushes the buttons down. The Lap/Reset button switches meaning with state — Lap while running, Reset once paused (disabled at a fresh00:00.000, since there's nothing to reset yet). Laps are stored newest-first (a new one is prepended inStopwatchViewModel), each showing both its own split and the cumulative total at that point. The lapLazyColumnexplicitly scrolls to index 0 (the newest lap) in aLaunchedEffect(laps)every time the list changes, rather than depending on the list's own default scroll-anchoring to keep the latest lap in view — that turned out not to be reliable on its own (tried first, still left Lap 1 pinned in view), so it's driven imperatively instead.StopwatchViewModeltimes offSystemClock.elapsedRealtime()rather thanSystem.currentTimeMillis(), so it can't jump if the wall clock changes mid-run (NTP sync, timezone, DST, the user editing the time). It's a plainViewModel, not backed by a foreground service orAlarmManager— a stopwatch has no completion to notify about, unlike a timer, so there's nothing worth a permanent notification for; it keeps running across tab swipes and a trip to Settings and back (both stay under the sameTabsScreenback-stack entry) but not past the app process being killed. - Changing an alarm's time switches it on, the way the stock Clock app does. Setting a time is how you set an alarm, so picking one on a switched-off alarm and leaving it off would just mean it silently doesn't ring. The quick time popup always enables; the full editor sheet only does so when the time actually changed, since that sheet has no on/off switch of its own (that lives on the list row) and merely renaming a switched-off alarm shouldn't quietly turn it on.
- Editing is in-place, not a separate screen. Tap an alarm's time for a quick time-only popup — which now also has a Delete action, so removing an alarm doesn't require opening the full editor either — or tap its label/repeat area for the full options sheet (label, repeat days, vibrate, fade-in, ringtone, snooze duration), which opens as a tall bottom sheet with an always-visible floating Save button rather than a scrolling screen. Timers get a similarly lightweight add dialog (H/M/S steppers + an optional name), and each timer row's own Pause/Resume/Cancel/Dismiss stay their original compact size — a card in a scrollable list of possibly several timers has less room to spare than a screen with only ever one running stopwatch.
- Fade-in is on by default. Alarms ramp from near-silent to full volume over the first 45 seconds; toggle it per-alarm in the options sheet.
- A snoozed alarm shows a "Snoozed until HH:MM" badge on its row until it rings again or is otherwise cleared.
- An hour before an alarm, an ongoing (non-dismissable until skipped or it rings) notification appears with a live countdown and a "Skip" action, so you can cancel that occurrence if you're already awake (repeating alarms just skip that one occurrence; one-off alarms get disabled) — and so there's a clear, persistent heads-up well before the alarm, not just a brief one a few minutes out.
- The fullscreen ringing screen uses large, full-width, stacked Dismiss/Snooze buttons (96dp tall, distinct filled colors, an icon plus large text each) instead of two side-by-side outlined buttons, so they're easy to tell apart and hit accurately the moment you wake up.
- Four home-screen widgets — analog and digital clock, each in an opaque
(background-filled) and a transparent (no-background) variant, all showing a live
"Next: HH:mm" line for the soonest alarm, refreshed whenever alarms change. The
transparent variants (
AnalogClockTransparentWidgetProvider/DigitalClockTransparentWidgetProvider) are separateAppWidgetProviders reusing the same layouts as their opaque counterparts — not a setting — so they show up as their own pickable entries in the system widget picker (like Android's own widgets do for their variants);ClockWidgetUpdaterjust skips thesetBackgroundColorcall for them, leaving the wallpaper showing through behind the analog widget's circle or the digital widget's text, with no rectangle behind either. - The digital widget's next-alarm
TextViewismatch_parentwidth withandroid:gravity="center"and up to 2 lines, notwrap_content— it used to bewrap_contentwith no width tied to its parent, so a long unbroken alarm label could inflate the wholeLinearLayout's measured width past the widget's actual bounds, pushing the time display above it out of view too, not just the alarm line itself. Now a long label wraps within the widget's real width instead. - The digital widgets'
TextClockstill ticks on its own with no code involved; the analog widgets' face used to be the systemandroid.widget.AnalogClock, but that rendered blank in practice (and, being a fixed system drawable, couldn't be recolored anyway) — it's now a full "watch face" drawn to aBitmapby hand (AnalogClockRenderer) and pushed viaRemoteViews.setImageViewBitmap: a filled disc in the background color with a subtle bezel ring; twelve dimmed tick marks (foregroundat ~55% alpha) close to the rim, the four quarter ticks (12/3/6/9) drawn longer than the other eight so the face reads at a glance without numerals; white hour/minute hands; and a red second hand with a two-tone white-halo/red-center pivot dot —ANALOG_CLOCK_SECOND_HAND_COLOR(Compose) andAnalogClockRenderer'sSECOND_HAND_COLORshare the same#E53935so the app and widget match. The bitmap itself is identical for both variants — the disc supplies the "face" either way — butClockWidgetUpdatergives theImageViewan 8dpsetViewPaddingon the opaque variant only, so the circle keeps a little breathing room inside its own rectangle, while the transparent variant (no rectangle to breathe against) fills the widget bounds right out to the edge. - When there's a next alarm, a centered badge — the Material "alarm" glyph (a clock
face with bell "ears" and a hand inside, from
R.drawable.ic_alarm_glyph, rasterized and tinted at runtime — the one non-Canvas-primitive element in the renderer; an earlier version reused the app icon's bell-with-cutout glyph, which read as Fluent rather than Material style) plus its bareHH:mm(no "Next:" prefix or label; the glyph already says "alarm") — sits just below the pivot, backed by an opaque patch in the same background color as the disc, painted after the hands so any hand passing behind the badge is cleanly masked out rather than a line poking through between the glyphs. No badge is drawn at all when there's no next alarm. The digital widgets keep their own separate next-alarm text row underneath the time (stillNext: <label> · HH:mm, hidden when there's no alarm) — only the analog badge was redesigned to sit centered-and-masked into the face. - The analog widgets have a static
previewImage(drawable-nodpi/widget_preview_analog.png, a 10:10 rendering of the same design at default colors) instead of a livepreviewLayout, so the system widget picker actually shows something — the picker only ever inflates the layout XML statically, andwidget_analog_clock.xml'sImageViewhas no defaultandroid:src(its content only ever exists as a bitmap pushed at runtime via code), so apreviewLayoutthere rendered as a blank circle. The digital widget didn't need this fix since itsTextClockrenders real ticking content even in a statically-inflated preview. - Redrawing it every second — so the second hand actually moves — needs a foreground
service (
AnalogWidgetTickerService), not justAlarmManager. AnAlarmManagertrigger (even the Doze-survivingsetExactAndAllowWhileIdle) is what the first version of this used, ticking once a minute at first and then once a second, but Android throttles that API to roughly every few seconds for an app that isn't in the foreground — a platform ceiling, not a bug in how it was being scheduled. A foreground service isn't subject to that: a plain coroutinedelay(1000)loop fires on schedule for as long as it's alive. The cost — and it's a real one, gone into with eyes open — is the low-priority, silent, ongoing "Keeping the analog widget's second hand live" notification Android requires any foreground service to show, plus a bit more battery use, for as long as an analog widget (opaque or transparent) is on your home screen.AnalogWidgetTicker.ensureRunning/stopIfNoInstancesstart and stop it — called fromonEnabled/onUpdate/after reboot, and fromonDisabled, tracking both analog provider variants together since a provider's ownonDisabledonly fires when that specific provider's last instance is removed, not the other variant's. - The analog widget's clock face is full-bleed (a
FrameLayoutwith theImageViewfilling the whole widget,widget_analog_clock.xmlno longer has a separate text view at all — the badge above is baked into the bitmap instead) instead of being squeezed above a fixed text row, so it's noticeably bigger. The digital widget's next-alarm row is likewise hidden entirely when there's no next alarm, instead of a permanent "No alarm set" placeholder. - A since-removed "dynamic launcher icon" feature was also found to be crashing the app
on every launch (
PackageManager.setComponentEnabledSettingon anactivity-aliasfailing inside an uncaught coroutine, fixed by removing that feature and givingHaAlarmClockApp's background coroutine scope aCoroutineExceptionHandlerthat logs instead of crashing) — but that turned out to be a red herring for an earlier blank- widget report: the actual cause back then waswidget_analog_clock.xml'sImageViewhavingandroid:layout_width="0dp"inside a verticalLinearLayout.layout_weightonly distributes space along aLinearLayout's main axis (height, here); on the cross axis a0dpwidth just stays a literal zero, so the face was always being measured and drawn into a zero-width view no matter how correct the bitmap it held was. Fixed by changing it tomatch_parent(superseded since by theFrameLayoutrework above). - All four widgets' colors are configurable in Settings — a background and a
foreground (hands/text) color, each pickable from a preset swatch row or typed as a
hex code, applied live via
RemoteViews.setInt(..., "setBackgroundColor", ...)/setTextColor/the bitmap renderer (the transparent variants still honor the foreground color, just never the background one on the surrounding rectangle — the analog disc itself is always filled with the background color, on both variants). Default is near-black on off-white (#1B1B1Bbackground,#E8E8E8foreground), matching a fairly typical dark watch face rather than the mid-grey used before; a "Reset to default" button restores that. One caveat: because the rectangle's color is applied as a plainColorDrawable, the opaque widgets lose their rounded corners on Android 11 and below — Android 12+ launchers round/clip every widget's outer bounds automatically regardless of its own background, so this only matters pre-12 (moot for the analog widgets' own circular face either way, and for the transparent variants' rectangle, which has no fill to round in the first place). - Uses plain
MaterialThemeon stablematerial3(pinned to1.4.0ingradle/libs.versions.toml, overriding the Compose BOM's own suggestion, which currently maps to an older 1.3.x). Material 3 Expressive's actual theme wrapper (MaterialExpressiveTheme,MotionScheme.expressive()) isn't public API yet as of 1.4.0 stable — it'sinternalthere, only promoted to public API starting in1.5.0-alpha19. Switchmaterial3to that alpha (or later) and swapui/theme/Theme.kt'sMaterialThemecall forMaterialExpressiveThemeif you'd rather have the real thing on a pre-release library than wait for it to stabilize.
app/src/main/java/com/targetcrafter/haalarmclock/
data/ Room entities/DAOs + AlarmRepository/TimerRepository (source of truth)
alarm/ AlarmManager scheduling, the BroadcastReceiver that fires alarms,
the ringing foreground service + full-screen UI, boot rescheduling
timer/ the timer counterpart to alarm/ — TimerScheduler, TimerReceiver
(trigger + notification Pause/Resume/Cancel actions), TimerService
(ringing phase only), TimerNotifications (the live countdown one)
widget/ the analog/digital clock home-screen widgets (opaque + transparent variant
of each) — AppWidgetProviders, AnalogClockRenderer (hand-drawn bitmap face),
AnalogWidgetTickerService (the foreground service that redraws the analog
face every second) + AnalogWidgetTicker (starts/stops it), ClockWidgetUpdater
(pushes colors + next-alarm text into every instance of all four)
ha/ HA settings storage, the REST push client, the command WebSocket
client, and the foreground service that ties them together
ui/ Jetpack Compose screens (alarm list, timer list, clock/world clocks,
stopwatch, editor, settings) + ViewModels; MainActivity's TabsScreen is the
HorizontalPager + bottom nav shared by all four main tabs; data/ holds
the plain-SharedPreferences stores behind Settings' clock-style and
widget-color pickers, the world-clock timezone list, and the
last-open-tab memory (ClockPreferencesStore, WidgetAppearanceStore,
WorldClockStore, TabPreferencesStore)
custom_components/ha_alarmclock/ Home Assistant custom integration (Python)
__init__.py sets up the REST view + Assist + forwards to the platforms below
http.py POST /api/ha_alarmclock/sync — receives pushes from the phone
store.py in-memory per-device state, rebuilt from the next push after restart
assist.py the create_alarm service + intent handler for Assist
services.yaml, custom_sentences/en/ service field docs; Assist sentence patterns
config_flow.py single-step "confirm setup" flow (no secrets collected on the HA side)
icons.json icon for the create_alarm service in HA's UI (entity icons are still
set in code via _attr_icon)
brand/ icon.png (256²) + [email protected] (512²) — the integration's icon; see
"Integration icon" below for why it lives here
binary_sensor.py, sensor.py, switch.py, button.py the entities themselves
Since Home Assistant 2026.3, a custom integration ships its own brand images in a
brand/ folder inside the integration directory, and they take priority over the brands
CDN. Nothing needs submitting anywhere: the files in
custom_components/ha_alarmclock/brand/ are the icon Home Assistant shows.
The home-assistant/brands repository no longer
accepts pull requests for custom integrations at all — its PR template says so outright,
pointing at the Brands Proxy API
announcement. Brand
images are now proxied through Home Assistant's own
/api/brands/integration/{domain}/{image} endpoint, which is what makes serving them
locally possible.
Recognised filenames are icon.png, logo.png, their @2x variants, and dark_
prefixed versions of each. This ships icon.png (256×256) and [email protected] (512×512).
No logo.png: logos are wordmarks and this project doesn't have one, so Home Assistant
falls back to the icon. No dark_icon.png either — the icon is designed to read on both
light and dark backgrounds, which is what the white fill below is for.
Both files are PNG with transparency, trimmed and padded to a 1:1 square. They're derived from the Android launcher icon's foreground layer, with the interior knockouts filled white: the adaptive icon draws the tree/circuit lines as transparent holes that let its white background layer through, and without that layer they'd be see-through and vanish against Home Assistant's dark theme.
If the icon still shows as a placeholder, check the Home Assistant version is 2026.3 or newer, then restart Home Assistant and hard-refresh the browser — the proxy caches brand images on disk.
Requires JDK 17+, the Android SDK (compileSdk 35), and network access to
dl.google.com/Maven Central for AndroidX, Compose, and Room. This project was
scaffolded in a sandboxed environment without Android SDK/network access to Google's
Maven repo, so the build has not been verified end-to-end with
./gradlew assembleDebug — please do that first thing in a normal dev environment or
CI (e.g. via android-actions/setup-android in GitHub Actions) and fix up anything
that doesn't compile.
./gradlew assembleDebug
The Python integration's structure has been validated: all modules import cleanly
against a real installed homeassistant package, and store.py's payload handling —
including slot assignment/reuse for both alarms and timers — and the entity classes
were exercised directly (construction, entities going unavailable and being picked
back up when a new alarm/timer claims a freed slot, timestamp parsing). assist.py
went further: a real homeassistant.core.HomeAssistant() instance was started, the
create_alarm service was registered and called, and the HaAlarmClockCreateAlarm
intent was invoked through homeassistant.helpers.intent.async_handle directly — both
confirmed to fire the expected ha_alarmclock_command event with the right payload,
and the custom_sentences/en/ha_alarmclock.yaml sentence patterns were matched against
sample phrases with the real hassil recognizer (including the "called {label}"
variants, which needed HassIL's [optional] bracket syntax to stop the free-text time
slot from greedily swallowing the label — a plain pair of alternative sentence
templates picked the wrong match). What's still untested is the full runtime path
inside an actual Home Assistant instance end to end (config flow with a real hass,
the HTTP view wired through HA's auth middleware, a real Assist voice pipeline) — worth
a real smoke test before relying on it.
An alarm that schedules correctly but doesn't actually ring is the worst possible failure mode for this app, and several of its causes live entirely outside app code — in OS-level settings only the user can grant. To make failures both less likely and (if they still happen) diagnosable:
-
Launch popup: if battery optimization isn't yet disabled for the app,
MainActivityshows a dialog every time the app is opened explaining why it matters and offering a "Fix now" button straight toACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS(or "Later" to dismiss just that launch — it reappears next time until actually granted, deliberately, since this is the single biggest lever the app has over whether alarms are reliable). -
Settings → "Alarm reliability" section: the same check, as a persistent (not dismissible-forever) warning card, hidden once both are granted, that surfaces two OS settings and offers a one-tap fix for each:
- Battery optimization exemption (
ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) — by far the most common real-world cause of "the alarm silently didn't go off": OEM battery managers (Samsung, Xiaomi, Huawei, OnePlus, etc., layered on top of stock Android Doze) can kill the app's process in the background beforeAlarmManagerever fires, regardless of how the alarm was scheduled. - Full-screen intent permission (
ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT, Android 14+/API 34+) — without it the OS can silently demote the ringing notification'sfullScreenIntentto a plain heads-up notification instead of actually launching the ringing screen over the lock screen.AlarmRingServicealso now detects this itself (NotificationManagerCompat.canUseFullScreenIntent()) and falls back to launchingRingingActivitydirectly when it's not granted. - The card re-checks both on every return to the Settings screen (a
LifecycleEventObserveronON_RESUME), since the only way to change either is a trip to a system settings screen and back.
- Battery optimization exemption (
-
startForeground()is called synchronously, before the alarm is loaded. This was a real bug, and the one that matched "the alarm works if I've used the app recently, but not when I set it at bedtime and don't open it again". The OS gives a service five seconds from thestartForegroundService()call to reachstartForeground(), and kills the app instead if it doesn't.AlarmRingServiceused to call it inside a coroutine, after a suspending Room read — andAlarmReceiverdid two more database reads before even starting the service. With the app recently open, all of that is warm and takes milliseconds. When the alarm is what wakes a process Android killed hours ago, those same reads come off cold storage on a device still spinning up out of Doze, and the window can be missed — so the app is killed instead of ringing, which looks exactly like an alarm that silently didn't go off. Now the receiver starts the service before touching the database, the service posts its notification immediately (a placeholder, filled in once the alarm loads — it carries the same full-screen intent, so the ringing screen still comes up from it), and every path that decides not to ring stops the service instead of leaving it foreground with no notification. -
Ringtone fallback chain:
AlarmRingService.startRinging()used to try exactly one ringtone URI and, ifMediaPlayerfailed to prepare it for any reason (a stale URI from an uninstalled ringtone-providing app, a moved/deleted file, a revoked permission), silently give up with no sound and no log — ringing on vibration alone, or on nothing if vibration was also off. It now tries the alarm's own ringtone, then the device default alarm sound, then any valid ringtone in turn, logging each failure, before giving up. -
Logging on every previously-silent failure path:
AlarmReceivernow logs (rather than silently returning) when a fired alarm's ID no longer exists in the database or the alarm is disabled;AlarmRingServicelogs each ringtone candidate's failure and whether every candidate failed. -
The alarm-stream volume is checked and surfaced. Alarms play on Android's
STREAM_ALARM, whose volume is separate from ringtone and media — it can sit at zero while the phone is otherwise perfectly loud, and then every alarm runs correctly and is still inaudible. Nothing in Android warns about this.AlarmRingServicenow logs the alarm volume on every ring (at error level when it's zero), and Settings shows a warning while it's zero. -
"Test the alarm sound" in Settings plays the sound on demand through the same ringtone candidates and
AudioAttributesa real alarm uses (alarm/AlarmAudio.kt, shared by both so the test can't pass while the real thing stays silent), and reports the alarm volume it's playing at. A bug that only appears at 07:00 is close to undebuggable; this makes it a ten-second check. It deliberately does not setRingingStateor start the ringing service, so testing can't push "an alarm is ringing" to Home Assistant and set off whatever you've automated against it. -
Alarms are re-armed on every process start, not just after a reboot.
AlarmManagerentries survive ordinary process death, but not a force-stop — and force-stopping idle apps is exactly what OEM battery managers do. Nothing tells an app it happened, so the alarm list would go on showing an alarm as enabled while the OS held no record of it.HaAlarmClockApp.onCreatenow re-arms everything (idempotent — rescheduling replaces the existing entry), so any run of the app repairs a dropped schedule. -
Settings reports whether Android actually holds the next alarm (
ScheduleHealth), by comparing the app's next trigger againstAlarmManager.getNextAlarmClock()— the same source as the status-bar alarm icon. A dropped schedule becomes something you can see, with a "Re-arm" button, instead of something you discover by not waking up. Because that API reports the next alarm clock across all apps, it's conclusive in only two directions; when another app has a sooner alarm the check says so rather than guessing.
Being honest about this matters more than reassurance: an app cannot make itself
force-stop-proof. Once Android force-stops an app, its alarms are cancelled, and nothing
belonging to it runs again — no service, no receiver, no WorkManager job, no watchdog —
until something launches it. Every defense above raises the odds; none makes it certain.
What genuinely closes the gap is redundancy outside the phone, which this project happens
to be well placed for: Home Assistant already knows when the next alarm is due
(sensor.<phone>_next_alarm) and whether the phone reported ringing
(binary_sensor.<phone>_ringing). A backup automation that fires if the alarm time passes
without the phone ever reporting a ring gives you a second, independent alarm on hardware
the phone's battery manager has no say over:
- alias: Backup alarm if the phone didn't ring
trigger:
- platform: time
at:
entity_id: sensor.pixel_8_alarm_clock_next_alarm
offset: "00:02:00" # two minutes after it should have gone off
condition:
- condition: state
entity_id: binary_sensor.pixel_8_alarm_clock_ringing
state: "off" # the phone never reported ringing
action:
- service: media_player.volume_set
target:
entity_id: media_player.bedroom_speaker
data:
volume_level: 0.7
- service: tts.speak
target:
entity_id: tts.piper
data:
media_player_entity_id: media_player.bedroom_speaker
message: Your phone alarm did not go off.
- service: light.turn_on
target:
entity_id: light.bedroomFor an alarm that has to work every time, that redundancy is worth more than any further hardening inside the app.
The phone and Home Assistant trust each other over a long-lived access token, and HA's event bus is shared by everything on the instance, so a few things are deliberately hardened:
- Commands are filtered by
device_id. HA's event bus is global: every phone connected to the same Home Assistant receives everyha_alarmclock_commandevent, including ones the integration resolved for a different phone.HaWebSocketClient.isForThisDevicedrops events aimed elsewhere, so a second phone in the household can't snooze/dismiss the first one's alarm — or, worse, apply another phone's alarm id to whatever unrelated alarm shares that id locally. (An event with nodevice_idis still accepted, so an older integration version keeps working rather than losing HA control silently.) - Alarm times from HA are range-checked.
create_alarm's time is matched by shape and range before anAlarmis built from it.AlarmRepository.savere-checks as an invariant, since the row is written to Room before it's scheduled: persisting an out-of-range time would meanrescheduleAllthrows on it again on every launch and every boot, not just once.rescheduleAlldisables such a row instead of scheduling it, which doubles as the repair path for anything written by an older build, and the HA command loop handles each command in its own try/catch so no future parse gap can take the app process down. - Cleartext HTTP is allowed but flagged. Most home HA installs are plain HTTP on the LAN,
so
network_security_config.xmlpermits it — but the access token rides on it in anAuthorizationheader and grants control of all of Home Assistant, not just alarms. Settings shows a warning whenever the configured URL ishttp://rather than failing the connection outright, since on a trusted LAN this is a reasonable tradeoff to make knowingly. - The token is excluded from backups. It lives in
EncryptedSharedPreferences, whose master key is in the hardware Keystore and can't be backed up — so the encrypted file is excluded from both cloud backup and device transfer (backup_rules.xml,data_extraction_rules.xml). Including it would copy the credential off the device and restore it somewhere undecryptable, which crashes the app at launch. Alarms and preferences are still backed up, so they survive a phone upgrade; the URL and token get re-entered. - The integration caps tracked devices (
store.MAX_DEVICES) so a buggy or malicious client can't fill Home Assistant with devices and entities by churningdevice_ids.
The sync endpoint itself is authenticated as a normal HA API view (requires_auth = True),
all Room queries are parameterized, every PendingIntent is FLAG_IMMUTABLE, and only
MainActivity and BootReceiver are exported (the latter accepting only protected system
broadcasts).
SCHEDULE_EXACT_ALARM/USE_EXACT_ALARM— required to usesetAlarmClock().REQUEST_IGNORE_BATTERY_OPTIMIZATIONS— lets the app launch the system dialog for the user to exempt it from battery optimization (see "Alarm reliability safeguards" above); doesn't grant the exemption itself.POST_NOTIFICATIONS— for the ringing alarm, the (minimum-priority) HA sync status notification, and the (also minimum-priority) analog-widget-ticker notification.FOREGROUND_SERVICE_MEDIA_PLAYBACK— the alarm and timer ringing services play sound.FOREGROUND_SERVICE_DATA_SYNC— the HA sync service keeps a network connection.FOREGROUND_SERVICE_SPECIAL_USE—AnalogWidgetTickerService's per-second redraw loop, since none of the predefined foreground service types (data sync, media playback, location, and so on) fit "keep a widget's second hand moving."RECEIVE_BOOT_COMPLETED— alarms, timers, the HA connection, and the analog widget ticker are all re-armed after reboot (neither AlarmManager entries nor a running service survive one on their own).
No new dangerous permission was needed for timers or the widgets.
Alarms, timers, a stopwatch, widgets, a Clock tab with world clocks, and a custom app
icon (an adaptive icon: a flat-white vector background layer plus a gradient-filled
foreground rasterized from the provided SVG into density-specific PNG mipmaps, since
Android's vector <gradient> element only supports a true circular radial gradient and
can't reproduce the source's elliptical stretch) are all in. The HA access token field
in Settings is masked like a password field (with a tap-to-reveal toggle) and the token
itself is stored in EncryptedSharedPreferences; clock style, widget colors, and the
world-clock list are in plain (unencrypted) SharedPreferences, since none of it is
sensitive. The stopwatch doesn't persist across app-process death (it's a plain
ViewModel, not backed by any service — see the UI/UX notes above) and isn't synced to
HA in any way. The integration's device state lives in memory only — it's rebuilt from
the phone's next push after a Home Assistant restart rather than persisted to disk.
Timers aren't controllable from HA (only exposed as sensors) — only alarms have a
switch entity and HA→phone commands; symmetric timer control (pause/resume/cancel from
HA) would be a natural follow-up if wanted.
GNU General Public License v3.0 — a strong copyleft license: anyone may use, modify and redistribute this, including commercially, but any work they distribute that builds on it has to be released under the GPLv3 as well, with source available. That is the point of choosing it here rather than a permissive license: improvements to a fork stay available to everyone rather than disappearing into a closed product.
All of the project's dependencies (AndroidX/Jetpack Compose, Room, OkHttp, Home Assistant itself) are Apache-2.0, which is one-way compatible with GPLv3 — Apache-2.0 code can be combined into a GPLv3 work, so this is a valid combination.
One thing worth knowing if distribution plans ever change: GPLv3 and Google Play's terms are an awkward fit (GPLv3's installation-information requirements versus Play's distribution terms), which is a long-running debate rather than a settled prohibition. Distributing the APK from GitHub releases, as here, raises none of that.