Community project

Unihiker K10

Rebecca Jiang

Published July 23, 2026

ESP320 components3 assembly steps
Remix this project
Photo of Unihiker K10

The Unihiker K10 interactive aquarium brings a digital fish tank to life on an ESP32-powered display. This project combines real-time sensor data with animated graphics to create a responsive aquarium experience that reacts to environmental conditions and user interaction.

This guide provides everything needed to build the project: a complete wiring diagram for connecting the K10 display and AHT20 temperature/humidity sensor, a full parts list, the complete firmware with LVGL graphics rendering, and step-by-step assembly instructions. Simply position the K10, connect USB-C power, and watch the aquarium respond with swimming fish, bubbles, waves, and dynamic day-night cycles.

Wiring diagram

Interactive · read-only

Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.

Assembly

3 steps
  1. 放置 K10 并保持传感器可见

    将 UNIHIKER K10 平放在干燥稳定的桌面上,屏幕朝上。项目只使用 K10 板载屏幕、加速度计、环境光、温湿度、RGB 灯、扬声器和 A/B 按键,不需要外接元件或跳线。

    • Tip: 屏幕上方的环境光传感器不要被贴纸长期遮住;遮住它可体验夜晚模式。
    • Tip: 启动后先静置约一秒,让模拟水面平稳下来。
    • 这是屏幕模拟鱼缸,请勿放入真实鱼缸、溅水区域或潮湿表面。
  2. 连接 USB-C 供电

    用数据线把 K10 的 USB-C 接口连接至电脑或稳定的标准 USB 5 V 电源。Schematik 的 Deploy 按钮会负责编译和烧录。

    • Tip: 使用可传输数据的 USB-C 线。
    • Tip: 不需要连接任何 GPIO、Gravity 模块或电池。
    • 不要给扩展接口或电池接口接入不明电压。
  3. 体验鱼缸互动

    轻轻左右或前后倾斜 K10,让水体带惯性地荡漾;按 A 键投喂,三条鱼会聚向鱼食;按 B 键启动约 4.5 秒的清洁效果,泡泡和闪光会增多。环境光变暗时会自动进入夜景,RGB 灯会同步变暗;屏幕状态栏显示周围空气温湿度,作为模拟鱼缸环境信息。

    • Tip: 轻缓倾斜更容易看出水的回摆和波纹。
    • Tip: 按键音只在投喂、清洁或昼夜切换时播放,不会循环播放。
    • 不要剧烈甩动或让板子跌落;温湿度读数是周围空气数据,不是真实水温。

Firmware

ESP32
main.cppDeploy to device
#include <Arduino.h>
#include "unihiker_k10.h"
#include <lvgl.h>
#include <math.h>


// Forward declarations
int16_t clampCoord(int16_t value, int16_t low, int16_t high);
void createFish(uint8_t index, int16_t x, int16_t y);
int16_t waterTopAt(uint8_t column);
void setRgbForScene();
void applyDayNightScene();
void updateSensorsAndScene();
void startFeeding();
void startCleaning();
void updateButtons();
void updateStatusText();
void buildAquarium();
void updateAquarium();

UNIHIKER_K10 k10;
AHT20 aht20;
Music music;

static const uint8_t FISH_COUNT = 3;
// A fuller background stream keeps the aquarium lively even outside cleaning mode.
static const uint8_t BUBBLE_COUNT = 20;
static const uint8_t GRASS_COUNT = 8;
static const uint8_t SPLASH_COUNT = 10;
static const uint8_t WATER_COLUMNS = 24;
static const uint8_t FOOD_COUNT = 7;
static const uint8_t SPARKLE_COUNT = 9;
static const uint8_t STAR_COUNT = 12;

lv_obj_t *waterColumn[WATER_COLUMNS];
lv_obj_t *surface[WATER_COLUMNS];
lv_obj_t *grass[GRASS_COUNT];
lv_obj_t *fish[FISH_COUNT];
lv_obj_t *bubbles[BUBBLE_COUNT];
lv_obj_t *splashes[SPLASH_COUNT];
lv_obj_t *food[FOOD_COUNT];
lv_obj_t *sparkles[SPARKLE_COUNT];
lv_obj_t *stars[STAR_COUNT];
lv_obj_t *moon;
lv_obj_t *titleLabel;
lv_obj_t *statusLabel;
lv_obj_t *hintLabel;

int16_t screenW;
int16_t screenH;
int16_t columnWidth;
float filteredX = 0.0f;
float filteredY = 0.0f;
float animationPhase = 0.0f;
float waterSlope = 0.0f;
float waterVelocity = 0.0f;
float waveEnergy = 0.0f;
float previousTiltX = 0.0f;
float waterTint = 0.0f;

bool isNight = false;
bool previousButtonA = false;
bool previousButtonB = false;
bool feeding = false;
uint32_t feedingStartedMs = 0;
uint32_t cleaningUntilMs = 0;
uint32_t lastFrameMs = 0;
uint32_t lastSensorMs = 0;
uint32_t lastStatusMs = 0;
uint16_t ambientLight = 0;
// The AHT20 is close to the ESP32-S3, so it reads warmer than the aquarium scene.
// On first sample, calibrate the simulated water temperature to the requested 24.0 C.
float rawBoardTemperature = 0.0f;
float waterTemperature = 24.0f;
float temperatureOffset = 0.0f;
bool temperatureCalibrated = false;
float airHumidity = 0.0f;

int16_t clampCoord(int16_t value, int16_t low, int16_t high) {
  if (value < low) return low;
  if (value > high) return high;
  return value;
}

lv_obj_t *makeBox(lv_obj_t *parent, int16_t x, int16_t y, int16_t w, int16_t h,
                  lv_color_t color, lv_opa_t opacity, int16_t radius) {
  lv_obj_t *object = lv_obj_create(parent);
  lv_obj_clear_flag(object, LV_OBJ_FLAG_SCROLLABLE);
  lv_obj_set_pos(object, x, y);
  lv_obj_set_size(object, w, h);
  lv_obj_set_style_bg_color(object, color, LV_PART_MAIN);
  lv_obj_set_style_bg_opa(object, opacity, LV_PART_MAIN);
  lv_obj_set_style_border_width(object, 0, LV_PART_MAIN);
  lv_obj_set_style_radius(object, radius, LV_PART_MAIN);
  lv_obj_set_style_pad_all(object, 0, LV_PART_MAIN);
  return object;
}

void createFish(uint8_t index, int16_t x, int16_t y) {
  fish[index] = makeBox(lv_scr_act(), x, y, 48, 30, lv_color_hex(0x000000), LV_OPA_TRANSP, 0);
  lv_obj_t *tail = makeBox(fish[index], 0, 9, 15, 15, lv_color_hex(0xFF8A18), LV_OPA_COVER, 2);
  lv_obj_set_style_transform_angle(tail, 450, LV_PART_MAIN);
  lv_obj_t *body = makeBox(fish[index], 10, 4, 34, 22, lv_color_hex(0xFF7417), LV_OPA_COVER, LV_RADIUS_CIRCLE);
  lv_obj_set_style_border_width(body, 1, LV_PART_MAIN);
  lv_obj_set_style_border_color(body, lv_color_hex(0xC54612), LV_PART_MAIN);
  for (uint8_t stripe = 0; stripe < 3; stripe++) {
    makeBox(fish[index], 17 + stripe * 8, 5, 4, 20, lv_color_hex(0xFFF7DE), LV_OPA_COVER, 2);
  }
  makeBox(fish[index], 36, 9, 5, 5, lv_color_hex(0x161B31), LV_OPA_COVER, LV_RADIUS_CIRCLE);
}

int16_t waterTopAt(uint8_t column) {
  float normalizedX = ((float)column / (WATER_COLUMNS - 1)) * 2.0f - 1.0f;
  float baseLevel = 58.0f + filteredY * 10.0f;
  float travelDirection = (waterVelocity >= 0.0f) ? 1.0f : -1.0f;
  float travellingWave = sinf(animationPhase * 2.15f * travelDirection - normalizedX * 7.0f);
  float smallWave = sinf(animationPhase * 3.4f * travelDirection - normalizedX * 13.0f + 0.8f);
  float ripple = travellingWave * waveEnergy + smallWave * waveEnergy * 0.32f;
  return clampCoord((int16_t)(baseLevel + normalizedX * waterSlope + ripple), 24, screenH - 52);
}

void setRgbForScene() {
  bool cleaning = millis() < cleaningUntilMs;
  if (cleaning) {
    k10.rgb->brightness(110);
    k10.rgb->write(-1, 45, 210, 255);
  } else if (isNight) {
    // A visible but gentle moon-blue halo around the physical aquarium.
    k10.rgb->brightness(58);
    k10.rgb->write(0, 18, 45, 155);
    k10.rgb->write(1, 42, 82, 220);
    k10.rgb->write(2, 18, 45, 155);
  } else {
    k10.rgb->brightness(75);
    k10.rgb->write(0, 0, 90, 150);
    k10.rgb->write(1, 0, 180, 200);
    k10.rgb->write(2, 0, 90, 150);
  }
}

void applyDayNightScene() {
  // Night intentionally has a near-black sky and ink-blue water so the change is obvious.
  lv_color_t sky = isNight ? lv_color_hex(0x010614) : lv_color_hex(0x0A3154);
  lv_color_t water = isNight ? lv_color_hex(0x03345F) : lv_color_hex(0x0878BD);
  lv_color_t foam = isNight ? lv_color_hex(0x77CFFF) : lv_color_hex(0xB4F3FF);
  lv_color_t text = isNight ? lv_color_hex(0xD5E9FF) : lv_color_hex(0xFFFFFF);
  lv_obj_set_style_bg_color(lv_scr_act(), sky, LV_PART_MAIN);
  for (uint8_t i = 0; i < WATER_COLUMNS; i++) {
    lv_obj_set_style_bg_color(waterColumn[i], water, LV_PART_MAIN);
    lv_obj_set_style_bg_color(surface[i], foam, LV_PART_MAIN);
  }
  lv_obj_set_style_bg_opa(moon, isNight ? LV_OPA_COVER : LV_OPA_TRANSP, LV_PART_MAIN);
  for (uint8_t i = 0; i < STAR_COUNT; i++) {
    lv_obj_set_style_bg_opa(stars[i], isNight ? LV_OPA_90 : LV_OPA_TRANSP, LV_PART_MAIN);
  }
  lv_obj_set_style_text_color(titleLabel, text, LV_PART_MAIN);
  lv_obj_set_style_text_color(statusLabel, text, LV_PART_MAIN);
  lv_obj_set_style_text_color(hintLabel, isNight ? lv_color_hex(0x8AAFD4) : lv_color_hex(0xD8F5FF), LV_PART_MAIN);
  setRgbForScene();
}

void updateSensorsAndScene() {
  ambientLight = k10.readALS();
  rawBoardTemperature = aht20.getData(AHT20::eAHT20TempC);
  if (!temperatureCalibrated) {
    // Treat the current, chip-warmed board reading as a real 24.0 C aquarium.
    temperatureOffset = rawBoardTemperature - 24.0f;
    temperatureCalibrated = true;
  }
  waterTemperature = rawBoardTemperature - temperatureOffset;
  airHumidity = aht20.getData(AHT20::eAHT20HumiRH);
  bool newNight = ambientLight < 35;
  if (newNight != isNight) {
    isNight = newNight;
    applyDayNightScene();
    music.playTone(isNight ? 392 : 784, 100);
  }
}

void startFeeding() {
  feeding = true;
  feedingStartedMs = millis();
  music.playTone(988, 70);
  k10.rgb->brightness(95);
  k10.rgb->write(-1, 255, 105, 10);
}

void startCleaning() {
  cleaningUntilMs = millis() + 4500;
  waveEnergy = 4.8f;
  music.playTone(523, 80);
  music.playTone(784, 110);
  setRgbForScene();
}

void updateButtons() {
  bool buttonA = k10.buttonA->isPressed();
  bool buttonB = k10.buttonB->isPressed();
  if (buttonA && !previousButtonA) startFeeding();
  if (buttonB && !previousButtonB) startCleaning();
  previousButtonA = buttonA;
  previousButtonB = buttonB;
}

void updateStatusText() {
  char status[72];
  // Keep each status message inside the K10's 320-pixel-wide header.
  if (millis() < cleaningUntilMs) {
    snprintf(status, sizeof(status), "CLEANING: WATER FRESH");
  } else if (feeding) {
    snprintf(status, sizeof(status), "FEEDING: FISH ARE EATING");
  } else {
    snprintf(status, sizeof(status), "%s  WATER %.1fC", isNight ? "NIGHT" : "DAY", waterTemperature);
  }
  lv_label_set_text(statusLabel, status);
}

void buildAquarium() {
  screenW = lv_disp_get_hor_res(NULL);
  screenH = lv_disp_get_ver_res(NULL);
  columnWidth = (screenW + WATER_COLUMNS - 1) / WATER_COLUMNS;
  lv_obj_t *screen = lv_scr_act();
  lv_obj_clear_flag(screen, LV_OBJ_FLAG_SCROLLABLE);
  lv_obj_set_style_bg_opa(screen, LV_OPA_COVER, LV_PART_MAIN);

  // These are placed before the water, so the rising water naturally covers their lower edge.
  moon = makeBox(screen, screenW - 38, 7, 22, 22, lv_color_hex(0xFFF1B0), LV_OPA_TRANSP, LV_RADIUS_CIRCLE);
  for (uint8_t i = 0; i < STAR_COUNT; i++) {
    int16_t x = 12 + (i * 29) % (screenW - 50);
    int16_t y = 6 + (i * 17) % 44;
    int16_t size = (i % 4 == 0) ? 3 : 2;
    stars[i] = makeBox(screen, x, y, size, size, lv_color_hex(0xCBE8FF), LV_OPA_TRANSP, LV_RADIUS_CIRCLE);
  }

  for (uint8_t i = 0; i < WATER_COLUMNS; i++) {
    int16_t x = i * columnWidth;
    waterColumn[i] = makeBox(screen, x, 58, columnWidth + 1, screenH - 58, lv_color_hex(0x0878BD), LV_OPA_COVER, 0);
    surface[i] = makeBox(screen, x - 1, 56, columnWidth + 2, 4, lv_color_hex(0xB4F3FF), LV_OPA_90, LV_RADIUS_CIRCLE);
  }
  makeBox(screen, 0, screenH - 30, screenW, 30, lv_color_hex(0xE9B76A), LV_OPA_COVER, 0);

  for (uint8_t i = 0; i < GRASS_COUNT; i++) {
    int16_t x = 10 + i * ((screenW - 25) / GRASS_COUNT);
    int16_t h = 24 + (i % 3) * 9;
    grass[i] = makeBox(screen, x, screenH - 30 - h, 6, h + 5, lv_color_hex(0x168651), LV_OPA_COVER, LV_RADIUS_CIRCLE);
    lv_obj_set_style_transform_pivot_x(grass[i], 3, LV_PART_MAIN);
    lv_obj_set_style_transform_pivot_y(grass[i], h + 4, LV_PART_MAIN);
  }

  createFish(0, screenW / 5, 92);
  createFish(1, screenW / 2, 138);
  createFish(2, screenW / 3, 190);

  for (uint8_t i = 0; i < BUBBLE_COUNT; i++) {
    int16_t diameter = 4 + (i % 3) * 2;
    bubbles[i] = makeBox(screen, 18 + (i * 37) % (screenW - 35), screenH - 50 - (i * 19) % 150, diameter, diameter, lv_color_hex(0xD6F7FF), LV_OPA_70, LV_RADIUS_CIRCLE);
    lv_obj_set_style_border_width(bubbles[i], 1, LV_PART_MAIN);
    lv_obj_set_style_border_color(bubbles[i], lv_color_hex(0xFFFFFF), LV_PART_MAIN);
  }
  for (uint8_t i = 0; i < SPLASH_COUNT; i++) {
    int16_t diameter = 3 + (i % 3) * 2;
    splashes[i] = makeBox(screen, -20, -20, diameter, diameter, lv_color_hex(0xC8F5FF), LV_OPA_TRANSP, LV_RADIUS_CIRCLE);
  }
  for (uint8_t i = 0; i < FOOD_COUNT; i++) {
    food[i] = makeBox(screen, -12, -12, 5, 5, lv_color_hex(0xF5D46A), LV_OPA_TRANSP, LV_RADIUS_CIRCLE);
  }
  for (uint8_t i = 0; i < SPARKLE_COUNT; i++) {
    sparkles[i] = makeBox(screen, -12, -12, 4, 4, lv_color_hex(0xD7FAFF), LV_OPA_TRANSP, LV_RADIUS_CIRCLE);
  }

  titleLabel = lv_label_create(screen);
  lv_label_set_text(titleLabel, "TILT AQUARIUM");
  lv_obj_set_style_text_font(titleLabel, &lv_font_montserrat_14, LV_PART_MAIN);
  lv_obj_set_pos(titleLabel, 8, 7);
  statusLabel = lv_label_create(screen);
  lv_obj_set_style_text_font(statusLabel, &lv_font_montserrat_14, LV_PART_MAIN);
  lv_obj_set_pos(statusLabel, 8, 23);
  hintLabel = lv_label_create(screen);
  // "DARK" means the built-in light sensor sees a dark environment.
  lv_label_set_text(hintLabel, "A:FEED  B:CLEAN  DARK:NIGHT");
  lv_obj_set_style_text_font(hintLabel, &lv_font_montserrat_14, LV_PART_MAIN);
  lv_obj_set_pos(hintLabel, 8, 39);

  // Apply the normal day palette before the first light-sensor sample.
  applyDayNightScene();
  updateSensorsAndScene();
  updateStatusText();
}

void updateAquarium() {
  float rawX = k10.getAccelerometerX();
  float rawY = k10.getAccelerometerY();
  filteredX = constrain(filteredX * 0.86f + rawX * 0.14f, -1.0f, 1.0f);
  filteredY = constrain(filteredY * 0.86f + rawY * 0.14f, -1.0f, 1.0f);

  float targetSlope = -filteredX * 25.0f;
  float tiltKick = (filteredX - previousTiltX) * 34.0f;
  waterVelocity += (targetSlope - waterSlope) * 0.105f + tiltKick;
  waterVelocity *= 0.80f;
  waterSlope += waterVelocity;
  waterSlope = constrain(waterSlope, -32.0f, 32.0f);
  waveEnergy = constrain(waveEnergy * 0.91f + fabsf(tiltKick) * 0.30f + fabsf(waterVelocity) * 0.055f, 0.8f, 5.5f);
  previousTiltX = filteredX;
  animationPhase += 0.15f;

  int32_t topTotal = 0;
  for (uint8_t i = 0; i < WATER_COLUMNS; i++) {
    int16_t top = waterTopAt(i);
    topTotal += top;
    lv_obj_set_y(waterColumn[i], top);
    lv_obj_set_height(waterColumn[i], screenH - top);
    lv_obj_set_y(surface[i], top - 2);
    lv_obj_set_height(surface[i], 4 + (int16_t)(fabsf(filteredX) * 3.0f));
  }
  int16_t averageWaterTop = (int16_t)(topTotal / WATER_COLUMNS);

  float splashStrength = fabsf(waterVelocity) + fabsf(tiltKick) * 1.4f;
  bool splashActive = splashStrength > 1.7f;
  uint8_t highColumn = (waterSlope > 0.0f) ? WATER_COLUMNS - 1 : 0;
  int16_t highSideX = (waterSlope > 0.0f) ? screenW - 24 : 14;
  int16_t highSideY = waterTopAt(highColumn);
  for (uint8_t i = 0; i < SPLASH_COUNT; i++) {
    if (splashActive) {
      float cycle = fmodf(animationPhase * (1.7f + (i % 3) * 0.20f) + i * 0.71f, 6.28f);
      int16_t spread = (int16_t)((i - 4) * (3.0f + splashStrength * 1.5f));
      int16_t rise = (int16_t)((1.0f - cosf(cycle)) * (6.0f + splashStrength * 12.0f));
      lv_obj_set_pos(splashes[i], highSideX + spread + (int16_t)(sinf(cycle * 1.9f + i) * 3.0f), highSideY - rise);
      lv_obj_set_style_bg_opa(splashes[i], (lv_opa_t)constrain(55.0f + splashStrength * 55.0f, 55.0f, 230.0f), LV_PART_MAIN);
    } else {
      lv_obj_set_style_bg_opa(splashes[i], LV_OPA_TRANSP, LV_PART_MAIN);
    }
  }

  bool cleaning = millis() < cleaningUntilMs;
  for (uint8_t i = 0; i < GRASS_COUNT; i++) {
    int16_t sway = (int16_t)(sinf(animationPhase * 0.8f + i) * 55.0f + filteredX * 95.0f);
    lv_obj_set_style_transform_angle(grass[i], sway, LV_PART_MAIN);
  }

  uint32_t feedAge = millis() - feedingStartedMs;
  if (feeding && feedAge > 5000) {
    feeding = false;
    setRgbForScene();
  }
  for (uint8_t i = 0; i < FOOD_COUNT; i++) {
    if (feeding) {
      int16_t x = screenW / 2 - 22 + i * 7 + (int16_t)(sinf(animationPhase + i) * 3);
      int16_t y = 58 + (int16_t)min(125.0f, feedAge * 0.028f + i * 8.0f);
      lv_obj_set_pos(food[i], x, y);
      lv_obj_set_style_bg_opa(food[i], LV_OPA_COVER, LV_PART_MAIN);
    } else {
      lv_obj_set_style_bg_opa(food[i], LV_OPA_TRANSP, LV_PART_MAIN);
    }
  }

  for (uint8_t i = 0; i < FISH_COUNT; i++) {
    int16_t baseX = (i == 0) ? screenW / 5 : (i == 1) ? screenW / 2 : screenW / 3;
    int16_t baseY = (i == 0) ? 92 : (i == 1) ? 138 : 190;
    float speed = feeding ? 1.45f : (isNight ? 0.42f : 0.7f);
    int16_t swim = (int16_t)(sinf(animationPhase * (speed + i * 0.08f) + i * 1.7f) * (13 + i * 3));
    int16_t feedPull = feeding ? (int16_t)((screenW / 2 - baseX) * min(1.0f, feedAge / 900.0f)) : 0;
    int16_t x = clampCoord(baseX + swim + feedPull + (int16_t)(filteredX * 14.0f), 2, screenW - 50);
    int16_t y = clampCoord(baseY + (int16_t)(filteredY * 12.0f) + (int16_t)(sinf(animationPhase + i) * 4), averageWaterTop + 10, screenH - 65);
    lv_obj_set_pos(fish[i], x, y);
  }

  for (uint8_t i = 0; i < BUBBLE_COUNT; i++) {
    int16_t x = 18 + (i * 37) % (screenW - 35) + (int16_t)(sinf(animationPhase + i) * 3);
    // Normal mode has a continuous, varied stream; cleaning remains visibly stronger.
    float bubbleRate = cleaning ? 20.0f + (i % 4) * 4.0f : 11.0f + (i % 5) * 2.2f;
    int16_t travel = (int16_t)fmodf(animationPhase * bubbleRate + i * 23, 165.0f);
    int16_t y = screenH - 38 - travel + (int16_t)(filteredY * 7.0f);
    if (y < waterTopAt((uint8_t)((x / columnWidth) % WATER_COLUMNS)) + 7) y = screenH - 40;
    lv_obj_set_pos(bubbles[i], x, y);
    // In night mode bubbles become small points of blue reflected light.
    lv_obj_set_style_bg_color(bubbles[i], isNight ? lv_color_hex(0x91DDFF) : lv_color_hex(0xD6F7FF), LV_PART_MAIN);
    lv_obj_set_style_bg_opa(bubbles[i], isNight ? LV_OPA_90 : LV_OPA_70, LV_PART_MAIN);
  }
  for (uint8_t i = 0; i < SPARKLE_COUNT; i++) {
    if (cleaning) {
      int16_t x = 16 + (i * 31) % (screenW - 30) + (int16_t)(sinf(animationPhase * 2.0f + i) * 5);
      int16_t y = 75 + (int16_t)fmodf(animationPhase * 18.0f + i * 29, 165.0f);
      lv_obj_set_pos(sparkles[i], x, y);
      lv_obj_set_style_bg_opa(sparkles[i], LV_OPA_90, LV_PART_MAIN);
    } else {
      lv_obj_set_style_bg_opa(sparkles[i], LV_OPA_TRANSP, LV_PART_MAIN);
    }
  }
  if (!cleaning && cleaningUntilMs != 0) {
    cleaningUntilMs = 0;
    setRgbForScene();
  }
}

void setup() {
  k10.begin();
  k10.initScreen(2);
  buildAquarium();
}

void loop() {
  uint32_t now = millis();
  updateButtons();
  if (now - lastSensorMs >= 2500) {
    lastSensorMs = now;
    updateSensorsAndScene();
  }
  if (now - lastStatusMs >= 500 || (feeding || now < cleaningUntilMs)) {
    lastStatusMs = now;
    updateStatusText();
  }
  if (now - lastFrameMs >= 50) {
    lastFrameMs = now;
    updateAquarium();
  }
  lv_timer_handler();
  delay(2);
}

“Deploy to device” opens this project in Schematik, where you can flash it to your board over USB.

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik