PIR motion sensor for ProbeHarbor
An HC-SR501 PIR sensor reports motion in the garage, shed or workshop to ProbeHarbor, with an RGB LED that glows green when quiet, red on motion and blue if Wi-Fi or ProbeHarbor is unreachable.
For ProbeHarbor, open-source freeze and flood monitoring for garages, workshops, attics and crawlspaces, with live charts and alerts. This board sends its readings to a ProbeHarbor device; create one at probeharbor.dev.
Wiring
| From | To board | Note |
|---|---|---|
| PIR VCC | 5V | The HC-SR501 needs 4.5 V or more; it has its own 3.3 V regulator |
| PIR OUT | GPIO 27 | Its output is 3.3 V when high, safe for the ESP32 as it is |
| PIR GND | GND | |
| RGB red | GPIO 25 | Through 220 Ω |
| RGB green | GPIO 26 | Through 220 Ω |
| RGB blue | GPIO 33 | Through 220 Ω |
| RGB common leg (the longest) | GND | For a common-anode LED: to 3V3, and set COMMON_ANODE to true |
Tested on this board
Not run on this board yet. Treat the wiring and code as a first draft.
Firmware, Arduino sketch for ESP32 Dev Module
// PIR motion to ProbeHarbor: an HC-SR501 reports motion to a ProbeHarbor push
// device, and an RGB LED shows what is happening.
// Green: quiet. Red: motion. Blue: Wi-Fi or ProbeHarbor trouble. Green blinking: PIR warming up.
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
// ---- Your settings ----
const char* WIFI_SSID = "your-wifi";
const char* WIFI_PASS = "your-password";
// ProbeHarbor: Dashboard, Devices, add a push device, copy its ingest address.
const char* INGEST_URL = "https://probeharbor.dev/api/ingest/YOUR_DEVICE_KEY";
// ProbeHarbor reads a true/false value whose name contains "motion" as a motion sensor.
const char* SENSOR_KEY = "motion1";
const unsigned long HEARTBEAT_MS = 5UL * 60UL * 1000UL; // re-send the state every 5 minutes
const unsigned long MIN_GAP_MS = 10UL * 1000UL; // never post more often than this
const unsigned long WARMUP_MS = 60UL * 1000UL; // the HC-SR501 settles for about a minute
// ---- Pins ----
const int PIR_PIN = 27;
const int LED_R = 25, LED_G = 26, LED_B = 33;
const bool COMMON_ANODE = false; // true if the LED's long leg goes to 3V3 instead of GND
// Root certificates for probeharbor.dev: Google Trust Services R1 and R4, and
// Let's Encrypt ISRG X1 and X2. Cloudflare issues the site's certificate from one of these.
const char* ROOT_CAS = R"PEM(
-----BEGIN CERTIFICATE-----
MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQsw
CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU
MBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw
MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp
Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIiMA0GCSqGSIb3DQEBAQUA
A4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx9vaMf/vo
27xqLpeXo4xL+Sv2sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vXmX7w
Cl7raKb0xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g4M/SCYe7zUjw
TcLCeoiKu7rPWRnWr4+wB7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0Pfybl
qAj+lug8aJRT7oM6iCsVlgmy4HqMLnXWnOunVmSPlk9orj2XwoSPwLxAwAtcvfaH
szVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly4cpk9+aCEI3oncKKiPo4Zor8
Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr06zqkUspzBmk
MiVOKvFlRNACzqrOSbTqn3yDsEB750Orp2yjj32JgfpMpf/VjsPOS+C12LOORc92
wO1AK/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYWk70p
aDPvOmbsB4om3xPXV2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+DVrN
VjzRlwW5y0vtOUucxD/SVRNuJLDWcfr0wbrM7Rv1/oFB2ACYPTrIrnqYNxgFlQID
AQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E
FgQU5K8rJnEaK0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEMBQADggIBAJ+qQibb
C5u+/x6Wki4+omVKapi6Ist9wTrYggoGxval3sBOh2Z5ofmmWJyq+bXmYOfg6LEe
QkEzCzc9zolwFcq1JKjPa7XSQCGYzyI0zzvFIoTgxQ6KfF2I5DUkzps+GlQebtuy
h6f88/qBVRRiClmpIgUxPoLW7ttXNLwzldMXG+gnoot7TiYaelpkttGsN/H9oPM4
7HLwEXWdyzRSjeZ2axfG34arJ45JK3VmgRAhpuo+9K4l/3wV3s6MJT/KYnAK9y8J
ZgfIPxz88NtFMN9iiMG1D53Dn0reWVlHxYciNuaCp+0KueIHoI17eko8cdLiA6Ef
MgfdG+RCzgwARWGAtQsgWSl4vflVy2PFPEz0tv/bal8xa5meLMFrUKTX5hgUvYU/
Z6tGn6D/Qqc6f1zLXbBwHSs09dR2CQzreExZBfMzQsNhFRAbd03OIozUhfJFfbdT
6u9AWpQKXCBfTkBdYiJ23//OYb2MI3jSNwLgjt7RETeJ9r/tSQdirpLsQBqvFAnZ
0E6yove+7u7Y/9waLd64NnHi/Hm3lCXRSHNboTXns5lndcEZOitHTtNCjv0xyBZm
2tIMPNuzjsmhDYAPexZ3FL//2wmUspO8IFgV6dtxQ/PeEMMA3KgqlbbC1j+Qa3bb
bP6MvPJwNQzcmRk13NfIRmPVNnGuV/u3gm3c
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYD
VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG
A1UEAxMLR1RTIFJvb3QgUjQwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw
WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz
IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcqhkjOPQIBBgUrgQQAIgNi
AATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa6zzuhXyi
QHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/lxKvR
HYqjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW
BBSATNbrdP9JNqPV2Py1PsVq8JQdjDAKBggqhkjOPQQDAwNpADBmAjEA6ED/g94D
9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/Cr8deVl5c1RxYIigL9zC2L7F8AjEA8GE8
p/SgguMh1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh4rsUecrNIdSUtUlD
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw
CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg
R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00
MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT
ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw
EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW
+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9
ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T
AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI
zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW
tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1
/q4AaOeMSQ+2b1tbFfLn
-----END CERTIFICATE-----
)PEM";
bool motion = false; // debounced PIR state
int sentState = -1; // last state ProbeHarbor accepted
unsigned long lastPost = 0, changedAt = 0, retryAfterMs = 0;
bool trouble = false;
void setColor(uint8_t r, uint8_t g, uint8_t b) {
if (COMMON_ANODE) { r = 255 - r; g = 255 - g; b = 255 - b; }
analogWrite(LED_R, r);
analogWrite(LED_G, g);
analogWrite(LED_B, b);
}
// Returns the HTTP status, or a negative number if the request never got an answer.
int postMotion(bool on) {
WiFiClientSecure tls;
tls.setCACert(ROOT_CAS);
HTTPClient http;
const char* keep[] = {"Retry-After"};
http.collectHeaders(keep, 1);
if (!http.begin(tls, INGEST_URL)) return -1;
http.addHeader("Content-Type", "application/json");
char body[64];
snprintf(body, sizeof(body), "{\"%s\":%s}", SENSOR_KEY, on ? "true" : "false");
const int code = http.POST(body);
if (code == 429) retryAfterMs = 1000UL * (unsigned long)max(10L, http.header("Retry-After").toInt());
http.end();
return code;
}
void setup() {
Serial.begin(115200);
pinMode(PIR_PIN, INPUT); // the HC-SR501 drives its output high and low itself
pinMode(LED_R, OUTPUT);
pinMode(LED_G, OUTPUT);
pinMode(LED_B, OUTPUT);
setColor(0, 0, 80);
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) delay(250);
Serial.printf("wifi ok %s\n", WiFi.localIP().toString().c_str());
}
void loop() {
const unsigned long now = millis();
// Debounce: the PIR output has to hold a new level for 200 ms.
const bool raw = digitalRead(PIR_PIN) == HIGH;
static bool lastRaw = false;
static unsigned long rawSince = 0;
if (raw != lastRaw) { lastRaw = raw; rawSince = now; }
if (raw != motion && now - rawSince >= 200) {
motion = raw;
changedAt = now;
Serial.printf("motion = %d\n", motion ? 1 : 0);
}
const bool warming = now < WARMUP_MS;
if (warming) setColor(0, (now / 300) % 2 ? 60 : 0, 0);
else if (trouble) setColor(0, 0, 160);
else if (motion) setColor(200, 0, 0);
else setColor(0, 60, 0);
if (warming) return;
if (WiFi.status() != WL_CONNECTED) {
trouble = true;
static unsigned long lastTry = 0;
if (now - lastTry > 10000) { lastTry = now; WiFi.reconnect(); }
return;
}
const bool changed = (int)motion != sentState;
const bool heartbeat = now - lastPost >= HEARTBEAT_MS;
const unsigned long gap = retryAfterMs ? retryAfterMs : MIN_GAP_MS;
if ((changed || heartbeat) && (lastPost == 0 || now - lastPost >= gap)) {
lastPost = now;
retryAfterMs = 0;
const int code = postMotion(motion);
trouble = code < 200 || code >= 300;
if (!trouble) sentState = motion;
Serial.printf("posted %s %s\n", motion ? "motion" : "quiet", trouble ? "FAILED" : "ok");
if (trouble) Serial.printf("ProbeHarbor answered HTTP %d\n", code);
}
}
Wiring
| From | To board | Note |
|---|---|---|
| PIR VCC | 5V | The HC-SR501 needs 4.5 V or more; it has its own 3.3 V regulator |
| PIR OUT | GPIO 4 | Its output is 3.3 V when high, safe for the ESP32 as it is |
| PIR GND | GND | |
| RGB red | GPIO 5 | Through 220 Ω |
| RGB green | GPIO 6 | Through 220 Ω |
| RGB blue | GPIO 7 | Through 220 Ω |
| RGB common leg (the longest) | GND | For a common-anode LED: to 3V3, and set COMMON_ANODE to true |
Tested on this board
Not run on this board yet. Treat the wiring and code as a first draft.
Firmware, Arduino sketch for ESP32S3 Dev Module
// PIR motion to ProbeHarbor: an HC-SR501 reports motion to a ProbeHarbor push
// device, and an RGB LED shows what is happening.
// Green: quiet. Red: motion. Blue: Wi-Fi or ProbeHarbor trouble. Green blinking: PIR warming up.
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
// ---- Your settings ----
const char* WIFI_SSID = "your-wifi";
const char* WIFI_PASS = "your-password";
// ProbeHarbor: Dashboard, Devices, add a push device, copy its ingest address.
const char* INGEST_URL = "https://probeharbor.dev/api/ingest/YOUR_DEVICE_KEY";
// ProbeHarbor reads a true/false value whose name contains "motion" as a motion sensor.
const char* SENSOR_KEY = "motion1";
const unsigned long HEARTBEAT_MS = 5UL * 60UL * 1000UL; // re-send the state every 5 minutes
const unsigned long MIN_GAP_MS = 10UL * 1000UL; // never post more often than this
const unsigned long WARMUP_MS = 60UL * 1000UL; // the HC-SR501 settles for about a minute
// ---- Pins ----
const int PIR_PIN = 4;
const int LED_R = 5, LED_G = 6, LED_B = 7;
const bool COMMON_ANODE = false; // true if the LED's long leg goes to 3V3 instead of GND
// Root certificates for probeharbor.dev: Google Trust Services R1 and R4, and
// Let's Encrypt ISRG X1 and X2. Cloudflare issues the site's certificate from one of these.
const char* ROOT_CAS = R"PEM(
-----BEGIN CERTIFICATE-----
MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQsw
CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU
MBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw
MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp
Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIiMA0GCSqGSIb3DQEBAQUA
A4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx9vaMf/vo
27xqLpeXo4xL+Sv2sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vXmX7w
Cl7raKb0xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g4M/SCYe7zUjw
TcLCeoiKu7rPWRnWr4+wB7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0Pfybl
qAj+lug8aJRT7oM6iCsVlgmy4HqMLnXWnOunVmSPlk9orj2XwoSPwLxAwAtcvfaH
szVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly4cpk9+aCEI3oncKKiPo4Zor8
Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr06zqkUspzBmk
MiVOKvFlRNACzqrOSbTqn3yDsEB750Orp2yjj32JgfpMpf/VjsPOS+C12LOORc92
wO1AK/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYWk70p
aDPvOmbsB4om3xPXV2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+DVrN
VjzRlwW5y0vtOUucxD/SVRNuJLDWcfr0wbrM7Rv1/oFB2ACYPTrIrnqYNxgFlQID
AQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E
FgQU5K8rJnEaK0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEMBQADggIBAJ+qQibb
C5u+/x6Wki4+omVKapi6Ist9wTrYggoGxval3sBOh2Z5ofmmWJyq+bXmYOfg6LEe
QkEzCzc9zolwFcq1JKjPa7XSQCGYzyI0zzvFIoTgxQ6KfF2I5DUkzps+GlQebtuy
h6f88/qBVRRiClmpIgUxPoLW7ttXNLwzldMXG+gnoot7TiYaelpkttGsN/H9oPM4
7HLwEXWdyzRSjeZ2axfG34arJ45JK3VmgRAhpuo+9K4l/3wV3s6MJT/KYnAK9y8J
ZgfIPxz88NtFMN9iiMG1D53Dn0reWVlHxYciNuaCp+0KueIHoI17eko8cdLiA6Ef
MgfdG+RCzgwARWGAtQsgWSl4vflVy2PFPEz0tv/bal8xa5meLMFrUKTX5hgUvYU/
Z6tGn6D/Qqc6f1zLXbBwHSs09dR2CQzreExZBfMzQsNhFRAbd03OIozUhfJFfbdT
6u9AWpQKXCBfTkBdYiJ23//OYb2MI3jSNwLgjt7RETeJ9r/tSQdirpLsQBqvFAnZ
0E6yove+7u7Y/9waLd64NnHi/Hm3lCXRSHNboTXns5lndcEZOitHTtNCjv0xyBZm
2tIMPNuzjsmhDYAPexZ3FL//2wmUspO8IFgV6dtxQ/PeEMMA3KgqlbbC1j+Qa3bb
bP6MvPJwNQzcmRk13NfIRmPVNnGuV/u3gm3c
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYD
VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG
A1UEAxMLR1RTIFJvb3QgUjQwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw
WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz
IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcqhkjOPQIBBgUrgQQAIgNi
AATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa6zzuhXyi
QHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/lxKvR
HYqjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW
BBSATNbrdP9JNqPV2Py1PsVq8JQdjDAKBggqhkjOPQQDAwNpADBmAjEA6ED/g94D
9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/Cr8deVl5c1RxYIigL9zC2L7F8AjEA8GE8
p/SgguMh1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh4rsUecrNIdSUtUlD
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw
CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg
R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00
MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT
ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw
EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW
+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9
ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T
AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI
zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW
tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1
/q4AaOeMSQ+2b1tbFfLn
-----END CERTIFICATE-----
)PEM";
bool motion = false; // debounced PIR state
int sentState = -1; // last state ProbeHarbor accepted
unsigned long lastPost = 0, changedAt = 0, retryAfterMs = 0;
bool trouble = false;
void setColor(uint8_t r, uint8_t g, uint8_t b) {
if (COMMON_ANODE) { r = 255 - r; g = 255 - g; b = 255 - b; }
analogWrite(LED_R, r);
analogWrite(LED_G, g);
analogWrite(LED_B, b);
}
// Returns the HTTP status, or a negative number if the request never got an answer.
int postMotion(bool on) {
WiFiClientSecure tls;
tls.setCACert(ROOT_CAS);
HTTPClient http;
const char* keep[] = {"Retry-After"};
http.collectHeaders(keep, 1);
if (!http.begin(tls, INGEST_URL)) return -1;
http.addHeader("Content-Type", "application/json");
char body[64];
snprintf(body, sizeof(body), "{\"%s\":%s}", SENSOR_KEY, on ? "true" : "false");
const int code = http.POST(body);
if (code == 429) retryAfterMs = 1000UL * (unsigned long)max(10L, http.header("Retry-After").toInt());
http.end();
return code;
}
void setup() {
Serial.begin(115200);
pinMode(PIR_PIN, INPUT); // the HC-SR501 drives its output high and low itself
pinMode(LED_R, OUTPUT);
pinMode(LED_G, OUTPUT);
pinMode(LED_B, OUTPUT);
setColor(0, 0, 80);
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) delay(250);
Serial.printf("wifi ok %s\n", WiFi.localIP().toString().c_str());
}
void loop() {
const unsigned long now = millis();
// Debounce: the PIR output has to hold a new level for 200 ms.
const bool raw = digitalRead(PIR_PIN) == HIGH;
static bool lastRaw = false;
static unsigned long rawSince = 0;
if (raw != lastRaw) { lastRaw = raw; rawSince = now; }
if (raw != motion && now - rawSince >= 200) {
motion = raw;
changedAt = now;
Serial.printf("motion = %d\n", motion ? 1 : 0);
}
const bool warming = now < WARMUP_MS;
if (warming) setColor(0, (now / 300) % 2 ? 60 : 0, 0);
else if (trouble) setColor(0, 0, 160);
else if (motion) setColor(200, 0, 0);
else setColor(0, 60, 0);
if (warming) return;
if (WiFi.status() != WL_CONNECTED) {
trouble = true;
static unsigned long lastTry = 0;
if (now - lastTry > 10000) { lastTry = now; WiFi.reconnect(); }
return;
}
const bool changed = (int)motion != sentState;
const bool heartbeat = now - lastPost >= HEARTBEAT_MS;
const unsigned long gap = retryAfterMs ? retryAfterMs : MIN_GAP_MS;
if ((changed || heartbeat) && (lastPost == 0 || now - lastPost >= gap)) {
lastPost = now;
retryAfterMs = 0;
const int code = postMotion(motion);
trouble = code < 200 || code >= 300;
if (!trouble) sentState = motion;
Serial.printf("posted %s %s\n", motion ? "motion" : "quiet", trouble ? "FAILED" : "ok");
if (trouble) Serial.printf("ProbeHarbor answered HTTP %d\n", code);
}
}
Wiring
| From | To board | Note |
|---|---|---|
| PIR VCC | 5V | The HC-SR501 needs 4.5 V or more; it has its own 3.3 V regulator |
| PIR OUT | GPIO 4 | Its output is 3.3 V when high, safe for the ESP32 as it is |
| PIR GND | GND | |
| RGB red | GPIO 5 | Through 220 Ω |
| RGB green | GPIO 6 | Through 220 Ω |
| RGB blue | GPIO 7 | Through 220 Ω |
| RGB common leg (the longest) | GND | For a common-anode LED: to 3V3, and set COMMON_ANODE to true |
Tested on this board
Not run on this board yet. Treat the wiring and code as a first draft.
Firmware, Arduino sketch for ESP32C3 Dev Module
// PIR motion to ProbeHarbor: an HC-SR501 reports motion to a ProbeHarbor push
// device, and an RGB LED shows what is happening.
// Green: quiet. Red: motion. Blue: Wi-Fi or ProbeHarbor trouble. Green blinking: PIR warming up.
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
// ---- Your settings ----
const char* WIFI_SSID = "your-wifi";
const char* WIFI_PASS = "your-password";
// ProbeHarbor: Dashboard, Devices, add a push device, copy its ingest address.
const char* INGEST_URL = "https://probeharbor.dev/api/ingest/YOUR_DEVICE_KEY";
// ProbeHarbor reads a true/false value whose name contains "motion" as a motion sensor.
const char* SENSOR_KEY = "motion1";
const unsigned long HEARTBEAT_MS = 5UL * 60UL * 1000UL; // re-send the state every 5 minutes
const unsigned long MIN_GAP_MS = 10UL * 1000UL; // never post more often than this
const unsigned long WARMUP_MS = 60UL * 1000UL; // the HC-SR501 settles for about a minute
// ---- Pins ----
const int PIR_PIN = 4;
const int LED_R = 5, LED_G = 6, LED_B = 7;
const bool COMMON_ANODE = false; // true if the LED's long leg goes to 3V3 instead of GND
// Root certificates for probeharbor.dev: Google Trust Services R1 and R4, and
// Let's Encrypt ISRG X1 and X2. Cloudflare issues the site's certificate from one of these.
const char* ROOT_CAS = R"PEM(
-----BEGIN CERTIFICATE-----
MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQsw
CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU
MBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw
MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp
Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIiMA0GCSqGSIb3DQEBAQUA
A4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx9vaMf/vo
27xqLpeXo4xL+Sv2sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vXmX7w
Cl7raKb0xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g4M/SCYe7zUjw
TcLCeoiKu7rPWRnWr4+wB7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0Pfybl
qAj+lug8aJRT7oM6iCsVlgmy4HqMLnXWnOunVmSPlk9orj2XwoSPwLxAwAtcvfaH
szVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly4cpk9+aCEI3oncKKiPo4Zor8
Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr06zqkUspzBmk
MiVOKvFlRNACzqrOSbTqn3yDsEB750Orp2yjj32JgfpMpf/VjsPOS+C12LOORc92
wO1AK/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYWk70p
aDPvOmbsB4om3xPXV2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+DVrN
VjzRlwW5y0vtOUucxD/SVRNuJLDWcfr0wbrM7Rv1/oFB2ACYPTrIrnqYNxgFlQID
AQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E
FgQU5K8rJnEaK0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEMBQADggIBAJ+qQibb
C5u+/x6Wki4+omVKapi6Ist9wTrYggoGxval3sBOh2Z5ofmmWJyq+bXmYOfg6LEe
QkEzCzc9zolwFcq1JKjPa7XSQCGYzyI0zzvFIoTgxQ6KfF2I5DUkzps+GlQebtuy
h6f88/qBVRRiClmpIgUxPoLW7ttXNLwzldMXG+gnoot7TiYaelpkttGsN/H9oPM4
7HLwEXWdyzRSjeZ2axfG34arJ45JK3VmgRAhpuo+9K4l/3wV3s6MJT/KYnAK9y8J
ZgfIPxz88NtFMN9iiMG1D53Dn0reWVlHxYciNuaCp+0KueIHoI17eko8cdLiA6Ef
MgfdG+RCzgwARWGAtQsgWSl4vflVy2PFPEz0tv/bal8xa5meLMFrUKTX5hgUvYU/
Z6tGn6D/Qqc6f1zLXbBwHSs09dR2CQzreExZBfMzQsNhFRAbd03OIozUhfJFfbdT
6u9AWpQKXCBfTkBdYiJ23//OYb2MI3jSNwLgjt7RETeJ9r/tSQdirpLsQBqvFAnZ
0E6yove+7u7Y/9waLd64NnHi/Hm3lCXRSHNboTXns5lndcEZOitHTtNCjv0xyBZm
2tIMPNuzjsmhDYAPexZ3FL//2wmUspO8IFgV6dtxQ/PeEMMA3KgqlbbC1j+Qa3bb
bP6MvPJwNQzcmRk13NfIRmPVNnGuV/u3gm3c
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYD
VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG
A1UEAxMLR1RTIFJvb3QgUjQwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw
WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz
IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcqhkjOPQIBBgUrgQQAIgNi
AATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa6zzuhXyi
QHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/lxKvR
HYqjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW
BBSATNbrdP9JNqPV2Py1PsVq8JQdjDAKBggqhkjOPQQDAwNpADBmAjEA6ED/g94D
9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/Cr8deVl5c1RxYIigL9zC2L7F8AjEA8GE8
p/SgguMh1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh4rsUecrNIdSUtUlD
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw
CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg
R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00
MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT
ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw
EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW
+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9
ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T
AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI
zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW
tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1
/q4AaOeMSQ+2b1tbFfLn
-----END CERTIFICATE-----
)PEM";
bool motion = false; // debounced PIR state
int sentState = -1; // last state ProbeHarbor accepted
unsigned long lastPost = 0, changedAt = 0, retryAfterMs = 0;
bool trouble = false;
void setColor(uint8_t r, uint8_t g, uint8_t b) {
if (COMMON_ANODE) { r = 255 - r; g = 255 - g; b = 255 - b; }
analogWrite(LED_R, r);
analogWrite(LED_G, g);
analogWrite(LED_B, b);
}
// Returns the HTTP status, or a negative number if the request never got an answer.
int postMotion(bool on) {
WiFiClientSecure tls;
tls.setCACert(ROOT_CAS);
HTTPClient http;
const char* keep[] = {"Retry-After"};
http.collectHeaders(keep, 1);
if (!http.begin(tls, INGEST_URL)) return -1;
http.addHeader("Content-Type", "application/json");
char body[64];
snprintf(body, sizeof(body), "{\"%s\":%s}", SENSOR_KEY, on ? "true" : "false");
const int code = http.POST(body);
if (code == 429) retryAfterMs = 1000UL * (unsigned long)max(10L, http.header("Retry-After").toInt());
http.end();
return code;
}
void setup() {
Serial.begin(115200);
pinMode(PIR_PIN, INPUT); // the HC-SR501 drives its output high and low itself
pinMode(LED_R, OUTPUT);
pinMode(LED_G, OUTPUT);
pinMode(LED_B, OUTPUT);
setColor(0, 0, 80);
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) delay(250);
Serial.printf("wifi ok %s\n", WiFi.localIP().toString().c_str());
}
void loop() {
const unsigned long now = millis();
// Debounce: the PIR output has to hold a new level for 200 ms.
const bool raw = digitalRead(PIR_PIN) == HIGH;
static bool lastRaw = false;
static unsigned long rawSince = 0;
if (raw != lastRaw) { lastRaw = raw; rawSince = now; }
if (raw != motion && now - rawSince >= 200) {
motion = raw;
changedAt = now;
Serial.printf("motion = %d\n", motion ? 1 : 0);
}
const bool warming = now < WARMUP_MS;
if (warming) setColor(0, (now / 300) % 2 ? 60 : 0, 0);
else if (trouble) setColor(0, 0, 160);
else if (motion) setColor(200, 0, 0);
else setColor(0, 60, 0);
if (warming) return;
if (WiFi.status() != WL_CONNECTED) {
trouble = true;
static unsigned long lastTry = 0;
if (now - lastTry > 10000) { lastTry = now; WiFi.reconnect(); }
return;
}
const bool changed = (int)motion != sentState;
const bool heartbeat = now - lastPost >= HEARTBEAT_MS;
const unsigned long gap = retryAfterMs ? retryAfterMs : MIN_GAP_MS;
if ((changed || heartbeat) && (lastPost == 0 || now - lastPost >= gap)) {
lastPost = now;
retryAfterMs = 0;
const int code = postMotion(motion);
trouble = code < 200 || code >= 300;
if (!trouble) sentState = motion;
Serial.printf("posted %s %s\n", motion ? "motion" : "quiet", trouble ? "FAILED" : "ok");
if (trouble) Serial.printf("ProbeHarbor answered HTTP %d\n", code);
}
}
Parts
| Quantity | Part | Where to buy |
|---|---|---|
| 1× | ESP32 dev board | Buy at Amazon |
| 1× | HC-SR501 PIR motion sensor | |
| 1× | RGB LED, common cathode or common anode (or three single LEDs) | |
| 3× | 220 Ω resistors, one per LED colour | |
| 8× | Jumper wires |
Amazon buy links may include an Associates tag, and this site may earn a commission from qualifying Amazon purchases at no extra cost to you. Adafruit links are direct.
Libraries
WiFi (built in), HTTPClient (built in)
Load it
- In the Arduino IDE, install the esp32 board package and pick the board named above the sketch.
- Paste the sketch, choose the board's port and upload. Hold BOOT if the upload stalls at Connecting.
- Close the IDE's serial monitor, then open the bench here to watch the output.
Field notes
- In ProbeHarbor, add a push device and paste its ingest address into INGEST_URL. The sketch sends {"motion1": true} or false: ProbeHarbor files a true/false value whose name contains motion as a motion sensor, shown as MOTION or CLEAR.
- It posts when motion starts and stops, and again every 5 minutes so ProbeHarbor knows the sensor is alive. Posts are at least 10 seconds apart, well inside ProbeHarbor's 60 a minute, and if ProbeHarbor answers 429 it waits as long as asked.
- The HC-SR501 has two orange trimmers. Sx sets sensitivity, roughly 3 to 7 metres. Tx sets how long the output stays high after motion, from about 3 seconds to about 5 minutes: turn it fully anticlockwise for the shortest time, so ProbeHarbor sees each visit separately.
- Set the jumper to H (repeat trigger): the output then stays high as long as something keeps moving, instead of dropping out mid-visit. L gives one fixed pulse per trigger.
- After power-up the sensor needs about a minute to settle and may trigger on its own. The LED blinks green during that minute and nothing is sent.
- Which leg is common? On most 5 mm RGB LEDs it is the longest. If green shows as everything-but-green, you have a common-anode LED: set COMMON_ANODE to true and move the common leg to 3V3.
- PIR sensors see warm moving bodies, so heaters, sunlight on a wall or a draught from a vent can trigger them. Point it away from windows and heat sources.
Not tested yet
- Not tested on hardware or with ProbeHarbor yet. Compiles for every board listed with Arduino-ESP32 core 3.3.12.
Community builds
Loading build reports from people who made this.
Built it? Send a build report with your board and what the serial port printed. Working reports become the "Tested on this board" entries above.