Laundry Spy firmware
// (see the blog post at https://andrewdupont.net/2018/04/27/laundry-spy-part-3-the-software/) | |
// Version 1.0 by https://github.com/savetheclocktower | |
// Version 1.1 by https://github.com/dhowdy/ | |
// 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. | |
// Adafruit LIS3DH docs: https://learn.adafruit.com/adafruit-lis3dh-triple-axis-accelerometer-breakout/pinouts | |
// SDO -> 3.3V (high) | |
#define ACCEL_I2C_ADDRESS_WASHER 0x19 | |
// SDO -> Ground (low) | |
#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[] = "laundry/washer/state"; | |
const char DRYER_FEED[] = "laundry/dryer/state"; | |
const char WASHER_FORCE_FEED[] = "laundry/washer/force"; | |
const char DRYER_FORCE_FEED[] = "laundry/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); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment