Bulletproof bedside alarm clock — no-touchscreen-lockup design, real voice AI, runs on a jailbroken Facebook Portal

The Bulletproof Bedside Alarm Clock (with real voice assistant, on whatever screen you’ve got lying around)
Built on a jailbroken Facebook Portal running View Assist. Runs fine on basically anything Home Assistant can throw a dashboard at.
The clock, with photo slideshow
Why this exists
I wanted a bedside clock that:
Shows the time, big and readable
Has real per-day recurring alarms (Alexa-style — different times for weekdays vs. weekends, any day toggled independently)
Has a real, capable voice assistant — not a canned command list
Never, ever locks up my touchscreen when I go to change something at 6am half-asleep
Doesn’t need me to remember exact phrasing to control it by voice
I tried a popular HACS alarm-clock card first. It looked great, but its settings dialog used real text-input fields, and Android’s native text-selection popup (Cut/Copy/Paste) kept hijacking the screen and locking up the kiosk with no way back out except a manual page refresh. Tried multiple versions — same bug every time. If you’ve hit this exact wall, this build is the fix: everything here uses buttons and toggles only, never a single text field, so that failure mode is structurally impossible.
What you get
Big, clean digital clock
A dedicated Alarm Schedule page — one row per day, big +/- buttons that nudge the time (5 or 15 min, your call), a toggle to turn that day on/off, no text entry anywhere
Real recurring per-day alarms — set it once, it just runs
A Snooze and Dismiss button that appear on screen only while the alarm is actually ringing
Voice control for the whole thing: “snooze the alarm,” “turn off my alarm,” “set my Wednesday alarm to 6:30,” “don’t wake me up tomorrow” (skips one firing without touching the day’s schedule)
Saying your wake word alone, with no follow-up command, dismisses a ringing alarm — because half-asleep people don’t want to construct a sentence
A “Test Alarm Sound” button on the settings page, so you can check your volume balance without waiting for a real alarm
Optional photo slideshow filling the bottom half of the screen (via the excellent album_slideshow HACS integration) — totally optional, easy to remove if you just want the clock
The voice assistant piece (this is the part I’m proudest of)
Home Assistant’s stock Assist pipeline is great, but I wanted a personality split — a snappier, chattier assistant in one room and a terse, no-nonsense one in the bedroom — and I didn’t want to be locked into Google or pay full retail Anthropic pricing for two separate assistants.
OpenRouter solved this cleanly: one API key, one HA integration entry, and you add as many Conversation subentries as you want under it — each with its own model, its own system prompt, its own personality. I run GPT-4o-mini in the bedroom (fast, cheap, terse) and GPT-4o in the kitchen (more personality, still fast). Point a Voice Assistant pipeline at each subentry’s conversation.* entity, assign it per-device, done.
Important gotcha: if you want automations to fire off spoken phrases (like our wake-word dismiss), know that sentence-trigger automations only fire on Home Assistant’s built-in conversation agent — not on OpenRouter, not on any external LLM agent. If your pipeline uses a non-default agent, you have to expose a script to Assist instead and let the LLM call it as a tool. Cost us an hour of confusion to figure out; hopefully saves you the same.
Requirements
HACS (all free, all one-click installs):
card-mod — for sizing/spacing
kiosk-mode — hides HA’s header/sidebar so a stray tap can never open the dashboard editor on your kiosk device
album_slideshow — optional, only if you want the photo slideshow
Core Home Assistant (no add-ons needed):
input_datetime and input_boolean helpers (built in)
timer helper (built in)
Any conversation agent with tool-calling support (OpenRouter, Anthropic, OpenAI, or even the free built-in one — the alarm logic itself doesn’t care)
Setup

  1. Helpers (Settings → Devices & Services → Helpers)
    For each day of the week (Monday–Sunday), create:
    An input_datetime (Time only) — I named mine alarm_monday, alarm_tuesday, etc.
    An input_booleanalarm_monday_enabled, etc.
    Plus three more:
    input_boolean.kobold_clock — the “ringer” flag (leftover name from the card I ditched, rename it if you like)
    input_boolean.skip_next_alarm — for the “don’t wake me up tomorrow” voice command
    timer.alarm_snooze — duration 0:09:00 (or however long you like your snooze)
  2. Automations
    Trigger automation — one dynamic time trigger per day, pointed at: the matching input_datetime entity (this means it auto-updates the instant you change the time, no polling needed). Each branch checks that day’s time weekday condition + that day’s enabled toggle + that the skip flag isn’t set, then flips the ringer boolean on.
alias: Bedroom Alarm Trigger
mode: single
triggers:
  - trigger: time
    at: input_datetime.alarm_monday
    id: mon
  - trigger: time
    at: input_datetime.alarm_tuesday
    id: tue
  - trigger: time
    at: input_datetime.alarm_wednesday
    id: wed
  - trigger: time
    at: input_datetime.alarm_thursday
    id: thu
  - trigger: time
    at: input_datetime.alarm_friday
    id: fri
  - trigger: time
    at: input_datetime.alarm_saturday
    id: sat
  - trigger: time
    at: input_datetime.alarm_sunday
    id: sun
actions:
  - choose:
      - conditions:
          - condition: trigger
            id: mon
          - condition: time
            weekday: [mon]
          - condition: state
            entity_id: input_boolean.alarm_monday_enabled
            state: "on"
          - condition: state
            entity_id: input_boolean.skip_next_alarm
            state: "off"
        sequence:
          - action: input_boolean.turn_on
            target: {entity_id: input_boolean.kobold_clock}
      # repeat this "choose" block for tue/wed/thu/fri/sat/sun,
      # swapping the day abbreviation and entity_ids each time
      - conditions:
          - condition: or
            conditions:
              - condition: trigger
                id: mon
              - condition: trigger
                id: tue
              - condition: trigger
                id: wed
              - condition: trigger
                id: thu
              - condition: trigger
                id: fri
              - condition: trigger
                id: sat
              - condition: trigger
                id: sun
          - condition: state
            entity_id: input_boolean.skip_next_alarm
            state: "on"
        sequence:
          - action: input_boolean.turn_off
            target: {entity_id: input_boolean.skip_next_alarm}

Ring automation — loops the alarm sound (respects your media player’s own volume) until dismissed. A fixed delay matching your song’s length is more reliable than watching for the media player’s idle state, which can fire early/flaky and cause stuttering restarts.

alias: Kobold Alarm - Ring
mode: restart
triggers:
  - trigger: state
    entity_id: input_boolean.kobold_clock
    to: "on"
    id: ring
  - trigger: state
    entity_id: input_boolean.kobold_clock
    to: "off"
    id: stop
actions:
  - choose:
      - conditions:
          - condition: trigger
            id: ring
        sequence:
          - repeat:
              until:
                - condition: state
                  entity_id: input_boolean.kobold_clock
                  state: "off"
              sequence:
                - action: media_player.play_media
                  target: {entity_id: media_player.YOUR_MEDIA_PLAYER}
                  data:
                    media_content_id: media-source://media_source/local/YOUR_ALARM_SOUND.mp3
                    media_content_type: music
                - delay: {minutes: 2, seconds: 35}  # match your song's length
      - conditions:
          - condition: trigger
            id: stop
        sequence:
          - action: media_player.media_stop
            target: {entity_id: media_player.YOUR_MEDIA_PLAYER}

Snooze re-ring + wake-word dismiss — the wake-word trigger watches your assist_satellite entity flip to listening (which fires the instant the wake word is heard, independent of what gets said afterward), and only acts if the alarm is actually ringing.

alias: Bedroom Alarm - Snooze Re-ring and Wake-Word Dismiss
mode: single
triggers:
  - trigger: state
    entity_id: timer.alarm_snooze
    to: finished
    id: snooze_finished
  - trigger: state
    entity_id: assist_satellite.YOUR_SATELLITE
    to: listening
    id: wake_word_heard
actions:
  - choose:
      - conditions:
          - condition: trigger
            id: snooze_finished
        sequence:
          - action: input_boolean.turn_on
            target: {entity_id: input_boolean.kobold_clock}
      - conditions:
          - condition: trigger
            id: wake_word_heard
          - condition: state
            entity_id: input_boolean.kobold_clock
            state: "on"
        sequence:
          - action: input_boolean.turn_off
            target: {entity_id: input_boolean.kobold_clock}
          - action: timer.cancel
            target: {entity_id: timer.alarm_snooze}
  1. Scripts (for the buttons and voice commands)
# Dismiss button + "turn off my alarm" voice command
alias: Dismiss Bedroom Alarm
sequence:
  - action: input_boolean.turn_off
    target: {entity_id: input_boolean.kobold_clock}
  - action: timer.cancel
    target: {entity_id: timer.alarm_snooze}
# Snooze button + "snooze the alarm" voice command
alias: Snooze Bedroom Alarm
sequence:
  - action: input_boolean.turn_off
    target: {entity_id: input_boolean.kobold_clock}
  - action: timer.start
    target: {entity_id: timer.alarm_snooze}

For voice control, expose both scripts to Assist (entity settings → Voice assistants → toggle on).
Optional — “set my alarm” and “skip tomorrow” by voice: build a Set Bedroom Alarm script with day and time fields (a choose block per day, each branch calling input_datetime.set_datetime on the matching entity), and expose input_boolean.skip_next_alarm directly to Assist so a phrase like “don’t wake me up tomorrow” just flips it — the trigger automation above already knows to check it. Name the skip-boolean something unambiguous (I learned this the hard way) — “Skip Next Alarm” got confused with “dismiss” by my LLM; renaming it to “Skip Tomorrows Scheduled Alarm” fixed it instantly. Precise entity naming matters more than clever prompting when you’re relying on an LLM to pick the right tool.
4. The dashboard
Two views under one dashboard:
Main view (panel type) — a single vertical-stack card containing:
A tiny settings-icon button, top-right, navigating to the schedule subview
A markdown card for the big clock digits ({{ now().strftime('%-I:%M') }})
A vertical-stack with visibility: [{condition: state, entity: input_boolean.kobold_clock, state: "on"}] — this is the trick that makes Snooze/Dismiss appear only while ringing. Inside: two big buttons calling the scripts above.
(Optional) the album-slideshow-card for photos
Schedule subview (sections type, subview: true) — one horizontal-stack per day: a minus button, a tile card showing that day’s time (with tap_action: none so it can never open an edit dialog), a plus button, and a toggle tile. The +/- buttons call a small script.alarm_adjust_time (fields: alarm_entity, delta_minutes) that does the time math and calls input_datetime.set_datetime — kept as a choose block with one hardcoded target per day rather than a templated target, since a templated target.entity_id fails silently if it ever resolves wrong.
One important detail if you’re on the View Assist Companion App specifically: it expects everything to live inside a dashboard registered at the exact url_path: view-assist — a separate top-level dashboard won’t be reachable by its internal routing. If you’re on Fully Kiosk or a plain browser instead, this doesn’t apply; any dashboard works.
Add kiosk_mode: {hide_header: true, hide_sidebar: true, hide_menubutton: true} at the top level of your dashboard config to strip out anything that could let someone accidentally wander into the dashboard editor.
The screenshot above
Alarm schedule settings page
That’s the full schedule page — seven day rows, minus/plus/toggle, no text field anywhere, plus the Test Alarm Sound button at the bottom for dialing in volume without waiting for a real alarm.
A few honest notes if you build this
Whatever LLM you use for voice will occasionally answer confidently about a capability it doesn’t actually have (mine once told my partner it “could set alarms” before I’d built that script). This isn’t a bug in the setup — it’s a known weakness of smaller/faster models guessing instead of admitting uncertainty. Build the real thing if it matters to you.
If your device’s Android WebView has the same text-field lockup bug I hit, the fix isn’t a CSS trick — Android controls that native selection popup at the OS level, not the page. The only real fix is avoiding real <input> fields entirely, which is exactly why this whole build leans on buttons and toggles.
The voice assistant backend (OpenRouter, straight Anthropic, straight OpenAI, whatever) is fully swappable — none of the alarm logic above cares which one is answering.
Happy to answer questions if anyone wants to adapt this to their own hardware.


Update (8/28): Snooze/Dismiss visual indicator + a real bug fix

1. Color-coded buttons. With sleepy eyes at 6am, “Snooze” and “Dismiss” can look identical at a glance. Added a thin colored ring to each — no fill, no gaudiness, just enough to tell them apart instantly:

yaml

# Snooze button card_mod
style: "ha-card { min-height: 90px; font-size: 18px; border: 3px solid #e8c547; }"  # yellow

# Dismiss button card_mod
style: "ha-card { min-height: 90px; font-size: 18px; border: 3px solid #d9534f; }"  # red

2. A real bug in the snooze re-ring trigger — worth knowing if you build this. My original automation watched for the snooze timer entity’s state to literally equal "finished":

yaml

# WRONG — this state value is never actually reported
triggers:
  - trigger: state
    entity_id: timer.alarm_snooze
    to: "finished"

Home Assistant timers never report a state literally called “finished.” They transition activeidle, and “finished” only ever shows up as the last_transition attribute on that idle state — not as the state value itself. The trigger above silently never fires. It took a real missed alarm (partner not thrilled) to catch this, since my own testing never let a full 9-minute snooze actually run to completion.

The fix — use the purpose-built timer.finished trigger instead of a generic state trigger:

yaml

triggers:
  - trigger: timer.finished
    target:
      entity_id: timer.alarm_snooze
    id: snooze_finished

If you’re on an older HA version without the purpose-specific trigger family, the equivalent event-trigger form also works:

yaml

triggers:
  - trigger: event
    event_type: timer.finished
    event_data:
      entity_id: timer.alarm_snooze
    id: snooze_finished

3. Dropped the “bare wake word alone dismisses the alarm” shortcut. It seemed clever — say “Hey Jarvis” with no follow-up, alarm stops, no thinking required. In practice it backfired: the wake word always opens the mic and waits for a command, regardless of any automation reacting to it in parallel. Say only the wake word with nothing after it, and once your dismiss automation has already silenced the alarm, the satellite’s own built-in silence-timeout still kicks in and it verbally asks “what would you like?” — a confusing non-sequitur after the alarm’s already stopped, and not something you can suppress from the automation side.

The reliable fix: don’t rely on the bare wake word at all. Route dismiss/snooze through the same exposed-script mechanism described above, and make sure the script’s description field explicitly lists the phrasings you actually use half-asleep:

yaml

alias: Dismiss Bedroom Alarm
description: >-
  Stops the bedroom alarm from ringing and cancels any pending snooze.
  Call this any time the user asks to stop, turn off, dismiss, silence,
  cancel, or kill the alarm — for example "stop", "stop the alarm",
  "stop alarm", "turn off my alarm", "turn it off", "shut it off",
  "shut up", "make it stop", "dismiss the alarm", "cancel the alarm",
  "kill the alarm", "silence the alarm", or any similar half-asleep
  phrasing of the same intent.
sequence:
  - action: input_boolean.turn_off
    target: {entity_id: input_boolean.kobold_clock}
  - action: timer.cancel
    target: {entity_id: timer.alarm_snooze}

Because this runs through the LLM’s own tool-calling rather than exact sentence matching, you don’t need to enumerate every possible phrasing perfectly — the model generalizes reasonably well from a handful of examples. I tested six different half-asleep phrasings (“stop”, “stop alarm”, “stop it”, “shut it off”, “turn off my alarm”) back to back and all six correctly fired the dismiss, with the actual entity state confirmed off each time — not just a confident-sounding response.