-
1Designing the Enclosure
![]()
I started by designing the entire enclosure in Autodesk Fusion 360, keeping the design compact, easy to assemble, and optimized for the Waveshare ESP32-S3 Matrix board.
To achieve a smooth and uniform glow, I used a lithophane-inspired diffuser instead of a standard flat diffuser. By varying its thickness, the light spreads more evenly across the surface, helping to reduce visible LED hotspots and creating a much softer ambient effect.
-
23D Printing the Enclosure
![]()
![]()
With the design complete, I printed all the parts using PLA filament. For the diffuser, I used matte white PLA with a 0.8 mm wall thickness. This provides excellent light diffusion while preserving the lithophane effect, resulting in a smooth, even glow across the entire surface.
For the housing, I used Black PLA with a 3 mm wall thickness to give the enclosure good rigidity and durability while maintaining a clean, minimal appearance.
-
3Hardware Assembly
![]()
![]()
![]()
Carefully insert the Waveshare ESP32-S3 Matrix board into the 3D-printed enclosure, making sure it sits flush and the LED matrix aligns with the front opening. The fit should be snug, so avoid applying excessive force during installation. Once everything is aligned, apply a very small amount of super glue around the edges to secure the board in place.
-
4Installing the Diffuser
![]()
![]()
Place the 3D-printed diffuser over the LED matrix, ensuring it sits evenly inside the enclosure without putting pressure on the board. Once you're satisfied with the alignment, secure it using a very small amount of super glue around the edges.
-
5OpenWeatherMap API Key
![]()
![]()
PrismCube uses OpenWeatherMap to retrieve live weather data. Before configuring the firmware, you'll need a free API key.
Visit the OpenWeatherMap website and create a free account. After verifying your email address, open the My API Keys page, where you'll find a default API key already generated for your account. You can use this key or create a new one if you prefer. Keep your API key handy—you'll need it in the next step when configuring Config.h.
Note: New API keys may take a few minutes to become active. If PrismCube can't fetch weather data immediately after uploading the firmware, wait a short while and try again.
-
6Configure the Firmware (Config.h)
Before uploading the firmware, you'll need to configure PrismCube to match your own setup. To keep things simple, all user-editable settings are stored in a single file called Config.h. Whether you're connecting to a different Wi-Fi network, using your own weather location, or adjusting the lighting behavior, this is the only file you'll normally need to modify.
Configure Your Wi-Fi & Weather
Start by entering your Wi-Fi credentials and your OpenWeatherMap API key.
#define WIFI_SSID "YOUR_WIFI_SSID" #define WIFI_PASSWORD "YOUR_WIFI_PASSWORD" #define OWM_API_KEY "YOUR_OPENWEATHERMAP_API_KEY"
Next, configure the location that PrismCube will use for live weather updates. I recommend using latitude and longitude instead of a city name, as it's more accurate and avoids ambiguity.
#define OWM_USE_LATLON 1 #define OWM_LAT 34.0522 #define OWM_LON -118.2437
The firmware automatically checks for new weather every 10 minutes, providing a good balance between responsiveness and network usage.
Verify the Hardware Configuration
If you're using the Waveshare ESP32-S3 Matrix board, you won't need to change anything in this section.
#define LED_DATA_PIN 14 #define LED_COUNT 64 #define IMU_SDA_PIN 11 #define IMU_SCL_PIN 12
These values define the onboard 8×8 RGB LED matrix and the built-in QMI8658 IMU.
Customize PrismCube
You'll also find several settings that control how PrismCube behaves.
#define BRIGHTNESS_DAY 235 #define BRIGHTNESS_EVENING 153 #define BRIGHTNESS_NIGHT 45 #define BRIGHTNESS_MAX_CAP 235
The maximum brightness is intentionally limited to 235 instead of the full 255 to reduce heat while keeping the cube bright enough for everyday use.
Animation timing can also be adjusted if you'd like to customize how each weather mood feels.
#define CYCLE_CLEAR_MS 16000 #define CYCLE_STORM_MS 7000 #define CYCLE_FOG_MS 24000
Finally, you'll find a few optional settings for features such as automatic rainbow detection and debugging. The default values are already tuned for normal use, so most makers won't need to change them.
-
7The Color Engine (PaletteEngine.h)
With the basic configuration complete, it's time to look at the part that gives PrismCube its personality. The Palette Engine is responsible for translating raw weather data into a lighting experience. Instead of deciding what the LEDs should display, it decides how the weather should feel through carefully designed color palettes and animations.
Defining the Weather Moods
The first thing you'll see is the WeatherMood enum.
enum class WeatherMood {CLEAR, PARTLY_CLOUDY, CLOUDY, LIGHT_RAIN, RAIN, THUNDERSTORM,FOG, SNOW, SUNRISE, SUNSET, NIGHT, RAINBOW_SPECIAL};Rather than working directly with weather condition IDs throughout the firmware, everything is first converted into one of these predefined moods. This keeps the rest of the code much cleaner and also makes it easy to add your own weather effects later.
Creating a Color Palette
Each mood is described using a simple structure called Palette3.
struct Palette3 {CRGB top, mid, bottom;uint16_t fadeMs;uint16_t cycleMs;bool rainbowWash = false;};Although the variables are named top, mid, and bottom, they don't represent different areas of the LED matrix. Since the enclosure uses a frosted diffuser, all 64 LEDs blend together into a single point of light. Instead, these three colors represent the journey the light takes over time, smoothly transitioning from one color to the next before looping back again.
Deciding Which Mood to Display
Whenever new weather data is received, the firmware calls decideMood().
inline WeatherMood PaletteEngine::decideMood(const WeatherState& w) {if (!w.valid) return WeatherMood::CLEAR;if (w.sunrise > 0 && w.sunset > 0) {time_t now = time(nullptr);const time_t window = 35 * 60;if (now > 1700000000) {if (llabs((long long)now - (long long)w.sunrise) < window) return WeatherMood::SUNRISE;if (llabs((long long)now - (long long)w.sunset) < window) return WeatherMood::SUNSET;if (!w.isDaytime) return WeatherMood::NIGHT;}}if (looksLikeRainbowConditions(w)) return WeatherMood::RAINBOW_SPECIAL;int id = w.conditionId;if (id >= 200 && id <= 232) return WeatherMood::THUNDERSTORM;if (id >= 300 && id <= 321) return WeatherMood::LIGHT_RAIN;if (id >= 500 && id <= 531) return WeatherMood::RAIN;if (id >= 600 && id <= 622) return WeatherMood::SNOW;if (id >= 701 && id <= 781) return WeatherMood::FOG;if (id == 800) return WeatherMood::CLEAR;if (id == 801 || id == 802) return WeatherMood::PARTLY_CLOUDY;if (id == 803 || id == 804) return WeatherMood::CLOUDY;return WeatherMood::CLEAR;}The order of these checks is intentional. Sunrise, sunset, and nighttime are evaluated first, followed by automatic rainbow conditions, before finally checking the weather condition ID returned by OpenWeatherMap. This ensures the cube always displays the most meaningful visual experience. For example, if it's lightly raining during sunset, PrismCube will still prioritize the sunset colors instead of immediately switching to a rain palette.
Detecting Rainbow Conditions
One feature I wanted to make feel natural was Rainbow Mode. Instead of requiring manual activation, the firmware looks for weather conditions where a real rainbow is likely to appear.
inline bool PaletteEngine::looksLikeRainbowConditions(const WeatherState& w) {if (!w.valid || !w.isDaytime) return false;bool lightRain =(w.conditionId >= 300 && w.conditionId <= 321) ||(w.conditionId == 500);return lightRain && w.cloudsPct <= RAINBOW_AUTO_CLOUD_MAX;}The logic is simple: if it's daytime, there's light rain, and cloud coverage is low enough for sunlight to break through, PrismCube automatically switches to its Rainbow Special palette. The same palette can also be activated manually at any time by simply flipping the cube upside down. Feel free to experiment with RAINBOW_AUTO_CLOUD_MAX if you want Rainbow Mode to trigger more or less often in your area.
Building Each Weather Palette
Once the mood has been selected, the firmware loads its corresponding palette.
case WeatherMood::RAIN:p.top = CRGB(0x2E, 0x5D, 0x9E);p.mid = CRGB(0x3E, 0x9B, 0xB8);p.bottom = CRGB(0x1D, 0x3E, 0x6B);p.fadeMs = FADE_RAIN_MS;p.cycleMs = CYCLE_RAIN_MS;p.rainbowWash = true;break;case WeatherMood::THUNDERSTORM:p.top = CRGB(0x1A, 0x0F, 0x33);p.mid = CRGB(0x2B, 0x16, 0x50);p.bottom = CRGB(0x14, 0x0A, 0x24);p.fadeMs = FADE_STORM_MS;p.cycleMs = CYCLE_STORM_MS;p.stormFlicker = true;break;
Every weather mood follows the same structure: three colors, a transition speed, a cycle duration, and optional visual effects. This makes it easy to customize the look of PrismCube without changing any animation logic. If you'd like warmer sunsets, brighter rainy days, or more dramatic storms, this is the best place to personalize the project.
Adding a Subtle Temperature Tint
As a final touch, every palette receives a small color adjustment based on the current temperature.
static CRGB tintForTemp(CRGB c, float tempC) {if (tempC <= 10.0f) {c.b = qadd8(c.b, 12);c.g = qadd8(c.g, 4);}else if (tempC >= 32.0f) {c.r = qadd8(c.r, 14);c.g = qadd8(c.g, 6);}return c;}Instead of changing the entire palette, this function simply nudges colder weather slightly toward blue and warmer weather toward amber. Since the tint is applied after the weather palette has been selected, the original mood remains unchanged while subtly reflecting the outdoor temperature.
-
8Animating the Colors (GradientAnimator.h)
The GradientAnimator is responsible for turning a selected palette into a smooth, continuous animation. It's the only module that directly controls the LEDs, ensuring every transition feels soft and natural rather than abrupt.
Creating the Color Journey
The animation starts with the sampleJourney() function.
inline CRGB GradientAnimator::sampleJourney(const Palette3& p, unsigned long nowMs) {uint16_t cycle = (p.cycleMs > 0) ? p.cycleMs : 1;float phase = (float)(nowMs % cycle) / (float)cycle;int seg = (int)(phase * 3.0f);if (seg > 2) seg = 2;float local = easeInOut(phase * 3.0f - seg);CRGB stops[3] = { p.top, p.mid, p.bottom };return lerpColor(stops[seg], stops[(seg + 1) % 3], local);}Instead of instantly changing between three colors, this function continuously blends them together. The animation loops through the palette in the following order: Top → Mid → Bottom → Top
Since the movement is eased instead of linear, the color naturally slows down before reaching each new tone, giving the cube its calm breathing appearance.
Switching Between Weather Conditions
Whenever the weather changes, the firmware loads a new palette. Instead of jumping directly to the new colors, setTarget() creates a smooth crossfade.
inline void GradientAnimator::setTarget(const Palette3& target) {unsigned long now = millis();if (!_hasRendered) {_active = target;_hasRendered = true;_fading = false;return;}_fadeFromColor = sampleJourney(_active, now);_active = target;_fadeStartMs = now;_fadeDurationMs = target.fadeMs;_fading = true;}Notice that the current color is sampled at the exact moment the weather changes. This means the cube always fades from whatever color is currently visible instead of restarting the animation from the beginning, making every transition feel seamless. During these transitions, the breathing effect is briefly reduced so the crossfade remains smooth and natural before gradually returning.
Rendering the Animation
Once the active color has been calculated, the render() function updates the LEDs.
inline void GradientAnimator::render() {if (!_leds || _count == 0) return;unsigned long nowMs = millis();CRGB journey = sampleJourney(_active, nowMs);// ...rendering code...}Every frame follows the same sequence: sample the current palette, blend smoothly if a weather transition is happening, apply the breathing effect, add optional effects such as lightning or rainbow wash, apply gamma correction, adjust the brightness, and finally send the same color to all 64 LEDs.
Although the board contains an 8×8 LED matrix, the frosted diffuser blends every LED into a single, soft point of light. That's why the entire matrix always displays one carefully calculated color. Before the LEDs are updated, the final color is also gently smoothed to eliminate tiny fluctuations, keeping the animation stable and fluid. Lightning effects intentionally bypass this smoothing so each flash remains crisp and instantaneous.
Making the Colors Look Natural
Before sending the color to the LEDs, PrismCube applies gamma correction.
inline CRGB GradientAnimator::gammaCorrect(CRGB c) {auto videoScale = [](uint8_t x) -> uint8_t {uint8_t r = ((uint16_t)x * x) >> 8;return (x && !r) ? 1 : r;};return CRGB(videoScale(c.r),videoScale(c.g),videoScale(c.b));}LED brightness isn't perceived linearly by our eyes. Without gamma correction, lower brightness levels often look washed out or uneven. Applying this correction produces much smoother fades, especially during nighttime animations and slow breathing effects.
-
9Getting Live Weather Data (WeatherManager.h)
The WeatherManager handles everything related to Wi-Fi and OpenWeatherMap, converting the online weather data into a simple WeatherState structure that the rest of the firmware can understand.
Connecting to Wi-Fi
The first task is connecting PrismCube to your Wi-Fi network using the credentials stored in Config.h.
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
During startup, the firmware continuously attempts to establish a connection. Once connected, it automatically synchronizes the system time and prepares to request live weather data. If Wi-Fi isn't available, PrismCube continues running and keeps trying to reconnect in the background instead of stopping completely.
Requesting the Weather
Once connected, the firmware sends a request to the OpenWeatherMap API.
http.begin(url);int httpCode = http.GET();
The request includes your configured location and API key, returning the latest weather information in JSON format. To balance responsiveness with network usage, PrismCube checks for new weather every 10 minutes, which also matches OpenWeatherMap's update interval.
Parsing the Response
After receiving the response, ArduinoJson extracts only the information needed by PrismCube.
WeatherState state;state.conditionId = ...state.tempC = ...state.cloudsPct = ...state.sunrise = ...state.sunset = ...
Rather than storing the entire JSON response, the firmware keeps only the values required by the color engine, including the weather condition, temperature, cloud coverage, sunrise, sunset, and whether it's currently daytime. If any field is unavailable, safe default values are used to keep the firmware running reliably.
Passing the Weather to the Color Engine
Once the WeatherState structure has been filled, it's passed to the Palette Engine, where the current conditions are translated into one of PrismCube's predefined weather moods. From this point onward, the rest of the firmware works entirely with WeatherState, keeping the networking code separate from the animation system.
-
10Detecting Orientation (IMUHandler.h)
PrismCube doesn't use buttons or a touchscreen. Instead, it uses the onboard QMI8658 6-axis IMU to detect how the cube is placed. The only interaction is simply flipping the cube upside down to activate Rainbow Mode.
Initializing the IMU
When the cube starts, the firmware initializes the IMU and automatically calibrates its resting position.
bool IMUHandler::begin() {if (!_imu.begin()) return false;calibrate();return true;}During calibration, the firmware records the direction of gravity while the cube is resting normally. This becomes the reference used for all future orientation detection, making the firmware work reliably without depending on fixed axis values.
Reading the Sensor
The IMU continuously measures acceleration along the X, Y, and Z axes.
_imu.readAcceleration(&_ax, &_ay, &_az);
Rather than reacting instantly to every movement, the firmware continuously updates the sensor readings and checks whether the cube has settled into a stable position.
Detecting the Orientation
Instead of checking whether a single axis reaches a specific value, PrismCube compares the current gravity vector with the calibrated reference captured during startup.
CubeOrientation IMUHandler::orientation() const {return _orientation;}When the cube remains in a new position for a short time, the orientation is updated automatically. This approach makes detection much more reliable, even if different board revisions or assembly tolerances slightly change the IMU's alignment.
Activating Rainbow Mode
Once the orientation changes, the main firmware simply checks whether the cube is upside down.
if (imu.orientation() == CubeOrientation::UPSIDE_DOWN) {rainbowForced = true;}else {rainbowForced = false;}Place the cube upright and it displays the live weather. Flip it upside down for about a second, and it smoothly transitions into Rainbow Mode. Turning it upright again automatically returns to the current weather.
Stable and Reliable Detection
To prevent accidental mode changes while picking up the cube, the firmware only accepts a new orientation after it has remained stable for a short period. This filtering eliminates false triggers and makes the interaction feel natural, allowing the cube to respond only to deliberate movements.
The Spanner









Discussions
Become a Hackaday.io Member
Create an account to leave a comment. Already have an account? Log In.