feat: Add restart button functionality and motion calibration
- Implemented a restart button with debounce handling and long-press detection in App.cpp. - Added motion calibration settings to Settings.cpp and Settings.h, allowing for roll and pitch zero offsets. - Enhanced WebUI to include motion calibration controls and a restart option. - Updated FaceRenderer to adjust eye and mouth positions based on screen dimensions. - Introduced deadzone handling for motion sensor readings to improve stability.
This commit is contained in:
@@ -42,6 +42,12 @@ static unsigned long nextTimeCheckMs = 0;
|
||||
static bool lastScheduleSleep = false;
|
||||
static String lastConfiguredTz;
|
||||
static unsigned long motionWakeUntilMs = 0;
|
||||
static bool restartButtonEnabled = true;
|
||||
static bool restartButtonLastRaw = true;
|
||||
static bool restartButtonStable = true;
|
||||
static unsigned long restartButtonLastEdgeMs = 0;
|
||||
static unsigned long restartButtonPressedSinceMs = 0;
|
||||
static bool restartButtonArmed = true;
|
||||
|
||||
static constexpr float DIM_LUX_MIN = 0.0f;
|
||||
static constexpr float DIM_LUX_MAX = 300.0f;
|
||||
@@ -49,6 +55,9 @@ static constexpr uint8_t DIM_CONTRAST_MIN = 0x10;
|
||||
static constexpr uint8_t DIM_CONTRAST_MAX = 0xFF;
|
||||
static constexpr int ROUTINE_WINDOW_MIN = 5;
|
||||
static constexpr unsigned long MOTION_WAKE_MS = 30000UL;
|
||||
static constexpr int PIN_RESTART_BUTTON = 2; // Active-low button to GND
|
||||
static constexpr unsigned long BUTTON_DEBOUNCE_MS = 30UL;
|
||||
static constexpr unsigned long BUTTON_HOLD_RESTART_MS = 1200UL;
|
||||
|
||||
static PlantEventType moodToEvent(FaceRenderer::Mood m) {
|
||||
if (m == FaceRenderer::DRY) return EVT_DRY;
|
||||
@@ -72,6 +81,56 @@ static uint8_t luxToContrast(float lux) {
|
||||
return (uint8_t)value;
|
||||
}
|
||||
|
||||
static void initRestartButton() {
|
||||
pinMode(PIN_RESTART_BUTTON, INPUT_PULLUP);
|
||||
bool raw = digitalRead(PIN_RESTART_BUTTON);
|
||||
restartButtonLastRaw = raw;
|
||||
restartButtonStable = raw;
|
||||
restartButtonLastEdgeMs = millis();
|
||||
restartButtonPressedSinceMs = 0;
|
||||
restartButtonArmed = true;
|
||||
|
||||
Serial.print("[Button] Restart button on GPIO ");
|
||||
Serial.print(PIN_RESTART_BUTTON);
|
||||
Serial.print(" (active-low), initial=");
|
||||
Serial.println(raw ? "released" : "pressed");
|
||||
}
|
||||
|
||||
static void handleRestartButton() {
|
||||
if (!restartButtonEnabled) return;
|
||||
|
||||
unsigned long now = millis();
|
||||
bool raw = digitalRead(PIN_RESTART_BUTTON);
|
||||
|
||||
if (raw != restartButtonLastRaw) {
|
||||
restartButtonLastRaw = raw;
|
||||
restartButtonLastEdgeMs = now;
|
||||
}
|
||||
|
||||
if ((now - restartButtonLastEdgeMs) >= BUTTON_DEBOUNCE_MS && restartButtonStable != raw) {
|
||||
restartButtonStable = raw;
|
||||
bool pressed = !restartButtonStable; // active-low
|
||||
|
||||
if (pressed) {
|
||||
restartButtonPressedSinceMs = now;
|
||||
Serial.println("[Button] Restart button pressed");
|
||||
} else {
|
||||
Serial.println("[Button] Restart button released");
|
||||
restartButtonPressedSinceMs = 0;
|
||||
restartButtonArmed = true;
|
||||
}
|
||||
}
|
||||
|
||||
bool pressed = !restartButtonStable;
|
||||
if (pressed && restartButtonArmed && restartButtonPressedSinceMs != 0 &&
|
||||
(now - restartButtonPressedSinceMs) >= BUTTON_HOLD_RESTART_MS) {
|
||||
restartButtonArmed = false;
|
||||
Serial.println("[Button] Restart hold detected - rebooting");
|
||||
delay(50);
|
||||
ESP.restart();
|
||||
}
|
||||
}
|
||||
|
||||
struct ScheduleState {
|
||||
bool hasTime = false;
|
||||
bool sleeping = false;
|
||||
@@ -234,6 +293,7 @@ void App::setup() {
|
||||
|
||||
display.begin();
|
||||
display.showStatus("FacePlant", "Starting...");
|
||||
initRestartButton();
|
||||
|
||||
wifi.begin(settings, forceSetup);
|
||||
|
||||
@@ -245,7 +305,12 @@ void App::setup() {
|
||||
|
||||
webhook.begin(settings);
|
||||
|
||||
web.begin(settings, wifi, moisture, face, webhook, bootMs);
|
||||
{
|
||||
MotionCalibration mc = settings.motionCalibration();
|
||||
motion.setZeroOffsets(mc.rollZeroDeg, mc.pitchZeroDeg);
|
||||
}
|
||||
|
||||
web.begin(settings, wifi, moisture, motion, face, webhook, bootMs);
|
||||
ota.begin(web.server(), &display);
|
||||
|
||||
lastMood = face.mood();
|
||||
@@ -259,6 +324,7 @@ void App::loop() {
|
||||
static unsigned long lastDisplayUpdate = 0;
|
||||
|
||||
BootTrigger::clearAfterStableUptime();
|
||||
handleRestartButton();
|
||||
|
||||
wifi.loop();
|
||||
web.loop();
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
void WebUI::begin(Settings& settings,
|
||||
WiFiManager& wifi,
|
||||
MoistureSensor& moisture,
|
||||
MotionSensor& motion,
|
||||
FaceRenderer& face,
|
||||
WebhookService& webhook,
|
||||
unsigned long bootMs) {
|
||||
@@ -23,6 +24,7 @@ void WebUI::begin(Settings& settings,
|
||||
String timezone = settings.timezone();
|
||||
String bedtime = settings.bedtime();
|
||||
String wakeTime = settings.wakeTime();
|
||||
MotionCalibration mc = settings.motionCalibration();
|
||||
|
||||
String page =
|
||||
"<!DOCTYPE html><html><head>"
|
||||
@@ -157,6 +159,26 @@ void WebUI::begin(Settings& settings,
|
||||
"</form>"
|
||||
"</div>"
|
||||
"<div class='card'>"
|
||||
"<h2><i class='fas fa-compass' style='margin-right: 10px;'></i>Accelerometer Calibration</h2>"
|
||||
"<div style='background: #f5f5f7; padding: 15px; border-radius: 12px; margin-bottom: 20px;'>"
|
||||
"<div style='font-size: 0.85em; color: #86868b; margin-bottom: 5px;'>Current Tilt</div>"
|
||||
"<div style='font-size: 1em; color: #1d1d1f;'>Roll: " + String(motion.rollDeg(), 1) + "°</div>"
|
||||
"<div style='font-size: 1em; color: #1d1d1f;'>Pitch: " + String(motion.pitchDeg(), 1) + "°</div>"
|
||||
"<div style='font-size: 0.85em; color: #86868b; margin-top: 8px;'>"
|
||||
"Neutral offsets: roll " + String(mc.rollZeroDeg, 1) + "°, pitch " + String(mc.pitchZeroDeg, 1) + "°</div>"
|
||||
"</div>"
|
||||
"<form method='POST' action='/motion-calibrate' style='margin-bottom: 12px;'>"
|
||||
"<div class='btn-group'>"
|
||||
"<button type='submit' name='action' value='zero_now' class='btn btn-primary'>Set Current Position as Neutral</button>"
|
||||
"</div>"
|
||||
"</form>"
|
||||
"<form method='POST' action='/motion-calibrate'>"
|
||||
"<div class='btn-group'>"
|
||||
"<button type='submit' name='action' value='reset' class='btn btn-secondary'>Reset Accelerometer Calibration</button>"
|
||||
"</div>"
|
||||
"</form>"
|
||||
"</div>"
|
||||
"<div class='card'>"
|
||||
"<h2><i class='fas fa-sliders-h' style='margin-right: 10px;'></i>Sensor Calibration</h2>"
|
||||
"<div style='background: #f5f5f7; padding: 15px; border-radius: 12px; margin-bottom: 20px;'>"
|
||||
"<div style='font-size: 0.85em; color: #86868b; margin-bottom: 5px;'>Current Reading</div>"
|
||||
@@ -184,6 +206,12 @@ void WebUI::begin(Settings& settings,
|
||||
"<div class='card' style='border: 2px solid #ff3b30;'>"
|
||||
"<h2 style='color: #ff3b30;'><i class='fas fa-exclamation-triangle' style='margin-right: 10px;'></i>Danger Zone</h2>"
|
||||
"<p style='color: #86868b; margin-bottom: 15px;'>Irreversible actions that will reset your device</p>"
|
||||
"<form method='POST' action='/restart' id='restartForm' style='margin-bottom: 20px;'>"
|
||||
"<p style='margin-bottom: 15px;'><strong>Restart Device</strong></p>"
|
||||
"<p style='color: #86868b; font-size: 0.9em; margin-bottom: 15px;'>"
|
||||
"Reboots FacePlant without erasing settings.</p>"
|
||||
"<button type='submit' class='btn btn-secondary' style='width: 100%;'>Restart</button>"
|
||||
"</form>"
|
||||
"<form method='POST' action='/factory-reset' id='resetForm'>"
|
||||
"<p style='margin-bottom: 15px;'><strong>Factory Reset</strong></p>"
|
||||
"<p style='color: #86868b; font-size: 0.9em; margin-bottom: 15px;'>"
|
||||
@@ -210,6 +238,12 @@ void WebUI::begin(Settings& settings,
|
||||
" }"
|
||||
" }"
|
||||
"});"
|
||||
"document.getElementById('restartForm').addEventListener('submit', function(e) {"
|
||||
" e.preventDefault();"
|
||||
" if (confirm('Restart FacePlant now?')) {"
|
||||
" this.submit();"
|
||||
" }"
|
||||
"});"
|
||||
"document.getElementById('resetForm').addEventListener('submit', function(e) {"
|
||||
" e.preventDefault();"
|
||||
" if (confirm('Are you sure you want to reset to factory defaults? This cannot be undone.')) {"
|
||||
@@ -283,6 +317,28 @@ void WebUI::begin(Settings& settings,
|
||||
_server.send(303);
|
||||
});
|
||||
|
||||
_server.on("/motion-calibrate", HTTP_POST, [&]() {
|
||||
String action = _server.hasArg("action") ? _server.arg("action") : "zero_now";
|
||||
if (action == "reset") {
|
||||
settings.clearMotionCalibration();
|
||||
motion.setZeroOffsets(0.0f, 0.0f);
|
||||
Serial.println("[WebUI] Accelerometer calibration reset");
|
||||
} else {
|
||||
if (motion.available()) {
|
||||
settings.setMotionCalibration(motion.rawRollDeg(), motion.rawPitchDeg());
|
||||
motion.setZeroOffsets(motion.rawRollDeg(), motion.rawPitchDeg());
|
||||
Serial.print("[WebUI] Accelerometer neutral set from current position: roll=");
|
||||
Serial.print(motion.rawRollDeg(), 2);
|
||||
Serial.print(" pitch=");
|
||||
Serial.println(motion.rawPitchDeg(), 2);
|
||||
} else {
|
||||
Serial.println("[WebUI] Accelerometer calibration requested but MPU6050 unavailable");
|
||||
}
|
||||
}
|
||||
_server.sendHeader("Location", "/");
|
||||
_server.send(303);
|
||||
});
|
||||
|
||||
_server.on("/factory-reset", HTTP_POST, [&]() {
|
||||
Serial.println("[WebUI] Factory reset requested");
|
||||
_server.send(200, "text/html",
|
||||
@@ -311,6 +367,30 @@ void WebUI::begin(Settings& settings,
|
||||
ESP.restart();
|
||||
});
|
||||
|
||||
_server.on("/restart", HTTP_POST, [&]() {
|
||||
Serial.println("[WebUI] Restart requested");
|
||||
_server.send(200, "text/html",
|
||||
"<!DOCTYPE html><html><head><meta http-equiv='refresh' content='8;url=/'>"
|
||||
"<link rel='stylesheet' href='https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css'>"
|
||||
"<style>body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;"
|
||||
"background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);min-height:100vh;display:flex;"
|
||||
"align-items:center;justify-content:center;margin:0;padding:20px;}"
|
||||
".card{background:rgba(255,255,255,0.95);border-radius:20px;padding:40px;text-align:center;"
|
||||
"box-shadow:0 20px 60px rgba(0,0,0,0.3);max-width:400px;}"
|
||||
"h1{font-size:2em;margin-bottom:10px;color:#1d1d1f;}"
|
||||
"p{color:#86868b;margin:10px 0;}"
|
||||
".spinner{font-size:3em;color:#667eea;margin:20px 0;animation:spin 2s linear infinite;}"
|
||||
"@keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}</style></head><body>"
|
||||
"<div class='card'>"
|
||||
"<div class='spinner'><i class='fas fa-sync-alt'></i></div>"
|
||||
"<h1>Restarting</h1>"
|
||||
"<p>FacePlant is rebooting...</p>"
|
||||
"<p>Settings are preserved.</p>"
|
||||
"</div></body></html>");
|
||||
delay(500);
|
||||
ESP.restart();
|
||||
});
|
||||
|
||||
_server.on("/status", HTTP_GET, [&]() {
|
||||
JsonDocument doc;
|
||||
doc["device"] = "FacePlant";
|
||||
@@ -327,6 +407,11 @@ void WebUI::begin(Settings& settings,
|
||||
doc["timezone"] = settings.timezone();
|
||||
doc["bedtime"] = settings.bedtime();
|
||||
doc["wake_time"] = settings.wakeTime();
|
||||
doc["motion_ok"] = motion.available();
|
||||
doc["motion_roll_deg"] = motion.rollDeg();
|
||||
doc["motion_pitch_deg"] = motion.pitchDeg();
|
||||
doc["motion_roll_zero_deg"] = motion.rollZeroDeg();
|
||||
doc["motion_pitch_zero_deg"] = motion.pitchZeroDeg();
|
||||
doc["dead_mode"] = face.isDeadMode();
|
||||
doc["uptime_ms"] = millis() - bootMs;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "../settings/Settings.h"
|
||||
#include "../net/WiFiManager.h"
|
||||
#include "../sensors/MoistureSensor.h"
|
||||
#include "../sensors/MotionSensor.h"
|
||||
#include "../ui/FaceRenderer.h"
|
||||
#include "../net/WebhookService.h"
|
||||
|
||||
@@ -11,6 +12,7 @@ public:
|
||||
void begin(Settings& settings,
|
||||
WiFiManager& wifi,
|
||||
MoistureSensor& moisture,
|
||||
MotionSensor& motion,
|
||||
FaceRenderer& face,
|
||||
WebhookService& webhook,
|
||||
unsigned long bootMs);
|
||||
@@ -20,4 +22,4 @@ public:
|
||||
|
||||
private:
|
||||
WebServer _server{80};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
#include <math.h>
|
||||
|
||||
static constexpr float GRAVITY_MS2 = 9.80665f;
|
||||
static constexpr float ROLL_DEADZONE_DEG = 6.0f;
|
||||
static constexpr float PITCH_DEADZONE_DEG = 6.0f;
|
||||
|
||||
static float applyDeadzone(float value, float deadzone) {
|
||||
if (fabsf(value) <= deadzone) return 0.0f;
|
||||
return value > 0.0f ? (value - deadzone) : (value + deadzone);
|
||||
}
|
||||
|
||||
float MotionSensor::clampf(float v, float lo, float hi) {
|
||||
if (v < lo) return lo;
|
||||
@@ -71,15 +78,26 @@ bool MotionSensor::consumePickupEvent() {
|
||||
return out;
|
||||
}
|
||||
|
||||
void MotionSensor::setZeroOffsets(float rollZeroDeg, float pitchZeroDeg) {
|
||||
_rollZeroDeg = rollZeroDeg;
|
||||
_pitchZeroDeg = pitchZeroDeg;
|
||||
Serial.print("[Motion] Calibration set: roll0=");
|
||||
Serial.print(_rollZeroDeg, 2);
|
||||
Serial.print(" pitch0=");
|
||||
Serial.println(_pitchZeroDeg, 2);
|
||||
}
|
||||
|
||||
int8_t MotionSensor::eyeOffsetX() const {
|
||||
if (!_ok) return 0;
|
||||
float roll = clampf(_rollDeg, -40.0f, 40.0f);
|
||||
float roll = applyDeadzone(rollDeg(), ROLL_DEADZONE_DEG);
|
||||
roll = clampf(roll, -40.0f, 40.0f);
|
||||
return (int8_t)lroundf((roll / 40.0f) * 7.0f);
|
||||
}
|
||||
|
||||
int8_t MotionSensor::pupilSizeDelta() const {
|
||||
if (!_ok) return 0;
|
||||
// Positive pitch is treated as forward tilt (bigger pupils).
|
||||
float pitch = clampf(_pitchDeg, -35.0f, 35.0f);
|
||||
float pitch = applyDeadzone(pitchDeg(), PITCH_DEADZONE_DEG);
|
||||
pitch = clampf(pitch, -35.0f, 35.0f);
|
||||
return (int8_t)lroundf((pitch / 35.0f) * 4.0f); // -4..+4 (used for whole-eye scaling now)
|
||||
}
|
||||
|
||||
@@ -13,8 +13,13 @@ public:
|
||||
bool isMoving() const;
|
||||
bool consumePickupEvent();
|
||||
|
||||
float rollDeg() const { return _rollDeg; } // left/right tilt
|
||||
float pitchDeg() const { return _pitchDeg; } // forward/back tilt
|
||||
float rollDeg() const { return _rollDeg - _rollZeroDeg; } // corrected left/right tilt
|
||||
float pitchDeg() const { return _pitchDeg - _pitchZeroDeg; } // corrected forward/back tilt
|
||||
float rawRollDeg() const { return _rollDeg; }
|
||||
float rawPitchDeg() const { return _pitchDeg; }
|
||||
void setZeroOffsets(float rollZeroDeg, float pitchZeroDeg);
|
||||
float rollZeroDeg() const { return _rollZeroDeg; }
|
||||
float pitchZeroDeg() const { return _pitchZeroDeg; }
|
||||
|
||||
int8_t eyeOffsetX() const;
|
||||
int8_t pupilSizeDelta() const;
|
||||
@@ -35,6 +40,8 @@ private:
|
||||
|
||||
unsigned long _movingUntilMs = 0;
|
||||
bool _pickupLatched = false;
|
||||
float _rollZeroDeg = 0.0f;
|
||||
float _pitchZeroDeg = 0.0f;
|
||||
|
||||
static float clampf(float v, float lo, float hi);
|
||||
};
|
||||
|
||||
@@ -83,6 +83,23 @@ void Settings::setWakeTime(const String& hhmm) {
|
||||
_prefs.putString("wake", isValidHHMM(hhmm) ? hhmm : "07:00");
|
||||
}
|
||||
|
||||
MotionCalibration Settings::motionCalibration() const {
|
||||
MotionCalibration c{};
|
||||
c.rollZeroDeg = _prefs.getFloat("accel_r0", 0.0f);
|
||||
c.pitchZeroDeg = _prefs.getFloat("accel_p0", 0.0f);
|
||||
return c;
|
||||
}
|
||||
|
||||
void Settings::setMotionCalibration(float rollZeroDeg, float pitchZeroDeg) {
|
||||
_prefs.putFloat("accel_r0", rollZeroDeg);
|
||||
_prefs.putFloat("accel_p0", pitchZeroDeg);
|
||||
}
|
||||
|
||||
void Settings::clearMotionCalibration() {
|
||||
_prefs.remove("accel_r0");
|
||||
_prefs.remove("accel_p0");
|
||||
}
|
||||
|
||||
void Settings::factoryReset() {
|
||||
Serial.println("[Settings] Factory reset - clearing all settings");
|
||||
_prefs.clear();
|
||||
|
||||
@@ -8,6 +8,11 @@ struct PlantThresholds {
|
||||
int tooWetPct;
|
||||
};
|
||||
|
||||
struct MotionCalibration {
|
||||
float rollZeroDeg;
|
||||
float pitchZeroDeg;
|
||||
};
|
||||
|
||||
class Settings {
|
||||
public:
|
||||
void begin();
|
||||
@@ -51,6 +56,11 @@ public:
|
||||
String wakeTime() const; // "HH:MM"
|
||||
void setWakeTime(const String& hhmm);
|
||||
|
||||
// Accelerometer calibration (neutral orientation offsets)
|
||||
MotionCalibration motionCalibration() const;
|
||||
void setMotionCalibration(float rollZeroDeg, float pitchZeroDeg);
|
||||
void clearMotionCalibration();
|
||||
|
||||
// Factory reset
|
||||
void factoryReset();
|
||||
|
||||
|
||||
@@ -160,9 +160,10 @@ void Display::drawBatteryIcon(int x, int y, int percent, bool blink, bool chargi
|
||||
if (charging) {
|
||||
// Draw animated lightning bolt when charging
|
||||
bool phase = (millis() / 300) % 2 == 0;
|
||||
int color = phase ? 1 : 0; // Blink between white and inverted
|
||||
bool invertBolt = (percent >= 50);
|
||||
int color = invertBolt ? 0 : (phase ? 1 : 0);
|
||||
|
||||
// Lightning bolt shape (simple zigzag)
|
||||
// Lightning bolt shape (simple zigzag). Above 50%, keep it black in the center.
|
||||
_oled.drawLine(x + 7, y + 1, x + 5, y + 3, color); // Top diagonal
|
||||
_oled.drawLine(x + 5, y + 3, x + 9, y + 3, color); // Middle horizontal
|
||||
_oled.drawLine(x + 9, y + 3, x + 7, y + 5, color); // Bottom diagonal
|
||||
|
||||
@@ -16,6 +16,40 @@ int8_t FaceRenderer::randRangeI8(int8_t lo, int8_t hi) {
|
||||
return (int8_t)(lo + random((int)(hi - lo + 1)));
|
||||
}
|
||||
|
||||
int FaceRenderer::batteryIconX() const {
|
||||
if (!_display) return 110;
|
||||
int x = _display->oled().width() - 18; // 16px icon + 2px margin
|
||||
return x < 0 ? 0 : x;
|
||||
}
|
||||
|
||||
int FaceRenderer::screenW() const {
|
||||
return _display ? _display->oled().width() : 128;
|
||||
}
|
||||
|
||||
int FaceRenderer::screenH() const {
|
||||
return _display ? _display->oled().height() : 64;
|
||||
}
|
||||
|
||||
int FaceRenderer::eyeCy() const {
|
||||
return screenH() * 3 / 8; // ~24 on 64px tall displays
|
||||
}
|
||||
|
||||
int FaceRenderer::leftEyeCxBase() const {
|
||||
return screenW() * 5 / 16; // 40 on 128px
|
||||
}
|
||||
|
||||
int FaceRenderer::rightEyeCxBase() const {
|
||||
return screenW() * 11 / 16; // 88 on 128px
|
||||
}
|
||||
|
||||
int FaceRenderer::mouthCx() const {
|
||||
return screenW() / 2;
|
||||
}
|
||||
|
||||
int FaceRenderer::mouthY() const {
|
||||
return screenH() * 3 / 4; // ~48 on 64px
|
||||
}
|
||||
|
||||
void FaceRenderer::setRoutineAnimation(RoutineAnim anim, uint16_t progressPermille) {
|
||||
_routineAnim = anim;
|
||||
_routineProgressPermille = progressPermille > 1000 ? 1000 : progressPermille;
|
||||
@@ -42,6 +76,7 @@ void FaceRenderer::begin(Display& display, Settings& settings) {
|
||||
_nextBlinkMs = now + randRange(3000, 9000);
|
||||
_nextSillyMs = now + randRange(20000, 60000);
|
||||
_nextGazeMs = now + randRange(800, 2000);
|
||||
_nextTooWetMouthEventMs = now + randRange(45000, 90000);
|
||||
}
|
||||
|
||||
void FaceRenderer::loop(const MoistureSensor& moisture, const BatterySensor& battery) {
|
||||
@@ -159,6 +194,11 @@ void FaceRenderer::updateTooWet(unsigned long now) {
|
||||
_lastBlinkMs = now;
|
||||
}
|
||||
if (_blinking && (long)(now - _blinkUntilMs) >= 0) _blinking = false;
|
||||
|
||||
if ((long)(now - _nextTooWetMouthEventMs) >= 0) {
|
||||
_tooWetMouthOpenUntilMs = now + randRange(1200, 2200);
|
||||
_nextTooWetMouthEventMs = now + randRange(45000, 90000);
|
||||
}
|
||||
}
|
||||
|
||||
void FaceRenderer::renderDead(unsigned long now, const BatterySensor& battery) {
|
||||
@@ -171,18 +211,21 @@ void FaceRenderer::renderDead(unsigned long now, const BatterySensor& battery) {
|
||||
}
|
||||
|
||||
if (_deadShowTombstone) {
|
||||
d.drawRoundRect(38, 12, 52, 40, 8, 1);
|
||||
d.setCursor(52, 28);
|
||||
int w = 52, h = 40;
|
||||
int x = (screenW() - w) / 2;
|
||||
int y = (screenH() - h) / 2;
|
||||
d.drawRoundRect(x, y, w, h, 8, 1);
|
||||
d.setCursor(x + 14, y + 16);
|
||||
d.setTextSize(2);
|
||||
d.print("RIP");
|
||||
} else {
|
||||
drawXEye(40, 24);
|
||||
drawXEye(88, 24);
|
||||
drawXEye(leftEyeCxBase(), eyeCy());
|
||||
drawXEye(rightEyeCxBase(), eyeCy());
|
||||
drawMouthFlat();
|
||||
}
|
||||
|
||||
// Draw battery icon in top-right corner
|
||||
_display->drawBatteryIcon(110, 2, battery.percent(), battery.shouldBlink(), battery.isCharging());
|
||||
_display->drawBatteryIcon(batteryIconX(), 2, battery.percent(), battery.shouldBlink(), battery.isCharging());
|
||||
|
||||
d.display();
|
||||
}
|
||||
@@ -224,7 +267,7 @@ void FaceRenderer::renderBatteryLow(unsigned long now, const BatterySensor& batt
|
||||
drawMouthNervous();
|
||||
|
||||
// Draw battery icon in top-right corner (blinking)
|
||||
_display->drawBatteryIcon(110, 2, battery.percent(), true, battery.isCharging());
|
||||
_display->drawBatteryIcon(batteryIconX(), 2, battery.percent(), true, battery.isCharging());
|
||||
|
||||
d.display();
|
||||
}
|
||||
@@ -250,12 +293,15 @@ void FaceRenderer::renderNormal(unsigned long now, const BatterySensor& battery)
|
||||
else {
|
||||
if (_blinking) drawEyesClosed();
|
||||
else drawEyesOpen(0, 0, 4);
|
||||
drawMouthFlat();
|
||||
drawBubbles((now / 100) % 12 - 6);
|
||||
bool mouthOpen = ((long)(now - _tooWetMouthOpenUntilMs) < 0);
|
||||
if (mouthOpen) drawMouthSurprised();
|
||||
else drawMouthFlat();
|
||||
drawBubbles((now / 120) % 36);
|
||||
if (mouthOpen) drawMouthBubbles(now);
|
||||
}
|
||||
|
||||
// Draw battery icon in top-right corner
|
||||
_display->drawBatteryIcon(110, 2, battery.percent(), battery.shouldBlink(), battery.isCharging());
|
||||
_display->drawBatteryIcon(batteryIconX(), 2, battery.percent(), battery.shouldBlink(), battery.isCharging());
|
||||
|
||||
d.display();
|
||||
}
|
||||
@@ -268,44 +314,52 @@ void FaceRenderer::renderRoutine(unsigned long now, const BatterySensor& battery
|
||||
// Progressively lower eyelids over 5 minutes.
|
||||
int close = (_routineProgressPermille * 12) / 1000; // 0..12
|
||||
int pupilY = (_routineProgressPermille > 700) ? 2 : 0;
|
||||
int lcx = leftEyeCxBase();
|
||||
int rcx = rightEyeCxBase();
|
||||
int cy = eyeCy();
|
||||
|
||||
d.fillCircle(40, 24, 12, 1);
|
||||
d.fillCircle(88, 24, 12, 1);
|
||||
d.fillCircle(40, 24 + pupilY, 3, 0);
|
||||
d.fillCircle(88, 24 + pupilY, 3, 0);
|
||||
d.fillCircle(lcx, cy, 12, 1);
|
||||
d.fillCircle(rcx, cy, 12, 1);
|
||||
d.fillCircle(lcx, cy + pupilY, 3, 0);
|
||||
d.fillCircle(rcx, cy + pupilY, 3, 0);
|
||||
if (close > 0) {
|
||||
d.fillRect(28, 12, 24, close, 0);
|
||||
d.fillRect(76, 12, 24, close, 0);
|
||||
d.fillRect(lcx - 12, cy - 12, 24, close, 0);
|
||||
d.fillRect(rcx - 12, cy - 12, 24, close, 0);
|
||||
}
|
||||
if (close > 6) {
|
||||
d.drawLine(28, 24, 52, 24, 1);
|
||||
d.drawLine(76, 24, 100, 24, 1);
|
||||
d.drawLine(lcx - 12, cy, lcx + 12, cy, 1);
|
||||
d.drawLine(rcx - 12, cy, rcx + 12, cy, 1);
|
||||
}
|
||||
d.drawLine(26, 14, 48, 16, 1);
|
||||
d.drawLine(80, 16, 102, 14, 1);
|
||||
d.drawLine(52, 48, 76, 48, 1);
|
||||
d.drawLine(lcx - 14, cy - 10, lcx + 8, cy - 8, 1);
|
||||
d.drawLine(rcx - 8, cy - 8, rcx + 14, cy - 10, 1);
|
||||
d.drawLine(mouthCx() - 12, mouthY(), mouthCx() + 12, mouthY(), 1);
|
||||
drawSleepyZs(now, (uint8_t)(1 + ((_routineProgressPermille * 3) / 1000)));
|
||||
} else {
|
||||
// Wake-up: eyes open wider and smile grows over 5 minutes.
|
||||
int open = 4 + (_routineProgressPermille * 8) / 1000; // pupil travel
|
||||
int pupilY = 4 - (_routineProgressPermille * 4) / 1000; // starts sleepy, rises to center
|
||||
int lcx = leftEyeCxBase();
|
||||
int rcx = rightEyeCxBase();
|
||||
int cy = eyeCy();
|
||||
|
||||
d.fillCircle(40, 24, 12, 1);
|
||||
d.fillCircle(88, 24, 12, 1);
|
||||
d.fillCircle(40, 24 + pupilY, open / 3, 0);
|
||||
d.fillCircle(88, 24 + pupilY, open / 3, 0);
|
||||
d.fillCircle(lcx, cy, 12, 1);
|
||||
d.fillCircle(rcx, cy, 12, 1);
|
||||
d.fillCircle(lcx, cy + pupilY, open / 3, 0);
|
||||
d.fillCircle(rcx, cy + pupilY, open / 3, 0);
|
||||
|
||||
// Bright eyebrows + growing smile
|
||||
d.drawLine(26, 14, 48, 11, 1);
|
||||
d.drawLine(80, 11, 102, 14, 1);
|
||||
d.drawLine(lcx - 14, cy - 10, lcx + 8, cy - 13, 1);
|
||||
d.drawLine(rcx - 8, cy - 13, rcx + 14, cy - 10, 1);
|
||||
int smileLift = (_routineProgressPermille * 3) / 1000;
|
||||
int cx = mouthCx();
|
||||
int y = mouthY() - 1;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
d.drawLine(55 + i, 47 + i / 2 - smileLift, 73 - i, 47 + i / 2 - smileLift, 1);
|
||||
d.drawLine(cx - 9 + i, y + i / 2 - smileLift, cx + 9 - i, y + i / 2 - smileLift, 1);
|
||||
}
|
||||
drawSunRise(now, (uint8_t)(1 + ((_routineProgressPermille * 4) / 1000)));
|
||||
}
|
||||
|
||||
_display->drawBatteryIcon(110, 2, battery.percent(), battery.shouldBlink(), battery.isCharging());
|
||||
_display->drawBatteryIcon(batteryIconX(), 2, battery.percent(), battery.shouldBlink(), battery.isCharging());
|
||||
d.display();
|
||||
}
|
||||
|
||||
@@ -315,11 +369,11 @@ void FaceRenderer::renderSurprised(unsigned long now, const BatterySensor& batte
|
||||
|
||||
int pulse = ((now / 180UL) % 2UL == 0UL) ? 1 : 0;
|
||||
drawEyesOpen(0, 0, 5 + pulse);
|
||||
d.drawLine(26, 16, 48, 10, 1);
|
||||
d.drawLine(80, 10, 102, 16, 1);
|
||||
d.drawLine(leftEyeCxBase() - 14, eyeCy() - 8, leftEyeCxBase() + 8, eyeCy() - 14, 1);
|
||||
d.drawLine(rightEyeCxBase() - 8, eyeCy() - 14, rightEyeCxBase() + 14, eyeCy() - 8, 1);
|
||||
drawMouthSurprised();
|
||||
|
||||
_display->drawBatteryIcon(110, 2, battery.percent(), battery.shouldBlink(), battery.isCharging());
|
||||
_display->drawBatteryIcon(batteryIconX(), 2, battery.percent(), battery.shouldBlink(), battery.isCharging());
|
||||
d.display();
|
||||
}
|
||||
|
||||
@@ -329,91 +383,127 @@ void FaceRenderer::drawEyesOpen(int dx, int dy, int pupilRadius) {
|
||||
auto &d = _display->oled();
|
||||
int eyeR = clampInt(12 + _tiltPupilSizeDelta, 6, 18);
|
||||
int pupilR = clampInt(pupilRadius + (_tiltPupilSizeDelta / 2), 1, eyeR - 2);
|
||||
int leftCx = 40 + _tiltEyeDx;
|
||||
int rightCx = 88 + _tiltEyeDx;
|
||||
d.fillCircle(leftCx, 24, eyeR, 1);
|
||||
d.fillCircle(rightCx, 24, eyeR, 1);
|
||||
d.fillCircle(leftCx + dx, 24 + dy, pupilR, 0);
|
||||
d.fillCircle(rightCx + dx, 24 + dy, pupilR, 0);
|
||||
int cy = eyeCy();
|
||||
int leftCx = clampInt(leftEyeCxBase() + _tiltEyeDx, eyeR, d.width() - eyeR - 1);
|
||||
int rightCx = clampInt(rightEyeCxBase() + _tiltEyeDx, eyeR, d.width() - eyeR - 1);
|
||||
d.fillCircle(leftCx, cy, eyeR, 1);
|
||||
d.fillCircle(rightCx, cy, eyeR, 1);
|
||||
d.fillCircle(leftCx + dx, cy + dy, pupilR, 0);
|
||||
d.fillCircle(rightCx + dx, cy + dy, pupilR, 0);
|
||||
}
|
||||
|
||||
void FaceRenderer::drawEyesClosed() {
|
||||
auto &d = _display->oled();
|
||||
d.drawLine(28, 24, 52, 24, 1);
|
||||
d.drawLine(76, 24, 100, 24, 1);
|
||||
int cy = eyeCy();
|
||||
d.drawLine(leftEyeCxBase() - 12, cy, leftEyeCxBase() + 12, cy, 1);
|
||||
d.drawLine(rightEyeCxBase() - 12, cy, rightEyeCxBase() + 12, cy, 1);
|
||||
}
|
||||
|
||||
void FaceRenderer::drawEyesSmallPupils(int dy, int sx, int sy, int pupilRadius) {
|
||||
auto &d = _display->oled();
|
||||
int eyeR = clampInt(12 + _tiltPupilSizeDelta, 6, 18);
|
||||
int r = clampInt(pupilRadius + (_tiltPupilSizeDelta / 2), 1, 7);
|
||||
int leftCx = 40 + _tiltEyeDx;
|
||||
int rightCx = 88 + _tiltEyeDx;
|
||||
d.fillCircle(leftCx, 24, eyeR, 1);
|
||||
d.fillCircle(rightCx, 24, eyeR, 1);
|
||||
d.fillCircle(leftCx + sx, 24 + dy + sy, r, 0);
|
||||
d.fillCircle(rightCx + sx, 24 + dy + sy, r, 0);
|
||||
int cy = eyeCy();
|
||||
int leftCx = clampInt(leftEyeCxBase() + _tiltEyeDx, eyeR, d.width() - eyeR - 1);
|
||||
int rightCx = clampInt(rightEyeCxBase() + _tiltEyeDx, eyeR, d.width() - eyeR - 1);
|
||||
d.fillCircle(leftCx, cy, eyeR, 1);
|
||||
d.fillCircle(rightCx, cy, eyeR, 1);
|
||||
d.fillCircle(leftCx + sx, cy + dy + sy, r, 0);
|
||||
d.fillCircle(rightCx + sx, cy + dy + sy, r, 0);
|
||||
}
|
||||
|
||||
void FaceRenderer::drawBrowsVerySad() {
|
||||
auto &d = _display->oled();
|
||||
d.drawLine(26, 12, 48, 18, 1);
|
||||
d.drawLine(80, 18, 102, 12, 1);
|
||||
int yTop = eyeCy() - 12;
|
||||
d.drawLine(leftEyeCxBase() - 14, yTop, leftEyeCxBase() + 8, yTop + 6, 1);
|
||||
d.drawLine(rightEyeCxBase() - 8, yTop + 6, rightEyeCxBase() + 14, yTop, 1);
|
||||
}
|
||||
|
||||
void FaceRenderer::drawBrowsWorried() {
|
||||
auto &d = _display->oled();
|
||||
// Raised/arched worried eyebrows
|
||||
d.drawLine(26, 14, 36, 10, 1);
|
||||
d.drawLine(36, 10, 48, 12, 1);
|
||||
d.drawLine(80, 12, 92, 10, 1);
|
||||
d.drawLine(92, 10, 102, 14, 1);
|
||||
int y = eyeCy() - 10;
|
||||
d.drawLine(leftEyeCxBase() - 14, y, leftEyeCxBase() - 4, y - 4, 1);
|
||||
d.drawLine(leftEyeCxBase() - 4, y - 4, leftEyeCxBase() + 8, y - 2, 1);
|
||||
d.drawLine(rightEyeCxBase() - 8, y - 2, rightEyeCxBase() + 4, y - 4, 1);
|
||||
d.drawLine(rightEyeCxBase() + 4, y - 4, rightEyeCxBase() + 14, y, 1);
|
||||
}
|
||||
|
||||
void FaceRenderer::drawMouthHappy() {
|
||||
auto &d = _display->oled();
|
||||
int cx = mouthCx();
|
||||
int y = mouthY() - 2;
|
||||
for (int i = 0; i < 5; i++)
|
||||
d.drawLine(54 + i, 46 + i / 2, 74 - i, 46 + i / 2, 1);
|
||||
d.drawLine(cx - 10 + i, y + i / 2, cx + 10 - i, y + i / 2, 1);
|
||||
}
|
||||
|
||||
void FaceRenderer::drawMouthFrown() {
|
||||
auto &d = _display->oled();
|
||||
int cx = mouthCx();
|
||||
int y = mouthY();
|
||||
for (int i = 0; i < 5; i++)
|
||||
d.drawLine(54 - i, 48 + i / 2, 74 + i, 48 + i / 2, 1);
|
||||
d.drawLine(cx - 10 - i, y + i / 2, cx + 10 + i, y + i / 2, 1);
|
||||
}
|
||||
|
||||
void FaceRenderer::drawMouthFlat() {
|
||||
auto &d = _display->oled();
|
||||
d.drawLine(52, 48, 76, 48, 1);
|
||||
int cx = mouthCx();
|
||||
int y = mouthY();
|
||||
d.drawLine(cx - 12, y, cx + 12, y, 1);
|
||||
}
|
||||
|
||||
void FaceRenderer::drawMouthNervous() {
|
||||
auto &d = _display->oled();
|
||||
// Wavy/uncertain nervous mouth
|
||||
d.drawLine(52, 48, 58, 47, 1);
|
||||
d.drawLine(58, 47, 64, 48, 1);
|
||||
d.drawLine(64, 48, 70, 47, 1);
|
||||
d.drawLine(70, 47, 76, 48, 1);
|
||||
int cx = mouthCx();
|
||||
int y = mouthY();
|
||||
d.drawLine(cx - 12, y, cx - 6, y - 1, 1);
|
||||
d.drawLine(cx - 6, y - 1, cx, y, 1);
|
||||
d.drawLine(cx, y, cx + 6, y - 1, 1);
|
||||
d.drawLine(cx + 6, y - 1, cx + 12, y, 1);
|
||||
}
|
||||
|
||||
void FaceRenderer::drawMouthSurprised() {
|
||||
auto &d = _display->oled();
|
||||
d.drawCircle(64, 48, 5, 1);
|
||||
d.drawCircle(64, 48, 4, 1);
|
||||
int cx = mouthCx();
|
||||
int y = mouthY();
|
||||
d.drawCircle(cx, y, 5, 1);
|
||||
d.drawCircle(cx, y, 4, 1);
|
||||
}
|
||||
|
||||
void FaceRenderer::drawBubbles(int off) {
|
||||
auto &d = _display->oled();
|
||||
d.drawCircle(10 + off, 12, 4, 1);
|
||||
d.drawCircle(20 + off, 20, 3, 1);
|
||||
d.drawCircle(30 + off, 14, 5, 1);
|
||||
int rise = off % 36; // 0..35
|
||||
|
||||
// Upward float with staggered phases so bubbles continuously rise.
|
||||
int y1 = 58 - rise;
|
||||
int y2 = 58 - ((rise + 12) % 36);
|
||||
int y3 = 58 - ((rise + 24) % 36);
|
||||
|
||||
d.drawCircle(12, y1, 4, 1);
|
||||
d.drawCircle(24, y2, 3, 1);
|
||||
d.drawCircle(34, y3, 5, 1);
|
||||
}
|
||||
|
||||
void FaceRenderer::drawMouthBubbles(unsigned long now) {
|
||||
auto &d = _display->oled();
|
||||
int cx = mouthCx();
|
||||
int y = mouthY();
|
||||
int rise = (now / 90UL) % 26UL;
|
||||
|
||||
int x1 = cx + 9 + ((now / 160UL) % 3);
|
||||
int y1 = y - 2 - rise;
|
||||
int y2 = y - 8 - ((rise + 8) % 26);
|
||||
int y3 = y - 14 - ((rise + 16) % 26);
|
||||
|
||||
if (y1 > 2) d.drawCircle(x1, y1, 2, 1);
|
||||
if (y2 > 2) d.drawCircle(cx + 15, y2, 2, 1);
|
||||
if (y3 > 2) d.drawCircle(cx + 20, y3, 3, 1);
|
||||
}
|
||||
|
||||
void FaceRenderer::drawSparkles(unsigned long now) {
|
||||
auto &d = _display->oled();
|
||||
if ((now / 250) % 2 == 0) {
|
||||
d.drawPixel(10, 10, 1);
|
||||
d.drawPixel(118, 12, 1);
|
||||
d.drawPixel(d.width() - 11, 12, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,14 +511,14 @@ void FaceRenderer::drawSilly(uint8_t v) {
|
||||
auto &d = _display->oled();
|
||||
if (v == 0) {
|
||||
drawEyesOpen(2, 2);
|
||||
d.drawCircle(64, 48, 6, 1);
|
||||
d.drawCircle(mouthCx(), mouthY(), 6, 1);
|
||||
} else if (v == 1) {
|
||||
drawEyesClosed();
|
||||
d.drawRect(58, 44, 12, 8, 1);
|
||||
d.drawRect(mouthCx() - 6, mouthY() - 4, 12, 8, 1);
|
||||
} else {
|
||||
drawEyesOpen(-2, 1);
|
||||
d.drawLine(52, 48, 76, 48, 1);
|
||||
d.drawPixel(64, 54, 1);
|
||||
d.drawLine(mouthCx() - 12, mouthY(), mouthCx() + 12, mouthY(), 1);
|
||||
d.drawPixel(mouthCx(), mouthY() + 6, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,8 +538,8 @@ void FaceRenderer::drawBigWaterText(unsigned long now) {
|
||||
uint16_t w = 0, h = 0;
|
||||
d.getTextBounds(msg, 0, 0, &x1, &y1, &w, &h);
|
||||
|
||||
int16_t x = (int16_t)((128 - (int)w) / 2);
|
||||
d.setCursor(x < 0 ? 0 : x, 56);
|
||||
int16_t x = (int16_t)(((int)d.width() - (int)w) / 2);
|
||||
d.setCursor(x < 0 ? 0 : x, d.height() - 8);
|
||||
d.print(msg);
|
||||
}
|
||||
|
||||
@@ -457,9 +547,9 @@ void FaceRenderer::drawSleepyZs(unsigned long now, uint8_t count) {
|
||||
auto &d = _display->oled();
|
||||
uint8_t phase = (now / 400UL) % 8UL;
|
||||
for (uint8_t i = 0; i < count; i++) {
|
||||
int x = 98 + i * 8 - (int)phase;
|
||||
int y = 22 - i * 7;
|
||||
if (x < 84 || y < 2) continue;
|
||||
int x = rightEyeCxBase() + 10 + i * 8 - (int)phase;
|
||||
int y = eyeCy() - 2 - i * 7;
|
||||
if (x < rightEyeCxBase() - 4 || y < 2) continue;
|
||||
d.drawLine(x, y, x + 4, y, 1);
|
||||
d.drawLine(x + 4, y, x, y + 5, 1);
|
||||
d.drawLine(x, y + 5, x + 4, y + 5, 1);
|
||||
@@ -469,7 +559,7 @@ void FaceRenderer::drawSleepyZs(unsigned long now, uint8_t count) {
|
||||
void FaceRenderer::drawSunRise(unsigned long now, uint8_t level) {
|
||||
auto &d = _display->oled();
|
||||
int cx = 12;
|
||||
int cy = 52;
|
||||
int cy = d.height() - 12;
|
||||
int r = 5;
|
||||
d.drawCircle(cx, cy, r, 1);
|
||||
d.drawLine(cx - 10, cy + 6, cx + 10, cy + 6, 1);
|
||||
|
||||
@@ -45,6 +45,9 @@ private:
|
||||
uint8_t _blinkStep = 0;
|
||||
unsigned long _blinkUntilMs = 0;
|
||||
|
||||
unsigned long _tooWetMouthOpenUntilMs = 0;
|
||||
unsigned long _nextTooWetMouthEventMs = 0;
|
||||
|
||||
bool _silly = false;
|
||||
uint8_t _sillyVariant = 0;
|
||||
unsigned long _sillyUntilMs = 0;
|
||||
@@ -86,6 +89,7 @@ private:
|
||||
void drawMouthSurprised();
|
||||
void drawDroplet(int x, int y, bool pulse);
|
||||
void drawBubbles(int off);
|
||||
void drawMouthBubbles(unsigned long now);
|
||||
void drawSparkles(unsigned long now);
|
||||
void drawSilly(uint8_t v);
|
||||
void drawXEye(int cx, int cy);
|
||||
@@ -95,4 +99,12 @@ private:
|
||||
|
||||
unsigned long randRange(unsigned long lo, unsigned long hi);
|
||||
int8_t randRangeI8(int8_t lo, int8_t hi);
|
||||
int batteryIconX() const;
|
||||
int screenW() const;
|
||||
int screenH() const;
|
||||
int eyeCy() const;
|
||||
int leftEyeCxBase() const;
|
||||
int rightEyeCxBase() const;
|
||||
int mouthCx() const;
|
||||
int mouthY() const;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user