

Color Sensor
All code for my Rubix Cube Solving Machine
- Size
- 90 × 72 mm
- Copper layers
- 2
- Thickness
- 1.6 mm
- Parts
- 52
- Nets
- 52
- Pads
- 168
- Tracks, vias
- 464, 83
- Pours
- 3
- Routed
- Yes
- KiCad
- KiCad 8.0 (file 20240108)
- Created
- 17 Oct 2023
- Updated
- 27 Aug 2026
Overview
Color Sensor is an open-source printed circuit board by chazlambo, from the chazlambo/CubeSolverProject repository on GitHub. It is a 2-layer board of 90 × 72 mm with 52 parts on 52 nets, routed with 464 tracks and 83 vias.
It is built around the MMBT2222A. Its parts include 13 capacitors, 13 resistors, 11 ICs, 9 diodes and 1 transistor. It belongs with the sensors and measurement and robots and motor control boards of this library.
Main components
1README
README.mdCubeSolver
A self-contained Rubik's Cube solving robot: six stepper-driven faces, two servo grippers on a moving ring, eighteen color sensors, an on-board Kociemba two-phase solver, and a 320×240 display. Scan, solve, execute — no PC involved.
Everything here is one person's design: mechanical (SolidWorks), electrical (KiCad), and firmware (Teensy 4.1).
Status
Working: full autonomous scan → solve → execute cycle, driven from the panel menu: the five Modes (Scramble Solve, Idle, Demo, Step Solve, Patterns), on-panel tuning with defaults in source and overrides in EEPROM, a hardware jog page, live sensor diagnostics, an EEPROM fault log and lifetime stats. The display stays live during scan and solve, the abort chord works throughout, encoder faults are detected and handled, a scan is validated piece-by-piece with automatic repair, and color classification carries a per-sensor confidence.
Known unfinished:
VirtualCube::rebuildFromCubeArray()— still labeledUNFINISHED NEEDS DEBUGGINGin its header, but the body is live and the firmware now relies on it (Cube State and pattern completion refresh the color array through it). The stale part is the label, not the code; it has no rollback on failure, which no current caller needs.- Color sensor board 2, sensor 2 has a dead green channel — it reads exactly
0for all six sticker colors in every archived calibration run. The firmware now detects this (ColorSensor::checkSensorHealth) but it is a hardware fault. - Face steppers run without an acceleration ramp (
MultiStepperis constant speed by design). Left as-is deliberately — a ramp would slow the solve.
Which sketch do I flash?
Code/Main Code/CubeSolver/CubeSolver.ino — that's the program.
Everything under Code/Test Code/ is bring-up and calibration tooling. The ones
still worth keeping:
| Sketch | Purpose |
|---|---|
Test Code/Bench_Tune | Headless Serial protocol for scripted tuning: moves, parameters, every encoder before/after each move. Driven by Code/tools/bench.py |
Test Code/Actuator_Test | Serial menu for manual moves and jam-recovery testing |
Test Code/Test_Motor_Calibrate | Motor encoder calibration (also Settings → Calibration → Motor Positions) |
Test Code/Test_Color_Calibrate | Color calibration (also Settings → Calibration → Color Sensors) |
Test Code/I2C_Search | Bus scanner — genuinely useful on a five-mux rig |
Test Code/Test_Menu | Menu, wheel and panel bench test — see below |
Test Code/Test_Menu drives the same CubeMenu and CubeDisplay the
firmware does, so anything that feels wrong there feels wrong in the firmware.
It runs no scan, solve or calibration. It does move real hardware from three
places: Load / Eject, the Actuators page (every servo and ring
position, each face motor and both whole-cube rotations, one part at a time),
and Diagnostics → Tuning → Servos, which drives a horn to each value as you
dial it. Everything under Screens is drawing only.
It also carries a Screens submenu that draws each operation screen (scan step
rows, calibration color chips, the solve progress bar, the error look) from
canned data, so the panel can be judged and photographed in seconds instead of
by running a 40-second scan to check one label. Input Report is a live
readout of the wheel count and every button — the fastest way to spot a flaky
encoder or a dead button.
It runs in the simulator too, with no machine at all:
cmake -S Code/sim -B Code/sim/build-test -DSIM_SKETCH=Test_Menu
cmake --build Code/sim/build-test -j && ./Code/sim/build-test/cubesimTest Code/_Archive/ does not compile against the current tree (it includes a
Cube.h that no longer exists). Git history is the archive.
Build
Board: Teensy 4.1. Not a 4.0 — the solver's lookup tables alone are 4.14 MiB, which is 214% of a 4.0's flash, and pins 40/41 don't exist on a 4.0.
Toolchain: Arduino IDE + Teensyduino.
TODO: record the exact Arduino IDE and Teensyduino versions you build with, plus CPU speed and USB type from the Tools menu.
Sketchbook layout — this is the non-obvious step. Code/libraries/ is an
Arduino sketchbook libraries folder, and the build must compile the repo's
copy, not a stale one. Two working setups:
- Point the sketchbook at the repo: set Arduino's sketchbook location to
the
Code/directory (File → Preferences → Sketchbook location). Then#include <CubeSystem.h>and#include <kociemba.h>resolve, andlv_conf.his found automatically. - Link from the default sketchbook (what this machine actually does —
the sketchbook stays at
Documents/Arduinoso Library Manager installs land in the usual place):Documents/Arduino/libraries/CubeSolver→ symlink toCode/libraries/CubeSolverDocuments/Arduino/libraries/kociemba→ junction toCode/libraries/kociembaDocuments/Arduino/libraries/lv_conf.h→ a one-line forwarder header that does#include "../../Projects/Cube_Solver/Code/libraries/lv_conf.h"
Either way, do not copy the libraries into the sketchbook — you'll edit
the repo and build a stale copy. And never place an lv_conf.h inside the
lvgl library folder itself: if lvgl's own directory ever lands on the
include path, __has_include("lv_conf.h") silently builds the whole project
against that copy instead of the repo's.
Dependencies
None of these are vendored except Kociemba. Install via Library Manager unless noted.
| Library | Source | Notes |
|---|---|---|
| AccelStepper | Library Manager / airspayce.com | GPL-2.0 |
| MultiStepper | ships inside AccelStepper | constant-speed by design |
| PWMServo | bundled with Teensyduino | Teensy-only; not the stock Servo |
| TCA9548 | RobTillaart/TCA9548 | I²C multiplexer |
| veml6040 | ThingPulse/VEML6040 | color sensors |
| RunningMedian | RobTillaart/RunningMedian | scan filtering |
| Adafruit_seesaw | Adafruit | ANO scroll-wheel breakout |
| ILI9341_T4 | vindar/ILI9341_T4 (GitHub) | Teensy-4-specific DMA driver |
| LVGL | lvgl/lvgl | v9.x — the code uses v9-only APIs |
| EEPROM / Wire | Teensyduino core | two I²C buses used |
| SPI | Teensyduino core | pulled in transitively by ILI9341_T4, not included directly |
| kociemba | vendored at Code/libraries/kociemba/ | from vindar/kociemba |
TODO: pin the versions you actually build against, especially LVGL — the code uses
lv_display_create/lv_tick_set_cb, which are v9-only.
Menu theme assets
The menu's look is generated, not hand-drawn. docs/theme/ holds the design as
two HTML files, and Code/tools/bake_theme.py rasterizes those same files
through headless Chrome at the panel's native 320x240, then packs the result
into LVGL C arrays under Code/libraries/CubeSolver/utility/:
python3 Code/tools/bake_theme.py # images + fonts (needs network)
python3 Code/tools/bake_theme.py --no-fonts # images onlyThe generated files are committed, so a normal build needs none of this —
run it only after changing the design. It needs google-chrome, python3-pil,
and npx for the font conversion.
Two things about those assets are load-bearing and easy to undo by accident:
- They live in
utility/.Code/libraries/CubeSolverhas nolibrary.properties, so the Arduino IDE treats it as a 1.0-format library and compiles only the library root andutility/. Assets moved to a prettier subdirectory are silently not compiled, and the sketch fails to link. - The recolorable masks are RGB565A8, not A8. LVGL reads an uncompressed
RGB565A8 straight out of flash, but copies every alpha-only image into RAM
first. The frame band alone is 49 KB — bigger than the 32 KB pool of the
time, and most of today's 64 KB — so as A8 it fails to allocate, and with
LV_USE_LOGat 0 it fails silently, drawing nothing at all. This costs about 220 KB of flash and is worth it.
Total asset cost: ~565 KB of flash, against the ~3.3 MB left after the solver's own 4.14 MiB of lookup tables. RAM is unchanged.
lv_conf.h
Code/libraries/lv_conf.h is required and its settings are not defaults.
LV_COLOR_DEPTH 16 must match what ILI9341_T4 expects; a mismatch does not fail
to build, it renders wrong colors and looks like a hardware fault. See the
comments in that file for the load-bearing settings.
LV_MEM_SIZE is 64 KB, and running out of it hangs the machine.
LV_USE_ASSERT_MALLOC is 1 and LVGL's default assert handler is while(1);, so
an exhausted pool is not a degraded UI — it is a halt, with no message, because
LV_USE_LOG is 0. It started at 32 KB, which the themed menu alone came within
~1 KB of; the operation screens took the peak to ~27 KB and the firmware hung
at boot with no output at all, so it went to 48 KB. The Sensor Test chips then
took the widget set to 35 KB and tripped the boot warning below, so it went to
64 KB. Every raise was set against a measurement, not a guess.
Two things watch this now: CubeDisplay::begin() prints the pool usage over
Serial once every widget exists, and warns below 8 KB free; and M in the
desktop simulator prints it live. Check the boot line before assuming a new
screen is free.
Wiring
Verified against the TEENSY_4.1 footprint's pad-to-net mapping in
Circuitry/KiCad/Cube Solver/Motherboard/Motherboard.kicad_pcb.
Code/libraries/CubeSolver/CubeHardwareConfig.cpp is the single source of truth
in firmware.
| Pin | Signal | Pin | Signal |
|---|---|---|---|
| 0 / 1 | U step / dir | 18 / 19 | I²C0 SDA / SCL |
| 2 / 3 | R step / dir | 22 / 23 | Bottom / Top servo |
| 4 / 5 | F step / dir | 24 / 25 | B step / dir |
| 6 / 7 | D step / dir | 26 | Stepper enable (all 7) |
| 8 / 9 | L step / dir | 28 / 29 | Ring step / dir |
| 10 | Display DC | 30 | Encoder mux /RESET |
| 11 / 12 | Display MOSI / MISO | 32 | Power sense |
| 13 | Display SCK (+ onboard LED) | 40 / 41 | Color LED 2 / 1 |
| 14 / 15 | Display CS / RESET | 16 / 17 | I²C1 SCL / SDA — see note |
I²C addresses
| Addr | Device | Bus |
|---|---|---|
0x70 | TCA9548 — encoder mux (7 AS5600 behind it) | Wire |
0x74–0x75 | TCA9548 ×2 — color board 1 | Wire |
0x76–0x77 | TCA9548 ×2 — color board 2 | Wire |
0x36 | AS5600 magnetic encoder ×7 | Wire (behind mux) |
0x10 | VEML6040 ×18 | Wire (behind muxes) |
0x49 | Adafruit seesaw (ANO wheel) | Wire1 |
No pin or address conflicts.
⚠ Known board/firmware divergence
The firmware puts the scroll wheel on Wire1 (pins 16/17), but Motherboard Rev A routes connector J17 to Wire0 (pins 18/19), and Teensy pads 16/17 are marked unconnected. Since the machine works, there must be a bodge wire.
TODO: confirm the bodge and either roll it into a Rev B or note it on the board. Right now this exists only in the hardware and in your head.
-
The color scanner is mounted rotated 90° from its original layout. It was turned in CAD to make the assembly fit, which changed which faces each scan pass reads — from
[L,B] [U,F] [D,R]to[Back,Right] [Left,Down] [Up,Front]— and no firmware changed, becausescanCube()identifies each face from its own centre color rather than assuming which one it is looking at. What the design does rely on is the two sensors' position relative to each other, and rotating the whole assembly preserves that. Moving one sensor relative to the other would not, and would breakleft1/left2inscanCube().The only thing tracking the physical order is
kScanPassFacesinCubeOpShape.cpp, which fills the panel's face row during a scan. Rotate the scanner again and that table is the one thing to update.
Two other things worth knowing:
- Pin 10 must be the display DC. ILI9341_T4 drives DC through the SPI peripheral's chip-select logic during DMA, so it has to be a hardware CS pin — on Teensy 4.1 SPI0 that's 10, 36 or 37. A plain GPIO fails confusingly.
- Pins 24/25 are Teensy's
Wire2, consumed by the B stepper. There is no third I²C bus available without moving that motor.
First run
Order matters — the solver needs both calibrations, and color calibration needs motors that already home correctly.
1. Motor encoder calibration
Menu → Settings → Calibration → Motor Positions, or flash
Test Code/Test_Motor_Calibrate.
Records four encoder positions per motor, one per quarter turn, into EEPROM.
TODO: describe the physical setup — how the fingers should be positioned relative to the cube before starting, what a good result looks like, and when this needs redoing after a mechanical change.
2. Color calibration
Menu → Settings → Calibration → Color Sensors, or flash
Test Code/Test_Color_Calibrate.
Needs a solved cube. Steps through orientations recording a reference RGBW per color per sensor, plus an "empty chamber" reference.
The firmware now derives a per-sensor decision threshold from this data
(ColorSensor::computeSeparations) rather than using one global tolerance, so a
bad calibration degrades classification measurably. Check the health report.
TODO: describe ambient lighting conditions, whether the lid should be on, and how to tell a good calibration from a marginal one.
3. Servo endpoints
Menu → Settings → Calibration → Servo Positions. Rows preview live as the wheel turns, behind a red confirm; values persist in the tuning EEPROM block.
The compiled defaults live in the shared parameter table
(Code/libraries/CubeSolver/CubeTuneTable.cpp), not in
CubeHardwareConfig.cpp — the topExtPos / botExtPos globals there are read
once at servo construction and then overridden at boot by the table, so editing
them no longer changes the machine.
TODO: record how the defaults were determined and how to retune after changing a servo horn or linkage.
Operating
- Power on. The machine homes its motors if calibrated.
- Turn the wheel to Load & Scan Cube, press SELECT.
- Insert a scrambled cube, press SELECT. It scans (~25–40 s) and the main menu becomes Cube Ready.
- Select Solve, press SELECT. It computes and reports the move count.
- Press SELECT to execute (LEFT cancels and leaves the cube as it is).
Abort: hold SELECT and LEFT together for 1.5 s during a scan or solve. The machine stops at the next mechanically safe point, releases the cube, and returns to the menu.
On any error that can leave the machine holding the cube, it is released
automatically — the solve paths and the abort paths all route through
safeStop(). Scan-time color and build errors do not, because at those points
the ring and both servos are already retracted.
Error codes
| Range | Meaning |
|---|---|
| Scan 1–3 | A sensor could not identify a face, or two faces read the same |
| Scan 1X/2X | Sensor 1/2's face was rejected when set on the virtual cube |
| Scan 3X | Could not determine orientation |
| Scan 41 | Fewer than six distinct faces set — a duplicate-centre misread |
| Scan 42-47 | Wrong number of some color (not nine of each) |
| Scan 5X | Could not build the cube array |
| Scan 60 | Physically impossible cube, auto-repair failed → rescan |
| Scan 70 | Aborted by user |
| Scan 80 / 81 | Reorientation move ROTX / ROTZ failed (encoder fault) |
| Scan 90 | A color sensor board failed to initialise at boot |
| Scan 91 | Motors failed to home before the first clamp (jam / encoder fault) |
| Cal 82 | A calibration reorientation move failed |
| Solve 11 | Cube not scanned yet |
| Solve 12 | No solution — illegal cube or solver timeout |
| Solve 13 | Centres not canonical → orientation was misread |
| Solve 14 | Impossible piece → a color was misread → rescan |
| Solve 15 | Solution longer than the move buffer |
| Exec 105 / 125 | Aborted by the user (between moves / inside a move) |
| Exec 1XX | A move failed; cube released and virtual state invalidated |
| Move 3 / 21 / 22 / 24 / 25 | Raw move (Modes, jog): invalid token / motors not calibrated / jam or align timeout / encoder unreadable / aborted |
| Move 1X | Virtual move failed — rescan |
| Cal 8 | Color calibration failed to save — machine is not calibrated |
| Cal 9 | Color calibration aborted; EEPROM left untouched |
The raw-move codes appear on the panel when a scramble, idle turn, pattern fold or jogged move fails, and in the Fault Log under the Mode and Jog sources.
Code 14 and 60 both mean "the cube I think I have cannot exist" — the fix is a rescan, not a retry.
Code 13 is effectively unreachable, by design. buildCubeArray() relabels
colors through the reported orientation, so the centres come out canonical
whatever that orientation was. A misread orientation therefore produces a
different but internally consistent cube that is legal, piece-valid and
solvable — the machine would execute ~20 moves in the wrong frame with no error
at any layer. The centre check is kept as cheap insurance against a corrupted
cubeArray; detecting an orientation misread needs a cross-check against
something outside the cube model and is not currently implemented.
Repo layout
3D Models/ SolidWorks parts and assemblies, plus print-ready STLs
Circuitry/ KiCad projects: Motherboard, Motor Breakout, Color Sensor
Code/
Main Code/CubeSolver/ <- THE PROGRAM
Test Code/ bring-up and calibration sketches
libraries/
lv_conf.h required LVGL config (see above)
CubeSolver/ the machine's own code
kociemba/ vendored solver (from vindar/kociemba).gitignore excludes regenerable KiCad output (auto-backup zips, footprint
caches, per-user project state) and SolidWorks lock files. .gitattributes
marks binary CAD/fab formats -text so git's heuristic can't CRLF-mangle them.
Notes for future-you
- The alignment step sign lives in one place.
CubeSystem::kAlignStepSign. Both closed-loop routines useencError(); if alignment ever drives away from target, flip that constant and nothing else. - Encoder reads can fail.
MotorEncoder::scanChecked()returns negative on I²C failure and every control path treats that as a fault, not a position. Never go back to rawscan()in a control loop. pumpDelay()instead ofdelay()anywhere that waits more than a few ms, so the display and abort keep working.- The panel is driven synchronously, on purpose.
CUBE_DISPLAY_ASYNC_DMAinCubeDisplay.his 0: every LVGL flush is written straight to the glass and returns when it is on the wire. The driver's async DMA/differential mode (mode 1) is faster, but on this board it left the glass holding the previous frame in large chunks, or coloured snow, while the driver believed the new one had been sent — and because that mode only ever re-sends pixels it thinks changed, the damage stayed until something drew over it. The header comment has the history.cubeDisplay.repaintAll()re-sends every pixel; the sketch calls it at the end of boot and on every return to the menu. In mode 1 never usetft->update(internal_fb)for that: handed its own buffer the driver rotates it onto itself. - A white panel heals itself now. On the same marginal wire, a corrupted
byte during a heavy flush can land in the panel's command slot —
RAMWR(0x2C) andCASET(0x2A) are each one flipped bit fromDISPOFF(0x28) — and a panel that latchesDISPOFF/SLPIN/SWRESETshows solid white, which no amount of pixel drawing repairs.CubeDisplay::panelHealthTick()(run from everyupdate()) re-sendsDISPON+SLPOUTeach second and re-runs the full init when the self-diagnostic register reads wrong, so a white-out lasts about a second instead of until power-cycle. It printsDisplay: panel lost its init (recovery #N)on Serial; that count climbing while scrolling is the wire talking. - Solve times: Kociemba runs from flash.
set_memory()would be ~4× faster but the 479 KiB buffer doesn't fit alongside the display's ~200 KB of RAM2.
Parts
52| Qty | Value | Footprint | References |
|---|---|---|---|
| 11 | 0.1µF | Capacitor_SMD:C_0805_2012Metric | C3, C4, C5, C6, C7, C8, C9, C10, C11, C12, C13 |
| 9 | 100Ω | Resistor_SMD:R_0805_2012Metric | R1, R4, R5, R6, R7, R8, R9, R10, R11 |
| 9 | GW JTLMS3.GM-G6GV-XX55-1-60-R33 | LED_SMD:LED_PLCC_2835 | D1, D2, D3, D4, D5, D6, D7, D8, D9 |
| 9 | VEML6040 | cube_solver_footprints:VISHAY_VEML6040_1.25X2X1 | U3, U4, U5, U6, U7, U8, U9, U10, U11 |
| 3 | 10kΩ | Resistor_SMD:R_0805_2012Metric | R2, R3, R13 |
| 2 | 10µF | Capacitor_SMD:C_0805_2012Metric | C1, C2 |
| 2 | TCA9548APWR | Package_SO:TSSOP-24_4.4x7.8mm_P0.65mm | U1, U2 |
| 1 | 330Ω | Resistor_SMD:R_0805_2012Metric | R12 |
| 1 | Color Sensor Input | Connector_JST:JST_XH_S7B-XH-A-1_1x07_P2.50mm_Horizontal | J1 |
| 1 | MMBT2222A | cube_solver_footprints:SOT23 | Q1 |
The 48 most common of 52 parts.
Questions
How big is the Color Sensor PCB?
It measures 90 × 72 mm and has 2 copper layers, 1.6 mm thick.
What parts does the Color Sensor use?
52 parts, built around the MMBT2222A. The full list of its parts is on this page, with links to each part's symbol and footprint where PCBKit's library has them.
Where can I download the Color Sensor KiCad files?
From the chazlambo/CubeSolverProject repository on GitHub, at the commit this page was made from: the board is Circuitry/KiCad/Cube Solver/Color Sensor/Color Sensor.kicad_pcb. PCBKit opens the board in the browser, with nothing to install.
Can I use the Color Sensor design in my own project?
Its repository states no licence, so its author keeps all rights: ask chazlambo before reusing the design.
More from chazlambo/CubeSolverProject
3- 2 layers90 × 72 mm

- 2 layers160 × 120 mm

- 2 layers36 × 52 mm

Similar boards
- 2 layers75 × 100 mm

Phased
Neumi/open_echo
Open Source echo sounder / depth sounder / SONAR for bathymetry and research. Arduino based and compatible.
77 parts530 - 2 layers80 × 129.1 mm

Smart Plant
JGAguado/Smart_Plant
The Smart Plant is a board that can be plugged directly on the soil, close to the plant, that allows the measurement of the environment (soil moisture, environment temperature & illuminance), and presents the…
75 parts303 - 2 layers80 × 107.5 mm

Minino
ElectronicCats/Minino
Minino is an original multiprotocol and multiband board made for sniffing, communicating, and attacking IoT devices. It was designed as a mini Cat that integrates the powerful ESP32C6, GPS, microSD and OLED.
79 parts183GPL-3.0 - 2 layers92.8 × 71.8 mm

USBSID Pico v1.3f
LouDnl/USBSID-Pico
USBSID-Pico is a RPi Pico (RP2040/W RP2350/W) based board for interfacing one or two MOS SID chips and/or hardware SID emulators over (WEB)USB with your computer, phone, ASID supporting player or USB midi controller
116 parts125GPL-2.0 - 2 layers16.3 × 11.7 mm

Brains
atomic14/ch32v003-audio
A collection of audio projects for the CH32V003J4M6 RISC-V microcontroller, demonstrating various audio synthesis and playback techniques on an 8-pin, $0.10 microcontroller.
15 parts78MIT - 4 layers125.7 × 76.5 mm

UGC Main R4K
HandHeldLegend/GC-Ultimate
The 3D shell, buttons, and other associated models are licensed under a Creative Commons Attribution 4.0 International License .
168 parts62 - 4 layers90.2 × 95.9 mm

ADCS board
onionharvester/boardwalk
A little gathering place for my PCBs. This is their favourite hangout spot! 🪼⋆.ೃ࿔*:・
205 parts55 - 2 layers35.6 × 94.6 mm
