During my time in college, I've been involved in computer science education in several different capacities. While I've mostly been involved in teaching older students, I was interested in how young kids can learn about the concepts and process of programming in a fun way. There are many tools out there already to teach kids about coding, but I wanted to build a product that would deal with what I saw as the three main issues with existing activities:
With these goals in mind, I hoped to create a new option for kids interested in coding that was engaging, affordable, and potentially expandable!
Character | Code |
---|---|
D | If any color |
E | If Blue |
F | If Yellow |
G | If Green |
H | End If |
A | Forward |
B | Turn |
C | Stop |
I | Beep |
J | Change Light Color |
K | Change Light Pattern |
I ran into a ton of problems with the code to connect these, as the block microcontrollers I used (ATTiny 412s) didn't have much memory and were difficult to debug since they involved using a programmer to upload code to. In the picture below, you'll see how I had to solder each ATTiny44 to a small board, solder connections to the board, and plug wires into that connection in order to upload the code I wrote:
Testing out these blocks also took quite a bit of tedious wiring. Here's a picture of how messy it ended up looking:
After a lot of debugging, I settled on this code for each of the Tinys. It involves each block sending its own name and the name of any former blocks to the one above it.
// Update this to be the letter of the current command char myName = 'K'; // Indicates whether a lower block has been found bool found = false; // Set up string variables const int LEN = 30; char str[LEN]; char TERM1 = '\0'; char TERM2 = '!'; bool newInfo = true; int seq_len = 0; void setup() { Serial.begin(9600); // Set up initial messageing str[0] = myName; for (int i = 1; i < LEN - 1; i++) { str[i] = TERM2; } str[LEN - 1] = TERM1; } void loop() { if (Serial.available()) { // Read incoming Serial.readBytes(str, LEN); // Add my name to str found = false; for (int i = 0; i < LEN - 1; i++) { if (str[i] == TERM2 && !found) { str[i] = myName; found = true; if (i > seq_len) { seq_len = i; newInfo = true; } else { newInfo = false; } } else if (found) { str[i] = TERM2; } } // Add terminal character str[LEN - 1] = TERM1; } delay(1000); if (newInfo) { // Send new message and flush output Serial.write(str, LEN); Serial.flush(); } }
Next I wrote code for the base code block, which housed a Xiao ESP32-C3. This one was a bit more manageable, and I ended up checking to make sure a stable message was being received and adding a Buzzer to indicate when a message was being received or transmitted.
#include#include "WiFi.h" #include #include "Buzzer.h" #include "notes.h" #define buzzerPin 9 const int LEN = 30; Buzzer buzz = Buzzer(buzzerPin); // Indicates whether current string is being sent to robot bool uploading = false; // To keep track of former messages to decide if message is stable int prev_len = 0; int reps = 0; // For sending via ESP-now char toSend[LEN]; String success; // Define Serial device mapped to the two internal UARTs HardwareSerial MySerial0(0); // Current and best overall messages String incomingString; String bestString; // Robot's MAC address uint8_t broadcastAddress[] = {0xC8, 0xF0, 0x9E, 0x47, 0xF1, 0x7C}; // Callback when data is sent void OnDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) { if (status ==0){ success = "Delivery Success :)"; } else{ success = "Delivery Fail :("; } } // Callback when data is received void OnDataRecv(const uint8_t * mac, const uint8_t *incomingData, int len) { return; } void setup() { // Serial setup Serial.begin(9600); while (!Serial); MySerial0.begin(9600, SERIAL_8N1, -1, -1); Serial.println("MySerial0 Set Up"); // Prepare buzzer pinMode(buzzerPin, OUTPUT); // Set up WiFi WiFi.mode(WIFI_MODE_STA); Serial.println(WiFi.macAddress()); // Communications // Set device as a Wi-Fi Station WiFi.mode(WIFI_STA); // Init ESP-NOW if (esp_now_init() != ESP_OK) { Serial.println("Error initializing ESP-NOW"); return; } // Register for a callback function that will be called when data is received esp_now_register_send_cb(OnDataSent); // Register peer esp_now_peer_info_t peerInfo; memset(&peerInfo, 0, sizeof(peerInfo)); memcpy(peerInfo.peer_addr, broadcastAddress, 6); peerInfo.channel = 0; peerInfo.encrypt = false; // Add peer if (esp_now_add_peer(&peerInfo) != ESP_OK){ Serial.println("Failed to add peer"); return; } // Register for a callback function that will be called when data is received esp_now_register_recv_cb(OnDataRecv); } // Calculate length of message int getSequenceLength(String s) { for (int i = 0; i < s.length(); i++) { if (s.charAt(i) == '!') { return i; } } return -1; } // Ensure only capital letters and end chars are detected bool checkValid(String s) { for (int i = 0; i < s.length(); i++) { char cur = s.charAt(i); if (cur != '!' && cur != '\0' && (cur < 65 || cur > 90)) { return false; } } return true; } void loop() { if (!uploading && MySerial0.available()) { // Indicate serial data received buzz.playNote(C4, 10); incomingString = MySerial0.readStringUntil('\0'); Serial.print("received: "); Serial.println(incomingString); bool valid = checkValid(incomingString); if (!valid) { // If current string not valid and last one was, use the last one if (prev_len > 0) { uploading = true; } } else { int curLen = getSequenceLength(incomingString); if (curLen < prev_len || (curLen == prev_len && reps > 5)) { // Message is shorter or repeated enough, so upload! uploading = true; } else { // New, longer message is received, so keep reading... reps = 0; bestString = incomingString; prev_len = curLen; } } } else if (uploading) { // Convert to char array for sending for (int i = 0; i < LEN; i++) { toSend[i] = bestString.charAt(i); } buzz.playUploaded(); esp_err_t result = esp_now_send(broadcastAddress, (uint8_t *) &toSend, sizeof(toSend)); // Send message and reset if (result == ESP_OK) { Serial.print(toSend); Serial.println(" sent with success!"); uploading = false; prev_len = 0; reps = 0; } else { Serial.println("Error sending the data"); } } delay(200); buzz.off(); delay(800); }
Buzzer.cpp (Buzzer.h and notes.h not included, but available on GitHub):
#include "Buzzer.h" #include#include "notes.h" float notes[5] = {C4, E4, G4, E4, C4}; Buzzer :: Buzzer(int buzzerPinIn) { buzzerPin = buzzerPinIn; pinMode(buzzerPin, OUTPUT); } // Plays note at specified frrequency for specified length void Buzzer :: playNote(float newFrequency, long newDuration) { tone(buzzerPin, newFrequency); } // Hard resets the buzzer to off void Buzzer :: off() { noTone(buzzerPin); duration = 0; } // Plays thirds CEGEC to indicate upload void Buzzer :: playUploaded() { for (int i = 0; i < 5; i++) { tone(buzzerPin, notes[i]); delay(300); noTone(buzzerPin); delay(50); } } void Buzzer :: update() { if (millis() - lastTime > duration) { noTone(buzzerPin); } }
The maximum length of 30 could certainly be expanded a bit at the moment, but I may need to try a slightly larger microcontroller if I want the code to get too much longer.
I used mainly 3d printing to build the final form of the blocks, and decided to conduct the signal through three magnets (power, ground, and Serial) in order to allow the blocks to click together cleanly. I went through a few drafts where there wasn't enough space for the wiring and the magnets were not precise enough, which was a big problem because if two of the magnets are not making direct contact, the signal cannot be sent.
Here are the models of the pieces I printed, with unique pairs for a standard code block, the start of an if statement, and the end of an if statement.
Link if below model does not work
After printing each block, I had to solder wire to magnets, then attach them to microcontrollers and each other. This was an extremely tedious process that took me at least half an hour per block, as I had no experience soldering in a situation with serious space constraints. (I couldn't have too much wire or the block would not close.) In the future, I think continued development on this project would include using a PCB to consolidate the mess of wires you can see inside this sample if block:
Overall, I was pretty happy with how these turned out! The connection could be tenuous at times, but it did work!Blocky, the output device, was controlled by an ESP32, and functionality was fairly modular, so I was able to split the code up into several classes. Most important among them was the Driving class, which controlled the wheels. I ran into problems with Blocky moving too quickly to stay on track, so the main coding part here was only moving the wheels forward 3/10 of the time. In the below code, I don't include the .h files as they're not quite as interesting, but they're all available on GitHub!
#include "Drive.h" #includeDrive :: Drive() { motor1A = 18; motor1B = 23; motor2A = 10; motor2B = 5; freq = 5000; channel1 = 1; channel2 = 2; resolution = 8; speed = 255; pinMode(channel1, OUTPUT); pinMode(motor1B, OUTPUT); pinMode(channel2, OUTPUT); pinMode(motor2B, OUTPUT); interval = 100; } void Drive :: setup() { ledcSetup(channel1, freq, resolution); ledcAttachPin(motor1A, channel1); ledcSetup(channel2, freq, resolution); ledcAttachPin(motor2A, channel2); stop(); } void Drive :: forward() { if (state == 0) { state = 1; } } void Drive :: backward() { state = 0; ledcWrite(channel1, 255 - speed); digitalWrite(motor1B, HIGH); ledcWrite(channel2, speed); digitalWrite(motor2B, LOW); } // Turning in only one direction // This should be a wiring problem though rather than code void Drive :: turn(int right) { state = 0; if (right) { Serial.println("RIGHT"); ledcWrite(channel1, 255 - speed); digitalWrite(motor1B, HIGH); ledcWrite(channel2, 255 - speed); digitalWrite(motor2B, HIGH); } else { Serial.println("LEFT"); ledcWrite(channel1, speed); digitalWrite(motor1B, LOW); ledcWrite(channel2, speed); digitalWrite(motor2B, LOW); } } void Drive :: stop() { state = 0; ledcWrite(channel1, 255 - speed); digitalWrite(motor1B, LOW); ledcWrite(channel2, 0); digitalWrite(motor2B, 0); } void Drive :: turnRandom() { state = 0; randomSeed(millis()); duration = random(300, 900); direction = 1; turn(direction); delay(duration); stop(); delay(500); } void Drive :: update() { // Driving forward only 3/10 of the time to slow down if (state == 0) { return; } if (millis() - prevTime > interval) { state = 1 + (state % 10); prevTime = millis(); } if (state == 1 || state == 4 || state == 7 ) { ledcWrite(channel1, speed); digitalWrite(motor1B, LOW); ledcWrite(channel2, 255 - speed); digitalWrite(motor2B, HIGH); } else { ledcWrite(channel1, 255 - speed); digitalWrite(motor1B, LOW); ledcWrite(channel2, 0); digitalWrite(motor2B, 0); } }
I also had a Strip Light that allowed the top to change colors and patterns. Here's the code I used for that:
#include "strip.h" #include#include "constants.h" #include Strip :: Strip(CRGB *leds_in) { leds = leds_in; on = false; mode = 1; last_time = millis(); buffer = 1000; } // Sets all LEDs to a certain color void Strip :: update_all(int c) { for (int i = 0; i < NUM_LEDS; i++) { leds[i] = colors[c]; } } // Switches state of all lights on or off void Strip :: blink_update(long duration) { // switch from on to off if (millis() - last_time > duration) { on = !on; last_time = millis(); } if (on) { update_all(color); } else { update_all(3); } } // Turns one light on at a time in a circle void Strip :: circle_update(long duration) { long time = millis() - last_time; // reset time if surpassed to avoid overflow if (time > duration){ last_time = millis(); time = 0; } for (int i = 0; i < NUM_LEDS; i ++) { if (time * NUM_LEDS / duration == i) { leds[i] = colors[color]; } else { leds[i] = colors[3]; } } } // Based on state, update all LEDs void Strip :: update() { if (mode == 1) { update_all(color); } if (mode == 0) { update_all(3); } if (mode == 2){ blink_update(500); } if (mode == 3){ blink_update(250); } if (mode == 4) { circle_update(1000); } FastLED.show(); } // 0 = off, 1 = constant, 2 = blink, 3 = blink fast, 4 = circle void Strip :: change_mode() { mode = (mode + 1) % 5; } // 0 = red, 1 = blue, 2 = green void Strip :: change_color() { color = (color + 1) % 3; } void Strip :: off() { mode = 0; color = 0; }
And here's code for a buzzer that can beep, but ended up being too quiet to hear well:
#include "Buzzer.h" #includeconst int durations[4] = {300, 50, 600, 500}; Buzzer :: Buzzer(int p, int n) { pin = p; note = n; state = 0; lastTime = millis(); } void Buzzer :: setup() { ledcSetup(channel, note, resolution); ledcAttachPin(pin, channel); } // Using delay due to problems with drifting off the board while driving void Buzzer :: beep() { ledcWriteTone(channel, note); delay(durations[0]); ledcWriteTone(channel, 0); delay(durations[1]); ledcWriteTone(channel, note); delay(durations[2]); ledcWriteTone(channel, 0); } // Turn off void Buzzer :: off() { ledcWriteTone(channel, 0); } // Currently not used, but should eventually replace the beep functionality void Buzzer :: update() { newTime = millis(); if (state == 0) { off(); } else if (state == 1 && newTime - lastTime > durations[0]) { state = 2; off(); lastTime = millis(); } else if (state == 2 && newTime - lastTime > durations[1]) { state = 3; ledcWriteTone(channel, note); lastTime = millis(); } else if (state == 3 && newTime - lastTime > durations[2]) { state = 4; off(); lastTime = millis(); } else if (state == 4 && newTime - lastTime > durations[3]) { state = 0; } }
Blocky also needed some way to sense what color it was looking at, so here's the code to go with the color detector I used, the TCS34725:
#include "ColorSensor.h" #include// Lower and upper bounds for acrylic green, yellow, and blue; const int lowers[3][4] = { {580, 840, 440, 2150}, {0, 0, 0, 2500}, {550, 850, 500, 2100} }; const int uppers[3][4] = { {650, 950, 490, 2200}, {10000, 10000, 10000, 10000}, {650, 950, 600, 2400} }; const String colors[3] = {"Green", "Yellow", "Blue"}; ColorSensor :: ColorSensor() { tcs = Adafruit_TCS34725(TCS34725_INTEGRATIONTIME_600MS, TCS34725_GAIN_1X); } void ColorSensor :: setup() { while (!tcs.begin()) { Serial.println("No TCS34725 found ..."); } Serial.println("Found sensor!"); } // Returns "Yellow", "Blue", "Green", or "Other" String ColorSensor :: getColorName() { senseColors(); for (int k = 0; k < 3; k++) { bool color = true; for (int i = 0; i < 4; i++) { if (rgbc[i] > uppers[k][i] || rgbc[i] < lowers[k][i]) { color = false; } } if (color) { return colors[k]; } } return "Other"; } // Senses colors quickly void ColorSensor :: senseColors() { rgbc[3] = tcs.read16(TCS34725_CDATAL); rgbc[0] = tcs.read16(TCS34725_RDATAL); rgbc[1] = tcs.read16(TCS34725_GDATAL); rgbc[2] = tcs.read16(TCS34725_BDATAL); } // Adds colors to buffer void ColorSensor :: getColors(int *buffer) { senseColors(); for (int i = 0; i < 4; i++) { buffer[i] = rgbc[i]; } }
Finally, all this was put together, as well as the reception of data from the code blocks, in a main file:
#include "WiFi.h" #include#include "ColorSensor.h" #include "Buzzer.h" #include #include "strip.h" #include "constants.h" #include "Drive.h" #define beep_note 1000 #define buzzer_pin 26 // Max message Length const int LEN = 30; // How long to run for at maximum const int timeout_minutes = 10; // Initialize helper objects Drive driver = Drive(); ColorSensor sensor = ColorSensor(); Buzzer buzz = Buzzer(buzzer_pin, beep_note); int rgbc[4]; CRGB leds[NUM_LEDS]; Strip stp = Strip(leds); // Is the above if statement satisfied? bool ifSatisfied; char commands[LEN] = ""; int num_commands = 0; /* Start of code having to do with ESP-Now --- --- --- */ String success; char dataReceived[LEN]; // char incomingString[LEN]; uint8_t broadcastAddress[] = { 0x34, 0x85, 0x18, 0x03, 0x19, 0x80 }; // Callback when data is sent void OnDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) { if (status == 0) { success = "Delivery Success :)"; } else { success = "Delivery Fail :("; } } // Callback when data is received void OnDataRecv(const uint8_t *mac, const uint8_t *incomingData, int len) { memcpy(&dataReceived, incomingData, sizeof(dataReceived)); } void setupESPNow() { // Set device as a Wi-Fi Station WiFi.mode(WIFI_STA); // Init ESP-NOW if (esp_now_init() != ESP_OK) { Serial.println("Error initializing ESP-NOW"); return; } // Register for a callback function that will be called when data is received esp_now_register_send_cb(OnDataSent); // Register peer esp_now_peer_info_t peerInfo; memset(&peerInfo, 0, sizeof(peerInfo)); memcpy(peerInfo.peer_addr, broadcastAddress, 6); peerInfo.channel = 0; peerInfo.encrypt = false; // Add peer if (esp_now_add_peer(&peerInfo) != ESP_OK) { Serial.println("Failed to add peer"); return; } // Register for a callback function that will be called when data is received esp_now_register_recv_cb(OnDataRecv); } /* End of code having to do with ESP-Now --- --- --- */ void setup() { Serial.begin(115200); WiFi.mode(WIFI_MODE_STA); Serial.println(WiFi.macAddress()); sensor.setup(); setupESPNow(); buzz.setup(); FastLED.addLeds (leds, NUM_LEDS); driver.setup(); driver.stop(); randomSeed(analogRead(2)); // Beep and turn on light stp.change_color(); buzz.beep(); } // Check if seeing the color passed in as argument. Either "Yellow", "Blue", "Green", or "Any" bool checkSatisfied(String currentColor) { String colorSensed = sensor.getColorName(); return (currentColor == colorSensed) || (currentColor == "Any" && colorSensed != "Other"); } // Handles mapping of single characters to actions. // See documentation for explanation of this mapping void handleCommand(char cmd) { Serial.println(cmd); if (cmd == 'D') { ifSatisfied = checkSatisfied("Any"); } else if (cmd == 'H') { ifSatisfied = true; } else if (!ifSatisfied) { return; } else if (cmd == 'I') { buzz.beep(); } else if (cmd == 'J') { stp.change_color(); } else if (cmd == 'K') { stp.change_mode(); } else if (cmd == 'A') { driver.forward(); } else if (cmd == 'B') { driver.turnRandom(); } else if (cmd == 'C') { driver.stop(); delay(500); } } // Checks if data recieved is in valid form bool checkValid() { for (int i = 0; i < LEN; i++) { char cur = dataReceived[i]; if (cur != '!' && cur != '\0' && (cur < 65 || cur > 90)) { return false; } } return true; } // Creates new set of commands and updates length of commands // Characters are sent in reverse order int makeNewCommands() { // Locate first '!' int first = -1; for (int i = 0; i < LEN; i++) { if (first < 0 && dataReceived[i] == '!') { first = i; } } // Reverse Commands: for (int i = 0; i < first; i++) { commands[i] = dataReceived[first - i - 1]; Serial.print(dataReceived[first - i - 1]); } return first; } void loop() { // Update command string if (checkValid()) { num_commands = makeNewCommands(); if (num_commands > 0) { Serial.println(commands); Serial.println(num_commands); } } // Update helper objects sensor.getColors(rgbc); // buzz.update(); stp.update(); driver.update(); // Process Code String if timeout not occurred ifSatisfied = true; if (millis() < timeout_minutes * 60 * 1000) { for (int i = 0; i < num_commands; i++) { char cmd = commands[i]; handleCommand(cmd); } } else { driver.stop(); } }
I think there's a lot about this code that can be improved, like more precise turning, calibration for the color sensor, and fewer delay uses, but it got the job done!
Finally for the output, I had to put all the parts of Blocky together, which was no small task. I 3D printed cases for the motors and color sensor, as well as bottom and top supports for the top box. Then I used a wooden circle as a base and cardboard as walls, all held together by a mix of screws, nuts, bolts, and press-fit. I thought a nice touch too was the acrylic on top that hid the LED strips!
3D design to hold the motors: (Link in Case Model Not Working)
3D design to hold the color sensor: (Link in Case Model Not Working)
And the 3D design to hold the box and acrylic! (Link in Case Model Not Working)
I think Blocky ended up looking pretty cute!
Overall, I'm really proud of the demo I put together, especially given how long it took me to do something like make an LED blink at the beginning of the semester. It's been a while since I've had a project that has put me through as many 10+ hour days and sleepless nights not because I was worried about a grade, but because I was really excited about working on it. I had a lot of fun showing Blocky off at the fair, and it was even more fun seeing all of my classmates with their super cool projects!
While I'm proud of the work I did to put this demo together, I think there's still a lot more that can be done to improve Blocky Coding. Here are some of the ideas I'd be most excited about trying out:
A lot of the future of Blocky Coding will depend on how much time (and access to a fabrication lab) I have next year. This summer, I plan on reaching out to educational experts to talk about what changes could help kids be more engaged and learn more, and talk to a few friends from high school who work in manufacturing to talk to them about how much it would cost to produce this at scale! If you're interested in making your own version of Blocky Coding or expanding on mine, please reach out, as I'd love to talk about ideas! Here's a link to my personal site with some contact info!