Prior to wasting time with Home Assistant one of my side projects was to built a smart feeder designed to split the food into two bowls. You’ve got two cat’s you need two bowls. The feeder I built comes with a Web interface, a camera mounted on a gimbal to see who’s eating and laser pointer mounted to the camera for play time.
There are multiple post on the internet about building a feeder, however I never came across one that incorporated a camera. The camera is great as it allows my wife to verify her cats are still alive when we’re traveling. This is a picture of the first feeder I built:
Size wise it’s larger than you’d get with a store bought device, but the height was required to get a good camera view.
The Rpi_Cam_Web_Interface project provided a great starting point for the feeder as it gives you camera integration with a web server for network based access. It didn’t have the pan and tilt feature so I added a frame with buttons to control the camera position. I wrote a python script to interface with the feeder motor and servos while providing network access for control. The Rpi_Cam_Web_Interface project provided the ability to add a few buttons, which I used to enable manual feeding and play interaction.
Here we have a view of the web interface:
While traveling I can access the setup via a VPN. Which is OK but I needed and easier integration for my wife. With my adventures with home assistant I realized once I added Nabu Casa I could make the feeder directly available to my wife via the Home Assistant interface. The network based interface I built for the feeder made for a easy HA integration.
This is what the interface looks like in Home assistant.
Fjramirez1987 has a PTZ camera card setup here that I used for the starting point. I added 4 additional icons for addional feeder functionality:
- apple icon for the small food drop
- burger and drink icon for the large food drop
- teeter totter to kick off laser play mode
- a light bulb to turn on the light in the room for better viewing at night
The feeder was already using the crontab to schedule automatic food drops. I could have used the home assistant scheduler as an alternative to using the crontab but I figured it was best to have the auto feeding continue to work standalone. So I used a stacked horizontal and vertical cards populated with this time picker card and entity cards to create an interface to update the crontab on the feeder. This gives the following interface.
The schedule creation takes up a lot of space so if you have a suggestion on some cards that would make this more compact please let me know.
In home assistant I needed a few variables and shell_commands to make this thing work. The following was included in my configuration.yam:
input_datetime:
feed1:
has_date: false
has_time: true
feed2:
has_date: false
has_time: true
feed3:
has_date: false
has_time: true
feed4:
has_date: false
has_time: true
input_select:
feed_size_1:
options:
- "0"
- "s"
- "D"
feed_size_2:
options:
- "0"
- "s"
- "D"
feed_size_3:
options:
- "0"
- "s"
- "D"
feed_size_4:
options:
- "0"
- "s"
- "D"
input_boolean:
update_feeder_schedule:
initial: off
shell_command:
feeder_ctl: '/config/shell_cmds/feeder_ctl {{ host }} {{ direction }}'
update_feeder_schedule: '/config/shell_cmds/update_feeder_schedule {{ l1 }} {{ l2 }} {{ l3 }} {{ l4 }}'
Here’s the content for the two shell scripts:
#!/bin/bash
# feeder_ctl
nc -w 0 $1 33333 <<< $2
#!/bin/bash
# update_feeder_schedule
ssh -i /config/.ssh/id_rsa -o StrictHostKeyChecking=no [email protected] ./update_feeder_schedule "$1,$2,$3,$4"
The update_feeder_schedule script is using a shared key to login to the feeder raspberry pi. So you need to create an rsa key pair and place them in a .ssh directory under your main homeassistant directory.
So that the shell scripts can be accesses I added these lines to the configuration.yaml file:
homeassistant:
allowlist_external_dirs:
- '/config/shell_cmds/'
Here’s the camera view card:
type: picture-elements
camera_view: live
camera_image: camera.feeder
elements:
- type: icon
icon: mdi:arrow-left-drop-circle
tap_action:
action: call-service
service: shell_command.feeder_ctl
service_data:
host: 192.168.10.10
direction: l
style:
bottom: 45%
left: 5%
color: white
opacity: 0.5
transform: scale(1.5, 1.5)
- type: icon
icon: mdi:arrow-right-drop-circle
tap_action:
action: call-service
service: shell_command.feeder_ctl
service_data:
host: 192.168.10.10
direction: r
style:
bottom: 45%
right: 5%
color: white
opacity: 0.5
transform: scale(1.5, 1.5)
- type: icon
icon: mdi:arrow-up-drop-circle
tap_action:
action: call-service
service: shell_command.feeder_ctl
service_data:
host: 192.168.10.10
direction: u
style:
top: 10%
left: 46%
color: white
opacity: 0.5
transform: scale(1.5, 1.5)
- type: icon
icon: mdi:arrow-down-drop-circle
tap_action:
action: call-service
service: shell_command.feeder_ctl
service_data:
host: 192.168.10.10
direction: d
style:
bottom: 10%
left: 46%
color: white
opacity: 0.5
transform: scale(1.5, 1.5)
- type: icon
icon: mdi:food-apple
tap_action:
action: call-service
service: shell_command.feeder_ctl
service_data:
host: 192.168.10.10
direction: u
style:
bottom: 10%
left: 2.5%
color: white
opacity: 0.5
transform: scale(1.5, 1.5)
- type: icon
icon: mdi:food
tap_action:
action: call-service
service: shell_command.feeder_ctl
service_data:
host: 192.168.10.10
direction: d
style:
bottom: 10%
left: 13%
color: white
opacity: 0.5
transform: scale(1.5, 1.5)
- type: icon
icon: mdi:seesaw
tap_action:
action: call-service
service: shell_command.feeder_ctl
service_data:
host: 192.168.10.10
direction: u
style:
bottom: 10%
left: 80%
color: white
opacity: 0.5
transform: scale(1.5, 1.5)
- type: icon
icon: mdi:arrow-expand-all
tap_action:
action: more-info
entity: camera.feeder
style:
top: 5%
right: 5%
color: white
opacity: 0.5
transform: scale(1.5, 1.5)
- entity: switch.master_bath
image: /local/light-on.png
state_filter:
'on': brightness(130%) saturate(1.5)
'off': brightness(50%) saturate(.5)
state_image:
'on': /local/light-on.png
style:
left: 93%
bottom: 3%
tap_action:
action: toggle
type: image
Here’s the schedule configuration card
type: vertical-stack
cards:
- type: horizontal-stack
cards:
- type: custom:time-picker-card
entity: input_datetime.feed1
hour_mode: 24
hour_step: 1
minute_step: 5
second_step: 5
name: ''
layout:
name: header
align_controls: center
- type: entity
entity: input_select.feed_size_1
- type: horizontal-stack
cards:
- type: custom:time-picker-card
entity: input_datetime.feed2
hour_mode: 24
hour_step: 1
minute_step: 5
second_step: 5
name: ''
layout:
name: header
align_controls: center
- type: entity
entity: input_select.feed_size_2
- type: horizontal-stack
cards:
- type: custom:time-picker-card
entity: input_datetime.feed3
hour_mode: 24
hour_step: 1
minute_step: 5
second_step: 5
name: ''
layout:
name: header
align_controls: center
- type: entity
entity: input_select.feed_size_3
- type: horizontal-stack
cards:
- type: custom:time-picker-card
entity: input_datetime.feed4
hour_mode: 24
hour_step: 1
minute_step: 5
second_step: 5
name: ''
layout:
name: header
align_controls: center
- type: entity
entity: input_select.feed_size_4
- type: horizontal-stack
cards:
- type: button
tap_action:
action: toggle
name: Send Schedule
icon_height: 20px
icon: mdi:send
entity: input_boolean.update_feeder_schedule
show_state: true
I added the following to the automation.yaml file to kick send the feeder schedule over to the feeder raspberry pi:
- id: '1643509662749'
alias: update_feeder_schedule
description: ''
trigger:
- platform: state
entity_id: input_boolean.update_feeder_schedule
to: 'on'
condition: []
action:
- service: shell_command.update_feeder_schedule
data_template:
l1: "{{ states('input_datetime.feed1') }},{{ states('input_select.feed_size_1') }}"
l2: "{{ states('input_datetime.feed2') }},{{ states('input_select.feed_size_2') }}"
l3: "{{ states('input_datetime.feed3') }},{{ states('input_select.feed_size_3') }}"
l4: "{{ states('input_datetime.feed4') }},{{ states('input_select.feed_size_4') }}"
- delay: 0:0:2
- service: input_boolean.turn_off
target:
entity_id: input_boolean.update_feeder_schedule
mode: single
My plan is to update this post with details on building the feeder. For now it’s just some hints.
Building the feeder
Parts
- Raspberry by 3 or 4 with case
- 3V Relay Power Switch Board
- A 5V Worm geared motor
- 2 Micro servos
- Gimbal
- Night Vision camera
- Long cable for camera
- jumper wires of whatever length makes sense
- 5V 2A power supply
- Coupling to connect motor to the feeder hopper
- Food Dispenser for the Dispenser
You can get the gimbal and 2 servos pre assembled here if you like.
These instructs provide access to the hat that controls the gimbal and GPIO interface for a motor to run the feeder, camera and a lazer.
After building loading raspberry pi os on the feeder by we need to install software that allows us to access the relay switch board. At the pi command prompt do the following:
- sudo apt-get update
- sudo apt-get install python-smbus i2c-tools
- sudo pip install adafruit-pca9685
- sudo pip3 install adafruit-pca9685
- sudo pip3 install adafruit-circuitpython-servokit
You need to make sure i2c is enable on the Pi
- sudo raspi-config
- Select “Interfacing Options”
- Select “I2C”
- Select “Yes” to question “Would you like the ARM I2C interface enabled
- Select “OK” once it tells you the interface is enabled
- Select “Finish” to exit the tool
Note: if it ask you if you want to reboot, respond yes
To check that the Pi sees the relay switch board do the following at the command promp
- sudo i2cdetect -y 1
The response should show 40 and 70 in the 0 column like this:
More to come
update_feeder_schedule script that gets run by HA via ssh
#! /bin/bash
echo Input $1 > my.out
#IFS=, read t1 f1 t2 f2 t3 f3 t4 f4 <<< $1
#echo read t1=$t1 f1=$f1 t2=$t2 f2=$f2 t3=$t3 f3=$f3 t4=$t4 f4=$f4
IFS=, read -r -a fields <<< $1
: > mycrontab
firstField=1
for element in "${fields[@]}"
do
if [ $firstField = 1 ]; then
timeStr=$element
IFS=: read hour minutes seconds <<< $timeStr
firstField=0
else
#echo $hour, $minutes and $element
firstField=1
if [ $element = "s" ]; then
echo "$minutes $hour * * * /var/www/html/macros/feed1.sh" >> mycrontab
elif [ $element = "D" ]; then
echo "$minutes $hour * * * /var/www/html/macros/feed2.sh" >> mycrontab
fi
fi
done
Main network script that controls feeder motor and gimbal
pi@raspberrypi:~/feeder_stuff $ cat networkControl.py
#!/usr/bin/python3
# Use PCA9685 PWM servo/LED controller library to control servos
# Author: Tony DiCola
# License: Public Domain
from __future__ import division
import time
import sys
import socket
import RPi.GPIO as GPIO
import random
import datetime
import math
from board import SCL, SDA
import busio
from adafruit_pca9685 import PCA9685
from adafruit_motor import servo
# Uncomment to enable debug output.
#import logging
#logging.basicConfig(level=logging.DEBUG)
freq = 60 # number of pulses per second
VERT_CHAN = 1
HORZ_CHAN = 2
FEED_CHAN = 7
#HORZ_NEUT_OFFSET=15
HORZ_NEUT_OFFSET=(5)
feed_method='M' # S for servo, anything else for motor
SECS_PER_SECTION=20 # NUM of seconds it takes to move a section
vert_position = None
horz_position = None
feed_position = None
horz_delay = .03
vert_delay = .03
feed_delay = .02
feed_min=0
feed_neutral=135
feed_max=270
#FEED_REC_SECS=120
FEED_REC_SECS=300
recording = False
recording = True
vert_min = 0
tpsg90_min = 0
tpsg90_max = 180
tpsg90_neutral = 90
feed_vert_pos = tpsg90_max - 5
feed_horz_pos = tpsg90_neutral+HORZ_NEUT_OFFSET
rest_vert_pos = feed_vert_pos
rest_horz_pos = feed_horz_pos
#rest_vert_pos = tpsg90_neutral - 25
#rest_horz_pos = tpsg90_neutral+HORZ_NEUT_OFFSET
# the following positions assume starting at position 0
#feed_drops = [120, 188, 270, 120, 68, 0]
#feed_drops = [150, 210, 270, 120, 60, 0]
feed_drops = [140, 210, 270, 160, 75, 0]
GPIO_LASER = 27
GPIO_FEED_MOTOR = 17
def move_servo( servo, start, end, delay ):
if start < end :
inc = 1
else:
inc = -1
end = end - 1
for i in range( start,end,inc):
#print("I = ",i)
servo.angle = i
time.sleep(delay)
def move_gimbal( hServo, hStart, hEnd, vServo, vStart, vEnd, delay ):
if hStart < hEnd :
hInc = 1
else:
hInc = -1
hEnd = hEnd - 1
hRange = abs( hEnd - hStart)
if vStart < vEnd :
vInc = 1
else:
vInc = -1
vEnd = vEnd - 1
vRange = abs( vEnd - vStart)
incRange = hRange
if vRange > hRange:
incRange = vRange
for i in range(1,incRange):
#print("I = ",i)
#update increments if we're still supposed to move
if hStart != hEnd:
hStart = hStart + (1*hInc)
if vStart != vEnd:
vStart = vStart + (1*vInc)
hServo.angle = hStart
vServo.angle = vStart
time.sleep(delay)
turn_off_servos()
def set_pulse_length( direction , step ):
global vert_position
global horz_position
global feed_position
print( direction )
if direction == 'u':
start = vert_position
vert_position = vert_position - step
if vert_position < vert_min:
vert_position = vert_min
end = vert_position
move_servo( vertServo, start, end , vert_delay)
turn_off_servos()
elif direction == 'd':
start = vert_position
vert_position = vert_position + step
if vert_position > tpsg90_max:
vert_position = tpsg90_max
end = vert_position
move_servo( vertServo, start, end , vert_delay)
turn_off_servos()
elif direction == 'r':
start = horz_position
horz_position = horz_position - step
if horz_position < tpsg90_min:
horz_position = tpsg90_min
end = horz_position
move_servo( horzServo, start, end , horz_delay)
turn_off_servos()
elif direction == 'l':
start = horz_position
horz_position = horz_position + step
if horz_position > tpsg90_max:
horz_position = tpsg90_max
end = horz_position
move_servo( horzServo, start, end , horz_delay)
turn_off_servos()
elif direction == '+':
start = feed_position
feed_position = feed_position + step
if feed_position > feed_max:
feed_position = feed_max
end = feed_position
move_servo( feedServo, start, end , feed_delay)
turn_off_servos()
elif direction == '-':
start = feed_position
feed_position = feed_position - step
if feed_position < feed_min:
feed_position = feed_min
end = feed_position
move_servo( feedServo, start, end , feed_delay)
turn_off_servos()
elif direction == '0':
start = feed_position
feed_position = feed_min
end = feed_position
move_servo( feedServo, start, end , feed_delay)
turn_off_servos()
elif direction == '1':
start = feed_position
feed_position = int(feed_max/3)
end = feed_position
move_servo( feedServo, start, end , feed_delay)
turn_off_servos()
elif direction == '2':
start = feed_position
feed_position = int((feed_max/3)*2)
end = feed_position
move_servo( feedServo, start, end , feed_delay)
turn_off_servos()
elif direction == '3':
start = feed_position
feed_position = feed_max
end = feed_position
move_servo( feedServo, start, end , feed_delay)
turn_off_servos()
elif direction == 'N':
feed_next_drop()
#elif direction == 's':
# horz_position = tpsg90_neutral
# vert_position = tpsg90_max
# pwm.set_pwm(VERT_CHAN, 0, vert_position);
# pwm.set_pwm(HORZ_CHAN, 0, horz_position);
print('horizonal {0} : vertical {1} : feed {2}'.format(horz_position, vert_position, feed_position))
def feed_drop_servo():
global feed_position
with open("/home/pi/feeder_stuff/feed_position", "r+") as f:
cur_pos = int(f.read())
next_pos = cur_pos + 1
if next_pos >= len(feed_drops):
next_pos = 0
cur_servo_val = feed_drops[ cur_pos ]
next_servo_val = feed_drops[ next_pos ]
print('Servo start {0} : Servo End {1}'.format(cur_servo_val,next_servo_val))
move_servo( feedServo, cur_servo_val, next_servo_val , feed_delay)
feed_position = next_servo_val
turn_off_servos()
f.seek(0)
f.write(str(next_pos))
f.truncate()
f.close()
def feed_drop_motor():
GPIO.output(GPIO_FEED_MOTOR, 1) # turn motor on
time.sleep(SECS_PER_SECTION) # sleep time it takes to move a section
GPIO.output(GPIO_FEED_MOTOR, 0) # turn motor on
def servo_rest_position():
global vert_position
global horz_position
global feed_position
if vert_position is None:
#start = tpsg90_neutral - 10
start = rest_vert_pos
else:
start = vert_position
vert_position = rest_vert_pos
move_servo( vertServo, start, vert_position , vert_delay)
print("Vert Server move: ",start, vert_position)
if horz_position is None:
start = tpsg90_neutral - 10
else:
start = horz_position
horz_position = rest_horz_pos
move_servo( horzServo, start, horz_position, horz_delay )
print("Horz Server move: ",start, horz_position)
turn_off_servos()
if feed_position is None:
with open("/home/pi/feeder_stuff/feed_position", "r") as f:
cur_pos = int(f.read())
feed_position = feed_drops[ cur_pos ]
f.close()
def turn_off_servos():
#pca.reset()
horzServo._pwm_out.duty_cycle = 0
vertServo._pwm_out.duty_cycle = 0
feedServo._pwm_out.duty_cycle = 0
print("Turn off servos")
def feedNeu( ):
global feed_position
# Move feeder to Neutral
start = feed_position
feed_position = feed_neutral
end = feed_position
move_servo( feedServo, start, end ,feed_delay)
turn_off_servos()
def feedMin( ):
global feed_position
# Move feeder to Neutral
start = feed_position
feed_position = feed_min
end = feed_position
move_servo( feedServo, start, end , feed_delay)
turn_off_servos()
def feedMax( ):
global feed_position
# Move feeder to Neutral
start = feed_position
feed_position = feed_max
end = feed_position
move_servo( feedServo, start, end , feed_delay)
turn_off_servos()
def feed( sections ):
global vert_position
global horz_position
global feed_position
if recording :
# set the camera to look at bowls
start = horz_position
horz_position = feed_horz_pos
move_servo( horzServo, start, horz_position , horz_delay)
start = vert_position
vert_position = feed_vert_pos
move_servo( vertServo, start, vert_position , vert_delay)
turn_off_servos()
# open FIFO used to start recording if need be
with open('/var/www/html/FIFO1', 'w') as f:
if recording :
# Tell raspimjpeg scheduler to start recording
f.write('1')
f.flush()
rectime=FEED_REC_SECS
#Drop the food
for i in range(0,sections,1):
if feed_method == 'S':
feed_drop_servo()
else:
feed_drop_motor()
rectime-=SECS_PER_SECTION # reduce time we'll wait to finish recording by time takes motor to move a section
if recording :
#Record a little bit
time.sleep(rectime)
# Tell raspimjpeg scheduler to stop recording
f.write('0')
f.flush()
f.close()
if recording :
servo_rest_position()
def play( howlong ):
global vert_position
global horz_position
HLIMIT = 30
VLIMIT = 30
#set range of motion in horizonal direction
hmax = tpsg90_max - HLIMIT
hmin = tpsg90_min + HLIMIT
#set range of motion in virtual direction
vmax = tpsg90_max - 30
vmin = tpsg90_neutral +20
# cal range for mod function
hrange = hmax - hmin
vrange = vmax - vmin
# Move gimbal to starting position
move_gimbal( horzServo, horz_position, tpsg90_neutral+HORZ_NEUT_OFFSET, vertServo, vert_position , vmin, horz_delay )
# update position variables
vert_position = vmin
horz_position = tpsg90_neutral+HORZ_NEUT_OFFSET
# turn laser on
GPIO.output(GPIO_LASER, 1)
#get time we're starting
start_time = datetime.datetime.now()
et = datetime.datetime.now() - start_time
# we need to start video collection
with open('/var/www/html/FIFO1', 'w') as f:
# Tell raspimjpeg scheduler to start recording
f.write('1')
f.flush()
while et.total_seconds() < howlong:
end_v = vmin + random.randint(0,vrange)
end_h = hmin + random.randint(0,hrange)
move_gimbal( horzServo, horz_position, end_h, vertServo, vert_position , end_v, horz_delay )
horz_position = end_h
vert_position = end_v
# delay some after each move
time.sleep(random.randint(1,4))
et = datetime.datetime.now() - start_time
# Tell raspimjpeg scheduler to stop recording
f.write('0')
f.flush()
f.close()
#turn off laser
GPIO.output(GPIO_LASER, 0)
servo_rest_position()
# -- Main ----
for arg in sys.argv[1:]:
print( arg )
i2c = busio.I2C(SCL, SDA)
# Create a simple PCA9685 class instance.
pca = PCA9685(i2c)
pca.frequency = freq
feedServo = servo.Servo(pca.channels[FEED_CHAN], actuation_range=feed_max, min_pulse=500, max_pulse=2500)
vertServo = servo.Servo(pca.channels[VERT_CHAN], min_pulse=500, max_pulse=2400)
horzServo = servo.Servo(pca.channels[HORZ_CHAN], min_pulse=500, max_pulse=2400)
# turn off servos
turn_off_servos()
servo_rest_position()
# Configure laser GPIO pin
GPIO.setmode(GPIO.BCM)
GPIO.setup(GPIO_LASER, GPIO.OUT)
#Make sure laser is turned off
GPIO.output(GPIO_LASER, 0)
#Setup GPIO for feed motor
GPIO.setup(GPIO_FEED_MOTOR, GPIO.OUT)
GPIO.output(GPIO_FEED_MOTOR, 0)
sys.stdout.flush()
HOST = '' # Symbolic name meaning all available interfaces
PORT = 33333 # Arbitrary non-privileged port
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((HOST, PORT))
s.listen(1)
try:
while True:
conn, addr = s.accept()
with conn:
print('Connected by', addr)
while True:
data = conn.recv(1024)
if not data: break
print('Data: ',data)
if chr(data[0]) == 'f':
feed(1)
elif chr(data[0]) == 'F':
feed(2)
elif chr(data[0]) == 'n':
feedNeu()
elif chr(data[0]) == 'm':
feedMin()
elif chr(data[0]) == 'M':
feedMax()
elif chr(data[0]) == 'R':
if recording :
print("Disable recording on feed")
recording = False
else:
print("Enable recording on feed")
recroding = True
elif chr(data[0]) == 'p':
#play( 180 )
play( 120 )
else:
set_pulse_length( chr(data[0]) , 20 )
conn.close()
sys.stdout.flush()
finally:
GPIO.output(GPIO_LASER, 0)
GPIO.output(GPIO_FEED_MOTOR, 0)
GPIO.cleanup()
# turn off servos
turn_off_servos()
pi@raspberrypi:~/feeder_stuff $