Video Demonstration

About

Cats are creatures of habit which makes it easy to establish a feeding habit. As it turns out, obesity is a problem for cats and even though humans enjoy big fluffy creatures, cats suffer just as much from overweight as humans do. Using an automatic feeder will both allow the owner to control the eating habits and establish a feeding schedule as well as dump some liability on technology which tends to be less forgetful than human beings.

Requirements

Other similar projects are presented on the Internet but are far more complex and require a rather elaborate train mechanism to open and close the feeder. This project uses mostly salvaged parts and is rather straightforward requiring only a few components most of which can be easily improvised.

  • two feeding bowls - these can be obtained together as a set for feeding pets. One bowl will be used to close over the other and prevent the cat from feeding during off hours. The choice of material is important because bowls made out of heavy / dense materials tend to be heavier and might be a problem for the servo motor to move. The choice for this project has been two surgical tin bowls that are both impervious to rusting as well as being lighter than even a plastic counterpart.
  • a servo motor - an MSG90S servo motor has been chosen that operates between $4.8V$ and up to $6V$ having a stall torque (maximum rotation strength) of $2.2 kgF \times cm$. The provided force is not that much especially since the servo will be powered by $5V$ that also powers an Arduino controller - however, it seems to get the job done.
  • a strong metal rod, perhaps aluminum - due to lack of materials and for ease of work, a strong wood rod has been used instead that was salvaged from chopsticks.
  • crafting materials - a Dremel was necessary to carve the wooden rod, cut the microscope guiding train to size, drill holes to attach the bowls, etc. Since most of the contraption was designed "as you go", there are not many rules and improvization is key. Perhaps one of the more important crafting material is a wooden cutting block that acts as a counter-weight to hold the structure in place. Overall, the design is not heavy but may move around a bit when the servo motor opens or closes the bowls.
  • a plastic or metal rod - the servo motor is attached to a rod, upside down. For the project, an adjustable train has been used such as the mechanisms used for microscopes. The advantage of the adjustable train is that the height at which the servo motor operates can be adjusted. Any rod that could act as a pole to attach the servo motor should do.
  • an Arduino controller - a WeMoD D1 Mini Pro has been used which is cheap, reliable and contains the necessary libraries to steer a servo motor.
  • a stepdown converter $110-220V$ AC to $5V$ DC - the stepdown converter used for the project yielded $12V$ and $6A$ output. the $12V$ were further stepped down to $5V$ to drive both the WeMoS controller and the servo motor. Providing $12V$ instead of $5V$ has been made with further developments in mind as discussed in the further developments section.

Design

The servo motor is attached to a rod or pole holding the motor upside down. A shaft is attached to the servo gear in order to transfer the mechanical force to the upper bowl. Even though it is not apparent, the bowls are placed at a millimeter distance from each other in order to reduce friction and make the servo motor's job easier.

One of the servo horns has been glued to the wooden rod using JB weld, superglue and fixed in place using the servo motor screw. The servo horn is used because the collar that goes around the servo motor gear is etched to fit the gear teeth perfectly and thus transfer the optimal amount of torque to the horn and from there to the shaft.

Similarly, the other end of the chopstick is sanded down adequately and fixed with a screw on top of the upper bowl.

As a general rule, the larger the contact surface between the mechanical components, the more torque is transferred from the motor to the last mechanical component in the chain. The solution presented here is not great but seems to work well for its purpose. A better option would have been to pick an aluminum rod and ensure a total contact between the bowl and the shaft.

Circuitry and Software

The circuitry is conveniently attached to the microscope train in a black box at the back and, at the time of writing, contains just a voltage stepdown module, the WeMoS D1 mini and pin connectors for the servo motor.

Connecting the servo motor is trivial:

  • red wire is the positive lead and takes, depending on the servo, between $5V$ and $6V$,
  • black is the common ground,
  • yellow has to be connected to a GPIO pin of the WeMoS D1 Mini.

The Arduino sketch used for controlling the servo motor is a derivate of the boilerplate pin toggling sketch but extended to include controlling servo motors whilst retaining the pin toggling semantics.

/*
This Arduino script is a boilerplate AP / connect and reconnect portal
that is used to subscribe to a configured MTT server and listen for
JSON messages in order to set the state of various GPIO pins or control
servo motors.
 
Based on the documentation for AutoConnect by 2018 Hieromon Ikasamo
and modified by 2019 Wizardry and Steamworks.
*/
 
#if defined(ARDUINO_ARCH_ESP8266)
#include <ESP8266WiFi.h>
#include <ESP8266HTTPClient.h>
#include <ESP8266mDNS.h>
#include <ESP8266WebServer.h>
#include <ESP8266HTTPUpdateServer.h>
#define GET_CHIPID()  (ESP.getChipId())
#elif defined(ARDUINO_ARCH_ESP32)
#include <WiFi.h>
#include <SPIFFS.h>
#include <HTTPClient.h>
#include <ESP32WebServer.h>
#include <ESPmDNS.h>
#define GET_CHIPID()  ((uint16_t)(ESP.getEfuseMac()>>32))
#endif
#include <FS.h>
#include <PubSubClient.h>
#include <AutoConnect.h>
#include <ArduinoOTA.h>
#include <Servo.h>
 
#define AP_PASSWORD "1234554321"
 
#define MQTT_SETTINGS_FILE "/mqtt_settings.json"
#define OTA_SETTINGS_FILE "/ota_settings.json"
#define MQTT_SETTINGS_URI "/mqtt_settings"
#define MQTT_SAVE_URI "/mqtt_save"
#define OTA_SETTINGS_URI "/update_settings"
#define OTA_SAVE_URI "/update_save"
 
// MQTT settings for portal interface.
static const char AUX_MQTT_SETTINGS[] PROGMEM = R"raw(
[
  {
    "title": "MQTT Settings",
    "uri": "/mqtt_settings",
    "menu": true,
    "element": [
      {
        "name": "style",
        "type": "ACStyle",
        "value": "label+input,label+select{position:sticky;left:120px;width:230px!important;box-sizing:border-box;}"
      },
      {
        "name": "header",
        "type": "ACText",
        "value": "<h2>MQTT broker settings</h2>",
        "style": "text-align:center;color:#2f4f4f;padding:10px;"
      },
      {
        "name": "caption",
        "type": "ACText",
        "value": "Subscribe to MQTT for pin toggling.",
        "style": "font-family:serif;color:#4682b4;"
      },
      {
        "name": "mqttServer",
        "type": "ACInput",
        "label": "Server",
        "placeholder": "MQTT server"
      },
      {
        "name": "mqttPort",
        "type": "ACInput",
        "label": "Port",
        "value": "1883",
        "placeholder": "MQTT port",
        "pattern": "^[0-9]+?$"
      },
      {
        "name": "mqttUsername",
        "type": "ACInput",
        "label": "MQTT username"
      },
      {
        "name": "mqttPassword",
        "type": "ACInput",
        "label": "MQTT password"
      },
      {
        "name": "mqttTopic",
        "type": "ACInput",
        "label": "MQTT topic"
      },
      {
        "name": "mqttClientId",
        "type": "ACInput",
        "label": "MQTT client ID"
      },
      {
        "name": "newline",
        "type": "ACElement",
        "value": "<hr>"
      },
      {
        "name": "save",
        "type": "ACSubmit",
        "value": "Save",
        "uri": "/mqtt_save"
      },
      {
        "name": "discard",
        "type": "ACSubmit",
        "value": "Discard",
        "uri": "/"
      }
    ]
  },
  {
    "title": "MQTT Settings",
    "uri": "/mqtt_save",
    "menu": false,
    "element": [
      {
        "name": "caption",
        "type": "ACText",
        "value": "<h4>Parameters saved as:</h4>",
        "style": "text-align:center;color:#2f4f4f;padding:10px;"
      },
      {
        "name": "parameters",
        "type": "ACText"
      }
    ]
  }
]
)raw";
 
// OTA Settings
static const char AUX_OTA_SETTINGS[] PROGMEM = R"raw(
[
  {
    "title": "OTA Settings",
    "uri": "/update_settings",
    "menu": true,
    "element": [
      {
        "name": "style",
        "type": "ACStyle",
        "value": "label+input,label+select{position:sticky;left:120px;width:230px!important;box-sizing:border-box;}"
      },
      {
        "name": "header",
        "type": "ACText",
        "value": "<h2>OTA Update Settings</h2>",
        "style": "text-align:center;color:#2f4f4f;padding:10px;"
      },
      {
        "name": "caption",
        "type": "ACText",
        "value": "Settings for OTA updates.",
        "style": "font-family:serif;color:#4682b4;"
      },
      {
        "name": "otaPassword",
        "type": "ACInput",
        "label": "Password",
        "placeholder": "esp"
      },
      {
        "name": "newline",
        "type": "ACElement",
        "value": "<hr>"
      },
      {
        "name": "save",
        "type": "ACSubmit",
        "value": "Save",
        "uri": "/update_save"
      },
      {
        "name": "discard",
        "type": "ACSubmit",
        "value": "Discard",
        "uri": "/"
      }
    ]
  },
  {
    "title": "OTA Settings",
    "uri": "/update_save",
    "menu": false,
    "element": [
      {
        "name": "caption",
        "type": "ACText",
        "value": "<h4>Parameters saved as:</h4>",
        "style": "text-align:center;color:#2f4f4f;padding:10px;"
      },
      {
        "name": "parameters",
        "type": "ACText"
      }
    ]
  }
]
)raw";
 
// Define GPIO pins for supported architectures.
#if defined(ARDUINO_ARCH_ESP8266)
int PINS[] = { D0, D1, D2, D3, D4, D5, D6, D7, D8 };
#elif defined(ARDUINO_ARCH_ESP32)
int PINS[] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 
              12, 13, 14, 15, 16, 17, 18, 19, 21, 22, 
              23, 25, 26, 27, 32, 33, 34, 35, 36, 37, 
              38, 39 };
#endif
 
#if defined(ARDUINO_ARCH_ESP8266)
ESP8266WebServer httpServer; 
ESP8266HTTPUpdateServer httpUpdate;
#endif
 
AutoConnect portal(httpServer);
AutoConnectConfig config;
WiFiClient wifiClient;
PubSubClient mqttClient(wifiClient);
 
// Wifi
wl_status_t WIFIStatus;
unsigned long WIFIUptime;
 
// MQTT
String mqttServerName;
String mqttUsername;
String mqttPassword;
String mqttPort;
String mqttTopic;
String mqttClientId;
 
// OTA
String otaPassword;
 
// Servo
Servo servo;
const char* ACTION[] = { "toggle", "servo" }; 
 
void actionToggle(const int pin, String state) {
  Serial.println("Setting pin: " + String(pin) + " to state: " + String(state));
 
  pinMode(PINS[pin], OUTPUT);
  if(state == "on") {
    digitalWrite(PINS[pin], HIGH);
    return;
  }
 
  digitalWrite(PINS[pin], LOW);
}
 
void actionServo(const int pin, const int angle) {
  // Attach servo motor.
  Serial.println("Setting servo on pin: " + String(pin) + " to angle: " + String(angle));
 
  servo.attach(PINS[pin]);
 
  delay(100);
 
  servo.write(angle);
 
  delay(1000);
 
  servo.detach();
}
 
void mqttCallback(char* topic, byte* payload, unsigned int length) {
  String msgTopic = String(topic);
  String msgPayload = (const char *) payload;
  Serial.println("Message received on topic: " + msgTopic + " with payload: " + msgPayload);
 
  // Do not process messages for topics that have not been subscribed to.
  if(msgTopic != mqttTopic) {
    return;
  }
 
  StaticJsonDocument<255> doc;
  DeserializationError error = deserializeJson(doc, msgPayload);
  if(error) {
    Serial.println("Failed to parse message as JSON: " + String(error.c_str()));
    return;
  }
 
  String action = (const char*) doc["action"];
  const int pin = (const int) doc["pin"];
  String state = (const char*) doc["state"];
  const int angle = (const int) doc["angle"];
 
  if(action == NULL || pin == NULL) {
    Serial.println("Unknown parameters received.");
    return;
  }
 
  int len = (sizeof (ACTION) / sizeof (*ACTION));
  for(int i = 0; i < len; ++i) {
    if(strncmp(ACTION[i], action.c_str(), 10) == 0) {
      switch(i) {
        case 0: // toggle
          actionToggle(pin, state);
          return;
        case 1: // servo
          actionServo(pin, angle);
          return;
        default:
          Serial.println("Unknown action.");
          return;
      }
    }
  }
}
 
bool mqttConnect() {
  uint8_t retry = 3;
  while (!mqttClient.connected()) {
    if (mqttServerName.length() <= 0) {
      break;
    }
 
    mqttClient.setServer(mqttServerName.c_str(), mqttPort.toInt());
    Serial.println("Attempting MQTT broker: " + mqttServerName);
 
    if(mqttClientId == NULL || mqttClientId.isEmpty()) {
      mqttClientId = "ESP-" + String(GET_CHIPID(), HEX);
    }
 
    if (mqttClient.connect(mqttClientId.c_str(), mqttUsername.c_str(), mqttPassword.c_str())) {
      Serial.println("Established: " + String(mqttClientId));
      mqttClient.setCallback(mqttCallback);
      if(mqttClient.subscribe(mqttTopic.c_str())) {
        Serial.println("Subscribed to topic: " + mqttTopic);
      }
      return true;
    }
    else {
      Serial.println("Connection failed:" + String(mqttClient.state()));
      if (!--retry) {
        break;
      }
      delay(3000);
    }
  }
  return false;
}
 
String loadOTASettings(AutoConnectAux& aux, PageArgument& args) {
  Serial.println("Loading OTA settings.");
 
  if (SPIFFS.exists(OTA_SETTINGS_FILE)) {
    File param = SPIFFS.open(OTA_SETTINGS_FILE, "r");
    if (aux.loadElement(param)) {
      otaPassword = aux["otaPassword"].value;
      Serial.println("Loaded settings file: " OTA_SETTINGS_FILE);
    }
    else {
      Serial.println(OTA_SETTINGS_FILE " failed to load");
    }
    param.close();
  }
  else {
    Serial.println("Failed to open parameters file: " OTA_SETTINGS_FILE);
#ifdef ARDUINO_ARCH_ESP32
    Serial.println("If you get error as 'SPIFFS: mount failed, -10025', Please modify with 'SPIFFS.begin(true)'.");
#endif
  }
 
  return String("");
}
 
String loadMQTTSettings(AutoConnectAux& aux, PageArgument& args) {
  Serial.println("Loading MQTT settings.");
 
  if (SPIFFS.exists(MQTT_SETTINGS_FILE)) {
    File param = SPIFFS.open(MQTT_SETTINGS_FILE, "r");
    if (aux.loadElement(param)) {
      mqttServerName = aux["mqttServer"].value;
      mqttPort = aux["mqttPort"].value;
      mqttUsername = aux["mqttUsername"].value;
      mqttPassword = aux["mqttPassword"].value;
      mqttTopic = aux["mqttTopic"].value;
      mqttClientId = aux["mqttClientId"].value;
      Serial.println("Loaded parameters file: " MQTT_SETTINGS_FILE);
    }
    else {
      Serial.println(MQTT_SETTINGS_FILE " failed to load");
    }
    param.close();
  }
  else {
    Serial.println("Failed to open settings file: " MQTT_SETTINGS_FILE);
#ifdef ARDUINO_ARCH_ESP32
    Serial.println("If you get error as 'SPIFFS: mount failed, -10025', Please modify with 'SPIFFS.begin(true)'.");
#endif
  }
 
  return String("");
}
 
String saveOTASettings(AutoConnectAux& aux, PageArgument& args) {
  AutoConnectAux& update_settings = *portal.aux("/update_settings");
  otaPassword = update_settings["otaPassword"].value;
 
  File param = SPIFFS.open(OTA_SETTINGS_FILE, "w");
  update_settings.saveElement(param, { "otaPassword" });
  param.close();
 
  AutoConnectText& echo = aux["parameters"].as<AutoConnectText>();
  echo.value = "Password: " + otaPassword + " ";
  echo.value += "<br>";
 
  return String("");
}
 
String saveMQTTSettings(AutoConnectAux& aux, PageArgument& args) {    
  AutoConnectAux& mqtt_settings = *portal.aux("/mqtt_settings");
  mqttServerName = mqtt_settings["mqttServer"].value;
  mqttPort = mqtt_settings["mqttPort"].value;
  mqttUsername = mqtt_settings["mqttUsername"].value;
  mqttPassword = mqtt_settings["mqttPassword"].value;
  mqttTopic = mqtt_settings["mqttTopic"].value;
  mqttClientId = mqtt_settings["mqttClientId"].value;
 
  File param = SPIFFS.open(MQTT_SETTINGS_FILE, "w");
  mqtt_settings.saveElement(param, { "mqttServer", "mqttPort", "mqttUsername", "mqttPassword", "mqttTopic", "mqttClientId" });
  param.close();
 
  // Echo back saved parameters to AutoConnectAux page.
  AutoConnectText& echo = aux["parameters"].as<AutoConnectText>();
  echo.value = "Server: " + mqttServerName + " ";
 
  AutoConnectInput& mqttserver = mqtt_settings["mqttServer"].as<AutoConnectInput>();
  echo.value += mqttserver.isValid() ? String(" (OK)") : String(" (ERR)");
  echo.value += "<br>";
  echo.value += "MQTT username: " + mqttUsername + "<br>";
  echo.value += "MQTT password: " + mqttPassword + "<br>";
  echo.value += "MQTT topic: " + mqttTopic + "<br>";
  echo.value += "MQTT client ID: " + mqttClientId + "<br>";
 
  // Disconnect MQTT for reconfiguration.
  mqttClient.disconnect();
 
  return String("");
}
 
void redirectAutoConnect() {
  httpServer.send(308, "text/plain", "");
  httpServer.sendHeader("Location", "/_ac");
}
 
void setup() {
  SPIFFS.begin();
  Serial.begin(115200);
  Serial.println();
 
  if (portal.load(FPSTR(AUX_MQTT_SETTINGS))) {
    AutoConnectAux& mqtt_settings = *portal.aux(MQTT_SETTINGS_URI);
    PageArgument args;
    loadMQTTSettings(mqtt_settings, args);
    portal.on(MQTT_SETTINGS_URI, loadMQTTSettings);
    portal.on(MQTT_SAVE_URI, saveMQTTSettings);
  } else {
    Serial.println("Unable to load MQTT settings.");
  }
 
  if(portal.load(FPSTR(AUX_OTA_SETTINGS))) {
    AutoConnectAux& update_settings = *portal.aux(OTA_SETTINGS_URI);
    PageArgument args;
    loadOTASettings(update_settings, args);
    portal.on(OTA_SETTINGS_URI, loadOTASettings);
    portal.on(OTA_SAVE_URI, saveOTASettings);
 
    // Set up OTA update server.
    httpUpdate.setup(&httpServer, "/update", "ESP-" + String(GET_CHIPID(), HEX), update_settings["otaPassword"].value);
    if (MDNS.begin("esp-webupdate")) {
      MDNS.addService("http", "tcp", 80);
    }
    Serial.println("OTA update server started.");
  } else {
    Serial.println("Unable to load OTA settings.");
  }
 
  // Append the ESP chip identifier to the AP BSSID for uniqueness.
  config.apid = "ESP-" + String(GET_CHIPID(), HEX);
  config.bootUri = AC_ONBOOTURI_HOME;
  config.homeUri = "/_ac";
  config.psk = String(AP_PASSWORD);
  httpServer.on("/", redirectAutoConnect);
  portal.config(config);
 
  Serial.print("WiFi starting up...");
  if (portal.begin()) {
    WIFIStatus = WiFi.status();
    WIFIUptime = millis();
    Serial.println("Connected to AP: " + WiFi.SSID());
    Serial.println("IP: " + WiFi.localIP().toString());
  }
  else {
    Serial.println("Could not connect to AP: " + String(WiFi.status()));
  }
}
 
void loop() {
  if (WiFi.status() == WL_CONNECTED) {
    if (!mqttClient.connected()) {
        mqttConnect();
    }
    mqttClient.loop();
  }
  MDNS.update();
  portal.handleClient();
}

The modifications allow sending an additional action key that can be either toggle or servo in order to switch between GPIO pin toggling and servo motor control. For instance, in order to control the servo motor and make it rotate $90^\circ$, the following payload is sent to the MQTT server and topic that the WeMoS connects and subscribes to:

{ "action": "servo", "pin": 6, "angle": 90 }'

that will:

  • make the sketch perform a "servo" action with,
  • a servo motor connected to GPIO pin 6 and,
  • rotate the servo motor to $90^\circ$.

It is important to remark that low-cost servo motors are far from precise and that one would have to go to great lengths to ensure that the movement and angle is correct. One can expect, perhaps a $2^\circ$ to $5^\circ$ degree aberration when the servo moves to its designated position. Since the cat does not care much about precision and more about getting to the food reliably, a small wooden hook has been added on the other side of the bowls to act as a stopper when the servo motor returns back to $0^\circ$ - similarly, the horn has been adjusted on top of the servo motor to ensure that $0^\circ$ means a little more than a perfect fit over the two bowls such that the movement will invariably stop against the wooden stopper and ensure a perfect fit regardless of the lack of precision.

Automating with Node-Red and Alexa

With the sketch uploaded, the WeMoS boots in AP mode and by connecting to the AP with a computer or a tablet the sketch can be configured to make the WeMoS associate with an AP as well as connect and subscribe to an MQTT topic. Once the WeMOS connects and subscribes to the chosen MQTT topic, payloads can be delivered in order to instruct the WeMoS to move the servo.

Using the command line and assuming esp/catfeed is the topic that the WeMoS inside the black box is made to subscribe to and that the command is issued on the same machine on which the MQTT server resides, then issuing the command:

mosquitto_pub -h 127.0.0.1 -t 'esp/catfeed' -m '{ "action": "servo", "pin": 6, "angle": 90 }'

should make the servo rotate to 90 degrees and hopefully open the bowl as demonstrated in the first section of this article.

Moving on to Node-Red, a very simple flow can be made to schedule opening and closing the cat feeder at regular intervals depending on the feeding schedule established by the owner.

The flow uses the Node-Red light scheduler (marked in pink) that delivers a signal at a specified time and date selectable from a popup calendar. The signal is then fed into the On/Off switch node that transfers the signal through to the upper terminal in case the signal was "ON", respectively to the lower terminal in case the signal was "OFF".

Assuming that the signal was "ON", then the "Open Cat Feed" node sets the JSON parameters into the msg.payload object, splits the message in two and sends one message to the JSON serializer (marked in brown) which then passes to the MQTT server. The second copy of the message is forwarded through a delay node (light purple) that will pause the flow for (in this case) 15 minutes. Once the time has elapsed, the "Close Cat Feed" node sets the JSON parameters (adjuststhe angle to 0) and feeds the message on to the JSON serializer that then passes the result to the MQTT server.

The effect of the former is that once the scheduled date and time is reached, the cat feeder is opened and after 15 minutes, the cat feeder closes automatically. Similarly, if the Alexa voice command "Alexa turn cat feeder on" is uttered, then it will have the exact same effect: the cat feeder will open and after 15 minutes the feeder will close.

In case the "OFF" signal is given (either by Alexa or by the scheduler) then the "Close Cat Feed" node sets the parameters to close the feeder without delay.

On the lower side, two nodes "Announce Open" and "Announce Closed" are combined with the Alexa TTS project in order to make Alexa say something whenever the feeder opens or closes.

Further Developments

Further developments could include adding a Peltier element in order to keep the food cool during off hours. Unfortunately, the Peltier has not made it at the time of writing due to the complexity of the cooling required by the Peltier element itself - the heatsink needs to be relatively large to the build and a custom heat pipe has to be built. Nevertheless, with added cooling the food can be kept refrigerated for longer periods of time.

Similarly, in spite of the cat's remarkable inner clock and its baffling sense of time, the cat was at times either early or late to the schedule. Perhaps a PIR sensor could be added in order to open the feeder if the cat shows up early or late given an error margin deemed acceptable.

Disclaimer

GDPR forms were offered to the cat but it refused to sign such that its identity shall not be disclosed. Stop asking for private photos!


iot/automatic_cat_feeder.txt · Last modified: 2023/01/05 02:42 by office

Access website using Tor Access website using i2p Wizardry and Steamworks PGP Key


For the contact, copyright, license, warranty and privacy terms for the usage of this website please see the contact, license, privacy, copyright.