Home Assistant Elixir

View Source

home_assistant_elixir is an embedded Elixir DSL for declaring Home Assistant entities and automations, then materializing those declarations as YAML files ready to be used by Home Assistant.

Installation

Add home_elixir to your dependencies in mix.exs:

def deps do
  [
    {:home_elixir, "~> 0.1.0"}
  ]
end

Configuration

In your config/config.exs:

config :home_assistant_api_client,
  url: "http://YOUR_HOME_ASSISTANT_URL:8123",
  token: "YOUR_LONG_LIVED_ACCESS_TOKEN"

config :home_elixir,
  entity_folder: "/path/to/your/home_assistant/config/",
  automation_folder: "/path/to/your/home_assistant/config/"

The entity_folder and automation_folder must point to your Home Assistant config/ directory. The DSL writes YAML files there at compile time.

:home_assistant_api_client is a separate package (hex.pm/packages/home_assistant_api_client) that home_elixir depends on for the runtime get_*/set_*/restart_home_assistant/0 helpers described below — it's pulled in automatically, you don't need to add it to your own deps.

To generate a long-lived access token, go to your Home Assistant profile page (/profile), open the Security tab, and scroll to Long-Lived Access Tokens.

How it works

Declaring entities and automations happens at compile time; writing the YAML files happens at runtime, through a function call you make explicitly:

  1. Define one or more Elixir modules with use HomeElixir.DSL.
  2. Inside those modules, declare entity and automation blocks.
  3. During compilation, the DSL macros accumulate those declarations in module attributes.
  4. Right before the module finishes compiling, __before_compile__/1 transforms the collected declarations into an intermediate representation and injects a generate_home_assistant_config!/0 function into the module — it does not write any files itself.
  5. At runtime (app start, a mix task, iex), call YourModule.generate_home_assistant_config!(). That's when the intermediate representation is compiled into Elixir maps and written as YAML files in the configured Home Assistant folders.

Generation is no longer driven by an external ProjectGenerator.generate/1 call — but it's also not automatic on compile. Calling the compiler/writer from inside __before_compile__/1 used to be the behavior, but it raced against the compilation of other referenced modules and failed intermittently, so it was moved to an explicit runtime call.

What it generates

The compiler writes one YAML file per declared entity, inside a folder named after its domain:

  • input_boolean/<entity_id>.yaml
  • input_number/<entity_id>.yaml
  • input_select/<entity_id>.yaml
  • alarm_control_panel/<entity_id>.yaml

And one YAML file per declared automation, named after its alias:

  • automations/<alias>.yaml

All of these live directly under the entity_folder/automation_folder you configured (see Configuration) — there is no intermediate entities/ directory.

Required Home Assistant structure

Home Assistant loads these folders through !include_dir_merge_named/!include_dir_merge_list directives in configuration.yaml, and it checks that the target directory already exists before it cares whether it's empty. So the folders below must exist as placeholders in your Home Assistant config/ directory before the very first compile — otherwise Home Assistant refuses to start, and the DSL never gets the chance to create them for you.

config/
├── input_boolean/
├── input_number/
├── input_select/
├── alarm_control_panel/
├── automations/
└── configuration.yaml

Create them upfront, even empty:

mkdir -p config/{input_boolean,input_number,input_select,alarm_control_panel,automations}

Git does not track empty directories, so if your Home Assistant config/ is under version control, add a placeholder file (e.g. .gitkeep) inside each one so the folders ship with the repo:

touch config/{input_boolean,input_number,input_select,alarm_control_panel,automations}/.gitkeep

Then wire them up in configuration.yaml:

input_boolean: !include_dir_merge_named input_boolean/
input_number: !include_dir_merge_named input_number/
input_select: !include_dir_merge_named input_select/
alarm_control_panel: !include_dir_merge_list alarm_control_panel/
automation: !include_dir_merge_list automations/

From then on, compiling your DSL modules fills these folders with one YAML file per entity/automation. File.mkdir_p!/1 recreates each domain folder on every write, but Home Assistant needs it to already be there from the start.

Configuration

Set the output folders in:

home_assistant_elixir/config/config.exs

You must configure:

  • :entity_folder
  • :automation_folder

Those paths must point to the config/ directory used by Home Assistant.

How to organize modules

The recommended shape is:

  • one or more DSL modules grouped by domain
  • each module should contain only declarations written with the DSL
  • compiling those modules triggers YAML generation

For example:

defmodule MyHome.Alarm do
  use HomeElixir.DSL

  entity :input_boolean, "alarm_enabled" do
    name "Alarm enabled"
    initial false
  end
end

defmodule MyHome.Covers do
  use HomeElixir.DSL

  entity :input_select, "living_room_cover" do
    options ["open", "close"]
    initial "close"
  end
end

Why this shape

This fits the current architecture well:

  • each DSL module is a declaration unit
  • the macros collect declarations locally in that module
  • __before_compile__/1 compiles only what that module declared
  • the compiler/writer layer is responsible for materialization

Minimal example

This is a small end-to-end example with:

  • one input_boolean
  • one automation reacting to a state change
  • one notification action
defmodule Demo.Switches do
  use HomeElixir.DSL

  entity :input_boolean, "test_switch" do
    name "Test switch"
    initial false
  end

  automation "notify when test switch turns on" do
    trigger do
      state do
        affected_entities [{:input_boolean, "test_switch"}]
        to true
      end
    end

    action do
      persistent_notification "create" do
        message "The test switch is on"
      end
    end
  end
end

When Demo.Switches is compiled, the DSL will:

  1. register the declared entities and automation steps in module attributes
  2. build the automation intermediate representation
  3. compile entities and automations into Elixir maps
  4. write the resulting YAML files to the configured folders

How the DSL works internally

Each module that uses HomeElixir.DSL gets:

  • module attributes used to accumulate declarations
  • macros for entities, triggers, conditions and actions
  • an internal __before_compile__/1 hook
  • generated helper functions

The process is:

  1. entity declarations are stored in type-specific attributes such as @input_boolean or @input_number.
  2. automation declarations are recorded as an ordered list of steps in @automation_steps.
  3. __before_compile__/1 reads those attributes and reorganizes them into a structured intermediate representation.
  4. The compiler layer turns that representation into domain maps.
  5. The YAML writer serializes those maps and persists them on disk.

Embedded helper functions

When a module uses HomeElixir.DSL, the DSL also injects helper functions into that module.

There are two groups of generated helpers:

  • internal introspection helpers that expose the declarations collected during compilation
  • client helpers that call HomeAssistantApiClient

Introspection helpers

These functions are generated automatically:

  • __home_elixir_entities__/0
  • __home_elixir_automations__/0

Example:

Demo.Switches.__home_elixir_entities__()
Demo.Switches.__home_elixir_automations__()

They are mainly useful for debugging and for inspecting what the DSL compiled internally.

Client helpers generated per entity

For every declared entity, helper functions are generated in the same module so you can interact with Home Assistant through HomeAssistantApiClient.

For input_boolean:

entity :input_boolean, "test_switch" do
  initial false
end

This injects:

  • get_input_boolean_test_switch/0
  • set_input_boolean_test_switch/1

Example:

Switches.get_input_boolean_test_switch()
Switches.set_input_boolean_test_switch(:turn_on)
Switches.set_input_boolean_test_switch(:turn_off)

For input_number:

entity :input_number, "temperature_limit" do
  min 10
  max 30
end

This injects:

  • get_input_number_temperature_limit/0
  • set_input_number_temperature_limit/1

Example:

Settings.get_input_number_temperature_limit()
Settings.set_input_number_temperature_limit(22)

For input_select:

entity :input_select, "cover_mode" do
  options ["open", "close"]
end

This injects:

  • get_input_select_cover_mode/0
  • set_input_select_cover_mode/1

Example:

Covers.get_input_select_cover_mode()
Covers.set_input_select_cover_mode("open")

For alarm_control_panel:

entity :alarm_control_panel, "alarm" do
  platform :manual
  code "1234"
end

This injects:

  • get_alarm_control_panel_alarm/0
  • set_alarm_control_panel_alarm_state/2

Example:

Alarm.get_alarm_control_panel_alarm()
Alarm.set_alarm_control_panel_alarm_state(:alarm_arm_home, "1234")
Alarm.set_alarm_control_panel_alarm_state(:alarm_disarm, "1234")

Restart helper

Every module using the DSL also gets:

  • restart_home_assistant/0

Example:

Switches.restart_home_assistant()

This calls the Home Assistant restart service through the client.

Notes about these helpers

  • these functions are generated from the declared entity names
  • entity names should therefore be readable and valid for generated function names
  • these helpers are runtime convenience wrappers around HomeAssistantApiClient
  • the YAML generation path is independent from calling these runtime helpers

Current DSL shape

Entities

Supported entity types:

  • :input_boolean
  • :input_number
  • :input_select
  • :alarm_control_panel

An entity declaration has three parts: the type (one of the domains above), the id (second argument — becomes the Home Assistant entity_id and the generated helper functions' suffix), and everything else inside the do block, which are optional attributes — name (the display name shown in the Home Assistant UI) plus type-specific ones like initial, icon, min/max, options, platform/code, etc. See the HomeElixir DSL guide for the full attribute list per type.

Examples:

entity :input_boolean, "presence" do
  name "Presence"
  initial false
end

entity :input_number, "temperature_limit" do
  min 10
  max 30
  initial 20
end

entity :input_select, "cover_mode" do
  options ["open", "close"]
  initial "open"
end

entity :alarm_control_panel, "alarm" do
  platform :manual
  code "1234"
end

Automations

The current high-level structure is:

automation "some alias" do
  trigger do
    ...
  end

  condition do
    ...
  end

  action do
    ...
  end
end

Available trigger forms

state do
  affected_entities [{:input_boolean, "presence"}]
  to true
end

numeric_state do
  affected_entities [{:input_number, "temperature_limit"}]
  above 20
end

sun do
  event :sunrise
end

Available condition forms

state_condition do
  affected_entities [{:alarm_control_panel, "alarm"}]
  state_value "armed_home"
end

logical_condition :or do
  state_condition do
    affected_entities [{:alarm_control_panel, "alarm"}]
    state_value "armed_home"
  end

  state_condition do
    affected_entities [{:alarm_control_panel, "alarm"}]
    state_value "armed_away"
  end
end

Available action forms

Generic service action:

service :input_boolean, "turn_on" do
  affected_entities [{:input_boolean, "presence"}]
end

input_select service action with option:

service :input_select, "select_option" do
  affected_entities [{:input_select, "cover_mode"}]
  option "open"
end

Persistent notification:

persistent_notification "create" do
  message "Hello from HomeElixir"
end

Notes

  • YAML generation now happens during module compilation, not through an external project generator.
  • If you split your DSL across many files, each compiled DSL module can materialize its own declarations.
  • The current DSL scope is intentionally small and focused on the entity and automation types already implemented in the compiler.