Community project
Unihiker K10 K10

This project brings two classical visual experiences to life on the UNIHIKER K10 display: a blooming lotus flower that unfolds in stages, and a sparkling iron-flower effect that mimics the traditional Chinese craft of打铁花 (iron flower striking). The guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions to get the K10 up and running.
The firmware uses the ESP32 to drive animated graphics on the K10's 240x320 display, rendering layered lotus petals in soft lavender tones, water ripples, and dynamic spark particles that respond to device motion. Builders will learn how to work with the K10 hardware abstraction layer, implement particle systems, and create smooth animations on a resource-constrained microcontroller.
Wiring diagram
Interactive · read-onlyPan and zoom to explore the wiring. Remix the project to edit it in your own workspace.
Assembly
3 steps准备 UNIHIKER K10
无需外接元件。将 UNIHIKER K10 放在手中或平稳桌面上,先保持静止约 1 秒,让程序读取静止状态下的加速度作为甩动基准。
- Tip: 首次启动时不要立刻甩动;静止校准后,甩动检测会适应传感器实际输出单位。
- ⚠ 请勿在 USB-C 线被拉紧、靠近桌边、人员或易碎物品时大力甩动设备。
观察荷花绽放
通电后,荷塘底图会先绘制一次。中央荷花随后以局部叠加方式逐层展开,使用尖端花瓣、深浅粉色外瓣与金色莲蓬表现接近真实荷花的层次。
- Tip: 画面不会在每一帧清屏;背景保持不动,只绘制新增花瓣图案。
体验打铁花
设备静止校准完成后,握紧 K10 做一次明确、快速的甩动或挥动。加速度相对静止重力偏离超过自适应阈值时,中央会出现向上弧形飞散的金、橙、白色熔铁火花。约 1.45 秒后,水波图案会局部覆盖火花并恢复荷花。
- Tip: 如果第一次没有触发,请先静止约 1 秒,再用更短促、幅度更明显的动作甩动。
- Tip: 连续触发间隔约 0.42 秒,避免同一次甩动重复触发。
- ⚠ 甩动只需明显、短促,不需要抛掷或碰撞设备。
Firmware
ESP32#include <Arduino.h>
#include <math.h>
#include "unihiker_k10.h"
struct Spark {
int startX;
int startY;
float vx;
float vy;
uint16_t lifeMs;
uint16_t ageMs;
int lastX;
int lastY;
int lastRadius;
bool wasDrawn;
};
// Forward declarations
uint32_t nextRandom();
int randomRange(int low, int high);
void dot(int x, int y, int radius, uint16_t color);
void pointedPetal(int baseX, int baseY, int tipX, int tipY, int width, uint16_t edge, uint16_t fill);
void drawPad(int x, int y, int radius);
void drawPond();
void drawStemAndRipples();
void drawLotusStage(uint8_t stage);
void coverLotusForNextBloom();
void startIronSparks();
void updateIronSparks(uint32_t now);
void coverSparkPixel(int x, int y, int radius);
void sampleShake(uint32_t now);
UNIHIKER_K10 k10;
// The K10 canvas is 240 x 320 in this orientation.
const uint16_t SKY = 0x75DE;
const uint16_t WATER = 0x1C9F;
const uint16_t WATER_SHADOW = 0x0B56;
const uint16_t RIPPLE = 0x7EFF;
const uint16_t PAD_DARK = 0x05A0;
const uint16_t PAD_LIGHT = 0x07E0;
const uint16_t STEM = 0x04A0;
// Lavender-pink lotus palette. PETAL_MID is RGB565 for approximately
// RGB(221,182,251); shadow and highlight colours make overlapping pointed
// petals read as layered, while each circular drawing unit remains outline=fill.
const uint16_t PETAL_OUTER = 0xBC7C; // approx. RGB(189,143,226), rear-petal shadow
const uint16_t PETAL_MID = 0xDDBF; // approx. RGB(221,182,251), main petal colour
const uint16_t PETAL_LIGHT = 0xEE9F; // approx. RGB(238,214,255), front-petal highlight
const uint16_t PETAL_VEIN = 0xCCFD;
const uint16_t GOLD = 0xFE40;
const uint16_t SPARK_ORANGE = 0xFC40;
const uint16_t SPARK_GOLD = 0xFFE0;
const uint16_t WHITE = 0xFFFF;
const uint32_t FLOWER_STEP_MS = 125;
const uint32_t SENSOR_SAMPLE_MS = 20;
// Keep the complete iron-flower performance visible before restoring the lotus.
const uint32_t SPARK_TIME_MS = 2200;
const uint32_t SPARK_FRAME_MS = 50;
const uint32_t RETRIGGER_LOCKOUT_MS = 700;
const uint8_t SPARK_COUNT = 96;
uint32_t lastFlowerStep = 0;
uint32_t lastSensorSample = 0;
uint32_t sparkStartedAt = 0;
uint32_t lastTriggerAt = 0;
uint32_t lastSparkFrame = 0;
uint8_t bloomStep = 0;
bool sparksVisible = false;
float gravityBaseline = 0.0f;
bool baselineReady = false;
uint8_t quietSamples = 0;
// Each spark keeps its own flight and brightness. They are drawn as small local
// overlays and erased with pond colours before their next position is drawn.
Spark sparks[SPARK_COUNT];
uint32_t rngState = 0x9E3779B9UL;
uint32_t nextRandom() {
rngState = rngState * 1664525UL + 1013904223UL;
return rngState;
}
int randomRange(int low, int high) {
return low + (int)(nextRandom() % (uint32_t)(high - low));
}
void dot(int x, int y, int radius, uint16_t color) {
k10.canvas->canvasCircle(x, y, radius, color, color, true);
}
// A pointed petal is built from overlapping, progressively smaller circles.
// It has a wide base and a narrow pointed tip instead of the old round flower blobs.
void pointedPetal(int baseX, int baseY, int tipX, int tipY, int width, uint16_t edge, uint16_t fill) {
const int layers = 9;
for (int i = 0; i < layers; ++i) {
float t = (float)i / (float)(layers - 1);
int x = baseX + (int)((tipX - baseX) * t);
int y = baseY + (int)((tipY - baseY) * t);
int r = 2 + (int)((1.0f - t) * width);
// Every circle uses one identical outline/fill colour. The taper comes from
// the overlap geometry, not from a contrasting circular border.
dot(x, y, r + 1, fill);
dot(x, y, r, fill);
}
// The final small dot creates a visibly sharp tip.
dot(tipX, tipY, 2, fill);
dot(tipX, tipY, 1, fill);
}
void drawPad(int x, int y, int radius) {
dot(x, y, radius, PAD_DARK);
dot(x - 3, y - 3, radius - 5, PAD_LIGHT);
// Notch and centre highlight suggest a real floating lotus leaf.
dot(x + radius / 2, y - radius / 2, 7, WATER);
dot(x - 4, y - 4, 3, 0x9EEC);
}
void drawPond() {
k10.setScreenBackground(SKY);
k10.canvas->canvasClear();
// Still water is a static base; it is never cleared during animation.
dot(25, 77, 60, WATER);
dot(102, 85, 62, WATER);
dot(192, 79, 64, WATER);
dot(30, 174, 74, WATER);
dot(115, 175, 72, WATER);
dot(205, 176, 77, WATER);
dot(40, 280, 86, WATER_SHADOW);
dot(130, 282, 90, WATER_SHADOW);
dot(220, 280, 83, WATER_SHADOW);
drawPad(32, 225, 30);
drawPad(205, 228, 34);
drawPad(39, 293, 36);
drawPad(205, 290, 39);
for (int i = 0; i < 14; ++i) {
dot(randomRange(8, 232), randomRange(75, 309), 2, RIPPLE);
}
}
void drawStemAndRipples() {
for (int y = 220; y < 291; y += 6) {
dot(120, y, 4, STEM);
}
// Concentric dotted ripples at the flower base.
for (int r = 18; r <= 36; r += 9) {
for (int a = 0; a < 12; ++a) {
float angle = 6.2831853f * a / 12.0f;
dot(120 + (int)(cosf(angle) * r), 235 + (int)(sinf(angle) * r / 3), 1, RIPPLE);
}
}
}
// Restore only the flower's local area with water and ripple motifs before a new bloom.
// This intentionally uses patterned coverage rather than clearing or refreshing the screen.
void coverLotusForNextBloom() {
dot(120, 185, 58, WATER);
dot(120, 214, 54, WATER);
dot(120, 236, 42, WATER);
for (int r = 16; r <= 43; r += 9) {
for (int a = 0; a < 12; ++a) {
float angle = 6.2831853f * a / 12.0f;
dot(120 + (int)(cosf(angle) * r), 225 + (int)(sinf(angle) * r / 3), 1, RIPPLE);
}
}
}
void drawLotusStage(uint8_t stage) {
// Each stage only adds new petal shapes. No screen clear and no background redraw.
const int cx = 120;
const int cy = 191;
int spread = 13 + stage * 4;
int rise = 12 + stage * 3;
drawStemAndRipples();
// Rear ring: cool pink shadow petals.
pointedPetal(cx - 8, cy + 16, cx - spread - 22, cy + 9, 10 + stage, PETAL_OUTER, PETAL_MID);
pointedPetal(cx + 8, cy + 16, cx + spread + 22, cy + 9, 10 + stage, PETAL_OUTER, PETAL_MID);
pointedPetal(cx - 5, cy + 14, cx - spread, cy - rise - 17, 10 + stage, PETAL_OUTER, PETAL_MID);
pointedPetal(cx + 5, cy + 14, cx + spread, cy - rise - 17, 10 + stage, PETAL_OUTER, PETAL_MID);
// Front ring: lighter petals with narrow, tapered tips.
pointedPetal(cx - 14, cy + 15, cx - spread - 9, cy - 10, 9 + stage, PETAL_MID, PETAL_LIGHT);
pointedPetal(cx + 14, cy + 15, cx + spread + 9, cy - 10, 9 + stage, PETAL_MID, PETAL_LIGHT);
pointedPetal(cx, cy + 16, cx, cy - rise - 30, 9 + stage, PETAL_MID, PETAL_LIGHT);
// The seedpod is visible only once the flower is sufficiently open.
if (stage >= 4) {
dot(cx, cy + 5, 11, GOLD);
for (int i = 0; i < 6; ++i) {
float a = 6.2831853f * i / 6.0f;
dot(cx + (int)(cosf(a) * 5), cy + 5 + (int)(sinf(a) * 4), 1, 0xA540);
}
}
}
// Cover only the previous spark's small footprint. There is deliberately no
// black erase colour: every fading point is replaced by a water/sky highlight.
void coverSparkPixel(int x, int y, int radius) {
uint16_t base = (y < 72) ? SKY : ((y > 250) ? WATER_SHADOW : WATER);
dot(x, y, radius + 1, base);
if (y >= 72 && (x + y) % 3 == 0) {
dot(x + 1, y, 1, RIPPLE);
}
}
void startIronSparks() {
for (uint8_t i = 0; i < SPARK_COUNT; ++i) {
Spark &s = sparks[i];
// Sources span the lower pond, then spread into every part of the 240x320 view.
s.startX = randomRange(70, 171);
s.startY = randomRange(190, 246);
s.vx = (float)randomRange(-165, 166) / 100.0f;
s.vy = -(float)randomRange(145, 310) / 100.0f;
// Each particle remains visible for a substantial part of the performance,
// so the shower cannot disappear immediately after a valid shake.
s.lifeMs = randomRange(1050, SPARK_TIME_MS + 1);
s.ageMs = 0;
s.lastX = s.startX;
s.lastY = s.startY;
s.lastRadius = 0;
s.wasDrawn = false;
}
sparkStartedAt = millis();
lastSparkFrame = 0;
sparksVisible = true;
}
void updateIronSparks(uint32_t now) {
if (!sparksVisible || now - lastSparkFrame < SPARK_FRAME_MS) return;
uint32_t elapsed = now - sparkStartedAt;
lastSparkFrame = now;
for (uint8_t i = 0; i < SPARK_COUNT; ++i) {
Spark &s = sparks[i];
if (s.wasDrawn) coverSparkPixel(s.lastX, s.lastY, s.lastRadius);
if (elapsed >= s.lifeMs) {
s.wasDrawn = false;
continue;
}
float t = (float)elapsed / 1000.0f;
float fade = 1.0f - (float)elapsed / (float)s.lifeMs;
// Ballistic arcs reach the whole canvas: bright molten iron rises, spreads,
// then falls and fades naturally in the air.
int x = s.startX + (int)(s.vx * 100.0f * t);
int y = s.startY + (int)(s.vy * 100.0f * t + 92.0f * t * t);
if (x < 2 || x > 237 || y < 2 || y > 317) {
s.wasDrawn = false;
continue;
}
int radius = (fade > 0.72f) ? 3 : ((fade > 0.32f) ? 2 : 1);
uint16_t color = (fade > 0.72f) ? WHITE : ((fade > 0.38f) ? SPARK_GOLD : SPARK_ORANGE);
dot(x, y, radius, color);
// A small tail makes the hot metal read as moving, not as bubbles.
if (fade > 0.25f) dot(x - (int)(s.vx * 2.0f), y + 2, 1, SPARK_ORANGE);
s.lastX = x;
s.lastY = y;
s.lastRadius = radius;
s.wasDrawn = true;
}
if (elapsed >= SPARK_TIME_MS) {
// A full visual reset is intentional here: sparks can cross every part of
// the pond, including lotus leaves and sky. Rebuilding the initial pond
// removes every fading-star trace instead of trying to patch it with dots.
drawPond();
bloomStep = 0;
lastFlowerStep = now;
drawLotusStage(bloomStep);
sparksVisible = false;
}
k10.canvas->updateCanvas();
}
void sampleShake(uint32_t now) {
if (now - lastSensorSample < SENSOR_SAMPLE_MS) return;
lastSensorSample = now;
float ax = k10.getAccelerometerX();
float ay = k10.getAccelerometerY();
float az = k10.getAccelerometerZ();
float magnitude = sqrtf(ax * ax + ay * ay + az * az);
if (!isfinite(magnitude) || magnitude <= 0.001f) return;
// Works whether this driver reports g or milli-g: the threshold scales from its
// own measured resting gravity rather than assuming a particular unit.
if (!baselineReady) {
gravityBaseline = magnitude;
baselineReady = true;
return;
}
float deviation = fabsf(magnitude - gravityBaseline);
// A firm hand swing is detected from a change relative to the measured
// resting gravity. The lower relative threshold restores reliable triggering
// across K10 sensor orientations and output units.
float triggerLevel = fmaxf(gravityBaseline * 0.12f, 0.10f);
if (deviation < gravityBaseline * 0.07f) {
gravityBaseline = gravityBaseline * 0.985f + magnitude * 0.015f;
if (quietSamples < 255) ++quietSamples;
} else {
quietSamples = 0;
}
if (deviation > triggerLevel && now - lastTriggerAt > RETRIGGER_LOCKOUT_MS) {
lastTriggerAt = now;
if (!sparksVisible) startIronSparks();
}
}
void setup() {
k10.begin();
k10.initScreen(2);
k10.creatCanvas();
drawPond();
drawLotusStage(0);
k10.canvas->updateCanvas();
}
void loop() {
const uint32_t now = millis();
sampleShake(now);
updateIronSparks(now);
// A slow, event-like bloom: one new overlay every 125 ms, then it remains still.
if (!sparksVisible && bloomStep < 6 && now - lastFlowerStep >= FLOWER_STEP_MS) {
lastFlowerStep = now;
++bloomStep;
drawLotusStage(bloomStep);
k10.canvas->updateCanvas();
}
}“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.