Home Assistant automations are a list of steps. You get conditions, loops, and a way to call another script. What you don’t get is a function — no parameters with real defaults, no return value, no dispatch table, no way to say call whichever one of these matches.

So when I wanted one entry point that could set the mood of a single room, of four rooms, or of the whole house — while respecting whatever a human had overridden by hand, and without writing the same twelve steps six times over — I had to build the missing layer myself.

It came out at about 180 lines of YAML. It behaves like a plugin registry with graceful degradation and an event bus. I want to walk through it, because every piece exists to solve a problem the house actually had, and because I think most people on a constrained platform end up building this same thing without noticing.

This is the companion piece to Mood Controller: Giving Your Home a Rhythm, which is about why a home needs moods. This one is about how the machinery underneath holds together.


The Combinatorial Problem

Six moods: Morning, Day, Evening, Unwind, Night, Movie. Four areas: kitchen, living room, children, parents. Four presets: default, bright, off, ceiling-off.

That’s ninety-six combinations. Nobody hand-writes ninety-six anything.

The obvious fix is one big script with a giant choose: block. That doesn’t work, and the reason it doesn’t work is the interesting part: the six moods aren’t parameters of one behaviour, they’re six different behaviours. Evening turns on the salt lamp and the coffee lamp and sets the kitchen counter to half brightness. Night defers entirely if someone is still moving around the kitchen. Movie locks the room so nothing can touch it.

You can’t collapse those into a single parameterized routine. What you can do is give them a shared front door.


Normalize at the Boundary

The first thing mood_set does is figure out which areas it’s actually talking about:

{% set resolved =
  ([target_areas] if target_areas is string else
   target_areas if target_areas is list else
   areas()) %}

target_areas might arrive as a single string from a button, as a list from an automation, or not at all — which means everywhere. Three shapes in, one shape out.

This is an ugly block and I’ve made my peace with it. Push the mess to the boundary and everything downstream gets to be clean. Every step after this one can assume it has a list. That trade is almost always worth making.


The Human Always Wins

If you’ve lived with home automation, you know the specific frustration of a house that undoes what you just did. You turn a light on; forty seconds later the motion timer turns it off. You brighten a room; the schedule dims it.

Every area has a lock: input_boolean.<area>_lock. When the projector turns on, living_room_lock goes up and the house stops touching that room until the movie is over. The resolver filters locked areas out before anything else happens:

{% for area in resolved %}
  {% if states('input_boolean.' ~ area ~ '_lock') != 'on' %}
    {% set ns.output = ns.output + [area] %}
  {% endif %}
{% endfor %}

There’s one deliberate exception. If the call targets exactly one area — or if it’s a global refresh — the lock check is skipped. Asking for one specific room is the override. Someone standing in a locked room pressing that room’s switch should get what they asked for.

The important part isn’t the feature. It’s the location. The lock lives inside the resolver, so nothing can accidentally bypass it. A rule that every caller has to remember is a rule that will eventually be forgotten.


What a Second Press Should Mean

Press the switch: the room goes bright. Press it again: it should go back to normal.

That sounds trivial until four rooms are involved. “Again” only means something if they agree with each other, so the toggle logic first checks whether every targeted area is currently in the same preset:

{% set all_same = ns.presets | count > 0
   and ns.presets | unique | list | count == 1 %}
{% if all_same and current_preset != 'default' and preset != 'default' %}
  {% set resolved = 'default' %}
{% endif %}

I got this wrong twice. The first version toggled each room independently, and the rooms drifted out of sync until the button appeared to do nothing — half the rooms went bright while the other half went back to default. The second version compared against the house mood instead of the areas’ actual presets, which broke the moment one room had been overridden.

The version that works asks a narrower question: are all the rooms I’m about to touch currently the same? If yes, a repeat press means undo. If no, the press means make them all match.


Group Before You Call

Once the areas and presets are resolved, the script builds a key for each area — mood ~ '|' ~ preset — and groups the areas that share one. Four rooms heading to Evening|default become one call instead of four.

This looks like a performance optimization and I’d rather you read it as a matter of taste. Four separate calls means four transitions starting a few dozen milliseconds apart, and you can see it. The house flickers instead of breathing.

Lights that move together look intentional. Lights that move nearly together look broken.


Dispatch by Name

Here’s the line the whole thing turns on:

mood_script: script.mood_{{ repeat.item.pair.split('|')[0] }}

There is no registry. No mapping table. No if mood == 'evening' chain that grows every time I have an idea.

The naming convention is the registry. A mood called Evening lives in script.mood_evening. Adding a seventh mood means writing one script and nothing else — no central file to edit, no list to remember to update, no place for the two to drift apart.

This is the same instinct as the vertical slice I use on the web: add a folder, everything works. Add a script, the mood exists. Convention over configuration, applied to a house.


Fail Loudly, Keep Running

Dispatching on a name means the name might be wrong. So the call is guarded:

- condition: template
  value_template: "{{ has_value(mood_script) }}"

If the script isn’t there, the house doesn’t crash and it doesn’t shrug. It writes a warning to the system log and raises a persistent notification in the UI:

- action: persistent_notification.create
  data:
    title: Mood Set
    message: "Mood script not found : {{ mood_script }}"
    notification_id: mood_script_not_found_{{ mood_script }}

And the call itself carries continue_on_error: true, so one missing mood script doesn’t stop the other rooms from getting theirs.

I care about this more than any other part. A house where the lights sometimes don’t change and you never find out why is a house you stop trusting — and once you stop trusting it, you start reaching for the switch yourself, and the whole system becomes decoration. Trust is the actual product. Silent failure is the thing that destroys it.


Broadcast, Don’t Couple

The last thing mood_set does is announce what happened:

- event: mood_setted
  event_data:
    target_areas: "{{ areas_resolved }}"
    mood: "{{ mood_resolved }}"
    preset: "{{ preset_resolved }}"
    is_homewide: "{{ is_homewide }}"
    is_triggered_by_user: "{{ is_triggered_by_user }}"

The dispatcher doesn’t know who’s listening and doesn’t want to. Anything that cares — logging, metrics, a follow-up automation — subscribes. I have a script that watches for birthdays and holidays in a calendar and repaints a few lights after the moods have settled. The dispatcher has no idea it exists.

The field I’d point at is the last one. is_triggered_by_user comes from a single check:

{{ context.user_id is not none }}

The house can tell the difference between a person pressing a switch and itself reacting to a sensor. Right now I only log it. But that distinction is the seed of something else entirely — a house that could eventually learn what you actually prefer, instead of only executing what you once wrote down.


What I’d Do Differently

The templates are long and difficult to read. Jinja embedded in YAML is a genuinely bad place to put logic, and every one of these blocks would be four clearer lines in Python. If I rebuilt this, the resolver would be a custom component and the YAML would only declare intent.

There’s no test suite. I verify by walking into rooms and looking at lights. That’s fine for a house and would be indefensible anywhere else.

And is_refresh and is_homewide overlap in ways that took me a long time to hold in my head at once — a sign the two concepts want to be one thing I haven’t found the name for yet.


The Takeaway

Every constrained platform eventually makes you build the abstraction it didn’t give you. Home Assistant gave me steps and scripts; I needed dispatch, defaults, and graceful failure, so those got built out of what was there.

The question was never whether I’d write a dispatcher. It was whether I’d notice that’s what I was doing — and build it on purpose, once, instead of growing it by accident across ninety-six copies.

The full source, mood templates, and setup instructions are on GitHub:

github.com/ZeFish/hass_mood_controller


Francis Fontaine is a developer and photographer based in Québec City. He builds modular systems for the web (stnd.build) and for the physical world, and believes the best technology is the kind that disappears into the background of daily life.

Are you absolutely sure?

This action cannot be undone.