A compact, battery-powered desktop weather station built around the UNIHIKER K10 (ESP32-S3). It reads temperature, humidity, pressure, ambient light and UV from a Gravity environmental sensor, and air quality (AQI / eCO2 / TVOC) from an ENS160 sensor, then shows everything on a clean dark-neon GUI. The onboard RGB LEDs pulse with the live air-quality level, and the 4 RGB LEDs on the building-block expansion board run selectable color moods. The two onboard buttons let you control the lighting without touching the code.
- Reads temperature, humidity, barometric pressure, ambient light (lux) and UV from the Gravity SEN0501 sensor.
- Reads air quality — AQI (1–5), equivalent CO2 (eCO2) and total VOCs (TVOC) — from the ENS160 sensor.
- Reads the battery percentage from the expansion board.
- Shows all of it on a 240×320 dark-neon card UI with an Air Quality panel and a colored AQI badge.
- Onboard RGB LEDs pulse in the air-quality color (green → yellow → amber → red), pulsing faster as air quality worsens.
- Expansion-board RGB LEDs show a selectable color mood (Rainbow, Ocean, Fire, Forest, Purple, White).
- Button A cycles which LEDs are on: ALL → UNIHIKER only → BASE only → OFF.
- Button B cycles the base LED color mood.
The UNIHIKER K10's I2C bus uses SDA = GPIO47 and SCL = GPIO48. The Gravity port, the expansion-board edge connector and the onboard sensors all share this one bus, so every device just needs a unique address:
Because they're all on the same bus, the sensors can be daisy-chained on the Gravity ports, and the code talks to each by address.
The expansion board is smartThe MBT0044 expansion board is not a passive breakout. It has its own I2C co-processor at address 0x33 that handles motors, servos, function pins and the battery gauge. The battery percentage is read from register 0x87. (This protocol was reverse engineered from DFRobot's official micro:bit MakeCode extension and ported to Arduino C++.) The board's four RGB LEDs, however, are plain WS2812s wired to the micro:bit edge pin P1, which maps to GPIO2 on the K10 — so those are driven directly with the NeoPixel library, not through the co-processor.
Air quality → colorThe ENS160 returns an AQI from 1 (excellent) to 5 (unhealthy). The sketch maps that to a color and a pulse speed:
So, the worse the air, the more urgently the onboard LEDs blink — an ambient alert you notice from across the room.
The displayThe K10 screen is 240×320 in portrait orientation. The UI is drawn on an off-screen canvas (k10.canvas) and pushed in one go with updateCanvas(), which keeps it flicker-free. The layout is six metric cards (temp, humidity, pressure, light, UV, battery) plus a full-width Air Quality panel showing the status word, a colored AQI badge, eCO2 and TVOC.
The two onboard buttons are read with k10.buttonA->isPressed() and k10.buttonB->isPressed(). The code uses simple edge detection (act only on a new press) so each click advances one step:
- Button A → LED power mode: ALL → UNIHIKER only → BASE only → OFF.
- Button B → base LED color mood: Rainbow → Ocean → Fire → Forest → Purple → White.
In Arduino IDE, add the UNIHIKER board package (per DFRobot's K10 wiki), then select the board UNIHIKER K10. With arduino-cli the board target (FQBN) is:
unihiker_k10 and Adafruit_NeoPixel ship with the K10 board package. Install the two sensor libraries from Library Manager (or DFRobot's GitHub):
- DFRobot_EnvironmentalSensor
- DFRobot_ENS160
Serial Monitor shows nothing? The K10 has *USB CDC On Boot* disabled by default, which sends Serial to UART0 instead of the USB port. To see Serial output over USB, enable USB CDC On Boot in the Tools menu (or use the FQBN option UNIHIKER:esp32:k10:CDCOnBoot=cdc). The screen UI doesn't need this — it's only for debugging.
1. Snap the UNIHIKER K10 onto the expansion board's edge connector.
2. Plug the SEN0501 and ENS160 sensors into the Gravity I2C ports (they share the bus — daisy-chain or use both I2C sockets).
3. Insert a charged 18650 cell into the expansion board and switch it on (needed for the battery reading and the base LEDs).
4. Connect the USB-C cable to upload.
Wiring and assemblyEverything is plug-and-play — no soldering. The two Gravity sensors share the K10's single I2C bus, and the K10 snaps onto the expansion board's edge connector.
Assembly steps1. Snap the UNIHIKER K10 onto the expansion board's edge connector.
2. Plug the SEN0501 and ENS160 sensors into the Gravity I2C ports (both share the bus).
3. Insert a charged 18650 cell and switch the board ON (required for the battery reading and the base LEDs).
4. Connect USB-C to upload the firmware.
The code/*!
* @file WeatherStation.ino
* @brief Futuristic weather station for the UNIHIKER K10.
*
* Sensors:
* - Gravity Temp/Humidity/Pressure/Light/UV (SEN0501, 0x22)
* - Gravity ENS160 Air Quality AQI/eCO2/TVOC (SEN0514, 0x53)
* - Battery % from MBT0044 Expansion Board (0x33)
*
* Buttons:
* A - cycle LED power: ALL -> UNIHIKER -> BASE -> OFF
* B - cycle BASE (expansion) LED colour mood:
* Rainbow -> Ocean -> Fire -> Forest -> Purple -> White
*
* Onboard RGB pulses by air-quality colour. Header shows LED state + mood.
*
* Build: arduino-cli ... --fqbn UNIHIKER:esp32:k10:CDCOnBoot=cdc
*/
#include "unihiker_k10.h"
#include "DFRobot_EnvironmentalSensor.h"
#include <DFRobot_ENS160.h>
#include <Adafruit_NeoPixel.h>
// ---------- palette (0xRRGGBB) ----------
#define BG 0x0A0A0B
#define CARD 0x17181B
#define HEADER 0x111113
#define CYAN 0x22D3EE
#define WHITE 0xF0F6FF
#define DIM 0x8896B0
#define ORANGE 0xFF8C42
#define BLUE 0x4EA8FF
#define PURPLE 0xB388FF
#define UVCLR 0xC77DFF
#define GREEN 0x35E07F
#define YELLOW 0xFFD93D
#define AMBER 0xFF9F1C
#define RED 0xFF4D6D
UNIHIKER_K10 k10;
DFRobot_EnvironmentalSensor environment(SEN050X_DEFAULT_DEVICE_ADDRESS, &Wire); // 0x22
DFRobot_ENS160_I2C ens160(&Wire, 0x53);
#define EXP_I2C_ADDR 0x33
#define EXP_LED_PIN 2
#define EXP_LED_COUNT 4
Adafruit_NeoPixel expLeds(EXP_LED_COUNT, EXP_LED_PIN, NEO_GRB + NEO_KHZ800);
// base LED colour moods (index 0 = rainbow, others = breathing hue)
#define NUM_FX 6
const uint16_t FX_HUE[NUM_FX] = { 0, 28000, 1200, 21845, 48000, 0 };
const uint8_t FX_SAT[NUM_FX] = { 0, 255, 255, 255, 255, 0 };
const char* FX_NAME[NUM_FX] = { "RAINBOW", "OCEAN", "FIRE", "FOREST", "PURPLE", "WHITE" };
const uint32_t FX_COL[NUM_FX] = { CYAN, BLUE, ORANGE, GREEN, PURPLE, WHITE };
bool envOk = false, ensOk = false, expOk = false;
float gTemp = 0, gHum = 0, gLux = 0, gUv = 0;
uint16_t gPress = 0;
uint8_t gAqi = 0; uint16_t gECO2 = 0, gTVOC = 0; uint8_t gEnsState = 0;
int gBatt = -1;
uint32_t lastBlink = 0, lastSensor = 0, lastBase = 0;
bool ledOn = false;
uint16_t rainbowHue = 0;
int breathPhase = 20, breathDir = 4;
int ledMode = 0; // 0 ALL, 1 UNIHIKER only, 2 BASE only, 3 OFF
int baseFx = 0; // base LED colour mood index
bool prevBtnA = false, prevBtnB = false;
const char* ledModeName() {
switch (ledMode) { case 0: return "ALL"; case 1: return "UNI"; case 2: return "BASE"; default: return "OFF"; }
}
bool baseActive() { return (ledMode == 0 || ledMode == 2); }
// ============ expansion board ============
bool expVersionOk() {
Wire.beginTransmission(EXP_I2C_ADDR); Wire.write(0xF0);
if (Wire.endTransmission() != 0) return false;
delay(8);
if (Wire.requestFrom((int)EXP_I2C_ADDR, 1) != 1) return false;
return (Wire.read() == 0x10);
}
void expInit() {
Wire.beginTransmission(EXP_I2C_ADDR); Wire.write(0xA0); Wire.write(0x01);
Wire.endTransmission(); delay(400);
}
int expBattery() {
Wire.beginTransmission(EXP_I2C_ADDR); Wire.write(0x87);
if (Wire.endTransmission() != 0) return -1;
delay(8);
if (Wire.requestFrom((int)EXP_I2C_ADDR, 1) != 1) return -1;
return Wire.read();
}
// ============ air-quality helpers ============
uint32_t aqiColor() {
switch (gAqi) { case 1: case 2: return GREEN; case 3: return YELLOW;
case 4: return AMBER; case 5: return RED; default: return DIM; }
}
const char* aqiWord() {
switch (gAqi) { case 1: return "EXCELLENT"; case 2: return "GOOD"; case 3: return "MODERATE";
case 4: return "POOR"; case 5: return "UNHEALTHY"; default: return "--"; }
}
uint32_t blinkInterval() {
switch (gAqi) { case 1: case 2: return 1100; case 3: return 650;
case 4: return 380; case 5: return 200; default: return 1500; }
}
// ============ base LED renderer ============
void renderBase() {
if (baseFx == 0) { // rainbow
rainbowHue += 768;
for (int i = 0; i < EXP_LED_COUNT; i++) {
uint16_t h = rainbowHue + (uint32_t)i * 65536UL / EXP_LED_COUNT;
expLeds.setPixelColor(i, expLeds.gamma32(expLeds.ColorHSV(h)));
}
} else { // breathing solid colour
breathPhase += breathDir;
if (breathPhase >= 255) { breathPhase = 255; breathDir = -4; }
if (breathPhase <= 20) { breathPhase = 20; breathDir = 4; }
uint32_t c = expLeds.gamma32(expLeds.ColorHSV(FX_HUE[baseFx], FX_SAT[baseFx], breathPhase));
for (int i = 0; i < EXP_LED_COUNT; i++) expLeds.setPixelColor(i, c);
}
expLeds.show();
}
// ============ text + layout ============
void txt(const char* s, int x, int y, uint32_t c, bool big) {
k10.canvas->canvasText(s, x, y, c,
big ? k10.canvas->eCNAndENFont24 : k10.canvas->eCNAndENFont16, 24, false);
}
void card(int x, int y, int w, int h, uint32_t accent, const char* label, const char* value) {
k10.canvas->canvasRectangle(x, y, w, h, accent, CARD, true);
txt(label, x + 10, y + 6, DIM, false);
txt(value, x + 10, y + 24, accent, true);
}
void drawUI() {
char buf[24];
k10.canvas->canvasRectangle(0, 0, 240, 320, BG, BG, true);
k10.canvas->canvasRectangle(0, 0, 240, 30, HEADER, HEADER, true);
txt("WEATHER", 8, 7, CYAN, false);
txt(ledModeName(), 86, 7, (ledMode == 3) ? DIM : WHITE, false);
txt(FX_NAME[baseFx], 132, 7, baseActive() ? FX_COL[baseFx] : DIM, false);
if (expOk && gBatt >= 0) { snprintf(buf, sizeof(buf), "%d%%", gBatt); txt(buf, 206, 7, GREEN, false); }
k10.canvas->canvasLine(0, 31, 240, 31, CYAN);
const int CW = 109, GAP = 6, M = 8, c1 = M, c2 = M + CW + GAP;
if (envOk) snprintf(buf, sizeof(buf), "%.1f", gTemp); else strcpy(buf, "--");
card(c1, 36, CW, 54, ORANGE, "TEMP C", buf);
if (envOk) snprintf(buf, sizeof(buf), "%.0f", gHum); else strcpy(buf, "--");
card(c2, 36, CW, 54, BLUE, "HUMIDITY %", buf);
if (envOk) snprintf(buf, sizeof(buf), "%u", gPress); else strcpy(buf, "--");
card(c1, 96, CW, 54, PURPLE, "PRESS hPa", buf);
if (envOk) snprintf(buf, sizeof(buf), "%.0f", gLux); else strcpy(buf, "--");
card(c2, 96, CW, 54, CYAN, "LIGHT lx", buf);
if (envOk) snprintf(buf, sizeof(buf), "%.1f", gUv); else strcpy(buf, "--");
card(c1, 156, CW, 54, UVCLR, "UV mW/cm2", buf);
if (expOk && gBatt >= 0) snprintf(buf, sizeof(buf), "%d", gBatt); else strcpy(buf, "--");
card(c2, 156, CW, 54, GREEN, "BATTERY %", buf);
uint32_t ac = aqiColor(); int py = 214, ph = 98;
k10.canvas->canvasRectangle(M, py, 224, ph, ac, CARD, true);
txt("AIR QUALITY", 16, py + 6, DIM, false);
if (ensOk) {
int cx = 202, cy = py + 26;
k10.canvas->canvasCircle(cx, cy, 20, ac, ac, true);
snprintf(buf, sizeof(buf), "%u", gAqi); txt(buf, cx - 6, cy - 13, BG, true);
if (gEnsState != 0) txt("warming", 116, py + 7, AMBER, false);
txt(aqiWord(), 16, py + 22, ac, true);
snprintf(buf, sizeof(buf), "eCO2 %u ppm", gECO2); txt(buf, 16, py + 52, WHITE, false);
snprintf(buf, sizeof(buf), "TVOC %u ppb", gTVOC); txt(buf, 16, py + 74, CYAN, false);
} else txt("SENSOR OFFLINE", 16, py + 30, RED, true);
k10.canvas->updateCanvas();
}
void readSensors() {
if (envOk) {
gTemp = environment.getTemperature(TEMP_C);
gHum = environment.getHumidity();
gPress= environment.getAtmospherePressure(HPA);
gLux = environment.getLuminousIntensity();
gUv = environment.getUltravioletIntensity();
}
if (ensOk) {
if (envOk) ens160.setTempAndHum(gTemp, gHum);
gEnsState = ens160.getENS160Status();
gAqi = ens160.getAQI(); gTVOC = ens160.getTVOC(); gECO2 = ens160.getECO2();
}
if (expOk) {
int b = expBattery();
if (b >= 0 && b <= 100 && (gBatt < 0 || abs(b - gBatt) <= 25)) gBatt = b;
}
}
void setup() {
Serial.begin(115200);
k10.begin();
k10.initScreen(2);
k10.creatCanvas();
k10.rgb->brightness(6);
expLeds.begin(); expLeds.setBrightness(45); expLeds.clear(); expLeds.show();
Wire.begin(); Wire.setClock(100000);
k10.canvas->canvasRectangle(0, 0, 240, 320, BG, BG, true);
txt("WEATHER", 78, 110, CYAN, true);
txt("STATION", 78, 142, CYAN, true);
txt("starting up...", 64, 186, DIM, false);
k10.canvas->updateCanvas();
envOk = (environment.begin() == 0);
ensOk = (ens160.begin() == NO_ERR);
if (ensOk) { ens160.setPWRMode(ENS160_STANDARD_MODE); ens160.setTempAndHum(25.0, 50.0); }
for (int i = 0; i < 6 && !expOk; i++) { expOk = expVersionOk(); if (!expOk) delay(200); }
if (expOk) expInit();
Serial.printf("env=%d ens=%d exp=%d\n", envOk, ensOk, expOk);
readSensors();
drawUI();
lastSensor = millis();
}
void loop() {
uint32_t now = millis();
// re-detect the expansion board if it wasn't present at boot (e.g. powered on later)
static uint32_t lastExpCheck = 0;
if (!expOk && now - lastExpCheck > 3000) {
lastExpCheck = now;
if (expVersionOk()) { expOk = true; expInit(); Serial.println("expansion board detected"); }
}
// Button A: LED power cycle ALL -> UNIHIKER -> BASE -> OFF
bool a = k10.buttonA->isPressed();
if (a && !prevBtnA) {
ledMode = (ledMode + 1) % 4;
if (ledMode == 2 || ledMode == 3) k10.rgb->write(-1, 0x000000); // onboard off
if (ledMode == 1 || ledMode == 3) { expLeds.clear(); expLeds.show(); } // base off
drawUI();
}
prevBtnA = a;
// Button B: cycle BASE LED colour mood
bool b = k10.buttonB->isPressed();
if (b && !prevBtnB) {
baseFx = (baseFx + 1) % NUM_FX;
breathPhase = 20; breathDir = 4;
if (baseActive()) renderBase();
drawUI();
}
prevBtnB = b;
// sensors + redraw every 2s
if (now - lastSensor >= 2000) {
readSensors();
drawUI();
lastSensor = now;
}
// onboard RGB: pulse by air quality (ALL / UNIHIKER)
if ((ledMode == 0 || ledMode == 1) && now - lastBlink >= blinkInterval()) {
ledOn = !ledOn;
k10.rgb->write(-1, ledOn ? aqiColor() : 0x000000);
lastBlink = now;
}
// base LEDs: render selected mood (ALL / BASE)
if (baseActive() && now - lastBase >= 40) {
renderBase();
lastBase = now;
}
}Wrap Up:This build demonstrates how the UNIHIKER K10 can be extended with expressive lighting and modular expansion boards to create interactive, educational experiences. By combining clear example code with accessible hardware, the project highlights how makers can quickly prototype mood‑driven IoT applications.




Comments