Note: this is an old project of mine. While functional, I do not expect to update it anymore. It has been replaced by another ESPHome based custom PCB that does the same and more (basically ticking all the boxes listed in the Future plans section of this project).
This is a simple project that has been developed to control lights in my house. It uses an Arduino which controls relays that turn mains voltage to invididual circuits for light sources located in various locations in the building.
At the moment, the only user interface that can be used to toggle the switches are momentary push buttons installed on walls.
The controller is able to perform different actions, based on how long the button was pressed. See Mapping for more details.
Alternatively - it is also possible to use text commands that can be passed via serial, however this is a very limited and low level interface that I have been primarily using while debugging.
There is nothing that would make adding other interfaces imposible and the project will be extended to add support for commands sent over WiFi (e.g. from an mobile app on a smartphone, or via protocols like MQTT). See Future Plans for more information.
The operation of the system can be customized by editing the Rules.json
file which defines what connections
are used. This file is used as an input to a RuleGenerator.py
script which generates Rules.h
header file that the firmware includes in the build process.
There are two main sections of the JSON file with rules:
expanders
is a list all expanders that your setup uses and specifies how cables are connected.mapping
is a definition of relation between triggers (like holding a button down) and effects (like toggling the state of a relay to turn it on or off).
So, the structure is something like:
{
"expanders" : [
// all expanders that you use are listed here
],
"mapping": {
// mapping between input events and actions
// that change output states lives here
}
}
An single expander is an object that has values defined for 3 keys - address
, inputs
and outputs
.
Address is an integer and means the address of the MCP23017 chip instance (ranging between 0 and 7, without the chip-specific part of the value, 0x20).
Inputs and output are lists of (up to) 8 strings which become name identifiers for connections - something you can reference in section describing mapping.
The names are later mangled by a script that generates a C++ header from it, so it's best to stick to a
latin.characters.separated.by.a.dot
convention.
An example single expander definition can look like this:
{
"expanders": [
{
"address": 0,
"inputs": [
"ground.entrance",
"ground.kitchen",
"ground.livingroom.left",
"ground.livingroom.right",
"ground.bedroom",
"upstairs.room.left",
"upstairs.room.right",
"upstairs.bathroom"
],
"outputs": [
"hallway.ceiling",
"kitchen.table",
"ground.ledstrip.side",
"ground.bedroom",
"kitchen.ceiling",
"upstairs.room.left",
"upstairs.room.right",
"upstairs.bathroom"
]
}
],
...
}
The program currently recognizes 3 types of events that can be triggered with push buttons - short, medium and long button press events.
Event | Duration |
---|---|
short press | released under 600 ms |
medium press | released between 600 ms and 3 seconds |
long press | pressed for longer than 3 seconds |
The actions that you can map to those events are as follows:
- toggle an output state (= turn ON if it is OFF, or turn it OFF if it's ON)
- turn an output ON and set a timer, which will turn it OFF automatically when it expires. The expiration timeout value is configurable.
- turn all outputs OFF.
Timers come in two flavors - resettable or cancellable. First type means that you can reset the counter to the original value by provoking the event again while the timer is running, effectively prolonging the time the output in ON. The latter is the opposite - if the timer event happens while the timer is running, the timer value is immediately shortened to 0, effectively turning the output OFF. You can set any timer expiry time to multiples of 30 second period.
By default, timers are set to 30 seconds and are cancellable.
There can only be a single effect of a single type of an event per input. This means that, for example, pressing and releasing a push button connected to first input pin of an expander can only do one of 3 actions listed above - say, toggle an output pin. However, you can define other action for another event associated with the same push button (e.g., medium press can turn all outputs off).
Chaining actions is currently not possible, and that's by design. The original intent was to use this project as a simple execution unit with as little logic as possible, letting more advanced logic live in another device, which would give orders to this basic controller.
Assuming the pin connections as above (refer to section explaining expanders), an example mapping can look like this:
{
...
"mapping": {
"ground.kitchen": {
"short": "toggle:kitchen.ceiling",
"medium": "toggle:kitchen.table"
},
"ground.livingroom.middle": {
"short": "timer:ground.ledstrip.side,2,on",
},
"ground.bedroom": {
"short": "toggle:ground.bedroom",
"long": "off"
},
"ground.entrance": "hallway.ceiling"
}
}
- Keys are input names and should be strings.
- Values are objects, defining mapping between trigger types (
"short"
,"medium"
or"long"
) and actions. - Actions are strings, which consist of output names prefixed with
toggle:
ortimer:
, indicating type of action. - There is also one special value that you can use for an action:
"off"
which means turn all outputs off. - You can use optional arguments for
timer:
entries. These should be comma separated, no whitespaces, and follow the output name. The first argument is expiration value (in multiplies of 30s period), and the second argument is eitheron
oroff
, for cancellable and resettable timers respectively. In the example below the timer is set to 1 minute and is resettable. If you skip arguments, they default to1
for expiry time (meaning 30 seconds) andoff
, meaning a cancellable timer. - Since the most common usage is mapping a short press to a toggle action, there is a shorter way of defining such entries: the value can simply be a string with output name (with no prefix or arguments).
Once you modify the Rules.json
file, you should regenerate the header file Arduino code uses.
To do that, use a Python script like so:
RuleGenerator.py -c Rules.json -o Rules.h
For quick changes, it's also possible to edit the header file by hand - it uses human-readable macros. However, if you do that make sure the rules for a single input are ordered from longest time of holding the button to shortest - it's an optimisation that makes the interpreter code simpler.
The program needs an Arduino to run. I have used Arduino Nano, but any other variant could be used - the only feature that must be available is I2C which is used to interface with port expanders - which control relays, and scan inputs to detect changes of button states.
In fact, this does not need to be a device using AVR chip. There is an early support for ESP8266 running Arduino present, and my plan is to switch to a board using this uC eventually to get more functionality.
Warning: handle mains voltage with caution. It can be lethal if you don't know what you're doing.
A working prototype that currently controls lights in my house uses 5 identical, custom designed PCBs that have connectors exposing pins for:
- power (a few 5V and GND pins),
- I2C SDA and CLK lines,
- 8 digital inputs connected to push buttons,
- 8 outputs conected to relay coils.
The components I used:
- Arduino Nano, soldered to one of the five boards
- MCP23017 port expanders (1 for each board, each with different address selected)
- ULN2803 chips to be able to provide enough current for driving coils (1 for each board)
- a few resistors and capacitors for each board
More details of the hardware, including the schematics will come. It is a fairly standard application of MCP23017 and ULN2803 though.
The boards are inside a case which can be mounted on a DIN rail. I chose relays with 5V coils, which have compatible DIN mounting available as well, so they can be installed in the same place where the controller and 4 supporting boards are.
All 5 boards are connected to the same 5V power supply (also in DIN-compatible case), and are connected to shared I2C data and clock lines.
Each of the PCBs have connectors for 8 inputs lines from push buttons. Buttons share a ground connection, and holding them down them pulls input pins of expanders low. Finally, coils of the relay share a 5V connection, and relays are switched on by setting an output pin low, so the current can flow.
The environment I currently use for editing is Visual Studio 2017, with Visual Micro add-on. It is also possible to use Platform I/O, and it's used for Travis continous integration. This is probably the easiest and quickest way to build everything.
Currently, the project files that are available in the repository may contain paths that are specific to the folder structure I use on my PC, however I will try to clean this up as I go.
There are two 3rd party libraries used:
Adafruit-MCP23017-Library
to simplify communication with MCP23017 chipsAdafruit-Sleepydog
to simplify using a Watchdog on AVR Arduinos. Not needed for ESP8266.
Both should be available in your Arduino environment for the project to build.
In setup phase, the program checks which port expanders are available (i.e., respond to I2C communication). Since I used MCP23017 chips, there can be maximum 8 expanders in the system.
All detected port expanders are configured to use PORTA
for inputs, and PORTB
for outputs.
It is currently not possible to split the 16 available GPIO pins unevenly, so there can be maximum of
64 inputs and 64 outputs configured. All input pins are pulled high by default.
There is a single run of the loop every 15 ms, which I call a tick. Debouncing of input pins is done implicitly, by ignoring changes which last shorter than a single tick.
There are just a few objects that are used to realise the logic: InputMonitor
is used to for polling
the state of all input pins, and it uses RuleInterpreter
to determine if an action is required.
If so, RuleInterpreter
returns a Command
which can be executed by OutputSetter
, which is a class that toggles
output pins.
Actions are performed as soon as it's clear what the trigger is - this means that if a button only has one rule defined for short press, the action can be executed if a button is held for longer than debounce time. Where there are more actions mapped to the same button, then it's possible that an action is executed on releasing of the button.
For more details, refer to the code.
- Switch to ESP8266
- Utilize WiFi for OTA updates
- Add MQTT interface for both state reporting and remote control
- Time of day awareness, and dawn/dusk triggers
- Integrate with Home Assistant
- Integrate with Google Home for voice control