I thought it would be fun to “play” with the internet of things (IoT) and looked for a suitable project. I assembled a collection of cheap IoT devices into a box, mounted it on my garage wall, and configured software to make it turn on an exterior light when motion of detected.
This is the story of how I did that.
Caveat – this was all done in the sense of a hobby project. It’s not necessarily the best way of achieving the same goal. I’ll share the code at the bottom.
The hardware
I assembled a number of devices together, only two are relevant here, a cheap PIR detector (HW-416B) and a microprocessor ESP8266 NodeMCU. They both can be bought for about £4. I printed a box, wired them together and mounted them high up on the wall of the garage. I have a 20W LED spotlight mounted on the wall and controlled by a Sonoff basic wi-fi relay (costs a few pounds). Finally there is an indoor light (known as the “cat light”, because everybody should have one) controlled by another Sonoff switch, which is used to monitor motion detections.
The PIR sensor provides a digital output, and the NodeMCU just provides access to that digital output. The PIR has controls for sensitivity and hold time, both of which are turned to the minimum value.
Although not essential to the question of detection, the detector box also has a light sensor and a camera.
The software
I had previously experimented with NodeRed, an MQTT server, and Tasmota running on the Sonoff switches.
This time I abandoned NodeRed and switched to Home Assistant (HA), ESPHome and App Daemon. These are all installed in separate Docker containers on my home server (running Docker under Ubuntu). About the only non-standard part of the installation was to put the HA container on both a macvtap network (so it can be discovered by Alexa) and also a network shared with the other two containers.
I built an ESPHome image for the detector and installed it on the NodeMCU using a USB connection. Subsequent changes were done over the air (OTA) using WiFi. Home Assistant discovered the new device using its ESPHome integration.
I wrote an AppDaemon script that did the following:
- Triggered on changes of state of the motion detector
- Flashed the internal light for 2s on detected motion
- Turned on the external light for 30s on detected motion
The light sensor was used to turn on the external light only if the light level was below a certain threshold. The camera was triggered on detected motion.
The thing I noticed (it was hard to miss) is the number of false positive detections of the PIR sensor, even if the sensitivity was turned to its minimum level. I can’t explain why. Sometimes it was stable for hours at a time, and other periods it triggered every 10s or so. I have no idea if this behaviour is electronic or environmental.
I built a tube to “focus” the detector on a patch of gravel on our drive, but that appeared to have little effect on the rate of false triggers.
Clearly this configuration is useless as an actual detector.
So I added another identical detector. I was hoping that false detections would be independent (uncorrelated) but true detections would be correlated. By “correlated” I mean that trigger events happened on both detectors within a certain period of time.
The two-detector configuration fixed the problem of false detections. If I walk up and down the drive, I get a detection. Although both detectors still spontaneously generate false detections, they generally don’t do so that they are close enough together in time to trigger the light.
Future ideas
Perhaps I might build in a microwave radar based proximity detector. I suspect this will be more reliable than PIR. It’s another thing to play with.
The Code
This code comes with no warrantee. It might not work for you. It might cause your house to explode and your cat to have apoplexy. If it does, I’m not to blame.
ESPHome code for motion detector
esphome:
name: garage_2
platform: ESP8266
board: nodemcuv2
wifi:
ssid: !secret ssid
password: !secret password
domain: !secret domain
captive_portal:
logger:
api:
ota:
binary_sensor:
- platform: gpio
pin: D1
device_class: motion
name: Motion Sensor 2
sensor:
- platform: uptime
name: Uptime Sensor
update_interval: 10s
AppDaemon code
import hassapi as hass
import datetime
class MotionDetector(hass.Hass):
def initialize(self):
# Configuration variables
self.trigInterval = 10 # Interval between m1/m2 triggers to be considered coincident
self.luxMinPhoto = 10 # minimum light level for a photo
self.luxMaxLight = 25 # maximum light level to turn on outside light
self.durationCatFlash = 2 # seconds duration of cat light flash
self.durationLight = 30 # seconds to turn on outside/garage light
self.delayPhoto = 1 # seconds from turning on light to taking photo
# State variables
self.catTriggered = 0 # Cat light triggered
self.m1Triggered = 0 # m1 triggered at most trigInterval previous
self.m2Triggered = 0 # m2 triggered at most trigInterval previous
# Listen for events
self.listen_state(self.m1, "binary_sensor.motion_sensor", new='on')
self.listen_state(self.m2, "binary_sensor.motion_sensor_2", new='on')
# m1 has been triggered
def m1(self, entity, attribute, old, new, kwargs):
self.log(f"m1 {entity} changed from {old} to {new}")
self.m1Triggered += 1
self.run_in(self.m1Done, self.trigInterval)
# If m2 has been triggered within the last trigInterval
if self.m2Triggered:
self.triggered(entity, attribute, old, new, kwargs)
# m1 trigger interval complete
def m1Done(self, kwargs):
self.log(f"m1 Done")
self.m1Triggered -= 1
def m2(self, entity, attribute, old, new, kwargs):
self.log(f"m2 {entity} changed from {old} to {new}")
self.m2Triggered += 1
self.run_in(self.m2Done, self.trigInterval)
# If m1 has been triggered within the last trigInterval
if self.m1Triggered:
self.triggered(entity, attribute, old, new, kwargs)
def m2Done(self, kwargs):
self.log(f"m2 Done")
self.m2Triggered -= 1
def triggered(self, entity, attribute, old, new, kwargs):
self.log(f"Triggered {entity} changed from {old} to {new}")
light_state = self.get_state('switch.garage_light_relay')
time_now = datetime.datetime.now().time()
light_level = float(self.get_state('sensor.garage_light_level'))
self.log(f'light level is {light_level}')
too_early = time_now < datetime.datetime.strptime("06:30", "%H:%M").time()
too_late = time_now > datetime.datetime.strptime("22:00", "%H:%M").time()
too_bright = light_level > self.luxMaxLight
already_on = light_state == 'on'
self.log(f'time now: {time_now} too_early: {too_early} too_late: {too_late} too_bright: {too_bright} already_on: {already_on}')
light_triggered = not too_bright and not too_early and not too_late and not already_on
if light_triggered:
# Low light level during waking hours, trigger garage light
# don't trigger if already on to avoid turning off a manual turn-on
self.triggerLight()
if (light_level > self.luxMinPhoto):
# enough light for a photo
self.makePhoto(kwargs)
else:
if light_triggered:
# Can do a photo, but have to wait a bit for it to turn on
self.log('delayed photo')
self.run_in(self.makePhoto, self.delayPhoto)
# Flash the cat light always
self.triggerCat()
# Flash the cat light for 2 s
def triggerCat(self):
if not self.catTriggered:
self.toggle('switch.cat_light')
self.catTriggered += 1
self.run_in(self.catDone, self.durationCatFlash)
def catDone(self, kwargs):
self.log(f"cat Done")
self.catTriggered -= 1
if not self.catTriggered:
self.toggle('switch.cat_light')
# Turn on garage light for 30s
def triggerLight(self):
self.log(f"Trigger Light")
self.turn_on('switch.garage_light_relay')
self.run_in(self.lightDone, self.durationLight)
def lightDone(self, kwargs):
self.log(f"Light Done")
self.turn_off('switch.garage_light_relay')
def makePhoto(self, kwargs):
date_string = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
file_name = f'/config/camera/{date_string}.jpg'
self.log(f'Snapshot file_name: {file_name}')
self.call_service('camera/snapshot', entity_id='camera.garage_camera', filename=file_name)