Building a Twitter-enabled Litterbox

I was thinking the other day: are there any valid uses of Twitter? I couldn't think of any. But I realized I could solve the problem and simultaneously fulfill a dream of humanity since ancient times: knowing the time, duration, and quantity of all of my cat's defecations. Look upon my works, ye Mighty, and despair: Nibbler Poop.

You may want to read the FAQ first.

To start, I needed a way of weighing the entire litterbox. I used some load cells I bought on eBay for about $8. They are not the highest quality, but they worked well enough. I chiseled a square from each corner of a piece of scrap plywood and screwed the cells into place:

I used all four load cells, wired in both parallel and anti-parallel:

The litterbox goes on the platform, and the platform rests on another piece of plywood. The plywood is not very flat, but that's ok: if one load cell doesn't carry enough weight, it will be transferred to another. The sum of all cells must be the total weight.

Load cells work on the principle of the Wheatstone Bridge:

When weight is applied to a load cell, one of the resistors is stretched slightly, changing its resistance relative to the others by a small amount.

However, each of my load cells only have three wires, with a resistor going between white-red and red-black. In other words, there are only two resistors, not four. To create a proper bridge, I first wire up two pairs in parallel: white-white, red-red, and black-black. I then take these two pairs and cross-wire the black and white wires (i.e., black-white and white-black). The two red wires go to the voltage sensor and the white/black wires go to Vcc and Vss (it doesn't matter which). Or, in MS Paint diagram form:

All this is hooked into an obscenely crude circuit with an Arduino, Bluetooth serial module, and 24-bit ADC (I've since soldered this all together into something more decent):

The ADC is an HX711 module I bought on eBay for $5. I used an Arduino to interface with it and send the results over a Bluetooth serial module ($6 on eBay).

The Arduino code, shown below, is really trivial. The ADC has a data/output pin and a clock/input pin. I drive the ADC by toggling the clock 25 times, reading a data bit after each of the first 24, and then toggling once more to reset the device. It then waits for the data line to go high again before rereading. The ADC produces 80 samples per second, but they're pretty noisy, so I average together 256 of them (and hence I get a sample roughly every 3 seconds). Good enough. The result is dumped out the serial port at 9600 baud.

#define ADC_CLOCK 2 #define ADC_DATA 3 #define NUM_SAMPLES_LOG2 8 #define NUM_SAMPLES (1 << NUM_SAMPLES_LOG2) #define ADC_BIT_PERIOD_MICROS 1 int32_t getADCReading() { int32_t v = 0; while (digitalRead(ADC_DATA) == HIGH); noInterrupts(); for (int i=0; i<24; i++) { digitalWrite(ADC_CLOCK, 1); delayMicroseconds(ADC_BIT_PERIOD_MICROS); v <<= 1; v |= (digitalRead(ADC_DATA) == HIGH) ? 1 : 0; digitalWrite(ADC_CLOCK, 0); delayMicroseconds(ADC_BIT_PERIOD_MICROS); } digitalWrite(ADC_CLOCK, 1); delayMicroseconds(ADC_BIT_PERIOD_MICROS); digitalWrite(ADC_CLOCK, 0); interrupts(); v |= (v & 0x00800000) ? 0xff000000 : 0x00000000; // sign extend return v; } int32_t getPreciseADCReading() { int32_t v = 0; for (int i=0; i<NUM_SAMPLES; i++) { v += getADCReading(); } return (v >> NUM_SAMPLES_LOG2); } int32_t calibration = 0; void setup() { Serial.begin(9600); Serial.println("catpoop v0.1"); pinMode(ADC_CLOCK, OUTPUT); pinMode(ADC_DATA, INPUT); calibration = getPreciseADCReading(); Serial.print("initial calibration: "); Serial.println(calibration); } void loop() { int32_t v = getPreciseADCReading(); Serial.print("v: "); Serial.println(v - calibration); }

The resulting raw data looks like the following:

You can see several things in this graph: first, there are glitches as the cat steps onto the scale that we have to account for. Next, the cat decided partway through to have another go, which we also must account for. We can read the total weight (about 4800 grams) from the peak weight, and see that there is a small "residual" mass after she leaves which is in fact the poop weight.

To process the results, I used a Raspberry Pi running Raspbian. I also used a $2 USB Bluetooth receiver which acted as a serial port. The data comes in as raw "units", which I converted to grams via the empirically determined divisor of 23.3 (you'll have to do your own test to find the right constant since all load cells are different). Finally, I used a straightforward Perl script to interpret the results, generate a tweet, and send the tweet via the Twitter API.

Note that I have erased the private Twitter keys; to use the script you must generate your own. Look through the Twitter API documentation to get started.

I found that my Bluetooth device very occasionally sends duplicate bytes, and also sometimes misses bytes. I therefore had to be fairly resistant to corruption of the data stream, which the code below handles.

#!/usr/bin/perl use strict; use IO::Handle; use Device::SerialPort; use Net::Twitter::Lite::WithAPIv1_1; $| = 1; my $poopingCat = 'Nibbler'; my $poopingCatNominative = "she"; my $poopingCatObjective = "her"; my $poopingState = 'idle'; my $poopStartTime; my $poopEndTime; my $poopingBaseline; my $poopingMaxWeight; my @weightHistory; my $globalBaseline; my $lowerWeightThreshold = 1000; my $upperWeightThreshold = 2000; my $unitsPerGram = 23.3; my $portname = "/dev/rfcomm0"; my $port = new Device::SerialPort($portname) or die "couldn't open '$portname'"; $port->handshake('xoff'); $port->baudrate(9600); $port->parity('odd'); $port->databits(8); $port->stopbits(1); my $logFilename; my $buf; my $logfh; my $g = 'g'; while (my $temp = $port->input) { # eat buffered (out of date) input } while (1) { my ($sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst) = gmtime time; my $newLogFilename = sprintf "%04d-%02d-%02d %02d:%02d:%02d.xls", 1900+$year, $mon+1, $mday, $hour, 0, 0; if ($logFilename ne $newLogFilename) { $logFilename = $newLogFilename; close $logfh if $logfh; print "opening new log file: $logFilename\n"; open $logfh, ">logs/$logFilename" or die "couldn't open 'logs/$logFilename'"; #$logfh->autoflush; print $logfh "[weight]\t[units]\n"; } $buf .= $port->input; if ($buf =~ /^v: (-?\d+)\r?\n/s) { #while ($buf !~ /^v: (-?\d+)/) { $buf =~ s/^.//s; } $buf =~ s/^v: (-?\d+)\r?\n//s or die "couldn't parse '$buf'"; my $grams = int($1 / $unitsPerGram); if (!defined $globalBaseline) { $globalBaseline = $grams; printf "Global baseline: $grams grams\n"; } $grams -= $globalBaseline; print "$g: $grams \r"; $g = ($g eq 'g') ? 'G' : 'g'; printf $logfh "%d\tgrams\n", $grams; processWeightSample($grams); } elsif (length $buf > 15) { $buf =~ s/^.//s; } else { sleep 1; } } sub processWeightSample { my ($g) = @_; push @weightHistory, $g; shift @weightHistory while @weightHistory > 15; if ($poopingState eq 'idle') { $poopingBaseline = $weightHistory[0]; if ($g > ($poopingBaseline + $upperWeightThreshold)) { $poopingState = 'prepooping'; $poopStartTime = time; $poopingMaxWeight = $g; printf "$poopingCat started pooping at %s\n", formatTime($poopStartTime); printf " Baseline weight was %d grams\n", $poopingBaseline; } } elsif ($poopingState eq 'prepooping') { $poopingMaxWeight = max($poopingMaxWeight, $g); if ((time - $poopStartTime) > 10) { if ($g < ($poopingBaseline + $lowerWeightThreshold)) { printf " False alarm. No pooping occurred.\n"; $poopingState = 'idle'; } else { printf " Looks like some pooping is going to happen. Current weight is %d grams.\n", $g - $poopingBaseline; $poopingState = 'pooping'; } } } elsif ($poopingState eq 'pooping') { $poopingMaxWeight = max($poopingMaxWeight, $g); if ($g < ($poopingBaseline + $lowerWeightThreshold)) { printf " Went below the weight threshold. Transitioning out of the pooping state.\n"; printf " Current weight is %d grams.\n", $g - $poopingBaseline; $poopingState = 'postpooping'; $poopEndTime = time; } elsif ((time - $poopStartTime) > (10*60)) { printf "Too much time was spent in the pooping state. Something went wrong--resetting.\n"; $poopingState = 'idle'; undef @weightHistory; } } elsif ($poopingState eq 'postpooping') { $poopingMaxWeight = max($poopingMaxWeight, $g); if ($g > ($poopingBaseline + $upperWeightThreshold)) { printf " Some more pooping to be done. Current weight is $g grams.\n"; $poopingState = 'pooping'; } elsif ((time - $poopEndTime) > 10) { $poopingState = 'finishpooping'; } } elsif ($poopingState eq 'finishpooping') { my $poopWeight = $g - $poopingBaseline; my $catWeight = $poopingMaxWeight - $poopingBaseline; printf " $poopingCat finished pooping at %s. She spent %d seconds.\n", formatTime($poopEndTime), ($poopEndTime - $poopStartTime); printf " She deposited approximately %d grams of poop.\n", $poopWeight; printf " Her current weight is %.2f kg\n", $catWeight / 1000; poopTweet($poopEndTime, $poopEndTime - $poopStartTime, $poopWeight, $catWeight); $poopingState = 'idle'; } else { die "unknown pooping state: '$poopingState'"; } } sub max { my ($a, $b) = @_; return ($a > $b) ? $a : $b; } sub formatTime { my ($t) = @_; my ($sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst) = localtime $t; return sprintf "%02d/%02d %02d:%02d:%02d", $mon+1, $mday, $hour, $min, $sec; } sub getLocalTimeInfo { my ($t) = @_; my %t; ($t{sec}, $t{min}, $t{hour}, $t{mday}, $t{mon}, $t{year}, $t{wday}, $t{yday}, $t{isdst}) = localtime $t; $t{year} += 1900; $t{mon} += 1; #print Dumper(\%t); return %t; } sub poopTweet { my ($poopTime, $poopDuration, $poopWeight, $catWeight) = @_; my @introMessage; my %t = getLocalTimeInfo($poopTime); if (0.5 < rand 1) { if (($t{hour} >= 8) && ($t{hour} < 11)) { @introMessage = ('Morning poops.', 'Breakfast poop.'); } elsif (($t{hour} >= 12) && ($t{hour} < 15)) { @introMessage = ('Pooping after lunch.'); } elsif (($t{hour} >= 15) && ($t{hour} < 18)) { @introMessage = ('Turds in flight; afternoon delight.'); } elsif (($t{hour} >= 18) && ($t{hour} < 22)) { @introMessage = ('Evening poop.', 'Clearing the pipes for dinner.'); } elsif (($t{hour} >= 22) || ($t{hour} < 2)) { @introMessage = ('Night poops.', 'Dinner has been digested.'); } else { @introMessage = ('Late night poops.', 'Woke up in the middle of the night and had to poop.'); } } else { my $h = ($t{hour} == 0) ? 12 : ($t{hour} > 12) ? ($t{hour} - 12) : $t{hour}; @introMessage = ('Incoming poop!', 'Poop alert.', 'Poop time.', 'Catpoop inbound.', 'Poop notification.', 'Got some poops here.', 'Poop waits for no cat.', "Got the $h o'clock poops."); } my @verb = ('deposited', 'dropped', 'buried', 'delivered', 'dumped'); my @weightMessage; if ($poopWeight < 5) { @weightMessage = ("But there was no poop forthcoming.", "$poopingCat tried but no poop came out."); } elsif ($poopWeight < 15) { @weightMessage = ("$poopingCat pushed but only a few tiny turds, $poopWeight grams worth, came out.", "Just a tiny poop of $poopWeight grams"); } elsif ($poopWeight < 50) { @weightMessage = ("$poopingCat completed a nice bowel movement of $poopWeight grams.", "$poopingCat pooped $poopWeight grams."); } elsif ($poopWeight < 250) { @weightMessage = ("$poopingCat delivered quite a load! $poopWeight grams worth of poop.", "$poopingCat deposited $poopWeight grams of poop."); } else { @weightMessage = ("What a poop! $poopingCat dropped $poopWeight grams of poop.", "That's a lot of poop. $poopWeight grams worth, in fact."); } my $introMessage = getRandomString(@introMessage); my $weightMessage = getRandomString(@weightMessage); my @catWeightMessage = ( (sprintf "%s currently weighs %.2f kg.", (($weightMessage =~ /$poopingCat/i) ? (ucfirst $poopingCatNominative) : $poopingCat), $catWeight / 1000), (sprintf "%s weight is %.2f kg.", (($weightMessage =~ /$poopingCat/i) ? (ucfirst $poopingCatObjective) : "$poopingCat's"), $catWeight / 1000), (sprintf "%s weighs %.2f kg (mostly fur).", (($weightMessage =~ /$poopingCat/i) ? (ucfirst $poopingCatNominative) : $poopingCat), $catWeight / 1000), (sprintf "%s weighs a svelte %.2f kg.", (($weightMessage =~ /$poopingCat/i) ? (ucfirst $poopingCatNominative) : $poopingCat), $catWeight / 1000), ); my $catWeightMessage = getRandomString(@catWeightMessage); my @poopDurationMessage = ( (sprintf "%s spent $poopDuration seconds pooping.", (($catWeightMessage =~ /$poopingCat/i) ? (ucfirst $poopingCatNominative) : $poopingCat), $poopDuration), (sprintf "%s spent $poopDuration seconds on the box.", (($catWeightMessage =~ /$poopingCat/i) ? (ucfirst $poopingCatNominative) : $poopingCat), $poopDuration), (sprintf "$poopDuration seconds were spent.", $poopDuration), (sprintf "%s was on the box for $poopDuration seconds.", (($catWeightMessage =~ /$poopingCat/i) ? (ucfirst $poopingCatNominative) : $poopingCat), $poopDuration), (sprintf "%s was done in $poopDuration seconds.", (($catWeightMessage =~ /$poopingCat/i) ? (ucfirst $poopingCatNominative) : $poopingCat), $poopDuration), ); my $poopDurationMessage = getRandomString(@poopDurationMessage); my $tweet = "$introMessage $weightMessage $catWeightMessage $poopDurationMessage"; if (140 < length $tweet) { # recurse if the tweet is too long and hope to get a shorter message poopTweet($poopTime, $poopDuration, $poopWeight, $catWeight); } else { my $key = "AAAAAAAA"; my $secret = "BBBBBBBB"; my $token = "CCCCCCCC"; my $tokenSecret = "DDDDDDDD"; my $nt = Net::Twitter::Lite::WithAPIv1_1->new( consumer_key => $key, consumer_secret => $secret, access_token => $token, access_token_secret => $tokenSecret, ssl => 1) or die "couldn't open twitter"; $nt->update($tweet) or die "couldn't tweet"; print " tweet='$tweet'\n"; printf " len=%d\n", length $tweet; } } sub getRandomString { return $_[rand scalar @_]; }

The script uses a state machine to detect the poop phases and eliminate false alarms. It uses a simple text randomizer to generate variations on the phrasing.

And that's it! The overall system is a bit Rube Goldberg, but it mostly works. Shoot me an email at scott at scottcutler.net if you have questions. Nibbler says hi!

FAQ

Why?
Because I thought the project would be a funny sort of meta-commentary on the value of Twitter. But there was an unanticipated benefit: when I am out of town, I can use the feed to see if my cat is still eating, etc. properly. Pets can be stressed when their owners leave and it's good to have a way of checking in.
Seems kinda complicated and expensive. Why both an Arduino and Raspberry Pi?
The overriding reason is that I did not have network access in the room with the litter box, so I needed something wireless. Bluetooth made this easy.
Secondary reasons: I had a RasPi collecting dust, and wanted to try it out; it's not really an extra expense since I can still use the RasPi for other things; I can't run Perl on the Arduino; WiFi on the Arduino is expensive and a bit annoying; etc.
Why not use the internal ADC on the Arduino?
First, I would still need an amplifier (probably an instrumentation amplifier) since the load cells only register changes of under a millivolt. The HX711 module was cheap and designed for the purpose. Furthermore, the Arduino ADC is only 10 bits, so even with perfect range scaling I could only get 5 gram precision (5 kg limit / 1024). I wanted better accuracy than that.
You can't tell the difference between poop and pee! Lies!
Guilty as charged. In my defense, it's a hard problem. I've ordered a gas sensor (a MQ-135) to see if I can detect the ammonia after peeing, but it remains to be seen if I can do this reliably.
The tweet feed sometimes repeats itself.
Yeah—the latest version of the script tries to improve this by generating several new tweets, and comparing them against other recent tweets, picking the one that's most "different." It's not perfect but it is better.
What's with the underweight tweets, like with 2.6 kg?
Nibbler apparently sometimes half-enters the litterbox, hangs around for a short time, and then leaves. I've since created some special tweets for this condition.

Copyright 2014 Scott Cutler. All code may be used freely and is without warranty. Acknowledgement is preferred but not required.