@@ -4,17 +4,19 @@
# include <WiFi.h>
# include <ESPAsyncWebServer.h>
# include <HTTPClient.h>
# include <Preferences.h>
# include <ArduinoJson.h>
# include <RevEng_PAJ7620.h>
# include <LittleFS.h>
// ── Hardware ──────────────────────────────────────────────────────────────────
// Both SSD1306 (0x3C) and PAJ7620 (0x73) share Wire on GPIO22(SDA)/GPIO23(SCL)
U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2 ( U8G2_R0 , U8X8_PIN_NONE , 23 , 22 ) ;
RevEng_PAJ7620 sensor ;
const char * WIFI_SSID = " SSID " ;
const char * WIFI_PASS = " PASSWORD " ;
// WiFi credentials — loaded from LittleFS /config.json at boot
static char WIFI_SSID [ 64 ] = " " ;
static char WIFI_PASS [ 64 ] = " " ;
// ── Gesture config ─────────────────────────────────────────────────────────────
// Index 0-8 maps to GES_UP..GES_WAVE (Gesture enum value - 1)
@@ -29,17 +31,24 @@ static const char *GKEY[NUM_GESTURES] = {
// Mood labels matching Mood enum values
static const char * MOOD_LABELS [ ] = {
" -- bez zmiany -- " , " happy ^_^ " , " sleepy zZz " , " surprised o_O " ,
" angry >_< " , " sad T_T " , " excited *_* " } ;
" angry >_< " , " sad T_T " , " excited *_* " , " wink L ;) " , " wink R (; " ,
" hungry :( " , " playful :D " , " dirty ... " } ;
// Actions that can be triggered by a gesture
enum Action
{
ACTION_NONE = 0 ,
ACTION_NONE = 0 ,
ACTION_DATETIME = 1 ,
ACTION_WIFI = 2
ACTION_WIFI = 2 ,
ACTION_FEED = 3 , // nakarm — zmniejsza głód
ACTION_PLAY = 4 , // pobaw się — zwiększa szczęście
ACTION_CLEAN = 5 , // umyj — zwiększa higienę
ACTION_STATUS = 6 // pokaż status potrzeb
} ;
static const uint8_t NUM_ACTIONS = 3 ;
static const char * ACTION_LABELS [ ] = { " -- brak -- " , " Data i godzina " , " Status WiFi " } ;
static const uint8_t NUM_ACTIONS = 7 ;
static const char * ACTION_LABELS [ ] = {
" -- brak -- " , " Data i godzina " , " Status WiFi " ,
" Nakarm " , " Pobaw sie " , " Umyj " , " Status tamagotchi " } ;
struct GestureConfig
{
@@ -50,7 +59,6 @@ struct GestureConfig
} ;
GestureConfig gConfig [ NUM_GESTURES ] ;
Preferences prefs ;
AsyncWebServer httpServer ( 80 ) ;
// ── Buddy ─────────────────────────────────────────────────────────────────────
@@ -62,7 +70,12 @@ enum Mood
MOOD_SURPRISED ,
MOOD_ANGRY ,
MOOD_SAD ,
MOOD_EXCITED
MOOD_EXCITED ,
MOOD_WINK_L , // lewe oko zamknięte
MOOD_WINK_R , // prawe oko zamknięte
MOOD_HUNGRY , // głodny — chce jeść
MOOD_PLAYFUL , // chce się bawić
MOOD_DIRTY // potrzeba mycia
} ;
enum BlinkState
{
@@ -74,10 +87,12 @@ enum BlinkState
static const uint8_t EYE_L_X = 38 ;
static const uint8_t EYE_R_X = 90 ;
static const uint8_t EYE_Y = 30 ;
static const uint8_t EYE_RX = 17 ;
static const uint8_t EYE_RY = 15 ;
static const uint8_t EYE_Y = 27 ;
static const uint8_t EYE_RX = 17 ;
static const uint8_t EYE_RY = 15 ;
static const uint8_t PUPIL_R = 6 ;
static const uint8_t MOUTH_X = 64 ;
static const uint8_t MOUTH_Y = 52 ;
struct
{
@@ -91,14 +106,48 @@ struct
int8_t pupilDx , pupilDy ;
int8_t pupilTargetDx , pupilTargetDy ;
uint32_t nextLook ;
uint8_t zzzPhase ;
uint8_t zzzPhase ;
uint32_t nextZzz ;
uint32_t nextMicroTremor ; // involuntary fixation tremor
} buddy ;
// ── Tamagotchi needs ──────────────────────────────────────────────────────────
// hunger 0=full→100=starving (++/2min), happiness 100=happy→0=bored (--/3min),
// hygiene 100=clean→0=dirty (--/4min). Threshold 70 triggers mood override.
struct {
uint8_t hunger ;
uint8_t happiness ;
uint8_t hygiene ;
uint32_t nextHungerTick ;
uint32_t nextHappyTick ;
uint32_t nextHygieneTick ;
} tama ;
void initTama ( ) {
tama . hunger = 10 ;
tama . happiness = 80 ;
tama . hygiene = 90 ;
uint32_t now = millis ( ) ;
tama . nextHungerTick = now + 120000UL ;
tama . nextHappyTick = now + 180000UL ;
tama . nextHygieneTick = now + 240000UL ;
}
// ── Action overlay ────────────────────────────────────────────────────────────
uint32_t overlayUntil = 0 ; // show overlay until this timestamp
Action overlayAction = ACTION_NONE ;
// ── Night dim ─────────────────────────────────────────────────────────────────
// 00:00– 05:00, after 5 s idle → display off (SSD1306 0xAE). Gesture restores.
// setContrast() is unreliable on SSD1306 (visual range too narrow);
// setPowerSave(1) sends the display-off command directly.
static bool g_dimmed = false ;
static void setDim ( bool dim ) {
if ( g_dimmed = = dim ) return ;
g_dimmed = dim ;
u8g2 . setPowerSave ( dim ? 1 : 0 ) ;
}
void setBuddyMood ( Mood m , uint32_t durationMs = 0 )
{
buddy . mood = m ;
@@ -137,110 +186,247 @@ void initBuddy()
buddy . blinkState = BLINK_OPEN ;
buddy . blinkRy = EYE_RY ;
buddy . lastEvent = millis ( ) ;
buddy . nextBlink = millis ( ) + 3000 ;
buddy . nextLook = millis ( ) + 2000 ;
buddy . nextZzz = millis ( ) + 3000 ;
buddy . nextBlink = millis ( ) + 3000 ;
buddy . nextLook = millis ( ) + 2000 ;
buddy . nextZzz = millis ( ) + 3000 ;
buddy . nextMicroTremor = millis ( ) + 500 ;
}
// ── Eye drawing ───────────────────────────────────────────────────────────────
// Manga style: white sclera + heavy top lid + large dark iris + highlights.
// effRy drives the blink animation (compresses the eye vertically).
// pdx/pdy shift the iris for gaze tracking (NORMAL/HAPPY).
static void drawWinkEye ( uint8_t cx , uint8_t cy ) {
// Closed crescent — upper arc of a circle centered below the eye
u8g2 . setDrawColor ( 1 ) ;
u8g2 . drawCircle ( cx , cy + 8 , 10 , U8G2_DRAW_UPPER_LEFT | U8G2_DRAW_UPPER_RIGHT ) ;
u8g2 . drawCircle ( cx , cy + 8 , 11 , U8G2_DRAW_UPPER_LEFT | U8G2_DRAW_UPPER_RIGHT ) ;
}
static void drawEye ( uint8_t cx , uint8_t cy , uint8_t effRy ,
int8_t pdx , int8_t pdy , bool isLeft )
{
if ( effRy = = 0 )
return ;
// Buddy faces the viewer, so buddy's LEFT eye = screen RIGHT (!isLeft)
if ( buddy . mood = = MOOD_WINK_L & & ! isLeft ) { drawWinkEye ( cx , cy ) ; return ; }
if ( buddy . mood = = MOOD_WINK_R & & isLeft ) { drawWinkEye ( cx , cy ) ; return ; }
if ( effRy = = 0 ) return ;
u8g2 . setDrawColor ( 1 ) ;
switch ( buddy . mood )
{
case MOOD_NORMAL :
case MOOD_HAPPY : {
// Filled ellipse + drifting specular glint
uint8_t ry = min ( effRy , EYE_RX ) ;
u8g2 . drawFilledEllipse ( cx , cy , EYE_RX , ry , U8G2_DRAW_ALL ) ;
if ( ry > = 5 ) {
u8g2 . setDrawColor ( 0 ) ;
int8_t gx = ( int8_t ) cx + 3 + pdx / 3 ;
int8_t gy = ( int8_t ) cy - 3 + pdy / 3 ;
u8g2 . drawDisc ( ( uint8_t ) gx , ( uint8_t ) gy , 2 ) ;
u8g2 . setDrawColor ( 1 ) ;
}
break ;
}
case MOOD_SLEEPY : {
// Thick horizontal dash; height shrinks with effRy for blink
uint8_t h = ( effRy > = 3 ) ? 4 : effRy ;
u8g2 . drawBox ( cx - EYE_RX , cy - h / 2 , EYE_RX * 2 , h ? h : 1 ) ;
break ;
}
case MOOD_SURPRISED : {
// Wider ellipse + larger glint
uint8_t r = EYE_RX + 2 ;
uint8_t ry = min ( ( uint8_t ) ( effRy + 2 ) , r ) ;
u8g2 . drawFilledEllipse ( cx , cy , r , ry , U8G2_DRAW_ALL ) ;
if ( ry > = 6 ) {
u8g2 . setDrawColor ( 0 ) ;
u8g2 . drawDisc ( cx + 5 , cy - 5 , 3 ) ;
u8g2 . setDrawColor ( 1 ) ;
}
break ;
}
case MOOD_ANGRY : {
// Angular wedge: full width outer-bottom, diagonal cut inner-top
const uint8_t w = EYE_RX + 3 ;
const uint8_t h = min ( effRy , ( uint8_t ) 6 ) ;
for ( int8_t row = 0 ; row < = ( int8_t ) ( h * 2 ) ; row + + ) {
int8_t y = ( int8_t ) cy - ( int8_t ) h + row ;
uint8_t lineW = ( row < ( int8_t ) h ) ? w - ( uint8_t ) ( ( int8_t ) h - row ) : w ;
if ( lineW < 2 ) lineW = 2 ;
uint8_t x = isLeft ? cx - w : ( uint8_t ) ( cx + w - lineW ) ;
u8g2 . drawHLine ( x , ( uint8_t ) y , lineW ) ;
}
break ;
}
case MOOD_SAD : {
// Droopy: lower half of ellipse only
uint8_t ry = min ( effRy , EYE_RX ) ;
u8g2 . drawFilledEllipse ( cx , cy - 2 , EYE_RX , ry ,
U8G2_DRAW_LOWER_LEFT | U8G2_DRAW_LOWER_RIGHT ) ;
break ;
}
case MOOD_EXCITED : {
// Shining disc with X-star cutout
uint8_t ry = min ( effRy , EYE_RX ) ;
u8g2 . drawFilledEllipse ( cx , cy , EYE_RX , ry , U8G2_DRAW_ALL ) ;
if ( ry > = 5 ) {
u8g2 . setDrawColor ( 0 ) ;
u8g2 . drawLine ( cx - 5 , cy - 5 , cx + 5 , cy + 5 ) ;
u8g2 . drawLine ( cx + 5 , cy - 5 , cx - 5 , cy + 5 ) ;
u8g2 . setDrawColor ( 1 ) ;
u8g2 . drawDisc ( cx , cy , 2 ) ;
}
break ;
}
case MOOD_HUNGRY : {
// Worried eyes — smaller, looking down toward imaginary food
uint8_t ry = min ( effRy , ( uint8_t ) ( EYE_RX - 2 ) ) ;
u8g2 . drawFilledEllipse ( cx , cy + 2 , EYE_RX - 2 , ry , U8G2_DRAW_ALL ) ;
if ( ry > = 4 ) {
u8g2 . setDrawColor ( 0 ) ;
u8g2 . drawDisc ( cx , cy + 4 , 2 ) ;
u8g2 . setDrawColor ( 1 ) ;
}
break ;
}
case MOOD_PLAYFUL : {
// Wide sparkling eyes — lively double glint
uint8_t ry = min ( ( uint8_t ) ( effRy + 1 ) , ( uint8_t ) ( EYE_RX + 1 ) ) ;
u8g2 . drawFilledEllipse ( cx , cy , EYE_RX , ry , U8G2_DRAW_ALL ) ;
if ( ry > = 5 ) {
u8g2 . setDrawColor ( 0 ) ;
u8g2 . drawDisc ( cx + 4 , cy - 4 , 3 ) ;
u8g2 . drawDisc ( cx - 3 , cy + 3 , 1 ) ;
u8g2 . setDrawColor ( 1 ) ;
}
break ;
}
case MOOD_DIRTY : {
// Dizzy/disgusted — two offset pupils, unfocused look
uint8_t ry = min ( effRy , EYE_RX ) ;
u8g2 . drawFilledEllipse ( cx , cy , EYE_RX , ry , U8G2_DRAW_ALL ) ;
if ( ry > = 5 ) {
u8g2 . setDrawColor ( 0 ) ;
u8g2 . drawDisc ( cx - 4 , cy - 2 , 2 ) ;
u8g2 . drawDisc ( cx + 4 , cy + 2 , 2 ) ;
u8g2 . setDrawColor ( 1 ) ;
}
break ;
}
default : {
uint8_t ry = min ( effRy , EYE_RY ) ;
u8g2 . drawFilledEllipse ( cx , cy , EYE_RX , ry , U8G2_DRAW_ALL ) ;
break ;
}
}
}
// ── Mouth drawing ─────────────────────────────────────────────────────────────
// Coordinate notes (Y increases downward):
// drawCircle(..., LOWER) → arc opens downward = smile ✓
// drawCircle(..., UPPER) → arc opens upward = frown ✓
static void drawMouth ( )
{
u8g2 . setDrawColor ( 1 ) ;
switch ( buddy . mood )
{
case MOOD_HAPPY :
u8g2 . setDrawColor ( 1 ) ;
u8g2 . drawFilledEllipse ( cx , cy , EYE_RX , effRy , U8G2_DRAW_ALL ) ;
u8g2 . setDrawColor ( 0 ) ;
u8g2 . drawBox ( cx - EYE_RX - 1 , cy - effRy - 1 , EYE_RX * 2 + 3 , effRy + 2 ) ;
u8g2 . setDrawColor ( 1 ) ;
// Wide "U" smile + round cheek blush dots
u8g2 . drawCircle ( MOUTH_X , MOUTH_Y - 7 , 10 ,
U8G2_DRAW_LOWER_LEFT | U8G2_DRAW_LOWER_RIGHT ) ;
u8g2 . drawCircle ( MOUTH_X , MOUTH_Y - 7 , 11 ,
U8G2_DRAW_LOWER_LEFT | U8G2_DRAW_LOWER_RIGHT ) ;
u8g2 . drawDisc ( MOUTH_X - 20 , MOUTH_Y , 3 ) ;
u8g2 . drawDisc ( MOUTH_X + 20 , MOUTH_Y , 3 ) ;
break ;
case MOOD_SLEEPY :
u8g2 . setDrawColor ( 1 ) ;
u8g2 . drawFilledEllipse ( cx , cy , EYE_RX , effRy , U8G2_DRAW_ALL ) ;
u8g2 . setDrawColor ( 0 ) ;
u8g2 . drawBox ( cx - EYE_RX - 1 , cy - effRy - 1 , EYE_RX * 2 + 3 , ( effRy * 3 ) / 2 + 1 ) ;
u8g2 . setDrawColor ( 1 ) ;
if ( effRy > 3 )
{
u8g2 . setDrawColor ( 0 ) ;
u8g2 . drawDisc ( cx + pdx , cy + effRy - 3 , ( uint8_t ) ( PUPIL_R - 3 ) ) ;
u8g2 . setDrawColor ( 1 ) ;
}
// Thick horizontal bar — tired, barely open
u8g2 . drawBox ( MOUTH_X - 8 , MOUTH_Y - 1 , 16 , 3 ) ;
break ;
case MOOD_SURPRISED :
u8g2 . setDrawColor ( 1 ) ;
u8g2 . drawFilledEllipse ( cx , cy , EYE_RX + 2 , effRy , U8G2_DRAW_ALL ) ;
// Open "O" — filled ring (outline + hollow)
u8g2 . drawFilledEllipse ( MOUTH_X , MOUTH_Y , 5 , 6 , U8G2_DRAW_ALL ) ;
u8g2 . setDrawColor ( 0 ) ;
u8g2 . drawDisc ( cx , cy , ( uint8_t ) ( PUPIL_R - 2 ) ) ;
u8g2 . drawFilledEllipse ( MOUTH_X , MOUTH_Y , 3 , 4 , U8G2_DRAW_ALL ) ;
u8g2 . setDrawColor ( 1 ) ;
u8g2 . drawDisc ( cx - 3 , cy - 2 , 2 ) ;
{
int8_t s = isLeft ? - 2 : 2 ;
u8g2 . drawLine ( cx - EYE_RX + 2 , cy - effRy - 6 , cx + EYE_RX - 2 , cy - effRy - 6 + s ) ;
}
break ;
case MOOD_ANGRY :
u8g2 . setDrawColor ( 1 ) ;
u8g2 . drawFilledEllipse ( cx , cy , EYE_RX , effRy , U8G2_DRAW_ALL ) ;
u8g2 . setDrawColor ( 0 ) ;
u8g2 . drawDisc ( cx + pdx , cy + pdy , PUPIL_R ) ;
u8g2 . setDrawColor ( 1 ) ;
u8g2 . drawDisc ( cx + pdx - 2 , cy + pdy - 2 , 2 ) ;
if ( isLeft )
u8g2 . drawLine ( cx - EYE_RX + 2 , cy - effRy - 4 , cx + EYE_RX - 2 , cy - effRy - 9 ) ;
else
u8g2 . drawLine ( cx - EYE_RX + 2 , cy - effRy - 9 , cx + EYE_RX - 2 , cy - effRy - 4 ) ;
// Tight frown with lower lip bar
u8g2 . drawCircle ( MOUTH_X , MOUTH_Y + 7 , 7 ,
U8G2_DRAW_UPPER_LEFT | U8G2_DRAW_UPPER_RIGHT ) ;
u8g2 . drawCircle ( MOUTH_X , MOUTH_Y + 7 , 8 ,
U8G2_DRAW_UPPER_LEFT | U8G2_DRAW_UPPER_RIGHT ) ;
u8g2 . drawBox ( MOUTH_X - 7 , MOUTH_Y + 6 , 14 , 2 ) ;
break ;
case MOOD_SAD :
u8g2 . setDrawColor ( 1 ) ;
u8g2 . drawFilledEllipse ( cx , cy , EYE_RX , effRy , U8G2_DRAW_ALL ) ;
u8g2 . setDrawColor ( 0 ) ;
u8g2 . drawDisc ( cx + pdx , cy + pdy , PUPIL_R ) ;
u8g2 . setDrawColor ( 1 ) ;
u8g2 . drawDisc ( cx + pdx - 2 , cy + pdy - 2 , 2 ) ;
if ( isLeft )
u8g2 . drawLine ( cx - EYE_RX + 2 , cy - effRy - 9 , cx + EYE_RX - 2 , cy - effRy - 4 ) ;
else
u8g2 . drawLine ( cx - EYE_RX + 2 , cy - effRy - 4 , cx + EYE_RX - 2 , cy - effRy - 9 ) ;
u8g2 . drawLine ( cx + ( isLeft ? 4 : - 4 ) , cy + effRy ,
cx + ( isLeft ? 4 : - 4 ) , cy + effRy + 7 ) ;
u8g2 . drawDisc ( cx + ( isLeft ? 4 : - 4 ) , cy + effRy + 8 , 2 ) ;
// Deep frown — teardrops on cheeks
u8g2 . drawCircle ( MOUTH_X , MOUTH_Y + 9 , 9 ,
U8G2_DRAW_UPPER_LEFT | U8G2_DRAW_UPPER_RIGHT ) ;
u8g2 . drawCircle ( MOUTH_X , MOUTH_Y + 9 , 10 ,
U8G2_DRAW_UPPER_LEFT | U8G2_DRAW_UPPER_RIGHT ) ;
// teardrops
u8g2 . drawDisc ( MOUTH_X - 18 , MOUTH_Y + 5 , 2 ) ;
u8g2 . drawLine ( MOUTH_X - 18 , MOUTH_Y + 7 , MOUTH_X - 18 , MOUTH_Y + 11 ) ;
u8g2 . drawDisc ( MOUTH_X + 18 , MOUTH_Y + 5 , 2 ) ;
u8g2 . drawLine ( MOUTH_X + 18 , MOUTH_Y + 7 , MOUTH_X + 18 , MOUTH_Y + 11 ) ;
break ;
case MOOD_EXCITED :
u8g2 . setDrawColor ( 1 ) ;
u8g2 . drawCircle ( cx , cy , EYE_RX - 1 , U8G2_DRAW_ALL ) ;
u8g2 . drawLine ( cx - 9 , cy - 9 , cx + 9 , cy + 9 ) ;
u8g2 . drawLine ( cx + 9 , cy - 9 , cx - 9 , cy + 9 ) ;
u8g2 . drawLine ( cx - 11 , cy , cx + 11 , cy ) ;
u8g2 . drawLine ( cx , cy - 11 , cx , cy + 11 ) ;
u8g2 . drawDisc ( cx , cy , 3 ) ;
// Wide open "D" mouth — flat top, arc bottom
u8g2 . drawCircle ( MOUTH_X , MOUTH_Y - 6 , 11 ,
U8G2_DRAW_LOWER_LEFT | U8G2_DRAW_LOWER_RIGHT ) ;
u8g2 . drawCircle ( MOUTH_X , MOUTH_Y - 6 , 12 ,
U8G2_DRAW_LOWER_LEFT | U8G2_DRAW_LOWER_RIGHT ) ;
u8g2 . drawBox ( MOUTH_X - 12 , MOUTH_Y - 7 , 24 , 2 ) ;
break ;
default :
u8g2 . setDrawColor ( 1 ) ;
u8g2 . drawFilledEllipse ( cx , cy , EYE_RX , effRy , U8G2_DRAW_ALL ) ;
if ( effRy > 3 )
{
int8_t pr = ( effRy > = PUPIL_R ) ? ( int8_t ) PUPIL_R : ( int8_t ) ( effRy - 1 ) ;
u8g2 . setDrawColor ( 0 ) ;
u8g2 . drawDisc ( cx + pdx , cy + pdy , ( uint8_t ) pr ) ;
if ( pr > = 3 )
{
u8g2 . setDrawColor ( 1 ) ;
u8g2 . drawDisc ( cx + pdx - 2 , cy + pdy - 2 , 2 ) ;
}
}
case MOOD_HUNGRY :
// Open oval mouth — anticipating food
u8g2 . drawFilledEllipse ( MOUTH_X , MOUTH_Y , 5 , 4 , U8G2_DRAW_ALL ) ;
u8g2 . setDrawColor ( 0 ) ;
u8g2 . drawFilledEllipse ( MOUTH_X , MOUTH_Y , 3 , 2 , U8G2_DRAW_ALL ) ;
u8g2 . setDrawColor ( 1 ) ;
break ;
case MOOD_PLAYFUL :
// Extra-wide smile
u8g2 . drawCircle ( MOUTH_X , MOUTH_Y - 8 , 12 ,
U8G2_DRAW_LOWER_LEFT | U8G2_DRAW_LOWER_RIGHT ) ;
u8g2 . drawCircle ( MOUTH_X , MOUTH_Y - 8 , 13 ,
U8G2_DRAW_LOWER_LEFT | U8G2_DRAW_LOWER_RIGHT ) ;
break ;
case MOOD_DIRTY :
// Wavy nauseous line
u8g2 . drawDisc ( MOUTH_X - 8 , MOUTH_Y + 1 , 1 ) ;
u8g2 . drawDisc ( MOUTH_X - 4 , MOUTH_Y - 1 , 1 ) ;
u8g2 . drawDisc ( MOUTH_X , MOUTH_Y + 2 , 1 ) ;
u8g2 . drawDisc ( MOUTH_X + 4 , MOUTH_Y - 1 , 1 ) ;
u8g2 . drawDisc ( MOUTH_X + 8 , MOUTH_Y + 1 , 1 ) ;
break ;
default : // MOOD_NORMAL + winks
// Small gentle smile
u8g2 . drawCircle ( MOUTH_X , MOUTH_Y - 3 , 6 ,
U8G2_DRAW_LOWER_LEFT | U8G2_DRAW_LOWER_RIGHT ) ;
u8g2 . drawCircle ( MOUTH_X , MOUTH_Y - 3 , 7 ,
U8G2_DRAW_LOWER_LEFT | U8G2_DRAW_LOWER_RIGHT ) ;
break ;
}
}
@@ -329,6 +515,89 @@ static void showDateTimeScreen()
u8g2 . sendBuffer ( ) ;
}
// ── Tamagotchi action screens ──────────────────────────────────────────────────
static void showFeedScreen ( ) {
u8g2 . clearBuffer ( ) ;
u8g2 . setDrawColor ( 1 ) ;
u8g2 . setFont ( u8g2_font_7x13_tr ) ;
u8g2 . drawStr ( 28 , 14 , " Mniam! " ) ;
// plate
u8g2 . drawEllipse ( 64 , 44 , 18 , 7 , U8G2_DRAW_ALL ) ;
// food on plate
u8g2 . drawFilledEllipse ( 64 , 40 , 10 , 5 , U8G2_DRAW_ALL ) ;
// steam lines
u8g2 . drawLine ( 57 , 30 , 55 , 22 ) ;
u8g2 . drawLine ( 64 , 30 , 64 , 22 ) ;
u8g2 . drawLine ( 71 , 30 , 73 , 22 ) ;
u8g2 . sendBuffer ( ) ;
}
static void showPlayScreen ( ) {
u8g2 . clearBuffer ( ) ;
u8g2 . setDrawColor ( 1 ) ;
u8g2 . setFont ( u8g2_font_7x13_tr ) ;
u8g2 . drawStr ( 16 , 14 , " Grajmy! " ) ;
// ball
u8g2 . drawDisc ( 44 , 42 , 10 ) ;
// star burst lines
for ( int8_t a = 0 ; a < 8 ; a + + ) {
float rad = a * 3.14159f / 4.0f ;
u8g2 . drawLine ( 80 , 38 ,
( uint8_t ) ( 80 + 13 * cos ( rad ) ) ,
( uint8_t ) ( 38 + 13 * sin ( rad ) ) ) ;
}
u8g2 . drawDisc ( 80 , 38 , 7 ) ;
u8g2 . setDrawColor ( 0 ) ;
u8g2 . drawStr ( 74 , 42 , " ! " ) ;
u8g2 . setDrawColor ( 1 ) ;
u8g2 . sendBuffer ( ) ;
}
static void showCleanScreen ( ) {
u8g2 . clearBuffer ( ) ;
u8g2 . setDrawColor ( 1 ) ;
u8g2 . setFont ( u8g2_font_7x13_tr ) ;
u8g2 . drawStr ( 24 , 14 , " Mycie! " ) ;
// water drops
for ( uint8_t i = 0 ; i < 5 ; i + + ) {
uint8_t dx = 30 + i * 16 ;
u8g2 . drawDisc ( dx , 44 , 5 ) ;
u8g2 . drawLine ( dx , 36 , dx , 38 ) ;
u8g2 . drawLine ( dx - 2 , 37 , dx + 2 , 37 ) ;
}
u8g2 . sendBuffer ( ) ;
}
static void showTamaStatusScreen ( ) {
u8g2 . clearBuffer ( ) ;
u8g2 . setDrawColor ( 1 ) ;
u8g2 . setFont ( u8g2_font_5x7_tr ) ;
auto drawBar = [ ] ( uint8_t y , const char * lbl , uint8_t val ) {
u8g2 . drawStr ( 0 , y , lbl ) ;
u8g2 . drawFrame ( 36 , y - 7 , 74 , 8 ) ;
u8g2 . drawBox ( 36 , y - 7 , ( uint8_t ) ( val * 74 / 100 ) , 8 ) ;
} ;
drawBar ( 11 , " Glod: " , tama . hunger ) ; // bar = how hungry (fill=bad)
// invert happiness/hygiene bars: fill = good
u8g2 . drawStr ( 0 , 27 , " Rad: " ) ;
u8g2 . drawFrame ( 36 , 20 , 74 , 8 ) ;
u8g2 . drawBox ( 36 , 20 , ( uint8_t ) ( tama . happiness * 74 / 100 ) , 8 ) ;
u8g2 . drawStr ( 0 , 43 , " Czyst: " ) ;
u8g2 . drawFrame ( 36 , 36 , 74 , 8 ) ;
u8g2 . drawBox ( 36 , 36 , ( uint8_t ) ( tama . hygiene * 74 / 100 ) , 8 ) ;
// value labels
char buf [ 5 ] ;
snprintf ( buf , sizeof ( buf ) , " %3d%% " , tama . hunger ) ; u8g2 . drawStr ( 112 , 11 , buf ) ;
snprintf ( buf , sizeof ( buf ) , " %3d%% " , tama . happiness ) ; u8g2 . drawStr ( 112 , 27 , buf ) ;
snprintf ( buf , sizeof ( buf ) , " %3d%% " , tama . hygiene ) ; u8g2 . drawStr ( 112 , 43 , buf ) ;
u8g2 . sendBuffer ( ) ;
}
void showBuddyScreen ( )
{
// Action overlay takes priority
@@ -336,14 +605,13 @@ void showBuddyScreen()
{
switch ( overlayAction )
{
case ACTION_DATETIME :
showDateTimeScreen ( ) ;
break ;
case ACTION_WIFI :
showWiFiStatusScreen ( ) ;
break ;
default :
break ;
case ACTION_DATETIME : showDateTimeScreen ( ) ; break ;
case ACTION_WIFI : showWiFiStatusScreen ( ) ; break ;
case ACTION_FEED : showFeedScreen ( ) ; break ;
case ACTION_PLAY : showPlayScreen ( ) ; break ;
case ACTION_CLEAN : showCleanScreen ( ) ; break ;
case ACTION_STATUS : showTamaStatusScreen ( ) ; break ;
default : break ;
}
return ;
}
@@ -353,30 +621,70 @@ void showBuddyScreen()
u8g2 . setDrawColor ( 1 ) ;
uint8_t effRy = buddy . blinkRy ;
if ( buddy . mood = = MOOD_HAPPY )
effRy = min ( effRy , ( uint8_t ) 13 ) ;
if ( buddy . mood = = MOOD_SLEEPY )
effRy = min ( effRy , ( uint8_t ) 11 ) ;
// SURPRISED: eyes open wider than normal max
if ( buddy . mood = = MOOD_SURPRISED )
effRy = EYE_RY + 3 ;
effRy = min ( ( uint8_t ) ( EYE_RY + 2 ) , ( uint8_t ) ( buddy . blinkRy + 2 ) ) ;
drawEye ( EYE_L_X , EYE_Y , effRy , buddy . pupilDx , buddy . pupilDy , true ) ;
drawEye ( EYE_L_X , EYE_Y , effRy , buddy . pupilDx , buddy . pupilDy , true ) ;
drawEye ( EYE_R_X , EYE_Y , effRy , - buddy . pupilDx , buddy . pupilDy , false ) ;
const char * labels [ ] = { " " , " ^_^ " , " zZz " , " o_O " , " >_< " , " T_T " , " *_* " } ;
u8g2 . setFont ( u8g2_font_5x7_tr ) ;
uint8_t lw = u8g2 . getStrWidth ( labels [ buddy . mood ] ) ;
u8g2 . drawStr ( ( 128 - lw ) / 2 , 63 , labels [ buddy . mood ] ) ;
drawMouth ( ) ;
if ( buddy . mood = = MOOD_SLEEPY & & buddy . zzzPhase > 0 )
{
const char * zStr [ ] = { " z " , " zz " , " zzz " } ;
uint8_t zi = buddy . zzzPhase - 1 ;
u8g2 . drawStr ( EYE_R_X + EYE_RX + 2 + zi * 2 , EYE_Y - EYE_RY - 3 - zi * 5 , zStr [ zi ] ) ;
u8g2 . setFont ( u8g2_font_5x7_tr ) ;
u8g2 . drawStr ( EYE_R_X + EYE_RX + 3 + zi * 2 ,
EYE_Y - EYE_RY - 2 - zi * 4 , zStr [ zi ] ) ;
}
// Tama need indicators — small icons bottom-left when threshold exceeded
{
u8g2 . setFont ( u8g2_font_4x6_tr ) ;
uint8_t ix = 1 ;
if ( tama . hunger > = 70 ) { u8g2 . drawStr ( ix , 63 , " G " ) ; ix + = 6 ; }
if ( tama . happiness < = 30 ) { u8g2 . drawStr ( ix , 63 , " Z " ) ; ix + = 6 ; }
if ( tama . hygiene < = 30 ) { u8g2 . drawStr ( ix , 63 , " M " ) ; }
}
u8g2 . sendBuffer ( ) ;
}
void updateTama ( )
{
uint32_t now = millis ( ) ;
// Tick needs over time
if ( now > = tama . nextHungerTick ) {
tama . nextHungerTick = now + 120000UL ;
if ( tama . hunger < 100 ) tama . hunger + + ;
}
if ( now > = tama . nextHappyTick ) {
tama . nextHappyTick = now + 180000UL ;
if ( tama . happiness > 0 ) tama . happiness - - ;
}
if ( now > = tama . nextHygieneTick ) {
tama . nextHygieneTick = now + 240000UL ;
if ( tama . hygiene > 0 ) tama . hygiene - - ;
}
// Override buddy mood based on needs (only when no temp mood + not in sleep)
// Direct assignment avoids resetting lastEvent
if ( buddy . revertAt = = 0 & & buddy . mood ! = MOOD_SLEEPY & &
buddy . mood ! = MOOD_WINK_L & & buddy . mood ! = MOOD_WINK_R ) {
if ( tama . hunger > = 80 )
buddy . mood = MOOD_HUNGRY ;
else if ( tama . hygiene < = 20 )
buddy . mood = MOOD_DIRTY ;
else if ( tama . happiness < = 20 )
buddy . mood = MOOD_PLAYFUL ;
else if ( buddy . mood = = MOOD_HUNGRY | | buddy . mood = = MOOD_DIRTY | |
buddy . mood = = MOOD_PLAYFUL )
buddy . mood = MOOD_NORMAL ; // needs satisfied → back to normal
}
}
void updateBuddyAnim ( )
{
uint32_t now = millis ( ) ;
@@ -389,7 +697,8 @@ void updateBuddyAnim()
if ( buddy . mood = = MOOD_NORMAL & & now - buddy . lastEvent > 300000UL )
setBuddyMood ( MOOD_SLEEPY , 0 ) ;
if ( buddy . mood ! = MOOD_SURPRISED & & buddy . mood ! = MOOD_EXCITED )
if ( buddy . mood ! = MOOD_SURPRISED & & buddy . mood ! = MOOD_EXCITED & &
buddy . mood ! = MOOD_WINK_L & & buddy . mood ! = MOOD_WINK_R )
{
switch ( buddy . blinkState )
{
@@ -427,20 +736,44 @@ void updateBuddyAnim()
buddy . blinkRy = EYE_RY ;
}
if ( buddy . mood = = MOOD_NORMAL & & now > = buddy . nextLook )
// Saccadic movement — fast when far (3 px/tick), slow when close (1 px/tick)
{
buddy . pupilTargetDx = ( int8_t ) random ( - 6 , 7 ) ;
buddy . pupilTargetDy = ( int8_t ) random ( - 4 , 5 ) ;
buddy . nextLook = now + random ( 1500 , 4000 ) ;
int8_t ddx = buddy . pupilTargetDx - buddy . pupilDx ;
int8_t ddy = buddy . pupilTargetDy - buddy . pupilDy ;
if ( ddx ) buddy . pupilDx + = ( ddx > 0 ? 1 : - 1 ) * ( ( abs ( ddx ) > = 4 ) ? 3 : 1 ) ;
if ( ddy ) buddy . pupilDy + = ( ddy > 0 ? 1 : - 1 ) * ( ( abs ( ddy ) > = 4 ) ? 3 : 1 ) ;
}
// Gaze — random saccades + micro-tremor for expressive moods
{
bool fixated = ( buddy . pupilDx = = buddy . pupilTargetDx & &
buddy . pupilDy = = buddy . pupilTargetDy ) ;
if ( buddy . mood = = MOOD_SLEEPY ) {
// Slow drowsy drift — limited range, eyes mostly down
if ( fixated & & now > = buddy . nextLook ) {
buddy . pupilTargetDx = ( int8_t ) random ( - 3 , 4 ) ;
buddy . pupilTargetDy = ( int8_t ) random ( 2 , 6 ) ;
buddy . nextLook = now + random ( 4000 , 9000 ) ;
}
} else if ( buddy . mood ! = MOOD_WINK_L & & buddy . mood ! = MOOD_WINK_R ) {
// Active gaze — full range saccades
if ( fixated & & now > = buddy . nextLook ) {
buddy . pupilTargetDx = ( int8_t ) random ( - 7 , 8 ) ;
buddy . pupilTargetDy = ( int8_t ) random ( - 5 , 6 ) ;
buddy . nextLook = now + random ( 1500 , 4500 ) ;
}
// Micro-tremor while fixated (NORMAL / HAPPY only)
if ( ( buddy . mood = = MOOD_NORMAL | | buddy . mood = = MOOD_HAPPY ) & &
fixated & & now > = buddy . nextMicroTremor ) {
buddy . pupilTargetDx = ( int8_t ) constrain (
buddy . pupilTargetDx + ( int8_t ) random ( - 1 , 2 ) , - 7 , 7 ) ;
buddy . pupilTargetDy = ( int8_t ) constrain (
buddy . pupilTargetDy + ( int8_t ) random ( - 1 , 2 ) , - 5 , 5 ) ;
buddy . nextMicroTremor = now + random ( 300 , 700 ) ;
}
}
}
if ( buddy . pupilDx < buddy . pupilTargetDx )
buddy . pupilDx + + ;
else if ( buddy . pupilDx > buddy . pupilTargetDx )
buddy . pupilDx - - ;
if ( buddy . pupilDy < buddy . pupilTargetDy )
buddy . pupilDy + + ;
else if ( buddy . pupilDy > buddy . pupilTargetDy )
buddy . pupilDy - - ;
if ( buddy . mood = = MOOD_SLEEPY & & now > = buddy . nextZzz )
{
@@ -449,51 +782,77 @@ void updateBuddyAnim()
}
}
// ── Config persistence (NVS via Preferences) ──────────────────────────────────
void loadConfig ( )
// ── Config persistence ─────────────────────── ──────────────────────────────────
// WiFi: /wifi.json — credentials, NEVER served over HTTP
// Gestures:/config.json — gesture config, served via GET /config.json
static void loadJsonFile ( const char * path , JsonDocument & doc )
{
prefs . begin ( " buddy " , false ) ; // false = read-write, creates namespace if missing
for ( uint8_t i = 0 ; i < NUM_GESTURES ; i + + )
{
char key [ 16 ] ;
snprintf ( key , sizeof ( key ) , " wh.%s.url " , GKEY [ i ] ) ;
String url = prefs . getString ( key , " " ) ;
strncpy ( gConfig [ i ] . url , url . c_str ( ) , sizeof ( gConfig [ i ] . url ) - 1 ) ;
snprintf ( key , sizeof ( key ) , " wh.%s.mood " , GKEY [ i ] ) ;
gConfig [ i ] . mood = ( uint8_t ) prefs . getUInt ( key , 0 ) ;
snprintf ( key , sizeof ( key ) , " wh.%s.en " , GKEY [ i ] ) ;
gConfig [ i ] . enabled = prefs . getBool ( key , true ) ;
snprintf ( key , sizeof ( key ) , " wh.%s.act " , GKEY [ i ] ) ;
gConfig [ i ] . action = ( uint8_t ) prefs . getUInt ( key , 0 ) ;
}
prefs . end ( ) ;
File f = LittleFS . open ( path , " r " ) ;
if ( ! f ) { Serial . printf ( " [Config] %s not found \n " , path ) ; return ; }
if ( deserializeJson ( doc , f ) ! = DeserializationError : : Ok )
Serial . printf ( " [Config] %s parse error \n " , path ) ;
f . close ( ) ;
}
void loadAllConfig ( )
{
if ( ! LittleFS . begin ( true ) ) {
Serial . println ( " [Config] LittleFS mount failed " ) ;
return ;
}
// WiFi from /wifi.json { "ssid": "...", "password": "..." }
{
JsonDocument wdoc ;
loadJsonFile ( " /wifi.json " , wdoc ) ;
strncpy ( WIFI_SSID , wdoc [ " ssid " ] | " " , sizeof ( WIFI_SSID ) - 1 ) ;
strncpy ( WIFI_PASS , wdoc [ " password " ] | " " , sizeof ( WIFI_PASS ) - 1 ) ;
Serial . printf ( " [Config] WiFi SSID: %s \n " , WIFI_SSID ) ;
}
// Gestures from /config.json { "up": {...}, "down": {...}, ... }
{
JsonDocument doc ;
loadJsonFile ( " /config.json " , doc ) ;
for ( uint8_t i = 0 ; i < NUM_GESTURES ; i + + ) {
JsonObject g = doc [ GNAME [ i ] ] ;
if ( g . isNull ( ) ) continue ;
strncpy ( gConfig [ i ] . url , g [ " url " ] | " " , sizeof ( gConfig [ i ] . url ) - 1 ) ;
gConfig [ i ] . mood = g [ " mood " ] | 0 ;
gConfig [ i ] . action = g [ " action " ] | 0 ;
gConfig [ i ] . enabled = g [ " enabled " ] | true ;
}
Serial . println ( " [Config] Gestures loaded " ) ;
}
LittleFS . end ( ) ;
}
// Saves ONLY gesture config — WiFi credentials are never written here
void saveConfig ( )
{
prefs . begin ( " buddy " , false ) ; // read-write
for ( uint8_t i = 0 ; i < NUM_GESTURES ; i + + )
{
char key [ 16 ] ;
snprintf ( key , sizeof ( key ) , " wh.%s.url " , GKEY [ i ] ) ;
prefs . putString ( key , gConfig [ i ] . url ) ;
snprintf ( key , sizeof ( key ) , " wh.%s.mood " , GKEY [ i ] ) ;
prefs . putUInt ( key , gConfig [ i ] . mood ) ;
snprintf ( key , sizeof ( key ) , " wh.%s.en " , GKEY [ i ] ) ;
prefs . putBool ( key , gConfig [ i ] . enabled ) ;
snprintf ( key , sizeof ( key ) , " wh.%s.act " , GKEY [ i ] ) ;
prefs . putUInt ( key , gConfig [ i ] . action ) ;
JsonDocument doc ;
for ( uint8_t i = 0 ; i < NUM_GESTURES ; i + + ) {
doc [ GNAME [ i ] ] [ " url " ] = gConfig [ i ] . url ;
doc [ GNAME [ i ] ] [ " mood " ] = gConfig [ i ] . mood ;
doc [ GNAME [ i ] ] [ " action " ] = gConfig [ i ] . action ;
doc [ GNAME [ i ] ] [ " enabled " ] = gConfig [ i ] . enabled ;
}
prefs . end ( ) ;
Serial . println ( " [Config] Saved to NVS " ) ;
if ( ! LittleFS . begin ( true ) ) {
Serial . println ( " [Config] LittleFS mount failed — save aborted " ) ;
return ;
}
File f = LittleFS . open ( " /config.json " , " w " ) ;
if ( ! f ) {
Serial . println ( " [Config] Cannot open /config.json for writing " ) ;
LittleFS . end ( ) ;
return ;
}
serializeJsonPretty ( doc , f ) ;
f . close ( ) ;
LittleFS . end ( ) ;
Serial . println ( " [Config] Gestures saved to /config.json " ) ;
}
// ── Async webhook via FreeRTOS task ───────────────────────────────────────────
@@ -529,10 +888,6 @@ void fireWebhook(uint8_t gestureIdx)
if ( ! cfg . enabled | | strlen ( cfg . url ) = = 0 )
return ;
// Set mood before firing (immediate feedback)
if ( cfg . mood > 0 )
setBuddyMood ( ( Mood ) cfg . mood , 4000 ) ;
// Fire async
WebhookTask * t = new WebhookTask ;
strncpy ( t - > url , cfg . url , sizeof ( t - > url ) - 1 ) ;
@@ -590,7 +945,7 @@ static String buildHtml()
html + = " '></td><td><select name='mood_ " ;
html + = GKEY [ i ] ;
html + = " '> " ;
for ( uint8_t m = 0 ; m < 7 ; m + + )
for ( uint8_t m = 0 ; m < 12 ; m + + )
{
html + = " <option value=' " ;
html + = m ;
@@ -700,6 +1055,24 @@ void setupHttpServer()
saveConfig ( ) ;
req - > send ( 200 , " application/json " , " { \" ok \" :true} " ) ; } ) ;
// GET /config.json — gesture config only (WiFi credentials are NEVER exposed)
httpServer . on ( " /config.json " , HTTP_GET , [ ] ( AsyncWebServerRequest * req ) {
if ( ! LittleFS . begin ( false ) ) {
req - > send ( 500 , " application/json " , " { \" error \" : \" LittleFS unavailable \" } " ) ;
return ;
}
File f = LittleFS . open ( " /config.json " , " r " ) ;
if ( ! f ) {
LittleFS . end ( ) ;
req - > send ( 404 , " application/json " , " { \" error \" : \" not found \" } " ) ;
return ;
}
String body = f . readString ( ) ;
f . close ( ) ;
LittleFS . end ( ) ;
req - > send ( 200 , " application/json " , body ) ;
} ) ;
httpServer . begin ( ) ;
Serial . printf ( " [HTTP] Server na http://%s/ \n " , WiFi . localIP ( ) . toString ( ) . c_str ( ) ) ;
}
@@ -769,9 +1142,33 @@ void executeAction(uint8_t idx)
Action a = ( Action ) gConfig [ idx ] . action ;
if ( a = = ACTION_NONE )
return ;
uint32_t dur = 8000 ;
switch ( a ) {
case ACTION_FEED :
tama . hunger = ( tama . hunger > = 30 ) ? tama . hunger - 30 : 0 ;
setBuddyMood ( MOOD_HAPPY , 4000 ) ;
dur = 3000 ;
break ;
case ACTION_PLAY :
tama . happiness = ( uint8_t ) min ( ( int ) tama . happiness + 25 , 100 ) ;
setBuddyMood ( MOOD_EXCITED , 4000 ) ;
dur = 3000 ;
break ;
case ACTION_CLEAN :
tama . hygiene = ( uint8_t ) min ( ( int ) tama . hygiene + 40 , 100 ) ;
setBuddyMood ( MOOD_SURPRISED , 3000 ) ;
dur = 3000 ;
break ;
default :
break ;
}
overlayAction = a ;
overlayUntil = millis ( ) + 8000 ; // show for 8 s
Serial . printf ( " [Action] %s -> %s \n " , GNAME [ idx ] , ACTION_LABELS [ a ] ) ;
overlayUntil = millis ( ) + dur ;
Serial . printf ( " [Action] %s -> %s | H:%d P:%d C:%d \n " ,
GNAME [ idx ] , ACTION_LABELS [ a ] ,
tama . hunger , tama . happiness , tama . hygiene ) ;
}
void handleGesture ( Gesture g )
@@ -779,6 +1176,7 @@ void handleGesture(Gesture g)
if ( g = = GES_NONE )
return ;
buddy . lastEvent = millis ( ) ;
if ( g_dimmed ) setDim ( false ) ;
int idx = gestureIndex ( g ) ;
if ( idx < 0 | | idx > = NUM_GESTURES )
@@ -789,14 +1187,15 @@ void handleGesture(Gesture g)
// Execute action (e.g. show datetime)
executeAction ( idx ) ;
// Fire webh ook if config ured
bool webhookFired = ( gConfig [ idx ] . enabled & & strlen ( gConfig [ idx ] . url ) > 0 ) ;
if ( webhookFired )
fireWebhook ( idx ) ;
// Set mood (webhook config overrides default)
if ( ! webhookFired | | gConfig [ idx ] . mood = = 0 )
// Set mood: use configured m ood if set, otherwise default for this gest ure
if ( gConfig [ idx ] . mood > 0 )
setBuddyMood ( ( Mood ) gConfig [ idx ] . mood , 4000 ) ;
else
setBuddyMood ( DEFAULT_MOOD [ idx ] , DEFAULT_MOOD_DUR [ idx ] ) ;
// Fire webhook if URL configured
if ( gConfig [ idx ] . enabled & & strlen ( gConfig [ idx ] . url ) > 0 )
fireWebhook ( idx ) ;
}
// ── Setup ─────────────────────────────────────────────────────────────────────
@@ -809,8 +1208,9 @@ void setup()
splash ( " Desk Buddy " , " Budze sie... " , " " ) ;
delay ( 600 ) ;
loadConfig ( ) ;
loadAllConfig ( ) ;
initBuddy ( ) ;
initTama ( ) ;
// Single I2C bus for both devices: SDA=GPIO22, SCL=GPIO23
Wire . begin ( 22 , 23 ) ;
@@ -845,6 +1245,16 @@ void loop()
handleGesture ( g ) ;
}
// Night dim: 00– 05, after 5 s idle → display off
static uint32_t lastDimCheck = 0 ;
if ( now - lastDimCheck > = 1000 ) {
lastDimCheck = now ;
struct tm t ;
bool night = getLocalTime ( & t ) & & t . tm_hour < 5 ;
bool idle = ( now - buddy . lastEvent ) > 300000UL ;
setDim ( night & & idle ) ;
}
// WiFi keepalive
static uint32_t lastWifi = 0 ;
if ( now - lastWifi > 30000 )
@@ -854,6 +1264,14 @@ void loop()
WiFi . reconnect ( ) ;
}
// Tamagotchi needs (every 10 s — cheap tick check)
static uint32_t lastTama = 0 ;
if ( now - lastTama > = 10000 )
{
lastTama = now ;
updateTama ( ) ;
}
// Buddy animation state (every 50 ms — no drawing here)
static uint32_t lastAnim = 0 ;
if ( now - lastAnim > = 50 )