Here’s a situation I expect most of you have hit.
An automation turns the porch light on at dusk. You turn it off, because you’re going to bed
early. Twenty minutes later another automation turns it back on; or the “restore” automation you
wrote to clean up afterwards puts back a state that stopped being true an hour ago.
The usual fixes are an input_boolean guard, a scene you capture and restore, or a set of
automations that all know about each other. They work until two of them disagree. If something
changes while your override is active, the state you captured is already stale by the time you
restore it.
Home Assistant does record context, so you can trace what triggered a call after the fact. What
it has no notion of is authority: nothing arbitrates between two callers that both want the
same entity. Every service call is a naked write, and the last one wins.
Building automation solved this a long time ago. BACnet gives every commandable point a priority
array: sixteen levels, the lowest-numbered occupied level wins, and writers release their claim
when they’re done. This brings that over, trimmed to five levels because sixteen is more than a
house needs.
What it does
Every command gets a level. The lowest-numbered occupied level is the one driving the device.
| Level | Name | Who writes here |
|---|---|---|
| 1 | Manual Emergency | You, overriding everything |
| 2 | Automatic Emergency | Life-safety automations (smoke, freeze, water leak) |
| 3 | Manual | You, when you don’t want ordinary automations interfering |
| 4 | Automatic | An automation that wants to hold against ordinary traffic |
| 5 | Default | Everything else |
A command at level 3 can’t be overridden by an automation writing at 4, but a smoke alarm
writing at 2 still gets through. When a level is released, control falls to the next occupied
level down, and that command is re-issued as it stands right now, not as a snapshot from
when the override began.
It changes nothing until you ask it to
Everything defaults to level 5, including automations. Writes at the same level just replace each other. So if you never mention priority anywhere, Home Assistant behaves exactly as it does today: last command wins, and you can always countermand an automation from the app, although that automation may fire again and countermand you.
Priority and leases
Two fields get added to every supported service call, alongside whatever that service already
takes:
action: light.turn_on
target:
entity_id: light.porch
data:
brightness_pct: 60
priority: 3 # Manual
priority_ttl: "00:20:00"
Both show up as real form fields in the automation editor, the script editor, and Developer
Tools; the integration rewrites the affected service descriptions at runtime, so you get a
proper dropdown rather than having to remember YAML.
priority_ttl is a lease. Twenty minutes later that level clears itself and the light falls back
to whatever is underneath. If nothing else wanted it on, that’s off. If an automation turned it
on in the meantime, it stays on, because that automation’s command was never destroyed; it was
outranked while the override held.
That’s the difference from turn on, delay, turn off. The lease doesn’t turn the light off after
twenty minutes, it stops overriding after twenty minutes, and the house resumes whatever it was
already trying to do. Nothing has to remember a previous state, so nothing can restore a stale
one. It also survives a restart, which a delay does not.
Leave priority_ttl off to hold until released. An override with no end is easy to issue and
very easy to forget, and until someone releases it everything underneath is dead.
Services
Four of its own, for scripts that need to write, release or inspect an array directly.
priority.set writes a level without going through the domain service:
action: priority.set
target:
entity_id: light.porch
data:
priority: 3
service: turn_on
data:
brightness: 200
priority_ttl: "00:30:00" # optional
service is the bare domain service (turn_on, set_temperature); the domain comes from the
target entity. Both the service and the payload are validated before the level is written, since
a slot that can’t dispatch would still win arbitration and sit there holding the entity with nothing driving it.
priority.relinquish clears one level. If that level was in control, whatever occupies the next
level down is re-driven:
action: priority.relinquish
target:
entity_id: light.porch
data:
priority: 1
priority.relinquish_all clears every level above Default.
priority.get returns the whole array as a response variable:
action: priority.get
target:
entity_id: light.porch
response_variable: priority_state
arrays:
light.porch:
entity_id: light.porch
effective_priority: 1
effective_priority_name: Manual Emergency
effective_command: {domain, service, data, written_at, written_by, expires_at}
slots:
"1": {...} # or null
"2": null
...
Slot keys are strings. The effective_* fields are null only when no level holds the entity at
all, which is not the same as Default holding it; an ordinary command lands at 5 and reports as
such. So effective_priority in [5, none] is the test for “nothing is overriding this”.
In the UI
Open any supported entity’s more-info dialog and there’s a priority row under the normal controls:
The pickers are modifiers, not buttons: choose a level, an optional expiration time, and then use the entity’s own controls (the toggle, the brightness slider, the cover position handle) and those commands carry the level you picked. Set a light to 47% at Manual Emergency for half an hour and that’s exactly what gets written.
Underneath, the whole array, so you can see what’s queued behind what’s winning:
Manual Emergency OFF 4m left ← driving
Manual ON 14m left
Default OFF
Each level can be released on its own. Taking, releasing and expiring an override are written to
the entity’s logbook.
There’s also a tile-card feature (features: - type: custom:priority-feature):
A card listing everything currently held with one-click release:
(“User” is my display name here)
…and a card for issuing commands at a level:
Supported
light, switch, fan, cover, climate, water_heater, humidifier, lock, valve,
media_player, input_boolean, input_number. Everything else passes straight through.
Wall switches, vendor apps and Zigbee group commands are noticed and recorded at Default, so
touching a physical switch behaves like any other ordinary command. While HA is running the array
is never re-asserted against someone standing at a light switch; the house shouldn’t argue with
you.
Install
HACS → Custom repositories → add https://github.com/triosniolin/ha-priority as an Integration → install → restart → add Priority Command Arbitration from Settings → Devices & Services. Hard-refresh your browser afterwards so the dashboard bits load.
Not in the HACS default store yet; that needs a brands PR which I’ll get to if people like this.
Honest caveats
- It’s new. It runs my house and has a decent test suite behind it, but I’m the only user so
far. Treat it accordingly. - The more-info row is a hack. There’s no supported way for a custom integration to add
controls to a built-in card, so that row patches a compiled frontend element. It’s written to
fail closed (if an HA update breaks it the row just stops appearing and nothing else changes)
but it will need maintenance. The tile-card feature uses a supported extension point and is the
fallback. - It wraps service calls. For each supported service it registers a wrapper over the existing
handler. If that makes you uneasy, that’s a reasonable instinct; removing the config entry puts
everything back. - Every override level (1 to 4) survives a restart, leases included; a lease that ran out while
HA was down is dropped rather than resurrected. Level 5 isn’t restored, since a stored copy of
it would be a claim about the physical world that may have moved while HA was down. - A hold at level 1 or 2 gets re-issued once shortly after startup. While HA is running the array
is never re-asserted against reality, but a restart is the one case that rule doesn’t cover
honestly: a change during downtime was observed by nobody, so a power blip that dropped a relay looks identical to a person deciding something. At the emergency levels that’s worth one
command; at 3 and 4 it isn’t.
Why I’m posting
Two threads asked for this and never got anywhere:
and there’s an architecture discussion on layered states that never got a maintainer response. The advice in one of those threads was “implement it as a custom component first”, so here it is.
If the level names or defaults are wrong for how you’d use it, tell me now while changing them is
still cheap.






