AppDaemon
Taking Home Assistant to the next level with Python — part 2
AppDaemon is a great way to take your smart home automations to the next level. In this series I will guide you through AppDaemon and how to use it to take your home automation to the next level.
- Part 1: setting up AppDaemon and creating a simple light sequence
- Part 2: turning off power to a sit-stand desk if it’s no longer in use after a certain time, and how to test your app (this article)
- Part 3: controlling mechanical ventilation using HTTP requests and reporting its status back to Home Assistant
In this part I’ll show you an app that turns on and off a workstation and how you can use unit tests to verify correct behaviour. I will not go into unit testing or framework specifics too much and not all unit tests will be shown here.
Background
I have a sit-stand desk with a power strip which powers my laptop, monitor and a few other electronic devices. When not in use, the whole setup uses around 10 watts which isn’t that much but watts add up so it’s nice to reduce standby usage if possible. I had a working automation.yaml just for this, but there were some situations where it would trigger so I decided to move it to AppDaemon.
For this project I had these requirements:
- Turn workstation on each working day at 07:45
- Turn workstation off each working day at 18:00
- Only turn off if the workstation is not in use (e.g. power usage ≤ 10 watts for 5 minutes)
I also wanted to unit test the custom methods I needed for this AppDaemon app. Why unit test? Because I want to make sure my code works, but also I want to be able to iterate on my apps without worrying about breaking things and not knowing about it.
With every automation, you always have to think: what will be the impact if it doesn’t work? — Paulus Schoutsen, founder of Home Assistant
Especially apps that control ‘critical’ automations can have a lot of impact when they stop working.
Project structure
This article will focus more on unit testing than actual AppDaemon functionality so I want to share my project structure with you, which will help you set up your project with unit tests too.
├── appdaemon
│ ├── apps
│ │ ├── __init__.py
│ │ ├── light_sequence.py
│ │ ├── ventilation.py
│ │ └── workstation.py
│ └── tests
│ ├── __init__.py
│ ├── conftest.py
│ ├── fixtures.py
│ ├── test_light_sequence.py
│ ├── test_ventilation.py
│ └── test_workstation.py
├── appdaemon.yaml
├── requirements.txt
└── requirements_dev.txt
Next to the default apps
directory I’ve created a tests
directory, inside there are a few files that are important for running the unit tests:
conftest.py
, inside this file there is a line which configures the fixtures:pytest_plugins = “tests.fixtures"
fixtures.py
, this file contains all the fixturestest_*.py
, these files contain all the unit tests. Inside these files I import the actual app code, e.g. insidetest_ventilation.py
I do:from apps.ventilation import Ventilation, VentilationClient
.
There is also a requirements.txt
and arequirements_dev.txt
file. The former contains the appdaemon package requirement and the latter the requirements for testing (pytest
and pytest_mock
).
The app
As you can see, the initialize()
method only calls other methods and splits the logic into smaller units. I consider it good practice to split your code into smaller chunks (units) of code. This will not only make it easier to read, it will also make it a lot easier to test.
I will go over each of these methods in the initialize()
method separately and will some of the unit tests I’ve created. The unit tests are written using the packages pytest
and pytest_mock
. The main reason I like to use pytest
is because of the easy fixtures.
Fixtures
Fixtures are reusable blocks of code that provide an object (or function) in a predetermined state. You can find the pytest
documentation here. For my tests. Whenever you add a @pytest.fixture()
decorator to a function, it will become a fixture. A fixture can return a value, instance of a class, a function etc.
The most important fixture is the workstation_fixture
, which is basically an instance of Workstation
with some mock values. Normally you wouldn’t instantiate your app class, but for the unit tests I had to. By providing some mock values, I managed to isolate my class under test from the default AppDaemon behaviour.
I’ve assigned a MagicMock
to the cancel_timer
, run_daily
and run_every
methods of the Workstation
class. This disables the actual implementation of these method and allows me to verify if calls to these methods were made.
To mock entities from Home Assistant, I’ve created a simple MockEntity
class which just has one field called state
. The mock_entities
fixture is a dict which has instances of the MockEntity
as values.
Set entities
The method
The _set_entities()
method reads the switch and sensor entity name from the app configuration and retrieves the actual entity using the entities dict provided by hassapi.Hass
. If either the switch entity or the sensor entity is None
, a ValueError
is raised.
The tests
What _set_entities needs to do is assign the entity values to the Workstation
instance and raise a ValueError
if the entities are not found. In the first test I verify if the assignment is done correctly by first asserting both values are None
before calling the _set_entities()
method. After the method call, I verify if the values have been set to the expected (mock) values.
In the second test I use pytest's
parametrize decorator to provide several parameters to a unit test. You can find more information about parametrize here, but what it basically does is iterate over the set of parameters and use those to populate the data inside then unit test. In the first set, I use the switch_entity
name to set the value of the entity to None
, in the second set I use the sensor_entity
name to set the value of the entity to None
. In both cases I verify that the raised ValueError
contains the expected error text.
Set times
The method
The _set_times()
method retrieves the startup and shutdown times from the Workstation args and applies them to the Workstation instance. To avoid duplicate code I’ve created a separate function for getting the times from the Workstation args.
The tests
The test_get_time_from_arg
test is parametrized and checks for two different sets of data. Within this test I only assert correct arg values, in test_get_time_from_arg_raises
I validate if the correct error is raised when the arg value is None
or has missing keys.
The test__set_times
test asserts that the startup_time
and shutdown_time
fields are properly set on the Workstation
instance.
Set callbacks
The method
The _set_callbacks()
method sets callbacks to run every workday to either turn the workstation on or off. It will check if startup_time
and shutdown_time
to prevent running with invalid data.
I’ve created two partials for the startup and shutdown. By creating partials, I can ‘freeze’ the function call with arguments and reuse it. So whenever I want to refactor this code, I only have to modify the function call in one place instead of multiples places.
For the shutdown run, I’ve created a custom constraint that checks if it is a weekday after shutdown time and if the switch is turned on. If this is not the case, the call to the turn_off_cb()
will not take place.
The tests
In the first test case, I verify if the run_daily
and run_every
methods are called with the expected parameters and assert that the cancel_timer
method is not called. In the second test I assign a value to the handles to make sure the cancel_timer
method gets called.
These tests rely on the MagicMock()
properties which I assigned to the workstation_fixture
(see Fixtures).
Final thoughts
For some apps this might be overkill, but for other apps (like making sure your coffee machine is ready in the morning) it might not be. The most important thing is to know that it is possible to unit test your AppDaemon apps and I hope this article helped you to get an understanding on how to do it. In the final part, I’ll show you how to combine AppDaemon with an API client