Skip to content

Instantly share code, notes, and snippets.

@savetheclocktower
Last active August 12, 2018 21:38
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save savetheclocktower/40ee61fc40aad5b2259b5e316e29f453 to your computer and use it in GitHub Desktop.
Save savetheclocktower/40ee61fc40aad5b2259b5e316e29f453 to your computer and use it in GitHub Desktop.
Laundry Spy firmware
// (see the blog post at https://andrewdupont.net/2018/04/27/laundry-spy-part-3-the-software/)
// GENERAL CONFIG
// ==============
// The baud rate of serial output for logging. If necessary, change the baud
// rate in your Serial Monitor to match this.
#define BAUD_RATE 115200
// The name by which this device will identify itself over mDNS (Bonjour).
// Easier to work with than an IP address.
#define HOST "laundry-spy"
// WASHER/DRYER CONFIG
// ===================
// Vibration threshold necessary to change from IDLE to MAYBE_ON. You might
// need to adjust these values depending on how much your washer and dryer
// vibrate.
#define WASHER_THRESHOLD 0.20
#define DRYER_THRESHOLD 0.20
// How often a machine's vibration score should exceed the threshold to be
// considered ON. If we think a machine is on, but it goes at least TIME_WINDOW
// ms without exceeding the threshold, we'll decide it was a false alarm.
#define TIME_WINDOW 3000
// How long a machine needs to spend (in ms) in each of the MAYBE states until
// we move it to the next state. These should always be more than 3000ms.
#define TIME_UNTIL_ON 30000 // 30 seconds
#define TIME_UNTIL_DONE 300000 // 5 minutes
// WIFI CONFIG
// ===========
#define WLAN_SSID "your-ssid-goes-here"
#define WLAN_PASS "your-password-goes-here"
// MQTT CONFIG
// ===========
#define MQTT_SERVER "999.999.999.999"
#define MQTT_SERVER_PORT 1883
#define MQTT_USERNAME "your-mqtt-server-username"
#define MQTT_PASSWORD "your-mqtt-server-password"
#define MQTT_CONN_KEEPALIVE 300
// SENSOR CONFIG
// =============
// The I2C addresses of the accelerometers. It doesn't matter which plug goes
// into which port, since they have unique IDs. Just keep track of which sensor
// is attached to which machine.
#define ACCEL_I2C_ADDRESS_WASHER 0x19
#define ACCEL_I2C_ADDRESS_DRYER 0x18
// END CONFIG
#include <math.h>
#include <ESP8266WiFi.h>
#include <ESP8266mDNS.h>
#include <PubSubClient.h>
#include <SimpleTimer.h>
#include <WiFiUdp.h>
#include <SparkFunLIS3DH.h>
void accel_setup(LIS3DH accel) {
accel.settings.accelSampleRate = 50;
accel.settings.accelRange = 2;
accel.settings.adcEnabled = 1;
accel.settings.tempEnabled = 0;
accel.settings.xAccelEnabled = 1;
accel.settings.yAccelEnabled = 1;
accel.settings.zAccelEnabled = 1;
accel.begin();
}
// MQTT
// ====
const char WASHER_FEED[] = HOST "/washer/state";
const char DRYER_FEED[] = HOST "/dryer/state";
const char WASHER_FORCE_FEED[] = HOST "/washer/force";
const char DRYER_FORCE_FEED[] = HOST "/dryer/force";
// Temporary string to hold values that we're publishing via MQTT.
char tempStateValue[2];
WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);
void MQTT_connect() {
Serial.println("Connecting to MQTT...");
mqtt.setServer(MQTT_SERVER, MQTT_SERVER_PORT);
bool connected = false;
while (!connected) {
connected = mqtt.connect(HOST, MQTT_USERNAME, MQTT_PASSWORD);
if (!connected) {
Serial.println("Couldn't connect to MQTT. Retrying in 10 seconds...");
mqtt.disconnect();
delay(10000);
}
}
Serial.println("MQTT connected!");
}
void MQTT_handle() {
if ( !mqtt.connected() ) {
MQTT_connect();
}
mqtt.loop();
}
SimpleTimer timer;
enum ApplianceState {
// Nothing is happening.
IDLE,
// We had a vibration event. We think the appliance may be on, but we're
// not sure yet.
MAYBE_ON,
// Enough vibration has happened in a short time that we're ready to
// proclaim that the appliance is on.
ON,
// It stopped moving. Maybe it's done?
MAYBE_DONE,
// It's been silent for long enough that we're sure it's done.
DONE
};
class Appliance {
private:
LIS3DH accel;
// Last time that we were in the idle state.
long lastIdleTime = 0;
// Last time that the force exceeded our threshold, regardless of state.
long lastActiveTime;
// Last vibration score.
float force = 0.0;
// The score above which we should move the machine from IDLE to MAYBE_ON.
float threshold;
// The initial readings we got for acceleration along each axis.
float initialX;
float initialY;
float initialZ;
// The most recent acceleration values along each axis.
float lastX;
float lastY;
float lastZ;
void readAccelerometer() {
float total = 0;
lastX = accel.readFloatAccelX();
lastY = accel.readFloatAccelY();
lastZ = accel.readFloatAccelZ();
total += fabs(lastX - initialX);
total += fabs(lastY - initialY);
total += fabs(lastZ - initialZ);
force = total;
}
public:
// The appliance we're dealing with (either "Washer" or "Dryer").
String name;
// The feed we're publishing our state to.
String feedNameState;
// The feed we're publishing force values to.
String feedNameForce;
ApplianceState state;
Appliance (String n, LIS3DH a, float t, String fns, String fnf) : accel(a) {
name = n;
threshold = t;
state = IDLE;
feedNameState = fns;
feedNameForce = fnf;
}
void setup () {
// Set this at startup to ensure that a machine starts in the IDLE state.
lastActiveTime = millis() - (TIME_WINDOW + 100);
accel_setup(accel);
// Take readings at startup.
initialX = accel.readFloatAccelX();
initialY = accel.readFloatAccelY();
initialZ = accel.readFloatAccelZ();
}
void update () {
readAccelerometer();
long now = millis();
if (force > threshold) {
lastActiveTime = now;
}
// "Recently" active means our force exceeded the threshold at least once
// within the last three seconds.
bool wasRecentlyActive = (now - lastActiveTime) < TIME_WINDOW;
// This is the logic that navigates us through the state machine.
// There's IDLE, ON, and DONE, which are obvious. The MAYBE_ON and
// MAYBE_DONE states are the only ones from which we can move either
// forward or backward. Once we go to ON, there's no way to go back
// to IDLE.
switch (state) {
case IDLE:
if (wasRecentlyActive) {
// Whenever there's so much as a twitch, we switch to the MAYBE_ON
// state.
setState(MAYBE_ON);
} else {
lastIdleTime = now;
}
break;
case MAYBE_ON:
if (wasRecentlyActive) {
// How long have we been active?
if (now > (lastIdleTime + TIME_UNTIL_ON)) {
// Long enough that this is not a false alarm.
setState(ON);
} else {
// Let's wait a bit longer before we act.
}
} else {
// We're not active, meaning there's been no vibration for a few
// seconds. False alarm!
setState(IDLE);
}
break;
case ON:
if (wasRecentlyActive) {
// We expect to be vibrating and we are. All is well. Do nothing.
} else {
// We stopped vibrating. Are we off? Switch to MAYBE_DONE so we can
// figure it out.
setState(MAYBE_DONE);
}
break;
case MAYBE_DONE:
if (wasRecentlyActive) {
// We thought we were done, but we're vibrating now. False alarm!
// Go back to ON.
setState(ON);
} else if (now > (lastActiveTime + TIME_UNTIL_DONE)) {
// We've been idle for long enough that we're certain that the
// cycle has stopped.
setState(DONE);
}
break;
case DONE:
// Once we get to DONE, there's nothing to do except go back to the
// initial IDLE state.
setState(IDLE);
break;
}
}
bool publishForce () {
Serial.print(name);
Serial.print(" publishing force: ");
Serial.println(force);
// Arduino's sprintf doesn't support floats.
bool published = mqtt.publish(
feedNameForce.c_str(),
String(force).c_str()
);
if (!published) {
Serial.println(" ...couldn't publish!");
}
return published;
}
bool publishState () {
sprintf(tempStateValue, "%d", state);
Serial.print(name);
Serial.print(" publishing state: ");
Serial.println(state);
bool published = mqtt.publish(
feedNameState.c_str(),
tempStateValue,
true
);
if (!published) {
Serial.println(" ...couldn't publish!");
}
return published;
}
void setState(ApplianceState s) {
state = s;
publishState();
}
};
LIS3DH accelWasher(I2C_MODE, ACCEL_I2C_ADDRESS_WASHER);
LIS3DH accelDryer(I2C_MODE, ACCEL_I2C_ADDRESS_DRYER);
Appliance washer(
"Washer",
accelWasher,
WASHER_THRESHOLD,
WASHER_FEED,
WASHER_FORCE_FEED
);
Appliance dryer(
"Dryer",
accelDryer,
DRYER_THRESHOLD,
DRYER_FEED,
DRYER_FORCE_FEED
);
bool shouldPublishState = false;
void schedulePublishState() {
shouldPublishState = true;
}
void publishState() {
washer.publishState();
dryer.publishState();
}
bool shouldPublishForce = false;
void schedulePublishForce() {
shouldPublishForce = true;
}
void publishForce() {
washer.publishForce();
dryer.publishForce();
}
void setup() {
Serial.begin(BAUD_RATE);
delay(10);
WiFi.mode(WIFI_STA);
WiFi.begin(WLAN_SSID, WLAN_PASS);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println();
Serial.println("WiFi connected!");
Serial.print("IP address: ");
Serial.println(WiFi.localIP());
if ( !MDNS.begin(HOST) ) {
Serial.print("Error! Failed to broadcast host via MDNS: ");
Serial.println(HOST);
delay(5000);
ESP.restart();
}
washer.setup();
dryer.setup();
// Publish to the MQTT feed every five minutes whether we've got a new state
// or not.
timer.setInterval(300000, schedulePublishState);
// Publish the most recent force reading every so often. This is useful for
// determining a good threshold.
timer.setInterval(2000, schedulePublishForce);
Serial.println("Ready!");
}
void loop() {
timer.run();
MQTT_handle();
if (shouldPublishState) {
publishState();
shouldPublishState = false;
}
if (shouldPublishForce) {
publishForce();
shouldPublishForce = false;
}
washer.update();
dryer.update();
delay(50);
}
@stephenmhall
Copy link

Can I just say I love your code layout, makes mine look like spaghetti thrown at a peg board :)

@dhowdy
Copy link

dhowdy commented Jun 14, 2018

This is a great project! So great that I decided to build it. Your code is very clean and well documented. You did an amazing job on both the code and the writeup! Very impressive.

I've got several ESP8266-based (about 25) IoT devices in my house right now, but most just run ESPEasy and I don't have anything set up for MQTT. Your post has shown me how useful MQTT can be. So I set up Node-RED on a VM and, after finding it too time consuming to adapt your code to use an MPU6050, I ordered a couple of LIS3DHs and build your setup from your excellent documentation.

I did find an issue when trying to publish to the MQTT server where the PubSubClient was trying to push to "</path/to/feed" instead of "<path/to/feed>". Upon further inspection, it looks like at the point of publishing to MQTT, the connection is already set up and we don't need the hostname anymore (unless you intend to publish to "host/path/to/feed".) Additionally, I added some comments for those that are using a LIS3DH that does not automatically assign different addresses on the I2C bus (such as the Adafruit boards.)

Anyhow, since we can't do pull requests on gists, I wanted to comment and link you to the changes I made in case you wanted to incorporate them for your readers. Additionally, if you want to convert this gist to a repo, I'm happy to create a pull request from that.

One last issue that I'm seeing is that sometimes when the ESP8266 boots, it doesn't properly zero out the accelerometers. It starts them at something like 1.41 instead of close to 0.00. A reset or two typically fixes it. I intend to chase this issue down a bit more and basically reset the ESP8266 until we get values of <0.05. Again, I'm glad to contribute those changes upstream.

Thanks!
Don Howdeshell

https://gist.github.com/dhowdy/158386b9f220635e47ae8f42de8313b7

@savetheclocktower
Copy link
Author

@dhowdy: In my case, I did re-use the hostname on purpose in the channel name so that I'd get laundry-spy/washer/force instead of washer/force — effectively namespacing my feeds. I see you did that a different way.

In theory, I did consider that my approach places too much trust in the initial values we get from the accelerometer, and assumes that the sensors won't change orientation after boot. In practice, neither of these turns out to be a problem for me, but your mileage clearly varied. I like your suggested fix, except that it still privileges one axis over the others — if you're expecting a certain axis to have a value near 0 at boot, you're declaring that that axis isn't allowed to be perpendicular to the ground.

One could make the sanity check be that two axes have values near 0 and one axis has a value near 1, not caring which axis is which. That still assumes that the sensor will rest almost completely vertically or horizontally, but I don't think that's a problem. Another option is to take a new “official” reading every so often, like perhaps once a minute, and then using that as the baseline for all readings over the next minute.

(My first version sidestepped all of this by only ever comparing the current reading to the very previous reading. I switched this because it seemed weird to effectively be measuring the second derivative of speed instead of the first.)

Anyway, I'll wait and see if other people report similar erratic results from the accelerometer. Maybe I just got lucky.

I'll look over these changes when I get a chance. Thanks!

@dhowdy
Copy link

dhowdy commented Jun 16, 2018

After further review, I can confirm that you are precisely correct in that the initial values do not seem to matter and there is no real need for them to start close to 0.00. In my case, however, my accelerometers seem to give initial values of exactly 0.00 and then the next values are around 3.50 and then kind of stick at 0.81 or 1.79 when they are not working correctly; if they don't essentially zero out at boot and stay near zero then they sometimes give goofy data and I have to reset them. I'm not sure what's going on with them but I'm pretty sure it has nothing to do with the code.

Thank you again for sharing your code. And I'll continue to contribute back any changes I make to my own setup in case anyone else finds value in it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment