Priority arbitration: let a manual action and an automation disagree without one clobbering the other

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.

GitHub - triosniolin/ha-priority: BACnet-style priority arbitration for Home Assistant: give every command a level of authority, so a manual action and an automation can disagree without one silently clobbering the other. · GitHub

Still getting my head around this… I have a number of input_boolean overrides - for testing purposes presumably I can just leave them “off”. Removing them would be a nightmare if I had to re-create them again. :roll_eyes:

Correct, this won’t change anything about how Home Assistant operates until you actually decide to override something with a priority. So, you could install this component and give it a try, run a couple of overrides, and see how that works for you.

A couple of pictures of the interface in action:

No overrides:


A few overrides:

In this state, the light will be OFF for 4 minutes because Priority 1, “Manual Emergency,” has control. After that 4 minute timeout, the “Manual” override will take over and the light will turn ON. It will stay ON for ~10 minutes, because its override expired in ~14 minutes from this screenshot and the Emergency override lasts ~4 minutes. After being ON for ~10 minutes, the “Manual” override will expire.

Pressing “Release All” will release all overrides and allow the “Default” to fall though, whatever state that may be. Or, you can release individual overrides below.

Overrides without an expiration will never expire, and can only be overridden by another command at the same or higher priority.

The available priorities:

How I am currently using this, is that most everything is still done at Default. Most of my Home Assistant runs just like out of the box. However, I’m on a time-of-use rate plan with my utility. I have a light that comes on and turns orange during peak hours to remind me not to do something foolish like start the oven.

However, that light is also useful for illumination, so I have it come on as a white light, synchronized with another light nearby; if I turn on the other light, this one comes on too. Before priorities, the automations for the alerting capability and the sync capability had to cooperate to not squash each other. Now, the sync automation fires at Default priority; the alert automation fires at Automatic. Automatic trumps Default, so no matter what the light had been doing before, the sync automation now has control. At the end of peak hours, the sync automation releases its override, and whatever the light would normally be doing, is sent through.

There is now no need for the automations to be aware of each other. They each serve just one function, but one has a higher priority and will “win” if they both want control.

I also am on a well; I have, once or twice, run the well dry. I now have an automation that turns the well pump off via a relay, at Automatic Emergency priority, if the energy monitor statistics on the pump indicate it’s sucking air. That means no other automation can turn it back on (and I do have another automation that commands that relay) until I have performed a manual reset.

If I wanted to turn on a pump, turn off a heater, or command a light, and be CERTAIN nothing else will touch that command and I won’t be fighting any automations, I can put it in at Manual Emergency priority. Technically, you can write an automation that can command/release at that priority, but, I would suggest not doing so. If you keep to that idiom, then whatever you command at Priority 1 - Manual Emergency - will remain, either indefinitely or for as long as you say so.

And, lastly, this gives the ability to do something that I have been missing in HA for a long time; I just want this light on for 20 minutes, or that heater on for an hour, and I don’t want to have to write a whole automation to turn it back off or have to remember. Now, just command it at Manual priority with a time limit, and it’ll go back to Default at the end.

I almost wrote, “it’ll go back to the prior state,” but that’s not strictly true; it’ll go back to Default. The difference being, if an automation normally controls a light, and I turn it on in Manual for 20 minutes, and in that 20 minutes the automation now would have turned that light on and kept it on, then after my 20 minute override, the light will stay ON. It doesn’t necessarily go back to “prior state,” but instead, to automation control, if any. If there is no automation control, then “prior state” would be accurate.

I have to admit, I was initially sceptical of this. Between the blatantly AI-generated text and the misleading " Home Assistant has no idea who commanded an entity" (see How to use context), I didn’t think this was useful at all until you posted the screenshots to clarify.

I can see now that this would be extremely useful for most users, even as a simple replacement for the context table for newbies. All it needs is its own service calls to release existing states & set others. That would be extremely useful for overriding.
Eg. Manual emergency mode can be released earlier & flipped back into manual mode if a button is double clicked. That way you don’t need to dig through the UI

Am I correct this replaces my battle with my automated sunscreens / covers? They open/close automatically, but sometimes I just want to override. I have an automation/boolean/thing now that disables the automation until the next day.

I would replace that with a ‘even though the cover wants to close, I’m keeping it open for another 3 hours with a level 3 command’?

Same for a host of manual override booleans/buttons on my AC?

I get that. The AI overview was very verbose, and it’s definitely got opinions. I did a little trimming myself, but I probably need to do a manual overhaul of all of that. I wanted to get it out where people could see it, and I realized later that without the screenshots, it didn’t really land; it was just a wall of text making grandiose statements.

That being said, it isn’t vibe-coded. I am a software developer and while Claude did the work, it was human reviewed during the development and it’s been in use without error for me for several days now. I’m quite happy with it, and I think it’s genuinely useful.

That is exactly what this is useful for. If you install this component, you can set a manual override on your cover, with an expiration, and after the override expires it goes back to automation control.

I’m a QA by profession, so excuse me if I take that statement with a heavy pinch of salt.

Having said that, would you like to address the rest of my post regarding service calls & overrides, or did I miss something?

I guess I didn’t see that it needed a response, because it is already in the integration. That’s what it does. The integration registers 4 service calls of its own: priority.set, priority.relinquish, priority.relinquish_all and priority.get.

So, for your double-click example, it’s one call:

- action: priority.relinquish
  target:
    entity_id: light.kitchen
  data:
    priority: 1

Clearing a slot that currently has control re-drives whatever occupies the next level down, using that level’s current command rather than a stored snapshot. So if Manual (3) is holding something, control lands there on its own; you don’t re-issue it.

If you want to release 1 and put something specific at 3 in the same script:

- action: priority.set
  target:
    entity_id: light.kitchen
  data:
    priority: 3
    service: turn_on
    data:
      brightness: 200

service is the bare domain service (turn_on, set_temperature); the domain comes from the target entity. It validates both the service and the payload before writing, since a slot that can’t dispatch would still win arbitration and sit there holding the entity, with nothing driving it.

priority.get returns a single top-level key arrays, keyed by entity_id:

arrays:
  light.kitchen:
    entity_id: light.kitchen
    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, and effective_* are all null when nothing holds the entity. That’s not that Default holds the entity; if any arbitrated command has been issued, even one at Default priority, effective_* will hold that priority 5 data. Null will mostly occur after a restart, for an entity where an automation or user has not given it any commands.

An example script:

- action: priority.get
  target:
    entity_id: light.kitchen
  response_variable: priority_state

Then branch on it:

- condition: template
  value_template: >
    {{ priority_state.arrays['light.kitchen'].effective_priority == 1 }}

Or, to test that nothing is currently overriding the entity:

- condition: template
  value_template: >
    {{ priority_state.arrays['light.kitchen'].effective_priority in [5, none] }}

Admittedly, none of this was disclosed in the initial post. I’ll set aside some time this evening to clean up the OP and add API documentation.

Perfect! That clarifies loads of stuff, thanks!

That is exactly what this is useful for. If you install this component, you can set a manual override on your cover, with an expiration, and after the override expires it goes back to automation control.

What does ‘manually’ mean here? Only when manually setting in HA or also when manually pulling the cord on the cover?

Manual here refers to a command placed in HA at the Manual override priority, which has priority level 3. If you manually pull the cord to put it in a new position (and your cover reports that position change to HA), it will stay at the new position, as explained below.

I’m going to go back to the light example because I don’t personally have covers that I use. Let’s say an automation would turn a light off at 10pm, and that automation isn’t set to send that command with any particular priority (it uses Default, level 5). If you wanted the light kept on, before 10 you could place a command on that entity at Manual priority (look at the screenshots above). If you wanted it on until you turned it off, you would set that command with no expiration; if you wanted it on for 2 hours, you would set that timeout on the command when you set it.

At this time, the “priority array,” that is, the list of commands that HA needs to keep track of and arbitrate, looks like this (excuse my incorrect YAML, I’m on my phone at the moment):

priority: 3
priority_ttl: "02:00:00"
service: turn_on

priority: 5
service: turn_on

Then, the automation fires at 10pm, commanding the light off at Default. Now the priority array looks like this:

priority: 3
priority_ttl: "01:50:00"
service: turn_on

priority: 5
service: turn_off

This is where the priority arbitration kicks in. Your command is at a higher priority (lower number = higher priority, think “priority one” meaning highest priority), so even though the automation fired, the light did not turn off. HA only recorded that something wants it off, at priority 5, but that priority didn’t win.

After another hour and 50 minutes, your Manual override expires. At that time, the array becomes:

priority: 5
service: turn_off

HA now evaluates that array and sends the turn_off command to the light. The automation is now back in control and the system is following its orders.

Now, let’s go back in time a little bit. It’s 11pm so your override still has an hour left, but you’re going to bed and go over and hit the physical switch on the light (in this case, I’m assuming the light remains powered and on the network, it just has an off switch you can use on it for local control instead of network control).

HA will see that the state changed, but it’ll also know that it didn’t perform the command itself; something outside of its control did it. It’ll assume that was a human hitting a physical pushbutton and it will not countermand you. It will not re-send the on command; humans should always have final say. The override will continue to live in the priority array until it expires, or a new command lands that has a high enough priority, and only in those cases, would it actually send any command. In this example, the Default level holds the only command (the priority 5 turn off, captured when you turned off the light) and it wants the light off, so it’ll just stay off.