Community project
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-onlyPan and zoom to explore the wiring. Remix the project to edit it in your own workspace.
Assembly
3 steps放置 K10 并保持传感器可见
将 UNIHIKER K10 平放在干燥稳定的桌面上,屏幕朝上。项目只使用 K10 板载屏幕、加速度计、环境光、温湿度、RGB 灯、扬声器和 A/B 按键,不需要外接元件或跳线。
- Tip: 屏幕上方的环境光传感器不要被贴纸长期遮住;遮住它可体验夜晚模式。
- Tip: 启动后先静置约一秒,让模拟水面平稳下来。
- ⚠ 这是屏幕模拟鱼缸,请勿放入真实鱼缸、溅水区域或潮湿表面。
连接 USB-C 供电
用数据线把 K10 的 USB-C 接口连接至电脑或稳定的标准 USB 5 V 电源。Schematik 的 Deploy 按钮会负责编译和烧录。
- Tip: 使用可传输数据的 USB-C 线。
- Tip: 不需要连接任何 GPIO、Gravity 模块或电池。
- ⚠ 不要给扩展接口或电池接口接入不明电压。
体验鱼缸互动
轻轻左右或前后倾斜 K10,让水体带惯性地荡漾;按 A 键投喂,三条鱼会聚向鱼食;按 B 键启动约 4.5 秒的清洁效果,泡泡和闪光会增多。环境光变暗时会自动进入夜景,RGB 灯会同步变暗;屏幕状态栏显示周围空气温湿度,作为模拟鱼缸环境信息。
- Tip: 轻缓倾斜更容易看出水的回摆和波纹。
- Tip: 按键音只在投喂、清洁或昼夜切换时播放,不会循环播放。
- ⚠ 不要剧烈甩动或让板子跌落;温湿度读数是周围空气数据,不是真实水温。
Firmware
ESP32#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.