I've been building a little Stream-Deck-style control panel for my desk that talks to Home Assistant (future more in-depth blog post coming soon). However, I want to share this deep dive into Home Assitant timers first.
I often set a timer for something in the kitchen, and then hop back on the computer while it runs. So I wanted to mirror the Home Assistant timer that I'd set via the Voice PE to the deck's display and trigger an alert animation on its screen as well.
That turned out to be way more involved than I expected, for one annoying reason: voice timers are invisible to everything except the device that created them. What follows is the windy road I took to get Voice PE timers working on both the original device, as well as anything else that can subscribe to Home Assistant websockets events.
#Why voice timers are invisible
When you say "set a timer for 10 minutes" to a Voice PE (or any ESPHome/Wyoming satellite), Home Assistant creates a timer that lives on that satellite device. It's delivered to the device via ESPHome triggers like on_timer_started / on_timer_finished, and that's also what plays the alarm sound when it's done.
The issue is that that timer is not a timer.* entity visible to the rest of Home Assistant, and there's no public websocket command for some other client (like my panel) to list or subscribe to active voice timers. So my display had no way to know a timer even existed.
#First attempt: route everything through a helper
My first instinct was to stop relying on the device-bound timer and make a regular timer. helper entity the source of truth: have the voice assistant start a timer.kitchen helper instead of its own internal timer, then bridge everything off of that. A timer.* helper has a state of idle / active / paused and, while active, a finishes_at attribute — everything a display needs.
Concretely that meant three things:
- Create the helper (Settings → Devices & Services → Helpers → Create Helper → Timer).
- Point the voice agent at it. I turned off "Prefer handling commands locally" so the Voice PE stopped grabbing "set a timer" itself, and added a few lines to my LLM agent's system prompt telling it to always call
timer.startontimer.kitchen. - Bridge the alarm back. Routing around the native timer meant the Voice PE's
on_timer_finishednever fired, so it went silent. The alarm is driven by an internaltimer_ringingswitch (markedinternal: true), but itsidstill exists in the compiled firmware — so a one-line template button that flips it, plus an automation on the helper'stimer.finishedevent, brought the native ring back.
This worked, the panel showed the countdown and the Voice PE rang. But it had a some downsides.
First,the Voice PE's LED countdown ring never animated, because, just like the alarm, that ring is driven entirely by the device's internal timer objects, with no external hook to trigger it. In addition, disabling "Prefer handling commands locally" was also a pure downgrade. There are a few other commands I use often like "what's the weather" that it's able to handle itself much faster by skipping the LLM call entirely.
#A cleaner approach: let the Voice PE keep its timer
Once I realized the LED ring was also native-only, I decided to pivot. Let the Voice PE create its native timer — which gives you the LED countdown and the alarm for free — and simply mirror that native timer into the helper so my panel can see it.
So I flipped "Prefer handling commands locally" back on, dropped the agent-prompt tweaks, deleted the ring bridge, and added the following to my Voice PE's ESPHome config to mirror the timer lifecycle into timer.kitchen:
voice_assistant:
on_timer_started:
- script.execute: control_leds # keep the native LED countdown
- homeassistant.service:
service: timer.start
data:
entity_id: timer.kitchen
duration: !lambda |-
int s = timer.total_seconds;
char b[9]; sprintf(b, "%02d:%02d:%02d", s/3600, (s%3600)/60, s%60);
return std::string(b);
on_timer_updated: # extend / resync remaining
- script.execute: control_leds
- homeassistant.service:
service: timer.start
data:
entity_id: timer.kitchen
duration: !lambda |-
int s = timer.seconds_left;
char b[9]; sprintf(b, "%02d:%02d:%02d", s/3600, (s%3600)/60, s%60);
return std::string(b);
on_timer_cancelled:
- script.execute: control_leds
- homeassistant.service: { service: timer.cancel, data: { entity_id: timer.kitchen } }
on_timer_finished:
- switch.turn_on: timer_ringing # keep the native ring
- homeassistant.service: { service: timer.finish, data: { entity_id: timer.kitchen } }The one gotcha that cost me an embarrassing amount of time: homeassistant.service calls from ESPHome are blocked until you enable "Allow the device to perform Home Assistant actions" on that device in Home Assistant's ESPHome integration. Flip that on and the mirror springs to life.
Now the helper is a passive shadow of the real timer, and the Voice PE does timers exactly the way it was designed to.
#Showing it on the panel
Either way, once the timer is a real timer.* entity, my panel can watch it. Over the Home Assistant websocket API the cleanest subscription is subscribe_entities with an explicit entity list:
{
"id":1,
"type":"subscribe_entities",
"entity_ids":[
"timer.kitchen"
]
}It's filtered to just the entities you name (so the frames stay tiny — the esp32 websocket library drops anything over 15 KB, which the unfiltered state_changed firehose blows past the moment some big entity updates), the initial event seeds current state, and the compact payload carries the finishes_at attribute. From there the panel just counts down against an NTP-synced clock and flashes a border when it hits zero.
#The result
I say "set a timer for two minutes" like always. The Voice PE starts its native timer — LED ring and all — and mirrors it into timer.kitchen, which my desk panel watches and counts down. When it finishes, the Voice PE rings its real alarm and the panel flashes. One helper entity ties it all together, and the timer is now visible to anything in my house that can speak websocket, not just the satellite that set it.
