Final project

Early Vision: 3D-Printed Prototype Casing for E-Paper Digital Badge with BLE

Project overview

For this project, I am developing a digital badge/nametag using the LILYGO® T5-2.13 inch E-Paper display. The badge will showcase personal details, such as a name, contact information, and a QR code, all of which can be updated wirelessly through a custom Flutter mobile app. Communication between the app and the badge will be handled via Bluetooth Low Energy (BLE). The badge is designed to be portable, powered by a rechargeable battery, and housed in a custom 3D-printed case for comfortable wear.

Project Plan

1. Technology Stack

  • Hardware:
    • LILYGO® T5-2.13 inch E-Paper (ESP32 based)
    • LiPo Battery (appropriate size and capacity)
    • 3D Printer & Filament (e.g., PLA, PETG)
  • Firmware:
    • ESP32 Development Framework: Arduino Core for ESP32 or ESP-IDF
    • Libraries: E-Paper Driver (e.g., GxEPD2), BLE (e.g., NimBLE-Arduino), Graphics (e.g., Adafruit GFX), QR Code Generation, JSON Parsing (e.g., ArduinoJson)
  • Mobile App:
    • Flutter SDK
    • Dart Programming Language
    • Flutter BLE Package (e.g., flutter_blue_plus)
    • State Management (e.g., Provider, Riverpod)
    • QR Code Package (e.g., qr_flutter)
  • Design Tools:
    • 3D Modeling Software (e.g., Fusion 360, Tinkercad)
    • Slicer Software (e.g., Cura, PrusaSlicer)

2. Risks & Mitigation

  • Risk: BLE connection instability.
    Mitigation: Use robust BLE libraries, implement retry logic in app, thorough testing.
  • Risk: E-Paper display library compatibility/issues.
    Mitigation: Research libraries (GxEPD2), start with examples, isolate testing.
  • Risk: Battery life insufficient.
    Mitigation: Choose appropriate battery, implement deep sleep, optimize refresh, measure consumption early.
  • Risk: 3D printed case fitment issues.
    Mitigation: Accurate measurements, iterative prototyping, allow tolerances.
  • Risk: Data transfer limitations/speed over BLE.
    Mitigation: Efficient protocol, potential compression, manage expectations, consider data splitting.
  • Risk: Flutter BLE package quirks across platforms.
    Mitigation: Test on iOS/Android, consult package docs, choose well-maintained package.

3. Diagrams

System Architecture Diagram

Diagram shows the Flutter App on a mobile device communicating via BLE to the badge. The badge contains an ESP32 running firmware, managing a BLE Server and driving the E-Paper display. A battery powers the components, all housed in a 3D printed case.

graph TD
    subgraph Mobile_Device[Mobile Device]
        FlutterApp[Flutter Mobile App]
    end
    
    subgraph Digital_Badge[Digital Badge]
        ESP32[ESP32 Microcontroller]
        EPaper[LILYGO T5-2.13 E-Paper]
        BLE_Server[BLE GATT Server]
        Battery[LiPo Battery]
        Firmware[Device Firmware]
        Case[3D Printed Case]
        Case -->|Houses| ESP32
        Case -->|Houses| EPaper
        Case -->|Houses| Battery
    end
    
    FlutterApp ---|BLE Communication| BLE_Server
    BLE_Server ---|Data RX/TX| Firmware
    Firmware ---|Control/Data| ESP32
    Firmware ---|Renders| EPaper
    ESP32 ---|Drives| EPaper
    ESP32 ---|Manages| BLE_Server
    Battery ---|Power| ESP32
    Battery ---|Power| EPaper
    
    style Mobile_Device fill:#5e81ac,stroke:#eceff4,stroke-width:2px
    style Digital_Badge fill:#4c566a,stroke:#eceff4,stroke-width:2px
	

BLE Communication Flow (Sequence Diagram)

Diagram illustrates the sequence: App scans, Badge advertises, App connects, discovers services, writes data (name, contact, QR data) to a characteristic. Badge firmware receives data, parses it, updates the E-Paper display, and optionally notifies the app.

sequenceDiagram
participant App as Flutter App
participant Badge as ESP32 Badge Firmware

App->>Badge: Scan for BLE Devices
Note right of Badge: Badge is Advertising
Badge-->>App: Advertising Packet (with Service UUID)
App->>Badge: Connect Request
Badge-->>App: Connection Established
App->>Badge: Discover Services & Characteristics
Badge-->>App: Service/Characteristic Details
App->>Badge: Write Characteristic (Data: Name, Contact, QR Data)
Note right of Badge: BLE Server receives data
Badge->>Badge: Parse Received Data
Badge->>Badge: Generate Display Buffer
Note right of Badge: Trigger E-Paper Update
Badge->>Badge: Update E-Paper Display
opt Send Status Update
Badge-->>App: Notify/Indicate Status (e.g., Success/Failure)
end
Note left of App: User sees updated badge / App shows status
	

4. Conceptual Data Structure

Preformatted (JSON Example)

Data sent over BLE could be structured as a JSON string like this:

{ "name": "Alex Doe", "line1": "Software Engineer", "line2": "alex.doe@email.com", "qr": "https://linkedin.com/in/alexdoe" }

Where qr contains the data to be encoded into the QR code displayed on the badge.

Future Enhancements

  • Multiple display templates/layouts selectable from the app.
  • Over-the-Air (OTA) firmware updates.
  • Store multiple profiles on the badge (button switchable).
  • Image display capability (simple logos/icons).

About me

I'm a tech enthusiast and a Cybernetics and Robotics Student, currently working in the Computational Robotics Laboratory at the Faculty of Electrical Engineering, CTU in Prague. My work focuses on hexapod robots featuring ROS. I'm also deeply interested in artificial intelligence, particularly LLMs, their architectures, and the latest advancements in this ever-evolving field.

Beyond Engineering

Outside the lab, I enjoy swimming, running, and tackling bike trails and downhill courses. These activities keep me active and fuel my love for exploration and adventure.

My Projects

This page is where I’ll showcase my journey through the course "Jak Vyrobit Téměř Cokoliv", CTU’s version of MIT’s legendary "How to Make (Almost) Anything"

Weekly progress

"The only way to do great work is to love what you do."    - Steve Jobs
Article Cover
20/02/2025
The Core of Web & Code

Week 1

Subject Information

This subject is inspired by the course How to Make (Almost) Anything, led by Prof. Neil Gershenfeld at MIT. It focuses on digital fabrication techniques, teaching students how to turn ideas into physical prototypes using skills like 3D design, laser cutting, 3D printing, electronics, programming, and tools such as CNC machines and microcontrollers. Each week, students must complete and document tasks on personal web pages, which may contribute to the final project.

Weekly Update

The first week was all about setting up my website and figuring out how to document everything for the course. I worked on getting the basic structure of my personal page up on GitLab, making sure it’s ready for weekly updates. I also started thinking about my final project and how to bring together 3D design, electronics, and programming. It was a bit overwhelming, but it felt good to make progress. I’m excited to see how everything comes together as I document my process each week.

GitLab Pages

I also worked on setting up GitLab Pages to host my site. To make GitLab Pages work, I needed to configure the .gitlab-ci.yml file with the following script:

pages: stage: deploy environment: production script: - echo 'Nothing to do...' artifacts: paths: - public rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

The final step was enabling Shared Runners in the project settings. To do this, navigate to Settings → CI/CD → Runners and enable "Shared runners for this project." Without this, the script won’t function properly. However, this step was already completed by the instructor due to permission restrictions, so I didn’t have to configure it manually.

Once the script ran successfully, I found my website URL under Deploy → Pages. The default URL format is: https://b242_b3b35jvc.pages.fel.cvut.cz/%USERNAME%

Article Cover
27/02/2025
Precision & Creativity: The Art of Laser Cutting & Stickers

Week 2

Weekly Update

This week, I tackled the three main tasks of the assignment:

  1. Select and measure an object to create a detailed CAD drawing.
  2. Execute a precise cut using a plotter.
  3. Design and laser cut a parametric cardboard building kit.
Throughout, I paid special attention to the kerf (laser beam width) and fine-tuned the parameters for clean joints.

OpenSCAD Prototype Casing

I developed a simple, functional prototype casing for my final project using OpenSCAD. This design serves as a modular foundation that can be iterated upon quickly, allowing for adjustments based on material constraints and assembly feedback.

Plotter Cutting: Vinyl Sticker Preparation

For the plotter cutting task, I focused on the preparation, design, and adjustment of a vinyl sticker. Using self-adhesive foil, I cut a piece slightly larger than the final desired sticker size and placed it on the plotter’s base.

Then, using Inkscape I designed an SVG of my sticker—a gear wheel with the text "JVC" inside, matching motifs from our course. I converted the design into a vector format and saved it as a DXF file. In the plotter software, I simply adjusted the size and sent it to the machine for cutting.

NOTE: The cutting plotter only accepts DXF files, not SVG files. When using Inkscape, please use File → Save As to save your design as a DXF file rather than using the Export option.

Simplified Step-by-Step Guide in Inkscape


After the cutter finished, I peeled off the parts that weren't part of my sticker design and applied the transfer layer foil.

I was quite pleased with the outcome.

Laser Cutting: Parametric Cardboard Building Kit

The final task was to design and laser cut a parametric building kit from cardboard. I developed the design in OpenSCAD and emphasized parameterization so that the kit can be assembled in multiple configurations.Key variables included the material thickness and the critical kerf (measured at approximately 0.26 mm), ensuring that the joints fit tightly and that the final product is both versatile and structurally sound. I used the OpenSCAD laser cut library during the design process.

include <lasercut.scad>; // Material and panel dimensions thickness = 2.3; unit = 70; center = unit/2 - thickness/2; // roughly the center coordinate // Define pip sizes pip_big = unit/12; // a larger pip for a bold accent pip_small = unit/28; // a smaller pip // Face 1: "Sunburst" – a big central pip with a ring of 6 small pips face1_ring = [ for (i = [0:5]) let(angle = i*60) [ pip_small, center + 15*cos(angle), center + 15*sin(angle) ] ]; face1 = concat( [ [pip_big, center, center] ], face1_ring ); // Face 2: "Diagonal Duo" – two pips of different sizes on opposite corners face2 = [ [ pip_small, center - 10, center - 10 ], [ pip_big, center + 10, center + 10 ] ]; // Face 3: "Triangular Triplet" – three pips arranged as an equilateral triangle face3 = [ [ pip_small, center, center + 10 ], [ pip_small, center - 10, center - 10 ], [ pip_small, center + 10, center - 10 ] ]; // Face 4: "Corner Quartet" – four pips placed in the corners with alternating sizes face4 = [ [ pip_big, center - 15, center + 15 ], [ pip_small, center + 15, center + 15 ], [ pip_small, center - 15, center - 15 ], [ pip_big, center + 15, center - 15 ] ]; // Face 5: "Central & Cardinal" – a dominant center pip with four smaller pips at the edges face5 = [ [ pip_big, center, center ], [ pip_small, center, center + 12 ], [ pip_small, center, center - 12 ], [ pip_small, center - 12, center ], [ pip_small, center + 12, center ] ]; // Face 6: "Bar Pattern" – two rows of three pips, alternating big and small for contrast face6 = [ // Top row [ pip_big, center - 15, center + 10 ], [ pip_small, center, center + 10 ], [ pip_big, center + 15, center + 10 ], // Bottom row [ pip_big, center - 15, center - 10 ], [ pip_small, center, center - 10 ], [ pip_big, center + 15, center - 10 ] ]; // Gather each face's design into one array (one element per face) creative_circles_remove_a = [ face1, face2, face3, face4, face5, face6 ]; // Generate the cube with our creative pip patterns removed from each face. lasercutoutBox( thickness = thickness, x = unit, y = unit, z = unit, sides = 6, circles_remove_a = creative_circles_remove_a );

I then converted the design into a 2D format so the cutter could process it, using a Python script from the library. I had to modify the script slightly to make it compatible with my computer.

Before starting the laser cutting process, we calibrated the laser. With the FabCore laser cutter we used, we placed a calibration puck on the material and lowered the laser head until the beam was focused perfectly at the center. Then, we measured the kerf by cutting a simple rectangular shape out of cardboard and comparing its dimensions with the cut opening, confirming a kerf of around 0.26 mm. This measurement was essential for designing the interlocking joints.

Prior to the first successful cut, I ran several tests on different sheets of cardboard using a “trial and error” method to set the optimal balance between laser power and head speed. For our cardboard, 100% power at 30 m/s proved to be ideal.

Article Cover
06/03/2025
Prague at Dusk: The Quiet Charm of Charles Square

Week 3

Weekly Update

This week, my assignment included two main tasks:

  1. Scan an object using photogrammetry or a 3D scanner.
  2. Model an object (max 5 cm) that is difficult to produce subtractively.

3D Scanning: Preparation and Process

For this task, our school’s 3D scanners were used. It was crucial to properly prepare the object for scanning:

  • All glossy surfaces needed to be coated with a chalk primer to help capture the details accurately.
  • The object had to be correctly placed on a stable stand to get the best possible scan.
  • The scanner was calibrated before use to ensure optimal results.

In my case, I was particularly interested in how a black object would scan. I used a 3D-printed character made from black filament.

As expected, the results weren’t perfect – the scanned object appeared somewhat rounded rather than sharp, and there were a few imperfections.

However, I was overall satisfied with the outcome given the challenges of scanning a black surface without any chalk primer.

3D Printing: Parametric Pot for Small Room Plants

I've always wanted to try printing the viral parametric vase—often known as the spiral vase—but I decided to create something more useful for me: a parametric pot for my small room plants. Although there are plenty of tutorials online, I wanted to figure everything out by myself. The pot’s design is simple, yet functional, and the process was a lot of fun.

I learned to use Fusion360 Form Modeling, which made the modeling process both enjoyable and educational.

Prusa Slicer Settings

Setting Value
Layer Height 0.30 mm (Modified Quality)
Filament Prusa PETG
Printer Prusa i3 MK3S / MK3S+
Nozzle Size 0.6 mm
Skirt 1 layer (clears extruder residuals)
Wall Width 1 mm
Extrusion Width 0.65 mm
Infill 0% (Spiral Vase mode enabled)

I also experimented with the Spiral Vase option in Prusa Slicer—which was new to me—and was very excited to see the results.

The print went smoothly, as you can see in the timelapse video below. Once finished, I easily detached the pot from the heatbed. To my surprise, even with such thin walls, the pot turned out to be quite stiff and durable.


Final result

3D Model

Article Cover
13/03/2025
From Traditional PCB to Flexible Circuits

Week 4

Weekly Update

This week’s assignment focused on creating an electronic circuit that does something simple—like blinking an LED—without using a solderless breadboard. My chosen path was:

  • Milling and assembling a simple blinker PCB
  • Attempting to make a flexible PCB of the same design using a vinyl cutter

Milling and Assembling the PCB

Due to time constraints from other project commitments—and because it can be relatively complex to convert a KiCad board design into G-code for our CNC (using CAM software)—I opted for a pre-prepared blinking circuit designed by our tutor, Filip Korf. Nonetheless, I recreated the schematic in KiCad to fully understand how the astable flip-flop (using two transistors, resistors, and capacitors) drives alternating LED blinks.

Before milling could begin, we needed to attach the copper board to the CNC work surface using double-sided adhesive tape. Then, we manually set the starting point for milling. An automatic leveling (height calibration) was performed to ensure the board was properly aligned.

Milling was carried out in three phases:

  1. Milling the copper layer
  2. Drilling holes for through-hole (THT) components
  3. Cutting out the board

For each phase, we had to manually change the drill bit. We used a Carvera Air Desktop CNC Machine in our university lab. After loading the G-code onto an Android tablet running the CNC software, the milling process began. The process took quite a while to remove the copper, and once milling was complete, we drilled the component holes and vacuumed away the hazardous debris, leaving us with a cleanly milled board.

Once the board was milled, I meticulously soldered all the components into place. With my friend off on an unexpectedly lengthy toilet run, I had plenty of time on my hands. So, I channeled my inner perfectionist and went on a tinning every joint, every trace, and even the remaining ground areas. When you're bored, you might as well give your PCB the spa treatment it deserves :]

Components

  • 2× Green LED
  • CR2032 Coin Cell (IKEA brand)
  • Coin Cell Holder
  • 3-Pin Switch
  • 2× NPN BC337 Transistor SMD
  • 2× 100Ω Resistor SMD
  • 2× 100kΩ Resistor SMD
  • 2× 10μF Capacitor SMD

The finished board blinks as intended, proving how convenient it is to create a simple circuit with a CNC machine and a few basic components.

Experimenting with a Flexible PCB via Vinyl Cutter

Next, I took on an extra challenge: making the same circuit on a flexible PCB using a vinyl cutter. This approach can be surprisingly accessible, especially for simple circuits. My process was inspired by various guides and personal experimentation.

Materials and Setup

  • Conductive Copper Tape (3M 1126 or similar)
  • Thin PVC Sheet or other flexible substrate
  • Optional: Kapton Tape to protect the substrate from heat
  • Vinyl Cutter (e.g., Silhouette Cameo)
  • Cutting Mat for the vinyl cutter
  • Electronic components for the circuit

The main idea is to adhere copper tape to a flexible support (e.g., PVC), load it into the vinyl cutter, and then cut out the circuit traces. One key limitation is the minimum trace width that the blade can handle.

Step-by-Step Process

  1. Prepare the Substrate: Adhere the copper tape to the thin PVC sheet. If you’re using Kapton tape, apply it first to protect the PVC from soldering heat.
  2. Attach to Cutting Mat: Stick the PVC sheet (with copper) onto the cutting mat, ensuring there are no bubbles or wrinkles.
  3. Load into Vinyl Cutter: Make sure the rollers grip the material securely. Lock the release lever once it’s in place.
  4. Software Setup:
    • Import or trace your circuit design in Silhouette Studio (or similar).
    • Scale the design to match your desired dimensions.
    • Trace the PNG or vector file so the cutter recognizes the paths.
  5. Weeding: Carefully remove excess copper from around the traces with tweezers or a blade. Ensure the circuit paths remain intact.
  6. Soldering: Use a slightly higher iron temperature, but minimize contact time to avoid melting the substrate. If a trace starts lifting, press it back down or reinforce it with a dab of glue.

My Experience and Challenges

I began by converting the Gerber file to an SVG using this online converter . Then, in Inkscape, I adjusted some of the paths to ensure the fills were correct. Once I was satisfied with the result, I saved the file as a DXF and imported it into Silhouette Studio.

After unpacking the tape, I noticed the copper foil had a few bumps. I had to take extra care to flatten it out before cutting to ensure everything aligned properly.

To fine-tune the cutting parameters, I ran small test cuts on a piece of copper foil. I encountered issues with the foil shifting when the force was too high—likely due to bumps or folds in the tape. After adjusting settings, I finally got a solid result.

Cutting Parameters

  • Blade Force: ~6–8
  • Blade Depth: ~3-4
  • Speed: 1 cm/s
  • Number of Passes 10

These values may vary based on your vinyl cutter model and blade condition. The goal is to cut through the copper but not through the backing.

The photo below shows my first attempt at cutting a small circuit on the vinyl cutter, before I fully tuned the cutting parameters. After carefully weeding the unwanted copper using tweezers, the remaining traces were revealed on the mat. You can see one or two broken traces or pads, indicating that further refinement is needed. Despite these imperfections, this initial test demonstrates that the vinyl cutter can handle small circuit designs.

After discovering the right cutting parameters, I finally achieved a decent result with no broken lines. Weeding the leftover copper was much easier this time, and the circuit turned out far cleaner overall.

Unfortunately, I didn’t have any proper vinyl-based flexible foil on hand to laminate the circuit, so I plan to refine this approach in the future. Still, the experiment was a success in showing how a vinyl cutter can produce simple circuit traces on flexible substrates.

Article Cover
20/03/2025
The Dynamics of Kinetic Structures

Week 5

Weekly Update

This week’s challenge was to design and fabricate a kinetic structure using 3D printing and/or laser cutting. The structure needed at least three interconnected components forming a mechanical system.

At first, I struggled to find a suitable idea. Everything I thought of was either too ambitious or too simple, and I wanted something intricate yet feasible. After much deliberation, I recalled a video I had seen a long time ago showcasing a stylized, modern kinetic sculpture inspired by an armillary sphere.

Modeling: Fusion 360 Assembly & Motion

I decided to model my kinetic sculpture in Fusion 360. My goal was to create an aesthetic sculpture rather than a functional astronomical tool.

I structured the design as an assembly, ensuring all moving parts were properly constrained. To visualize the mechanism, I used motion study tools to animate how the rings would rotate around a central axis.

3D Model

ANIMATION: Motion Study


Export & Laser Cutting: Precision Matters

Once the design was complete, I exported my sketch as a PDF drawing with high-resolution interpolation to maintain smooth curves. The next step was to prepare them for laser cutting using our university’s Epilog laser cutter.

Laser Cutting Process

Begin by importing DXF/PDF files of the sketches into the laser software. Elements intended for cutting, rather than engraving, must have their stroke width set to exactly 0.02 mm. Occasionally, toggling the red laser icon and outline mode in the top-left panel may be necessary.

To start the cutting process, the user opens the print dialog by pressing Ctrl+P and navigates to the laser’s configurations, ensuring they match the material preset. Before initiating the cut, the cutting area can be previewed by lifting the glass lid and pressing the green test button. Once the placement is confirmed, you can close the lid, activate the exhaust fan, and the cutting job starts.


I experimented with different power and speed settings to find the optimal cut for the plywood I was using. My tip: Refer to the quick-reference table located next to the machine for recommended settings. It provides a great starting point for various materials.

Laser Cutting Settings

  • Power: 100%
  • Speed: 10%
  • Frequency: 500 Hz

After some trial and error, I successfully cut all the pieces.

Assembly: Unexpected Challenges

With all the pieces ready, I moved on to assembly. However, I quickly encountered problems:

  • The wooden rings were slightly warped due to material imperfections.
  • Screw holes were not perfectly aligned, leading to misalignment.
  • Some rings intersected due to minor precision errors, preventing smooth rotation.

Despite these setbacks, I tried to adjust the components, but the structure still didn’t rotate as intended.

Plan B: Simplified Home-Built Version

Instead of giving up, I decided to salvage the project by creating a simplified version using materials I had at home. The new design was much easier to assemble and, surprisingly, quite amusing to watch.


In the end, despite the issues with precision and material choice, I was quite satisfied with the result. The final kinetic sculpture may not have been perfect, but it still managed to capture the essence of my original vision. :]

Article Cover
27/03/2025
Making Lights Dance to Music!

Week 6

Weekly Update

This week's objective was to interface a sensor with an Arduino to measure a physical quantity, calibrate it, and present the findings. The task required using a sensor from the JVC kit or available loaner components.

Since I had some cool parts lying around gathering dust, I decided to build something I've always wanted: an Audio Spectrum Analyzer. You know, those flashy lights that bounce to the music? Yep, that's the plan! Listen to the room, figure out the beats and melodies, and make an LED matrix dance accordingly.

The Gear: My Chosen Weapons

To pull this off, I raided my parts bin for:

  • Arduino Nano
  • MAX9814 Microphone Module: Not just any mic! This little guy has a built-in amp with Automatic Gain Control (AGC). Translation: it tries to automatically adjust for loud vs. quiet sounds, saving me a headache. Plus, selectable gain and some noise filtering? Yes, please.
  • WS2812B 8x8 Neopixel Matrix

Having these already at home meant zero waiting time and maximum tinkering time. Win-win!

Focus On: The MAX9814 Microphone Module

This specialized integrated circuit combines a low-noise preamplifier, a variable gain amplifier (VGA), and control circuitry onto a single chip, often mounted on a convenient breakout board with an included electret microphone.

Key Features:

  • Automatic Gain Control (AGC): This is the standout feature. The AGC circuitry automatically adjusts the internal amplification level. It reduces the gain for loud sounds to prevent distortion (clipping) and increases the gain for quiet sounds, aiming to maintain a relatively constant output signal level.
  • Selectable Maximum Gain: While the AGC adjusts gain dynamically, the maximum potential gain can be set via the 'Gain' pin. This allows tailoring the module's sensitivity baseline.
  • Adjustable Attack/Release Ratio: The 'AR' pin allows modification of the AGC's response timing – how quickly it reacts to changes in sound level (attack) and how quickly it returns to the baseline gain after a loud sound stops (release).
  • Low-Noise Design: The module incorporates a low-noise preamplifier and internal microphone bias voltage generator, contributing to clearer audio capture with reduced background hiss.

Pin Configuration:

The breakout board used in this project provides access to the following essential pins:

Pin Name Description
Vdd (or VCC) Power Supply Input (typically 2.7V to 5.5V DC)
GND Ground Reference (0V)
Out Analog Output Signal. This signal represents the amplified sound level, typically biased around a DC voltage (e.g., 1.25V).
Gain Input for setting the maximum amplifier gain level.
AR Input for setting the AGC Attack/Release ratio.

Adjustable Settings via Pins:

The Gain and AR pins are typically configured by connecting them to Vdd, GND, or leaving them unconnected (floating). The specific settings can vary slightly between module implementations, but common configurations are:

Gain Pin Configuration
Gain Pin Connection Resulting Maximum Gain
Floating (Unconnected) 60dB (Highest)
Connected to GND 50dB (Medium)
Connected to Vdd 40dB (Lowest)
AR Pin Configuration
AR Pin Connection Resulting Attack/Release Ratio
Floating (Unconnected) 1:4000 (Slowest response, default)
Connected to Vdd 1:2000 (Medium response)
Connected to GND 1:500 (Fastest response)

Note: In this specific project build, the Gain pin was connected to Vdd, setting the maximum gain to 40dB, and the AR pin was left floating for the default 1:4000 ratio.

The MAX9814 module significantly simplifies the audio input stage by providing amplification and level management, allowing the microcontroller (Arduino) to focus on processing the resulting analog signal for analysis and visualization.

Wiring it Up: Connecting the Dots

Time to connect the bits. It looks something like this (don't worry, it's less scary than it sounds):

Connection Cheat Sheet:

  • Power (Super Important!): We need an external 5V power supply. The LEDs are power-hungry beasts and the Arduino's USB port won't cut it. This external supply feeds both the Arduino (via VIN/5V pin) and the LED Matrix (VCC/GND). The Mic module also sips power from the Arduino's 5V output.
    • A 22uF capacitor is placed across the Mic module's VCC and GND for power smoothing – helps keep the mic happy.
  • MAX9814 Mic Module:
    • Vdd (or VCC) to Arduino 5V.
    • GND to Arduino GND.
    • Out (Audio Signal) goes through a 10nF capacitor (marked 103) to Arduino Analog Pin A0. This capacitor blocks any DC offset from the mic, letting only the AC audio signal through.
    • Gain pin is tied directly to Vdd (5V). For the MAX9814, this usually sets the gain to the lowest setting (e.g., 40dB), which is often a good starting point. We can adjust amplification in software later if needed.
  • WS2812B LED Matrix:
    • VCC to the external 5V power supply.
    • GND to the external power supply's GND (and make sure this GND is also connected to the Arduino's GND!).
    • DIN (Data In) connects to Arduino Digital Pin D6 via a 220 ohm resistor. This little guy is surprisingly important!
      • Its main job is to improve signal integrity by dampening signal reflections ('ringing'), ensuring the data gets to the first LED cleanly, preventing flickers or weird colors.
      • It also adds a layer of protection for the first LED's delicate data input pin against potential voltage spikes.
      • As a bonus safety measure, it protects the Arduino's output pin by limiting the current if you happen to connect GND and DIN *before* connecting the main +5V power to the LEDs. This prevents the Arduino pin from trying to power the unpowered strip through the data line (which could damage the Arduino).

    Software Sorcery: Turning Sound into Light

    Making it blink to the beat requires some code magic. Here's the basic recipe:

    1. Listen Closely (Audio Sampling): Grab the audio signal from the mic module via pin A0 using analogRead(). We need to do this thousands of times per second to catch the details in the sound.
    2. Break it Down (Frequency Analysis - FHT): This is the cool part. We need to figure out "how much" of each frequency (low bass, mid-range, high treble) is in the sound sample. The classic way is the Fast Fourier Transform (FFT). But FFTs can be slow on little Arduinos. I found a nifty alternative: the Fast Hartley Transform (FHT). It's related to the FFT but can be speedier for real-world signals like audio from a mic on simpler processors. I used the arduinoFHT library, which is optimized for this kind of job, as it is recommended by the author of a popular Arduino FFT library for handling such tasks efficiently. Faster code = smoother lights!
    3. Paint the Picture (Mapping & Visualization): The FHT gives us numbers representing the intensity of different frequency ranges (or "bins"). Now we map these numbers to the LED grid:
      • Each column on the 8x8 matrix represents a specific frequency band (e.g., bass on the left, treble on the right).
      • The height of the lit LEDs in a column shows how loud that frequency band is.
      • We can even use color to make it fancier – maybe different colors for different columns/frequencies, and brighter shades for louder sounds.

    Calibration Corner

    "Calibration" here isn't about scientific accuracy, it's about making it *look good*. This involved tweaking values in the code:

    • Sensitivity/Noise Floor: Adjusting how quiet a sound needs to be before the lights react (the noiseThreshold in the code). Too sensitive, and it reacts to everything; too insensitive, and it misses quiet parts.
    • Gain/Amplification: Boosting the incoming signal (signalAmplification) if the mic pickup seems too low, or reducing it if everything maxes out too easily.
    • Frequency Mapping: Deciding exactly which FHT bins map to which columns (FREQUENCY_BANDS).
    • Visual Flair: Tuning the smoothing (barSmoothness), peak dot behavior (PeakDotsConfig), colors (COLUMN_PALETTE), and overall brightness. It's more art than science!

    (Essentially: Fiddle with numbers until the blinky lights look awesome.)

    The Grand Reveal: It's Alive!

    Success! After uploading the code and playing some tunes, the matrix sprang to life. Bass hits make the low-frequency columns jump, while higher notes and voices light up the other side. The height tracks the loudness, creating a mesmerizing visual beat. It actually works!

    Bouncing to Dire Straits
    Trying Some Piano Tunes

    FULL CODE

    #include <FastLED.h> #define LOG_OUT 1 #include <FHT.h> #define LED_PIN 6 #define AUDIO_INPUT_PIN 0 // A0 // ------------------------- Configuration Structures ------------------------- struct DisplayConfig { const uint8_t width = 8; // Matrix width (number of LEDs) const uint8_t height = 8; // Matrix height (number of LEDs) const uint8_t brightness = 8; // LED brightness (0-255) }; struct AudioConfig { const float signalAmplification = 1.3; // Input signal amplification factor const uint8_t noiseThreshold = 30; // Lower noise sensitivity threshold const float peakFactor = 1.05; // Coefficient for peak scaling }; struct AnimationConfig { const float barSmoothness = 0.4; // Bar movement smoothness (0-1) const uint8_t refreshDelay = 4; // Display refresh delay in milliseconds }; struct PeakDotsConfig { const bool enabled = true; // Enable peak dots display const uint16_t fallDelay = 50; // Delay (ms) between peak falls const uint16_t fallPause = 700; // Time (ms) to hold peak before falling const uint8_t hueShift = 45; // Hue shift for peak dots }; struct PinConfig { const uint8_t audioInput = AUDIO_INPUT_PIN; // Audio input pin }; // Frequency band mapping (approximately parabolic: 80Hz to 16kHz) const uint8_t FREQUENCY_BANDS[9] = {2, 6, 10, 14, 20, 30, 60, 100, 120}; // FFT configuration const uint16_t FFT_SAMPLES = 256; // Column color palette (left-to-right) const CRGB COLUMN_PALETTE[8] = { CRGB(160, 80, 0), // Brownish/Orange CRGB(184, 120, 0), // Orange-gold CRGB(168, 168, 0), // Olive CRGB(64, 184, 0), // Green CRGB(0, 184, 64), // Teal CRGB(0, 184, 184), // Cyan CRGB(0, 120, 184), // Blue CRGB(0, 64, 184) // Deep Blue }; // ADC register manipulation macros #define setRegisterBit(sfr, bit) (_SFR_BYTE(sfr) |= _BV(bit)) #define clearRegisterBit(sfr, bit) (_SFR_BYTE(sfr) &= ~_BV(bit)) // ------------------------- AudioVisualizer Class ------------------------- class AudioVisualizer { private: // Configuration objects DisplayConfig displayCfg; AudioConfig audioCfg; AnimationConfig animCfg; PeakDotsConfig peakCfg; PinConfig pinCfg; // LED array CRGB *leds; uint16_t numLeds; // Audio processing variables int currentGain; uint8_t maxAmplitude; float amplitudeSmoothing; float smoothedMaxAmplitude; // Peak tracking arrays for each column (0 to displayCfg.width-1) int peakLevels[8]; uint8_t previousLevels[8]; unsigned long peakTimestamps[8]; // Timing variables unsigned long gainUpdateTimer; unsigned long peakFallTimer; unsigned long lastRefreshTime; bool peakFallTrigger; public: AudioVisualizer() { numLeds = displayCfg.width * displayCfg.height; leds = new CRGB[numLeds]; amplitudeSmoothing = 0.05; smoothedMaxAmplitude = 0.0; for (uint8_t i = 0; i < displayCfg.width; i++) { peakLevels[i] = 0; previousLevels[i] = 0; peakTimestamps[i] = 0; } gainUpdateTimer = 0; peakFallTimer = 0; lastRefreshTime = 0; peakFallTrigger = false; } ~AudioVisualizer() { delete[] leds; } void setup() { // Increase ADC sampling frequency setRegisterBit(ADCSRA, ADPS2); clearRegisterBit(ADCSRA, ADPS1); setRegisterBit(ADCSRA, ADPS0); // Optionally, set analogReference(EXTERNAL); FastLED.setBrightness(displayCfg.brightness); FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, numLeds); } // Capture FFT_SAMPLES audio samples and process the FFT void captureAndProcessAudio() { for (uint16_t i = 0; i < FFT_SAMPLES; i++) { // Loop variable as uint16_t fht_input[i] = analogRead(pinCfg.audioInput); } fht_window(); fht_reorder(); fht_run(); fht_mag_log(); } // Filter, amplify, and normalize the FFT results void processFrequencyData() { for (uint16_t i = 0; i < 128; i++) { if (fht_log_out[i] < audioCfg.noiseThreshold) { fht_log_out[i] = 0; } fht_log_out[i] = static_cast<float>(fht_log_out[i]) * audioCfg.signalAmplification; } } // Render the LED matrix visualization based on frequency data void renderVisualization() { maxAmplitude = 0; FastLED.clear(); for (uint8_t column = 0; column < displayCfg.width; column++) { int columnLevel = fht_log_out[FREQUENCY_BANDS[column]]; // Sum contributions from adjacent frequency bins for a smoother transition if (column > 0 && column < displayCfg.width) { uint8_t lowerBandWidth = FREQUENCY_BANDS[column] - FREQUENCY_BANDS[column - 1]; for (uint8_t i = 0; i < lowerBandWidth; i++) { float weight = static_cast<float>(i) / lowerBandWidth; columnLevel += weight * fht_log_out[FREQUENCY_BANDS[column] - lowerBandWidth + i]; } uint8_t upperBandWidth = FREQUENCY_BANDS[column + 1] - FREQUENCY_BANDS[column]; for (uint8_t i = 0; i < upperBandWidth; i++) { float weight = static_cast<float>(i) / upperBandWidth; columnLevel += weight * fht_log_out[FREQUENCY_BANDS[column] + upperBandWidth - i]; } } // Update global max amplitude for gain adjustment if (columnLevel > maxAmplitude) { maxAmplitude = columnLevel; } // Smooth the column level for fluid animation int smoothedLevel = columnLevel * animCfg.barSmoothness + previousLevels[column] * (1 - animCfg.barSmoothness); previousLevels[column] = smoothedLevel; // Scale the smoothed level to the display height smoothedLevel = map(smoothedLevel, audioCfg.noiseThreshold, currentGain, 0, displayCfg.height); smoothedLevel = constrain(smoothedLevel, 0, displayCfg.height); // Render the column with a vertical brightness gradient renderColumn(column, smoothedLevel); // Update and render the peak dot for this column updatePeakLevel(column, smoothedLevel); renderPeakDot(column); updatePeakFalling(column); } FastLED.show(); } // Render a single column with a vertical gradient void renderColumn(uint8_t column, int height) { if (height <= 0) return; for (uint8_t row = 0; row < height; row++) { CRGB baseColor = COLUMN_PALETTE[column]; uint8_t brightness = map(row, 0, displayCfg.height - 1, 70, 255); CRGB pixelColor = baseColor; pixelColor.nscale8(brightness); leds[column * displayCfg.height + row] = pixelColor; } } // Update the peak level for the given column void updatePeakLevel(uint8_t column, int level) { if (level > 0 && level > peakLevels[column]) { peakLevels[column] = level; peakTimestamps[column] = millis(); } } // Render the peak dot using a hue-shifted version of the base color void renderPeakDot(uint8_t column) { if (peakLevels[column] >= 0 && peakCfg.enabled) { CRGB peakColor = COLUMN_PALETTE[column]; uint8_t brightness = map(peakLevels[column], 0, displayCfg.height - 1, 70, 255); peakColor.nscale8(brightness); // Convert to HSV and shift hue by the configured hueShift for a darker/different appearance CHSV hsvColor = rgb2hsv_approximate(peakColor); hsvColor.hue += peakCfg.hueShift; peakColor = hsvColor; peakColor.nscale8(brightness); leds[column * displayCfg.height + peakLevels[column]] = peakColor; } } // Make peaks fall after holding for a specified time void updatePeakFalling(uint8_t column) { if (peakFallTrigger) { if (millis() - peakTimestamps[column] > peakCfg.fallPause) { if (peakLevels[column] >= 0) { peakLevels[column]--; } } } } // Update the audio gain based on the maximum amplitude with smoothing void updateGain() { if (millis() - gainUpdateTimer > 10) { smoothedMaxAmplitude = maxAmplitude * amplitudeSmoothing + smoothedMaxAmplitude * (1 - amplitudeSmoothing); currentGain = (smoothedMaxAmplitude > audioCfg.noiseThreshold) ? (audioCfg.peakFactor * smoothedMaxAmplitude) : 100; gainUpdateTimer = millis(); } } // Update the trigger for peak falling void updatePeakFallTimer() { peakFallTrigger = false; if (millis() - peakFallTimer > peakCfg.fallDelay) { peakFallTrigger = true; peakFallTimer = millis(); } } // Main loop: capture audio, process it, update visualization and gain void loop() { if (millis() - lastRefreshTime > animCfg.refreshDelay) { lastRefreshTime = millis(); captureAndProcessAudio(); processFrequencyData(); renderVisualization(); updateGain(); updatePeakFallTimer(); } } }; // ------------------------- Main Program ------------------------- AudioVisualizer visualizer; void setup() { visualizer.setup(); } void loop() { visualizer.loop(); }

    Wrapping Up: Lessons Learned

    This was a super fun project! It nicely tied together sensor input (the mic), data processing (the FHT), and visual output (the LEDs). The MAX9814 mic made the hardware side easier, and the FHT library kept the Arduino from melting (metaphorically). Getting the sensitivity and visuals tuned just right took some patience, but seeing the lights dance to music? Totally worth it! :]

Article Cover
03/04/2025
Resonant Bodies: How Machines Speak Through Motion and Light

Week 7

Weekly Update

This week shifted focus to interactive output devices. The assignment involved selecting and controlling an output device not previously used, and integrating it with an input mechanism.

  1. Connect and utilize an output device not used previously in the course.
  2. Design a program linking at least one input and one output device.

For this, I utilized the LILYGO T5 V2.3.1 development board, which conveniently integrates an ESP32 microcontroller with a 2.13-inch E-Paper Display (EPD). I explored controlling the EPD using the GxEPD2 library, including generating dynamic QR codes. The input mechanism was implemented using Bluetooth Low Energy (BLE), allowing commands to be sent wirelessly to update the display.

Focusing on the E-Paper display and BLE for this week's assignment was a strategic choice, as it feeds directly into my final project. It was great to be able to use this assignment to build out a core piece of the digital badge.

The LILYGO T5 V2.3.1 Board

LILYGO T5 V2.3.1 Development Board

Configuring the GxEPD2 Library for the Display

E-paper displays require specific drivers due to their unique electrophoretic technology. The GxEPD2 library supports many different EPD panels, but you must tell it exactly which one you are using and how it's connected to the ESP32 (via SPI pins).

The recommended way to configure the library for your specific display and board is not to edit files directly within the installed library folder (as updates would overwrite your changes). Instead, the library provides convenient configuration header files within its example sketches. These files simplify the selection process.

The correct approach is to:

  1. Navigate to the GxEPD2 library's installation directory (usually within your Arduino libraries folder).
  2. Open the examples subfolder.
  3. Choose a relevant base example sketch folder (e.g., GxEPD2_Example).
  4. Identify the main configuration header file (often GxEPD2_display_selection_new_style.h) and its necessary companion files (like GxEPD2_selection_check.h and potentially GxEPD2_wiring_examples.h) within that single example folder.
  5. Copy these specific header files (typically 2 or 3 files) from that chosen example folder into your own Arduino sketch's main folder (the same directory where your .ino or main .cpp file resides).
When you include the main configuration header (e.g., #include "GxEPD2_display_selection_new_style.h") in your sketch and build your project, the Arduino IDE (or your build environment) will automatically use your local copies and find the included companion files (like GxEPD2_selection_check.h) in the same directory. These local files contain all the necessary #define statements for selecting your display driver and pins.

Project Directory Structure Example:

MyEPaperProject/
├── MyEPaperProject.ino                      <-- Your main sketch file
├── GxEPD2_display_selection_new_style.h     <-- Copied & edited configuration from an example
├── GxEPD2_selection_check.h             <-- Copied dependency from the same example
└── GxEPD2_wiring_examples.h             <-- Optional: Copied if present/needed by selection file

Once you have these configuration files copied into your project directory, you can safely edit your local copy of the main selection file (e.g., GxEPD2_display_selection_new_style.h). The first step remains identifying the specific E-Paper panel model or controller chip used on your board. Look for markings on the board's silkscreen, check the documentation, or inspect the display's flexible flat cable (FPC).

As seen in the image above, the FPC cable might have a label like FPC-A002. This code, along with the display size (2.13 inches) and potentially the board's schematic or documentation, helps identify the correct EPD controller (e.g., GDEH0213B72, GDEH0213B73, etc.). Once identified, you need to find the corresponding #define GxEPD2_DRIVER_CLASS line in your copied main selection header file and uncomment it (remove the leading //), ensuring only one driver class is active. Then, scroll down further in the same file to the section for pin definitions (often within #if defined(...) blocks for specific boards) and uncomment or adjust the lines corresponding to your specific board (like the LILYGO T5 ESP32 variant), ensuring the SPI pins (SCK, MOSI, CS, DC, RST, BUSY) match your hardware connections. These pin definitions might be set directly or via a constructor call in the example's .ino file, which you might need to adapt in your own sketch.

Example Snippet from your copied GxEPD2_display_selection_new_style.h

// This file needs GxEPD2_selection_check.h in the same directory #include "GxEPD2_selection_check.h" // --- Select display class --- #define GxEPD2_DISPLAY_CLASS GxEPD2_BW // ... other display classes commented out ... // --- Select display driver class --- // ... other drivers commented out ... #define GxEPD2_DRIVER_CLASS GxEPD2_213_GDEY0213B74 // GDEY0213B74 122x250, SSD1680, (FPC-A002 20.04.08) <-- UNCOMMENTED FOR THIS EXAMPLE // ... other drivers commented out ... // --- Pin Configuration Section (often board-specific) --- // ... many #if defined(...) sections ... #if defined(ARDUINO_ARCH_ESP32) // Example block for ESP32 // ... different board wirings commented out ... // Wiring for LILYGO T5 V2.3.1 (example, verify your board!) // This might be set via constructor in the .ino file instead of defines here. // Check the example you copied from. If pins ARE defined here, uncomment the correct block: // Example GxEPD2_DISPLAY_CLASS<...> display(GxEPD2_DRIVER_CLASS(/*CS=5*/ EPD_CS, /*DC=*/ 17, /*RST=*/ 16, /*BUSY=*/ 4)); // Common wiring #endif // ... more board sections ...

The library provides standard graphics functions (display.print, display.drawRect, etc.) and handles the complex update sequences.

Partial vs. Full Updates

A key feature of many EPDs supported by GxEPD2 is partial update capability.

  • Full Update: Refreshes the entire screen. Necessary for complex changes or to eliminate ghosting. Causes a noticeable black/white flashing cycle.
  • Partial Update: Refreshes only a defined rectangular portion. Much faster, avoids flashing, uses less power. Ideal for small, frequent changes (e.g., status text, sensor values).

Code Snippet: Partial Update Concept

// --- Concept: Using Partial Update --- // Define the area const int PARTIAL_AREA_X = 50, PARTIAL_AREA_Y = 40, PARTIAL_AREA_W = 150, PARTIAL_AREA_H = 30; char statusBuffer[40]; // Buffer for text void showStatus(const char* message) { snprintf(statusBuffer, sizeof(statusBuffer), "%s", message); // Copy message display.setPartialWindow(PARTIAL_AREA_X, PARTIAL_AREA_Y, PARTIAL_AREA_W, PARTIAL_AREA_H); display.firstPage(); do { display.fillScreen(GxEPD_WHITE); // Clear *only* the partial window // ... code to set font, calculate position, and display.print(statusBuffer) ... } while (display.nextPage()); display.hibernate(); }

Generating and Displaying QR Codes

QR codes provide a compact way to share information like URLs or contact details. To generate them on the ESP32 for display on the E-Paper, I integrated a QR code generation library (like qrcode.h derived from libqrcodegen).

The Generation Process

Generating a QR code involves several steps handled by the library:

  1. Input Data: The text or URL to be encoded is provided to the library.
  2. Encoding Mode: The library typically selects the most efficient encoding (numeric, alphanumeric, byte, Kanji) based on the input data.
  3. Version Selection: A QR code Version (1-40) is determined, often automatically or specified by the user. Higher versions mean a larger grid size (more modules) and higher data capacity.
  4. Error Correction Level (ECC): A level (Low, Medium, Quartile, High) is chosen. Higher ECC allows the code to be read even if partially damaged or obscured, but reduces data capacity.
  5. Module Calculation: The library performs complex calculations based on the QR standard to determine the pattern of black and white squares (modules) representing the data, encoding mode, version, ECC level, and required finder/alignment patterns.
  6. Buffer Output: The final module pattern (a grid of boolean values or similar) is typically stored in a memory buffer (e.g., a uint8_t array).

Drawing the QR Code

Once the module pattern is in the buffer:

  1. The E-Paper display is prepared for a full update.
  2. The screen buffer is cleared (usually to white).
  3. The code iterates through the QR module buffer.
  4. For each module marked as "black" in the buffer, a filled rectangle (display.fillRect) is drawn on the screen buffer.
  5. The size and position of this rectangle are determined by a chosen scale factor (how many pixels wide/high each module should be) and calculated offsets to center the QR code on the display.
  6. After iterating through all modules, the screen buffer is sent to the E-Paper display.

Integrating Bluetooth Input with E-Paper Output

To fulfill the task of linking input and output, I used the ESP32's built-in Bluetooth Low Energy (BLE) capability as the input mechanism to control the E-Paper display (output). A simple BLE service was created with a single writable characteristic.

Using a BLE mobile app (like nRF Connect or LightBlue), commands can be sent to this characteristic:

  • Sending a URL string triggers the ESP32 to generate the corresponding QR code and display it using a full screen update.
  • Sending the specific text command "clear" triggers the ESP32 to clear the E-Paper display using a full screen update.
  • Connection status messages ("Connected", "Disconnected", "Waiting...") or error messages ("Data Too Long") are displayed using efficient partial updates in a central area of the screen.

Final Integrated Code

The following code implements the BLE service, QR generation, E-Paper drawing logic with partial/full update handling, and command parsing:

/** * @file BleQrBadge_SingleChar_PartialText_V2.ino * @brief E-Paper badge: Displays QR code from BLE URL via a single characteristic. * Uses partial updates for text status, full updates for QR/Clear. * Command "clear" on the same characteristic clears the screen. * Landscape & Centered. Single Service/Characteristic. * @author Amir Akrami */ // Core GxEPD2 library #include <GxEPD2_BW.h> // Font library for status messages #include <Fonts/FreeSans9pt7b.h> // Using 9pt font // QR Code Generation Library #include <qrcode.h> // Include Arduino core #include <Arduino.h> // === IMPORTANT: Display Configuration Header === // Selected display type is in this file #include "GxEPD2_display_selection_new_style.h" // === IMPORTANT === // --- BLE Includes --- #include <BLEDevice.h> #include <BLEUtils.h> #include <BLEServer.h> #include <BLECharacteristic.h> #include <BLEAdvertising.h> // =================================================================================== // Configuration Constants // =================================================================================== // --- QR Code Configuration --- const int FIXED_QR_VERSION = 7; const int FIXED_QR_SCALE = 2; const int MAX_INPUT_STRING_LENGTH = 90; const int QR_QUIET_ZONE_MODULES = 4; // --- BLE Configuration --- // TODO: Generate my own unique UUIDs for production! #define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b" #define DATA_CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8" // Single characteristic for URL or "clear" command const char* bleDeviceName = "EPaper QR Badge"; // --- Partial Update Configuration --- const int STATUS_AREA_WIDTH = 200; const int STATUS_AREA_HEIGHT = 50; int STATUS_AREA_X = 0; int STATUS_AREA_Y = 0; // =================================================================================== // Global Variables // =================================================================================== // --- Display --- // 'display' object created in GxEPD2_display_selection_new_style.h // --- Data Handling --- String currentDataString = ""; bool newQrDataReceived = false; bool clearDisplayRequested = false; bool statusUpdateRequested = false; String statusMessageToShow = ""; // *** NEW FLAG ***: Tracks if the screen needs a full clear before the next status message bool needsFullClearBeforeNextStatus = true; // Start true to ensure first status message clears screen properly // --- BLE --- BLEServer *pServer = NULL; BLECharacteristic *pDataCharacteristic = NULL; bool deviceConnected = false; // =================================================================================== // Function Prototypes // =================================================================================== void setupBLE(); void showStatusMessage(const char* message); // Uses PARTIAL update void updateQrDisplay(const char* textToEncode); // Uses FULL update void drawQrScreen(const char* textToEncode); // Draws content for QR full update void clearDisplay(); // Uses FULL update (blank screen) void performQuickFullClear(); // Helper for full clear without hibernation void drawCenteredText(const char *text, int baselineY, const GFXfont *font, int targetW = -1, int targetX = 0); bool drawQrCode(int x_target_area, int y_target_area, int w_target_area, int h_target_area, const char *text); // =================================================================================== // BLE Callback Classes // =================================================================================== // --- Server Connection Callbacks --- class MyServerCallbacks: public BLEServerCallbacks { void onConnect(BLEServer* pServer) { deviceConnected = true; Serial.println("BLE Client Connected"); statusMessageToShow = "Connected.\nWaiting for data..."; statusUpdateRequested = true; } void onDisconnect(BLEServer* pServer) { deviceConnected = false; Serial.println("BLE Client Disconnected"); statusMessageToShow = "Disconnected.\nAdvertising..."; statusUpdateRequested = true; delay(500); BLEDevice::startAdvertising(); Serial.println("Advertising restarted"); } }; // --- Data Characteristic Write Callback --- class DataCharacteristicCallbacks: public BLECharacteristicCallbacks { void onWrite(BLECharacteristic *pCharacteristic) { std::string value = pCharacteristic->getValue(); String valueStr = String(value.c_str()); valueStr.trim(); Serial.print("Received Value: '"); Serial.print(valueStr); Serial.println("'"); if (valueStr.equalsIgnoreCase("clear")) { Serial.println("Clear command recognized."); clearDisplayRequested = true; newQrDataReceived = false; statusUpdateRequested = false; } else if (valueStr.length() > 0) { if (valueStr.length() > MAX_INPUT_STRING_LENGTH) { Serial.printf("Error: Received data length (%d) exceeds maximum (%d)\n", valueStr.length(), MAX_INPUT_STRING_LENGTH); statusMessageToShow = "Error: Data Too Long"; statusUpdateRequested = true; // Trigger status update newQrDataReceived = false; clearDisplayRequested = false; } else { Serial.println("New QR data received."); currentDataString = valueStr; newQrDataReceived = true; clearDisplayRequested = false; statusUpdateRequested = false; } } else { Serial.println("Received empty value. Ignoring."); } } }; // =================================================================================== // Setup Function // =================================================================================== void setup() { Serial.begin(115200); while (!Serial && millis() < 2000); Serial.println("\nStarting BLE QR Badge (Single Char, Partial Text V2)"); // --- Initialize Display --- display.init(115200); Serial.println("Display initialized"); display.setRotation(1); int screenW = display.width(); int screenH = display.height(); Serial.printf("Display rotation: %d. Width: %d, Height: %d\n", display.getRotation(), screenW, screenH); // --- Calculate Status Area Position --- STATUS_AREA_X = (screenW - STATUS_AREA_WIDTH) / 2; STATUS_AREA_Y = (screenH - STATUS_AREA_HEIGHT) / 2; if (STATUS_AREA_X < 0) STATUS_AREA_X = 0; if (STATUS_AREA_Y < 0) STATUS_AREA_Y = 0; Serial.printf("Status message area (for partial update): x=%d, y=%d, w=%d, h=%d\n", STATUS_AREA_X, STATUS_AREA_Y, STATUS_AREA_WIDTH, STATUS_AREA_HEIGHT); // --- Initial Screen Clear (Full Update) --- Serial.println("Performing initial full screen clear."); performQuickFullClear(); // Use helper to clear without hibernating yet needsFullClearBeforeNextStatus = false; // Screen is now clean, reset flag // --- Show Initial Message (using partial update) --- statusMessageToShow = "Initializing BLE..."; statusUpdateRequested = true; // Trigger initial status update in loop // --- Initialize BLE --- setupBLE(); // --- Update Waiting Message --- statusMessageToShow = "Waiting for Connection..."; statusUpdateRequested = true; // Trigger update in loop Serial.println("Setup complete. Advertising..."); } // =================================================================================== // Loop Function // =================================================================================== void loop() { // --- Handle Display Actions based on Flags (Priority: Clear > QR > Status) --- if (clearDisplayRequested) { clearDisplayRequested = false; // Reset flag *before* action Serial.println("Processing 'clear' command..."); clearDisplay(); // FULL update to blank screen Serial.println("Display cleared via BLE command."); needsFullClearBeforeNextStatus = true; // Set flag: next status needs full clear display.hibernate(); // Hibernate after FULL update } else if (newQrDataReceived) { newQrDataReceived = false; // Reset flag *before* action Serial.println("Processing new QR data..."); updateQrDisplay(currentDataString.c_str()); // FULL update (draws QR or error) Serial.println("QR display update attempt complete."); needsFullClearBeforeNextStatus = true; // Set flag: next status needs full clear display.hibernate(); // Hibernate after FULL update } else if (statusUpdateRequested) { statusUpdateRequested = false; // Reset flag *before* action Serial.println("Processing status message update..."); // *** CHECK if full clear is needed before this partial update *** if (needsFullClearBeforeNextStatus) { Serial.println("...Performing full screen clear before status update (previous update was full)."); performQuickFullClear(); // Clear the whole screen needsFullClearBeforeNextStatus = false; // Reset the flag } // Now proceed with the partial update for the status message showStatusMessage(statusMessageToShow.c_str()); // PARTIAL update Serial.println("Status message updated."); // Note: needsFullClearBeforeNextStatus remains false after a partial update display.hibernate(); // Hibernate after PARTIAL update } // --- Allow BLE stack time to process --- delay(100); } // =================================================================================== // BLE Setup Function // =================================================================================== void setupBLE() { Serial.println("Initializing BLE..."); BLEDevice::init(bleDeviceName); pServer = BLEDevice::createServer(); pServer->setCallbacks(new MyServerCallbacks()); BLEService *pService = pServer->createService(SERVICE_UUID); pDataCharacteristic = pService->createCharacteristic( DATA_CHARACTERISTIC_UUID, BLECharacteristic::PROPERTY_WRITE ); pDataCharacteristic->setCallbacks(new DataCharacteristicCallbacks()); // Add a User Description Descriptor (UUID 0x2901) // This is **highly recommended** for debugging with generic BLE tools (e.g., nRF Connect) // It allows tools to show a human-readable name for the characteristic. // While not strictly essential for function if using a dedicated app, it aids development greatly. BLEDescriptor* pDataDesc = new BLEDescriptor(BLEUUID((uint16_t)0x2901)); pDataDesc->setValue("URL data or 'clear' command"); pDataCharacteristic->addDescriptor(pDataDesc); pService->start(); BLEAdvertising *pAdvertising = BLEDevice::getAdvertising(); pAdvertising->addServiceUUID(SERVICE_UUID); pAdvertising->setScanResponse(true); BLEDevice::startAdvertising(); Serial.println("BLE Service Started, Advertising."); Serial.printf("Device Name: %s\n", bleDeviceName); Serial.printf("Service UUID: %s\n", SERVICE_UUID); Serial.printf(" Data Characteristic: %s (Write URL or 'clear')\n", DATA_CHARACTERISTIC_UUID); } // =================================================================================== // Show Status Message Function (PARTIAL UPDATE) // =================================================================================== void showStatusMessage(const char* message) { // (Function remains the same as previous version) if (!message || message[0] == '\0') { Serial.println("showStatusMessage: Skipping empty message."); return; } Serial.printf("Display Status (Partial Update in area %d,%d %dx%d): %s\n", STATUS_AREA_X, STATUS_AREA_Y, STATUS_AREA_WIDTH, STATUS_AREA_HEIGHT, message); display.setPartialWindow(STATUS_AREA_X, STATUS_AREA_Y, STATUS_AREA_WIDTH, STATUS_AREA_HEIGHT); display.firstPage(); do { display.fillScreen(GxEPD_WHITE); // Clear only the partial window display.setFont(&FreeSans9pt7b); int16_t x1, y1; uint16_t w, h; const char* newline = strchr(message, '\n'); if (newline != NULL) { // Two Line Message Centering int lenLine1 = newline - message; char line1[lenLine1 + 1]; strncpy(line1, message, lenLine1); line1[lenLine1] = '\0'; const char* line2 = newline + 1; display.getTextBounds(line1, 0, 0, &x1, &y1, &w, &h); // Use height of one line int totalTextHeight = h * 2 + 5; int baselineY1 = STATUS_AREA_Y + (STATUS_AREA_HEIGHT - totalTextHeight) / 2 + h; if (baselineY1 < STATUS_AREA_Y + h) baselineY1 = STATUS_AREA_Y + h; drawCenteredText(line1, baselineY1, &FreeSans9pt7b, STATUS_AREA_WIDTH, STATUS_AREA_X); drawCenteredText(line2, baselineY1 + h + 5, &FreeSans9pt7b, STATUS_AREA_WIDTH, STATUS_AREA_X); } else { // Single Line Message Centering display.getTextBounds(message, 0, 0, &x1, &y1, &w, &h); int topY = STATUS_AREA_Y + (STATUS_AREA_HEIGHT - h) / 2; int baselineY = topY - y1; if (baselineY < STATUS_AREA_Y - y1) baselineY = STATUS_AREA_Y - y1; if (baselineY > STATUS_AREA_Y + STATUS_AREA_HEIGHT ) baselineY = STATUS_AREA_Y + STATUS_AREA_HEIGHT; drawCenteredText(message, baselineY, &FreeSans9pt7b, STATUS_AREA_WIDTH, STATUS_AREA_X); } } while (display.nextPage()); } // =================================================================================== // Clear Display Function (FULL UPDATE) // =================================================================================== void clearDisplay() { Serial.println("Executing clearDisplay() (Full Update)..."); performQuickFullClear(); // Use the helper function Serial.println("Display cleared (Full Update finished). Screen is blank."); // Hibernation handled by the loop } // =================================================================================== // Helper Function: Perform Quick Full Clear (No Hibernate) // =================================================================================== /** * @brief Clears the entire display using a full update but DOES NOT hibernate. * Used internally before showing a partial update after a full update. */ void performQuickFullClear() { display.setFullWindow(); display.firstPage(); do { display.fillScreen(GxEPD_WHITE); } while (display.nextPage()); } // =================================================================================== // Update QR Display Function (FULL UPDATE) // =================================================================================== void updateQrDisplay(const char* textToEncode) { Serial.printf("Updating QR display (Full Update) for: '%s'\n", textToEncode); display.setFullWindow(); display.firstPage(); do { display.fillScreen(GxEPD_WHITE); drawQrScreen(textToEncode); } while (display.nextPage()); // Hibernation handled by the loop } // =================================================================================== // Draw QR Screen Function (Called during FULL UPDATE) // =================================================================================== void drawQrScreen(const char* textToEncode) { // (Function remains the same as previous version) bool qrSuccess = drawQrCode(0, 0, display.width(), display.height(), textToEncode); if (!qrSuccess) { Serial.println("QR Code drawing failed. Displaying error message (Full Update)."); display.setFont(&FreeSans9pt7b); int16_t x1, y1; uint16_t w, h; const char* errMsg = "QR Generation Failed"; display.getTextBounds(errMsg, 0, 0, &x1, &y1, &w, &h); int baselineY = (display.height() - h) / 2 - y1; drawCenteredText(errMsg, baselineY, &FreeSans9pt7b); } else { Serial.println("QR Code drawn successfully (Full Update)."); } } // =================================================================================== // Helper Function: Draw Centered Text // =================================================================================== void drawCenteredText(const char *text, int baselineY, const GFXfont *font, int targetW /* = -1 */, int targetX /* = 0 */) { // (Function remains the same as previous version) if (!font || !text || text[0] == '\0') return; int16_t x1, y1; uint16_t w, h; display.setFont(font); display.setTextColor(GxEPD_BLACK); display.setTextSize(1); display.getTextBounds(text, 0, 0, &x1, &y1, &w, &h); int areaWidth = (targetW <= 0) ? display.width() : targetW; int areaOriginX = (targetW <= 0) ? 0 : targetX; int cursorX = areaOriginX + (areaWidth - w) / 2 - x1; display.setCursor(cursorX, baselineY); display.print(text); } // =================================================================================== // Draw QR Code Function (Used by FULL UPDATE's drawQrScreen) // =================================================================================== bool drawQrCode(int x_target_area, int y_target_area, int w_target_area, int h_target_area, const char *text) { // (Function remains the same as previous version) if (text == NULL || text[0] == '\0') { Serial.println("QR Error: No text provided."); return false; } int inputLength = strlen(text); if (inputLength > MAX_INPUT_STRING_LENGTH) { Serial.printf("QR Error: Input text too long (%d > %d).\n", inputLength, MAX_INPUT_STRING_LENGTH); return false; } Serial.printf("Generating QR Code for: '%s' (Length: %d)\n", text, inputLength); uint32_t bufferSize = qrcode_getBufferSize(FIXED_QR_VERSION); if (bufferSize == 0) { Serial.printf("QR Error: Could not get buffer size for version %d.\n", FIXED_QR_VERSION); return false; } const int MAX_STACK_QR_BUFFER = 4096; if (bufferSize > MAX_STACK_QR_BUFFER) { Serial.printf("QR Error: Calculated buffer size (%d) may be too large for stack.\n", bufferSize); return false; } uint8_t qrcodeData[bufferSize]; QRCode qrcode; esp_err_t err = qrcode_initText(&qrcode, qrcodeData, FIXED_QR_VERSION, ECC_LOW, text); if (err != ESP_OK) { Serial.printf("QR Error: qrcode_initText failed. Error code: %d. Input may be too long for Version %d/ECC_LOW.\n", err, FIXED_QR_VERSION); return false; } Serial.printf("QR generated successfully: Version=%d, Size=%dx%d modules\n", qrcode.version, qrcode.size, qrcode.size); int qr_modules_size = qrcode.size; int module_pixel_size = FIXED_QR_SCALE; int final_qr_pixel_size = qr_modules_size * module_pixel_size; if (final_qr_pixel_size > w_target_area || final_qr_pixel_size > h_target_area) { Serial.printf("QR Warning: Scaled QR code data area (%dpx) might be larger than target area (%dx%d). Clipping might occur.\n", final_qr_pixel_size, w_target_area, h_target_area); } int x_offset = x_target_area + (w_target_area - final_qr_pixel_size) / 2; int y_offset = y_target_area + (h_target_area - final_qr_pixel_size) / 2; if (x_offset < x_target_area) x_offset = x_target_area; if (y_offset < y_target_area) y_offset = y_target_area; Serial.printf("Drawing QR Code at display offset (%d, %d) with scale %d. Target area: (%d,%d %dx%d)\n", x_offset, y_offset, module_pixel_size, x_target_area, y_target_area, w_target_area, h_target_area); display.startWrite(); for (int y = 0; y < qr_modules_size; y++) { for (int x = 0; x < qr_modules_size; x++) { if (qrcode_getModule(&qrcode, x, y)) { int moduleX = x_offset + x * module_pixel_size; int moduleY = y_offset + y * module_pixel_size; if (moduleX >= 0 && (moduleX + module_pixel_size) <= display.width() && moduleY >= 0 && (moduleY + module_pixel_size) <= display.height()) { display.fillRect(moduleX, moduleY, module_pixel_size, module_pixel_size, GxEPD_BLACK); } } } } display.endWrite(); return true; }

Fusion 360 CAM Setup for JVC Sign
10/04/2025
Mastering Large Format CNC Machining

Week 8

Weekly Update

This week, we dove into the world of subtractive manufacturing on a larger scale with CNC milling. The task was to design and fabricate something "large" using a sheet of plywood (typically 500 x 400 mm, with available thicknesses like 12mm or 18mm). We were encouraged to utilize various 2.5D CAM operations within Fusion 360, such as Pocket, Contour, Bore, Face, Trace, and 2D Chamfer, using standard wood end mills.

Having zero previous experience in CAM and wanting to create a lasting memento from this course, I decided to design a sign featuring "JVC" (the initials of our course) and "2025" (the current year). My goal was to focus on the design and CAM preparation stages, creating a piece that would serve as a nice reminder of the skills learned.

Modeling & Initial CAM Strategy: Fusion 360

I modeled a simple rectangular plaque in Fusion 360 with the text "JVC" and "2025" raised from the surface, surrounded by a raised border. The next step was to define the manufacturing process using Fusion 360's Manufacturing workspace.

My initial plan involved using two primary CAM operations with a single tool:

  • A 2D Pocket operation to clear out the background material around the letters and the border, making them stand proud.
  • A 2D Contour operation to cut the final sign shape out from the larger plywood stock, cutting through the full material thickness.

I set up these operations selecting a standard 6mm Flat End Mill from the Fusion 360 tool library.

The complete Fusion 360 design and manufacturing setup, including toolpaths, can be explored via the link below.

Design & Initial CAM Visualization


Initial CAM Simulation Video

Simulation showing the initial 2D Pocket and 2D Contour toolpaths.

Initial CAM Parameter Details

Here are the key parameters from the initial Fusion 360 CAM setup:

Setup (Setup3)

  • Operation Type: Milling
  • WCS Origin: Stock box point (Top-left corner)
  • Model: Selected body
  • Fixture: None selected (Clearances set to 5mm)

Tool (T1)

  • Description: #1 - Ø6mm flat (Flat end mill from Fusion library)
  • Diameter: 6 mm
  • Flute Length: 18 mm
  • Overall Length: 36 mm
  • Coolant: Disabled

Feeds & Speeds (Used for initial Pocket & Contour)

  • Preset: Custom
  • Spindle Speed: 10000 rpm
  • Cutting Feedrate: 1000 mm/min
  • Lead-In / Lead-Out Feedrate: 1000 mm/min
  • Ramp / Plunge Feedrate: 333.3 mm/min (approx.)
  • Feed per Tooth: 0.025 mm (calculated)

2D Pocket (Pocket3) Specifics

  • Linking - Retracts: High Feedrate Mode (Preserve rapid retract), Safe Distance (1mm)
  • Linking - Stay-Down: Keep Tool Down (Enabled), Max Stay-Down (50mm), Lift Height (0mm)
  • Leads & Transitions: Horizontal Radius (0.6mm), Linear Distance (0.6mm), Sweep Angle (90deg), Vertical Radius (0.6mm), Same settings for Lead-Out
  • Ramp: Type (Helix), Angle (2 deg), Max Stepdown (4mm), Clearance Height (2.5mm)

2D Contour (Contour1) Specifics

  • Heights - Clearance: 10 mm offset (From Retract height)
  • Heights - Retract: 5 mm offset (From Stock top)
  • Heights - Feed: 5 mm offset (From Top height)
  • Heights - Top: 0 mm offset (From Stock top)
  • Heights - Bottom: 0 mm offset (From Stock bottom - ensuring cut-through)

Process Adjustment: Efficiency First

After simulating the toolpaths, particularly the 2D Pocket operation, it became clear that machining the entire background would be very time-consuming (estimated machining time was significant) and generate a large amount of wood debris.

Considering the available machine time and the desire for a cleaner, quicker process, it was decided collaboratively with the instructors to revise the approach. Instead of pocketing, we opted to machine only the outlines of the letters and the border frame using a shallow engraving pass. This would still provide the desired visual definition but significantly reduce machining time and material waste. The CAM setup was conceptually adjusted to use a 2D Contour or Trace operation following the lines of the text and the border at a small depth, instead of the extensive pocketing. The final perimeter cut-out operation (2D Contour) would remain similar but might use adjusted settings.

Export and Supervised Machining

Based on the revised strategy (outlines only), the final G-code toolpaths were generated. My part of the hands-on process concluded with exporting these machining program files.

From this point, the instructors took over. They reviewed the generated G-code for safety and feasibility, uploaded it to the CNC machine controller, secured the 12mm plywood stock (the final thickness used), and set the work coordinate system (WCS) origin on the machine. Crucially, they monitored the entire machining process, ensuring everything ran smoothly and safely, which is standard procedure for these larger student projects.

Even though we didn't get to run the machine ourselves this time, it was still really helpful. We'd already had lectures on how to use it, so watching the instructors set everything up and run our design file gave us some great real-world insight. You could really see how the workflow goes, why tweaking the settings is so important, and all the safety stuff you need to think about when you're working with big CNC machines.

Final Result & Reflection

The final sign looks pretty good! It definitely works as a memento using just the outlines. It's different from what I originally had in mind – I was thinking of something more involved with fully pocketed letters – but the outlined version is really clean and easy to read. Plus, it was way faster to make. I think deciding to skip all that pocketing was the right call in the end.

Final CNC machined JVC 2025 sign with outlined letters and frame
The finished sign - outlines only, machined from 12mm plywood.

Looking at the finished sign, I noticed a little burn mark on the '0' and '2'. It's probably something to do with the feed rate, how sharp the tool was, or maybe even the way the tool moved in those corners. Even though I didn't actually get to run the machine myself this week, it was still a really great introduction to CAM. I'm actually pretty happy with how this personalized memento turned out, and I definitely feel like I've got a good foundation in CAM now!

T-Display showing Prague Departures
17/04/2025
Networked Timetables: ESP32, APIs, and Beating the Bus

Week 9

Weekly Update

This week's assignment dove into the world of networking, pushing us to connect our microcontrollers beyond their immediate circuits. The core tasks were:

  1. Control your circuit over a local wireless network.
  2. Send or receive data from the Internet.

Confession time: I have a complicated relationship with Prague's public transport timetables. Let's just say my arrival time at school often has more variance than a quantum particle. Could technology finally help me catch that elusive bus? This week's assignment felt like the perfect opportunity to try.

My plan: build a small, networked display showing real-time departure information for a specific bus stop near me. This involved fetching data from an online API and displaying it locally, hitting both assignment requirements. I chose the LILYGO T-Display board for its integrated ESP32 and color TFT screen, and the Golemio API for Prague's public transport data.

The Hardware: LILYGO T-Display

LILYGO T-Display ESP32 Board

The LILYGO T-Display is a compact development board featuring an ESP32-WROOM-32 module (providing WiFi and Bluetooth), integrated with a 1.14-inch color IPS TFT LCD (ST7789 driver, 135x240 pixels). It also includes standard peripherals like USB-C (with a CH9102 USB-to-Serial chip on my version), buttons, and pins for expansion. Its small size and built-in display make it ideal for projects needing both connectivity and a simple visual output, like this departure board. Power is supplied via USB-C or an external battery connection.

The Data Source: Golemio Prague Public Transport API

Golemio is Prague's official open data platform, providing various datasets and APIs related to the city, including public transport schedules, locations, and real-time updates. Crucially for this project, it's free to use for development and public projects, though registration is required to obtain an API key.

I specifically used the PID Departure Boards (v2) endpoint. This API allows fetching upcoming departures from specific stops.

Getting Started with Golemio API:

  1. Sign Up & Get Key: Register at api.golemio.cz/api-keys to get your personal API access token (JWT). This key is needed for authentication.
  2. Explore the Docs (Swagger UI): Golemio provides excellent interactive API documentation using Swagger UI (linked above). This interface is key for understanding and testing the API:
    • Authorize: First, click the "Authorize" button (often a padlock icon) and paste your API key into the value field to authenticate your session.
    • Explore Parameters: Find the /v2/pid/departureboards GET endpoint. You can see all the available query parameters like ids (GTFS ID), aswIds (ASW Node/Stop ID), cisIds (CIS ID), names (Stop Name), limit, minutesAfter, skip, etc. For my project, I focused on using aswIds.
    • Find Your Stop ID (aswIds): This was the trickiest part. The API documentation for aswIds mentions: "First part of the number represents the whole node... second part... represents individual stops... Use _ instead of /... A list of ASW IDs can be found in Prague Open data."
      • Follow the "Prague Open data" link.
      • Find the "Zastávky" (Stops) section and look for "Seznam zastávek (JSON)" (List of Stops JSON).
      • Click "Stáhnout" (Download) to get the stops.json file. Warning: It's a large file!
      • This JSON contains all stops, grouped by node ID. Each individual stop within a node has an id like "NODE/STOP_NUMBER" (e.g., "9/1").
      • To find the ID for a specific stop (e.g., "Vinořský zámek"), you need to search this file. A simple Python script helps (see below).
      • Once you find your stop name, note its node ID and the specific stop id (like "9/1"). For the API's aswIds parameter, combine them using an underscore: NODE_STOPNUMBER (e.g., 9_1). You might also just use the node ID if you want departures from all platforms at that node (e.g., 9). For my stop "Vinořský zámek", the relevant node ID was 9549, and the specific platform I needed was 9549/2, so I used aswIds=9549_2 in the API call.
    • Try it Out: Back in the Swagger UI, click the "Try it out" button for the endpoint. Fill in the required parameters (you need at least one ID like aswIds, ids, cisIds or names). Set limit (e.g., 4), minutesAfter (e.g., 120), and your chosen aswIds.
    • Execute: Click the "Execute" button.
    • Examine Results: The UI will show:
      • A curl command example (useful for command-line testing).
      • The exact Request URL that was generated based on your parameters. This is super helpful for constructing the URL in your ESP32 code.
      • The Server response body (the JSON data) and the response code (hopefully 200 OK!). Studying this JSON structure is essential for parsing it later.

Testing directly in the Swagger UI, especially seeing the generated Request URL and the exact JSON response, was invaluable for figuring out the correct stop_id, experimenting with parameters, and understanding the data structure before writing any ESP32 code.

Python Script to Find Stop IDs from stops.json

To search the large stops.json file downloaded from Prague Open Data, you can use this simple Python script. Save it as find_stop_id.py in the same directory as stops.json.

import json import sys def find_stop_info(filename="stops.json", search_name=""): if not search_name: print("Please provide a stop name to search for.") print("Usage: python find_stop_id.py \"Stop Name\"") return try: with open(filename, 'r', encoding='utf-8') as f: data = json.load(f) except FileNotFoundError: print(f"Error: File '{filename}' not found.") print("Make sure stops.json is in the same directory.") return except json.JSONDecodeError: print(f"Error: Could not decode JSON from '{filename}'.") return found_stops = [] search_name_lower = search_name.lower() # Iterate through stop groups for group in data.get("stopGroups", []): group_name = group.get("name", "") # Check if the group name matches (case-insensitive) if search_name_lower in group_name.lower(): node_id = group.get("node") print(f"\nFound matching group: '{group_name}' (Node ID: {node_id})") # Iterate through individual stops within the group for stop in group.get("stops", []): stop_id = stop.get("id") # Format "NODE/STOP_NUM" platform = stop.get("platform", "N/A") gtfs_ids = stop.get("gtfsIds", []) asw_id_api_format = f"{node_id}_{stop_id.split('/')[-1]}" if stop_id and '/' in stop_id else str(node_id) print(f" - Stop ID: {stop_id} (Platform: {platform})") print(f" ASW ID for API (Node_StopNum): {asw_id_api_format}") print(f" GTFS IDs: {gtfs_ids}") # You could add more details here if needed (lines, etc.) found_stops.append(group_name) # Mark as found if not found_stops: print(f"\nNo stop groups found containing the name '{search_name}'.") else: print(f"\nFound {len(found_stops)} matching group(s). Use the 'Node ID' and 'Stop ID' or the combined 'ASW ID for API' format.") if __name__ == "__main__": if len(sys.argv) > 1: stop_name_to_find = sys.argv[1] find_stop_info(search_name=stop_name_to_find) else: # Example usage if no command line argument is given print("No stop name provided via command line.") print("Example Usage: python find_stop_id.py \"Vinořský zámek\"") # find_stop_info(search_name="Vinořský zámek") # Uncomment to run default search

Run it from your terminal like this:
python find_stop_id.py "Your Stop Name" (e.g., python find_stop_id.py "Vinořský zámek")

The script will print the Node ID and individual Stop IDs (along with the format needed for the aswIds parameter) for any stops containing the name you searched for.

Found matching group: 'Vinořský zámek' (Node ID: 9549) - Stop ID: 9549/1 (Platform: A) ASW ID for API (Node_StopNum): 9549_1 GTFS IDs: ['U9549Z1P', 'U9549Z1'] - Stop ID: 9549/2 (Platform: B) ASW ID for API (Node_StopNum): 9549_2 GTFS IDs: ['U9549Z2P', 'U9549Z2']

Project Setup: PlatformIO and Libraries

For this project, I used the PlatformIO IDE extension within Visual Studio Code. PlatformIO simplifies managing development environments, libraries, and board configurations, especially for complex projects involving specific hardware like the T-Display.

Libraries Used

The project relies on several key libraries, managed via PlatformIO's library manager:

  • WiFi.h: (Built-in ESP32 framework) For connecting to the local WiFi network.
  • WiFiClientSecure.h: (Built-in) For establishing secure HTTPS connections and handling TLS.
  • ArduinoJson.h (by Benoit Blanchon): For efficiently parsing the JSON data returned by the Golemio API.
  • TFT_eSPI.h (by Bodmer): A powerful library for driving various TFT displays, including the ST7789 on the LILYGO board. Crucially, this library needs specific configuration for the target board.
  • time.h: (Built-in) For handling time conversions (Epoch time, local time).

PlatformIO Configuration (platformio.ini)

The heart of the PlatformIO project setup is the platformio.ini file. Instead of manually editing library configuration files (like User_Setup.h for TFT_eSPI), PlatformIO allows us to define these settings directly using build flags. This keeps the configuration specific to the project and prevents issues when updating libraries.

Here's the platformio.ini configuration used for the T-Display:

PlatformIO Project Configuration File ; ; Build options: build flags, source filter ; Upload options: custom upload port, speed and extra flags ; Library options: dependencies, extra library storages ; Advanced options: extra scripting ; ; Please visit documentation for the other options and examples ; https://docs.platformio.org/page/projectconf.html [env:esp32dev] platform = espressif32 ; Use the Espressif 32 platform board = esp32dev ; Base board type (specific pins defined in flags) framework = arduino ; Use the Arduino framework monitor_speed = 115200 ; Serial monitor baud rate build_flags = ; Flags passed to the compiler ; --- TFT_eSPI Configuration Flags --- -DUSER_SETUP_LOADED=1 ; Tell TFT_eSPI we are loading setup here -DST7789_DRIVER=1 ; Specify the ST7789 display driver IC -DTFT_WIDTH=135 ; Define display width in pixels -DTFT_HEIGHT=240 ; Define display height in pixels -DCGRAM_OFFSET=1 ; Specific offset needed for some ST7789 variants ; --- Pin Definitions for LILYGO T-Display --- -DTFT_MOSI=19 ; SPI MOSI pin -DTFT_SCLK=18 ; SPI Clock pin -DTFT_CS=5 ; SPI Chip Select pin -DTFT_DC=16 ; Data/Command pin -DTFT_RST=23 ; Reset pin (use 23 for T-Display, -1 if not connected) -DTFT_BL=4 ; Backlight control pin -DTFT_BACKLIGHT_ON=HIGH ; Logic level to turn backlight on ; --- Font Loading Flags for TFT_eSPI --- -DLOAD_GLCD=1 ; Load default GLCD font -DLOAD_FONT2=1 ; Load Font 2 -DLOAD_FONT4=1 ; Load Font 4 -DLOAD_FONT6=1 ; ...and so on -DLOAD_FONT7=1 -DLOAD_FONT8=1 -DLOAD_GFXFF=1 ; Load Adafruit GFX Free Fonts -DSMOOTH_FONT=1 ; Enable anti-aliased fonts ; --- SPI Speed Settings --- -DSPI_FREQUENCY=40000000 ; Set SPI frequency (40MHz is often stable) -DSPI_READ_FREQUENCY=6000000 ; SPI read frequency (if applicable) lib_deps = ; Library dependencies bodmer/TFT_eSPI@^2.5.43 ; TFT_eSPI library from PlatformIO registry bblanchon/ArduinoJson@^7.4.1 ; ArduinoJson library

Key points about these flags:

  • -DUSER_SETUP_LOADED=1 tells TFT_eSPI to ignore its internal setup files and use the definitions provided here.
  • -DST7789_DRIVER, -DTFT_WIDTH, -DTFT_HEIGHT configure the specific display type.
  • -DTFT_MOSI, -DTFT_SCLK, etc., map the library's pin functions to the actual ESP32 pins connected to the display on the T-Display board. Getting these right is critical!
  • -DLOAD_FONT... flags enable specific fonts within the library, making them available for use in the code.
  • The lib_deps section automatically downloads and includes the specified versions of TFT_eSPI and ArduinoJson when building the project.

HTTPS Connection and Certificate Handling

Fetching data from the Golemio API requires an internet connection (via WiFi) and making an HTTPS request. HTTPS encrypts the data exchanged, but requires verifying the server's identity using SSL/TLS certificates.

Securing the Connection: SSL Certificates

To ensure the ESP32 talks to the real api.golemio.cz, the WiFiClientSecure library needs to verify the server's certificate against a trusted Root Certificate Authority (CA) certificate embedded in the code.

My preferred workflow for getting the Root CA certificate was:

  1. Navigate to https://api.golemio.cz in a web browser.
  2. Inspect the certificate details via the padlock icon.
  3. Identify and export the top-level Root CA (e.g., "GTS Root R4") in Base64 PEM format.
  4. Copy the exported certificate text (including BEGIN/END lines).
  5. Use a tool like unreeeal's ESP SSL Converter to format it into the C/C++ multi-line string.
  6. Paste the result into the api_cfg.root_ca variable in the code.

This Root CA certificate verifies the server's identity and is typically valid for few months.

Code Implementation and Logic

The Arduino code (available below) orchestrates the entire process. Here's a breakdown of the logic:

Full Code

#include <Arduino.h> #include <WiFi.h> #include <WiFiClientSecure.h> // For HTTPS #include <ArduinoJson.h> // For parsing API response (v7+) #include <TFT_eSPI.h> // For the LILYGO T-Display #include <SPI.h> // Required by TFT_eSPI #include <time.h> // For time functions //------------------------------------------------------------------------------ // Configuration Structs - Define network, API, timing, and display settings //------------------------------------------------------------------------------ struct NetworkConfig { const char *ssid; // WiFi network name const char *password; // WiFi password unsigned long wifi_timeout_ms; // How long to wait for WiFi connection }; struct ApiConfig { const char *server; // API server hostname const int port; // API server port (443 for HTTPS) const char *root_ca; // Root CA certificate for verifying the server const char *api_key; // Your Golemio API key const char *id_type; // Type of stop ID used (e.g., "aswIds") const char *stop_id; // The specific stop ID to query int limit; // Max number of departures to fetch const char *order; // Order of results (e.g., "timetable") const char *filter_param; // Filter parameter (e.g., "none") const char *skip_param; // Skip parameter (e.g., "canceled") int minutes_after; // How many minutes ahead to look for departures const char *air_condition; // Include air conditioning info (string "true" or "false") }; struct TimingConfig { unsigned long refresh_interval_ms; // How often to refresh data (milliseconds) unsigned long https_timeout_ms; // Timeout for the HTTPS request }; struct TftDisplayConfig { uint8_t rotation; // Display rotation (0-3) uint8_t default_text_size; // Default font size for TFT_eSPI uint16_t bg_color; // Background color uint16_t default_text_color; // Default text color uint16_t header_bg_color; // Header background color uint16_t header_text_color; // Header text color int header_y; // Y position of the header bar int start_y; // Y position where departure data starts int line_height; // Vertical space for each departure line // Column X positions for data alignment int col_x_route; int col_x_depart; int col_x_info_start; // Destination starts here int col_x_status; // Delay/cancel status starts here // Backlight PWM settings const int bl_pin; // TFT Backlight control pin (often predefined) const int bl_pwm_channel; // LEDC PWM channel for backlight const int bl_pwm_freq; // PWM frequency const int bl_pwm_resolution; // PWM resolution (e.g., 8-bit for 0-255) const uint8_t default_brightness; // Default backlight brightness (0-255) }; //------------------------------------------------------------------------------ // INSTANTIATE CONFIGURATIONS ( *** MODIFY THESE FOR YOUR SETUP *** ) //------------------------------------------------------------------------------ const NetworkConfig network_cfg = { .ssid = "YOUR_WIFI_SSID", // <<< CHANGE THIS .password = "YOUR_WIFI_PASSWORD", // <<< CHANGE THIS .wifi_timeout_ms = 15000}; const ApiConfig api_cfg = { .server = "api.golemio.cz", .port = 443, .root_ca = // DigiCert Global Root CA (Valid for Golemio API as of early 2025) "-----BEGIN CERTIFICATE-----\n" "MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYD\n" "VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG\n" //more lines here "9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/Cr8deVl5c1RxYIigL9zC2L7F8AjEA8GE8\n" "p/SgguMh1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh4rsUecrNIdSUtUlD\n" "-----END CERTIFICATE-----\n", .api_key = "YOUR_GOLEMIO_API_KEY", // <<< CHANGE THIS .id_type = "aswIds", // Use ASW IDs for stops .stop_id = "9549_2", // Specific stop ID (e.g., a bus stop) <<< CHANGE IF NEEDED .limit = 4, // Show top 4 departures .order = "timetable", // Order by scheduled time .filter_param = "none", // No specific filter .skip_param = "canceled", // Skip canceled departures in the API response .minutes_after = 120, // Look for departures up to 120 mins from now .air_condition = "false" // Don't specifically request AC info }; const TimingConfig timing_cfg = { .refresh_interval_ms = 60000, // Refresh every 60 seconds .https_timeout_ms = 15000 // HTTPS request timeout }; const TftDisplayConfig display_cfg = { .rotation = 1, // Landscape mode (adjust if needed) .default_text_size = 2, // Standard text size .bg_color = TFT_BLACK, .default_text_color = TFT_WHITE, .header_bg_color = TFT_NAVY, .header_text_color = TFT_WHITE, .header_y = 2, // Header Y position .start_y = 25, // Data starts below header .line_height = 24, // Space per line // Column X positions (adjust for your screen/font) .col_x_route = 3, // Route number column .col_x_depart = 38, // Departure time/minutes column .col_x_info_start = 85, // Destination column .col_x_status = 195, // Delay/Status column // Backlight settings (TFT_BL might be defined in TFT_eSPI user setup) .bl_pin = TFT_BL, .bl_pwm_channel = 0, .bl_pwm_freq = 5000, .bl_pwm_resolution = 8, // 8-bit = 0-255 brightness .default_brightness = 150 // Default brightness }; //------------------------------------------------------------------------------ // Global Variables & Objects //------------------------------------------------------------------------------ TFT_eSPI tft = TFT_eSPI(); // Instantiate the TFT display driver WiFiClientSecure client; // Secure client for HTTPS communication const size_t API_BUFFER_SIZE = 4096; // Max size for API response buffer char apiResponseBuffer[API_BUFFER_SIZE]; // Buffer to store the API response unsigned long lastRefreshTime = 0; // Tracks when the data was last refreshed time_t currentEpochTime = 0; // Stores the current time (obtained from server) //------------------------------------------------------------------------------ // Function Prototypes //------------------------------------------------------------------------------ void connectWiFi(const NetworkConfig &config); bool performApiRequest(const ApiConfig &config, const TimingConfig &timings, char *responseBuffer, size_t bufferSize); String buildApiUrl(const ApiConfig &config); bool parseAndDisplayData(const char *responseData); void renderTftDisplay(JsonArray departures); void displayTftMessage(const char *message, uint16_t textColor = display_cfg.default_text_color); void displayTftError(const char *message); bool fetchAndDisplayDepartures(); String removePrahaPrefix(const char *headsign); String transliterateCzech(const String &input); bool parseHttpDate(const char *dateStr, time_t &epoch); bool parseScheduledTime(const char *timeStr, time_t &epoch); void setTimezone(); void setupBacklight(const TftDisplayConfig &config); void setBacklight(const TftDisplayConfig &config, uint8_t brightness); //------------------------------------------------------------------------------ // Set Timezone Function (Crucial for correct time display) //------------------------------------------------------------------------------ void setTimezone() { // POSIX string for Prague/Central European Time (CET/CEST) // CET-1CEST: Std time CET is UTC+1, DST time CEST is UTC+2. // ,M3.5.0: DST starts last Sunday of March (default 2am) // ,M10.5.0: DST ends last Sunday of October (default 2am) const char *pragueTZ = "CET-1CEST,M3.5.0,M10.5.0"; Serial.printf("Setting Timezone to %s\n", pragueTZ); setenv("TZ", pragueTZ, 1); // Set the TZ environment variable tzset(); // Apply the timezone setting } //------------------------------------------------------------------------------ // Setup Function //------------------------------------------------------------------------------ void setup() { Serial.begin(115200); while (!Serial) delay(100); // Wait for serial connection Serial.println(F("\n\n--- TFT Departure Board ---")); setTimezone(); // Configure the timezone early setupBacklight(display_cfg); // Initialize backlight PWM setBacklight(display_cfg, display_cfg.default_brightness); // Set initial brightness tft.init(); // Initialize the TFT display tft.setRotation(display_cfg.rotation); tft.fillScreen(display_cfg.bg_color); tft.setTextSize(display_cfg.default_text_size); tft.setTextColor(display_cfg.default_text_color, display_cfg.bg_color); tft.setTextWrap(false); // Prevent text wrapping Serial.println(F("TFT Display initialized.")); displayTftMessage("Connecting WiFi..."); connectWiFi(network_cfg); // Connect to the network bool initialFetchSuccess = false; if (WiFi.status() == WL_CONNECTED) { displayTftMessage("Fetching data..."); initialFetchSuccess = fetchAndDisplayDepartures(); // Perform first data fetch } if (!initialFetchSuccess && WiFi.status() != WL_CONNECTED) { displayTftError("WiFi Failed!"); } else if (!initialFetchSuccess) { Serial.println(F("Initial data fetch failed.")); displayTftError("Fetch Fail"); // Show error if fetch failed after WiFi connect } else { Serial.println(F("Initial data fetched and displayed.")); } Serial.println(F("Setup complete.")); } //------------------------------------------------------------------------------ // Main Loop //------------------------------------------------------------------------------ void loop() { unsigned long currentTime = millis(); // Start the refresh timer only after setup is clearly finished if (lastRefreshTime == 0 && currentTime > 5000) { // Avoid immediate refresh right at boot lastRefreshTime = currentTime; Serial.println("Starting refresh timer."); } // Perform periodic refresh if the interval has passed if (lastRefreshTime != 0 && (currentTime - lastRefreshTime >= timing_cfg.refresh_interval_ms)) { Serial.println(F("Refresh interval elapsed. Performing refresh.")); if (!fetchAndDisplayDepartures()) { Serial.println(F("Periodic refresh failed.")); // Error message is already displayed on TFT by fetchAndDisplayDepartures } else { Serial.println(F("Refresh successful.")); } // lastRefreshTime is updated inside fetchAndDisplayDepartures on success } delay(100); // Small delay to prevent busy-waiting and yield to other tasks } //------------------------------------------------------------------------------ // Backlight Functions //------------------------------------------------------------------------------ void setupBacklight(const TftDisplayConfig &config) { if (config.bl_pin >= 0) // Check if a valid backlight pin is configured { Serial.printf("Setting up PWM for BL Pin %d\n", config.bl_pin); ledcSetup(config.bl_pwm_channel, config.bl_pwm_freq, config.bl_pwm_resolution); ledcAttachPin(config.bl_pin, config.bl_pwm_channel); } else { Serial.println("Backlight Pin not configured or invalid."); } } void setBacklight(const TftDisplayConfig &config, uint8_t brightness) { if (config.bl_pin >= 0) { ledcWrite(config.bl_pwm_channel, brightness); // Set brightness using PWM } } //------------------------------------------------------------------------------ // Helper Function: Parse HTTP Date Header (GMT to Epoch) // Essential for getting accurate current time from the server response //------------------------------------------------------------------------------ bool parseHttpDate(const char *dateStr, time_t &epoch) { const char *dateValueStart = strchr(dateStr, ':'); // Find first colon if (!dateValueStart) return false; dateValueStart++; // Move past the colon while (*dateValueStart == ' ') dateValueStart++; // Skip leading spaces struct tm t; memset(&t, 0, sizeof(tm)); // Clear struct // Try parsing standard RFC 1123 format (e.g., "Date: Wed, 21 Oct 2015 07:28:00 GMT") if (strptime(dateValueStart, "%a, %d %b %Y %H:%M:%S GMT", &t) != NULL) { t.tm_isdst = 0; // Tell mktime this is GMT and not subject to local DST rules // Temporarily set system TZ to GMT to correctly convert the parsed GMT time to epoch char *originalTZ = getenv("TZ"); setenv("TZ", "GMT0", 1); tzset(); epoch = mktime(&t); // Convert struct tm (in GMT) to time_t (seconds since epoch) // Restore original timezone setenv("TZ", originalTZ ? originalTZ : "", 1); tzset(); if (epoch == (time_t)-1) { Serial.println("WARN: mktime failed for HTTP Date header."); return false; } return true; } else { Serial.println("WARN: strptime failed to parse HTTP Date header."); return false; } } //------------------------------------------------------------------------------ // Helper Function: Parse Scheduled Time (ISO 8601-like with offset to Epoch) // Parses departure time strings like "2024-04-17T15:30:00+02:00" //------------------------------------------------------------------------------ bool parseScheduledTime(const char *timeStr, time_t &epoch) { struct tm t; int year = 0, month = 0, day = 0, hour = 0, minute = 0, second = 0; int tz_hour = 0, tz_minute = 0; char tz_sign = '+'; memset(&t, 0, sizeof(tm)); // Clear struct // Parse the ISO 8601-like string if (sscanf(timeStr, "%d-%d-%dT%d:%d:%d%c%d:%d", &year, &month, &day, &hour, &minute, &second, &tz_sign, &tz_hour, &tz_minute) >= 8) { t.tm_year = year - 1900; // Years since 1900 t.tm_mon = month - 1; // Months since January (0-11) t.tm_mday = day; t.tm_hour = hour; t.tm_min = minute; t.tm_sec = second; t.tm_isdst = -1; // Let system determine if DST is active for this date/time *if* using local mktime // Calculate the timezone offset in seconds from the parsed string long timezone_offset_seconds = (long)tz_hour * 3600L + (long)tz_minute * 60L; if (tz_sign == '-') { timezone_offset_seconds = -timezone_offset_seconds; } // Convert the parsed date/time parts to Epoch (seconds since 1970-01-01 00:00:00 UTC) // Crucially, treat the parsed time as if it were GMT/UTC first, then adjust by the offset. char *originalTZ = getenv("TZ"); setenv("TZ", "GMT0", 1); // Temporarily set TZ to GMT for mktime calculation tzset(); time_t time_in_gmt = mktime(&t); // Get epoch assuming the Y/M/D H:M:S were GMT setenv("TZ", originalTZ ? originalTZ : "", 1); // Restore original timezone tzset(); if (time_in_gmt == (time_t)-1) { Serial.println("WARN: mktime failed for scheduled time."); return false; } // Adjust the GMT epoch time by the timezone offset to get the true UTC epoch time epoch = time_in_gmt - timezone_offset_seconds; return true; } else { Serial.println("WARN: sscanf failed to parse scheduled time string."); return false; } } //------------------------------------------------------------------------------ // Helper Function: Remove "Praha," Prefix from headsigns for cleaner display //------------------------------------------------------------------------------ String removePrahaPrefix(const char *headsign) { if (headsign == nullptr) return ""; String s = String(headsign); if (s.startsWith("Praha,")) { int startIndex = 6; // Length of "Praha," // Skip the space after the comma if it exists if (startIndex < s.length() && s.charAt(startIndex) == ' ') { startIndex++; } return s.substring(startIndex); } return s; // Return original string if prefix not found } //------------------------------------------------------------------------------ // Helper Function: Transliterate common Czech characters to basic ASCII // Needed because TFT_eSPI built-in fonts don't support all UTF-8 chars //------------------------------------------------------------------------------ String transliterateCzech(const String &input) { String output = ""; output.reserve(input.length()); // Pre-allocate memory for efficiency for (unsigned int i = 0; i < input.length();) { uint8_t c1 = input.charAt(i); uint8_t c2 = (i + 1 < input.length()) ? input.charAt(i + 1) : 0; char replacement = '?'; // Default replacement for unknown UTF-8 sequences int charsToSkip = 1; // How many bytes the UTF-8 sequence occupies if (c1 < 128) { // Standard ASCII character replacement = (char)c1; } else if (c1 == 0xC3) { // Common accented vowels (Latin Extended-A) charsToSkip = 2; switch (c2) { case 0xA1: replacement = 'a'; break; // á case 0xAD: replacement = 'i'; break; // í case 0xB3: replacement = 'o'; break; // ó case 0xBA: replacement = 'u'; break; // ú case 0xBD: replacement = 'y'; break; // ý case 0x81: replacement = 'A'; break; // Á case 0x8D: replacement = 'I'; break; // Í case 0x93: replacement = 'O'; break; // Ó case 0x9A: replacement = 'U'; break; // Ú case 0x9D: replacement = 'Y'; break; // Ý default: charsToSkip = 1; replacement = '?'; // Unknown C3 sequence } } else if (c1 == 0xC4) { // Č,Ď,Ě (Latin Extended-A) charsToSkip = 2; switch (c2) { case 0x8D: replacement = 'c'; break; // č case 0x8F: replacement = 'd'; break; // ď case 0x9B: replacement = 'e'; break; // ě case 0x8C: replacement = 'C'; break; // Č case 0x8E: replacement = 'D'; break; // Ď case 0x9A: replacement = 'E'; break; // Ě default: charsToSkip = 1; replacement = '?'; // Unknown C4 sequence } } else if (c1 == 0xC5) { // Ň,Ř,Š,Ť,Ů,Ž (Latin Extended-A) charsToSkip = 2; switch (c2) { case 0x88: replacement = 'n'; break; // ň case 0x99: replacement = 'r'; break; // ř case 0xA1: replacement = 's'; break; // š case 0xA5: replacement = 't'; break; // ť case 0xAF: replacement = 'u'; break; // ů case 0xBE: replacement = 'z'; break; // ž case 0x87: replacement = 'N'; break; // Ň case 0x98: replacement = 'R'; break; // Ř case 0xA0: replacement = 'S'; break; // Š case 0xA4: replacement = 'T'; break; // Ť case 0xAE: replacement = 'U'; break; // Ů case 0xBD: replacement = 'Z'; break; // Ž default: charsToSkip = 1; replacement = '?'; // Unknown C5 sequence } } else { // Skip other multi-byte UTF-8 sequences or invalid bytes if ((c1 & 0xE0) == 0xC0) charsToSkip = 2; // 2-byte sequence start else if ((c1 & 0xF0) == 0xE0) charsToSkip = 3; // 3-byte sequence start else if ((c1 & 0xF8) == 0xF0) charsToSkip = 4; // 4-byte sequence start else charsToSkip = 1; // Treat as invalid single byte replacement = '?'; } output += replacement; i += charsToSkip; } return output; } //------------------------------------------------------------------------------ // Combined Fetch, Parse, and Display Function //------------------------------------------------------------------------------ bool fetchAndDisplayDepartures() { Serial.println(F("\nFetching departures...")); // Check WiFi connection and attempt reconnect if necessary if (WiFi.status() != WL_CONNECTED) { Serial.println(F("WiFi disconnected. Reconnecting...")); connectWiFi(network_cfg); if (WiFi.status() != WL_CONNECTED) { displayTftError("No WiFi"); // Show error on display return false; // Failed to connect } } // Perform the HTTPS request if (!performApiRequest(api_cfg, timing_cfg, apiResponseBuffer, API_BUFFER_SIZE)) { // Error message already displayed on TFT by performApiRequest return false; // Request failed } // Parse the JSON response and update the display if (!parseAndDisplayData(apiResponseBuffer)) { // Error message already displayed on TFT by parseAndDisplayData return false; // Parsing or display failed } // If everything succeeded, update the last refresh time lastRefreshTime = millis(); Serial.println(F("Fetch/Display OK.")); return true; } //------------------------------------------------------------------------------ // WiFi Connection Function //------------------------------------------------------------------------------ void connectWiFi(const NetworkConfig &config) { Serial.print(F("Connecting to ")); Serial.println(config.ssid); WiFi.mode(WIFI_STA); // Set WiFi mode to Station WiFi.begin(config.ssid, config.password); unsigned long startTime = millis(); while (WiFi.status() != WL_CONNECTED && millis() - startTime < config.wifi_timeout_ms) { Serial.print("."); delay(500); } if (WiFi.status() == WL_CONNECTED) { Serial.println(F("\nWiFi Connected")); Serial.print(F("IP Address: ")); Serial.println(WiFi.localIP()); } else { Serial.println(F("\nWiFi Connection Failed!")); WiFi.disconnect(true); // Disconnect completely on failure delay(100); } } //------------------------------------------------------------------------------ // URL Builder Function - Constructs the API request path and query parameters //------------------------------------------------------------------------------ String buildApiUrl(const ApiConfig &config) { String url = "/v2/pid/departureboards/?"; url += config.id_type; // e.g., aswIds url += "="; url += config.stop_id; // e.g., 9549_2 url += "&limit="; url += String(config.limit); url += "&order="; url += config.order; url += "&filter="; url += config.filter_param; url += "&skip="; url += config.skip_param; url += "&minutesAfter="; url += String(config.minutes_after); url += "&airCondition="; url += config.air_condition; Serial.print(F("Constructed API Path: ")); Serial.println(url); return url; } //------------------------------------------------------------------------------ // API Request Function (Handles HTTPS connection and data retrieval) //------------------------------------------------------------------------------ bool performApiRequest(const ApiConfig &config, const TimingConfig &timings, char *responseBuffer, size_t bufferSize) { currentEpochTime = 0; // Reset current time before each request Serial.println(F("Setting up HTTPS client...")); client.setCACert(config.root_ca); // Provide the Root CA cert for server verification client.setTimeout(timings.https_timeout_ms); // Set connection timeout Serial.print(F("Connecting to API server: ")); Serial.print(config.server); Serial.print(":"); Serial.println(config.port); if (!client.connect(config.server, config.port)) { Serial.println(F("Connection failed!")); displayTftError("Connect Fail"); return false; } Serial.println(F("Connection successful.")); String urlPath = buildApiUrl(config); // Get the specific API path Serial.println(F("Sending HTTPS GET request...")); // Construct the HTTP GET request client.print(F("GET ")); client.print(urlPath); client.println(F(" HTTP/1.1")); client.print(F("Host: ")); client.println(config.server); client.println(F("Connection: close")); // Indicate server should close connection after response client.print(F("x-access-token: ")); // Send the API key header client.println(config.api_key); client.println(); // End of headers (blank line) if (client.println() == 0) { // Check if sending failed Serial.println(F("Failed to send request headers.")); client.stop(); displayTftError("Send Fail"); return false; } Serial.println(F("Waiting for response headers...")); // Wait for the server to start sending data unsigned long headerTimeoutStart = millis(); while (client.connected() && !client.available()) { if (millis() - headerTimeoutStart > 5000) { // 5 second timeout for headers Serial.println("Header Timeout!"); client.stop(); displayTftError("Hdr Timeout"); return false; } delay(10); // Small delay } // Read and process HTTP headers bool httpStatusOK = false; String httpStatusCode = ""; String dateHeaderValue = ""; bool dateHeaderFound = false; while (client.available()) { String line = client.readStringUntil('\n'); line.trim(); // Remove leading/trailing whitespace and \r // Serial.println(line); // Uncomment to debug headers if (line.startsWith("HTTP/1.")) { // Check status code line int firstSpace = line.indexOf(' '); int secondSpace = line.indexOf(' ', firstSpace + 1); if (secondSpace > firstSpace && firstSpace > 0) { httpStatusCode = line.substring(firstSpace + 1, secondSpace); if (httpStatusCode == "200") { httpStatusOK = true; Serial.print("HTTP Status: 200 OK"); } else { Serial.print("HTTP Status: "); Serial.print(httpStatusCode); } Serial.println(); } } else if (line.startsWith("date:")) { // Find the Date header (case-insensitive) dateHeaderValue = line; dateHeaderFound = true; Serial.print("Found Date header: "); Serial.println(dateHeaderValue); // Try to parse the date header to get current server time (GMT) if (!parseHttpDate(dateHeaderValue.c_str(), currentEpochTime)) { Serial.println("Failed to parse Date header."); currentEpochTime = 0; // Reset if parsing fails } else { Serial.print("Parsed current Epoch time (GMT): "); Serial.println(currentEpochTime); } } else if (line.length() == 0) { // Empty line signifies end of headers Serial.println("End of headers reached."); break; } } if (!httpStatusOK) { Serial.print(F("HTTP Error: ")); Serial.println(httpStatusCode); client.stop(); String errorMsg = "HTTP Err " + (httpStatusCode.isEmpty() ? "?" : httpStatusCode); displayTftError(errorMsg.c_str()); return false; } if (!dateHeaderFound) { Serial.println("WARN: Date header missing from response! Time calculations may be inaccurate."); currentEpochTime = 0; // Ensure time is marked as invalid if header missing } Serial.println(F("Reading response body...")); memset(responseBuffer, 0, bufferSize); // Clear the buffer size_t bytesRead = 0; unsigned long bodyTimeoutStart = millis(); // Read the response body content while (client.connected() || client.available()) { if (client.available()) { size_t len = client.readBytes(&responseBuffer[bytesRead], bufferSize - 1 - bytesRead); if (len > 0) { bytesRead += len; bodyTimeoutStart = millis(); // Reset timeout timer on receiving data if (bytesRead >= bufferSize - 1) { Serial.println("WARN: Response buffer full!"); break; // Stop reading if buffer is full } } } else if (millis() - bodyTimeoutStart > (timings.https_timeout_ms - 5000)) { // Body timeout Serial.println("Body Read Timeout!"); break; } } responseBuffer[bytesRead] = '\0'; // Null-terminate the received data client.stop(); // Close the connection Serial.printf("Read %d bytes of response body.\n", bytesRead); if (bytesRead == 0 && httpStatusOK) { Serial.println(F("Received OK status but no response body.")); displayTftMessage("No data in response"); // Display a message instead of error return false; // Treat as failure for this specific application } if (currentEpochTime == 0) { Serial.println("WARN: Could not determine current time from server. Departure calculations will be inaccurate."); // Display might show scheduled times but not 'minutes until' } return true; // Request and reading completed successfully } //------------------------------------------------------------------------------ // JSON Parsing and Display Trigger //------------------------------------------------------------------------------ bool parseAndDisplayData(const char *responseData) { // Check for potential chunked encoding size prefix (e.g., "1a2f\r\n{...") const char *jsonStart = responseData; if (strlen(responseData) > 5 && responseData[0] != '{' && responseData[0] != '[') { const char *chunkEnd = strstr(responseData, "\r\n"); if (chunkEnd && (chunkEnd[2] == '{' || chunkEnd[2] == '[')) { jsonStart = chunkEnd + 2; // Start parsing after the chunk size and CRLF Serial.println("Skipped potential chunk size line."); } else { // If not chunked, try finding the first '{' as a fallback const char *firstBrace = strchr(responseData, '{'); if (firstBrace) { Serial.println("WARN: Response doesn't start with '{' or '['. Attempting parse from first '{'."); jsonStart = firstBrace; } else { Serial.println("ERROR: Cannot find start of JSON ('{' or '[') in the response."); displayTftError("Bad Resp"); return false; // Cannot parse } } } Serial.println(F("Parsing JSON response...")); JsonDocument doc; // Use dynamic allocation (can adjust size if needed) DeserializationError error = deserializeJson(doc, jsonStart); if (error) { Serial.print(F("JSON parsing failed: ")); Serial.println(error.f_str()); displayTftError("JSON Error"); return false; // Parsing failed } Serial.println(F("JSON parsed successfully.")); // Extract the "departures" array. If it doesn't exist or isn't an array, // departures.isNull() will be true or departures.size() will be 0. JsonArrayConst departures = doc["departures"].as<JsonArrayConst>(); // Update the TFT display with the parsed data (or lack thereof) renderTftDisplay(departures); // Return true signifies that parsing and the attempt to display were completed return true; } //------------------------------------------------------------------------------ // Render Departures on TFT Display //------------------------------------------------------------------------------ void renderTftDisplay(JsonArrayConst departures) // Use JsonArrayConst for read-only access { // --- Draw Header --- int headerTextY = display_cfg.header_y + 3; // Y position for text inside header bar int headerHeight = tft.fontHeight(1) + 6; // Calculate header bar height tft.fillRect(0, display_cfg.header_y, tft.width(), headerHeight, display_cfg.header_bg_color); tft.setTextColor(display_cfg.header_text_color, display_cfg.header_bg_color); // Draw header text using text datum for alignment tft.setTextDatum(TL_DATUM); // Top Left tft.drawString("BUS", 3, headerTextY); tft.setTextDatum(TC_DATUM); // Top Center tft.drawString("DEPART", tft.width() / 2, headerTextY); tft.setTextDatum(TR_DATUM); // Top Right tft.drawString("INFO", tft.width() - 3, headerTextY); // --- Reset text settings for data area --- tft.setTextDatum(TL_DATUM); // Reset datum to Top Left for data rows tft.setTextColor(display_cfg.default_text_color, display_cfg.bg_color); // --- Clear Data Area --- int dataAreaY = display_cfg.header_y + headerHeight; // Calculate clear height: bottom edge minus data start Y minus bottom time display area int dataClearHeight = tft.height() - dataAreaY - (tft.fontHeight(1) + 4); if (dataClearHeight < 0) dataClearHeight = 0; // Ensure non-negative height tft.fillRect(0, dataAreaY, tft.width(), dataClearHeight, display_cfg.bg_color); // --- Draw Departures --- int yPos = display_cfg.start_y; // Starting Y for the first data line if (departures.isNull() || departures.size() == 0) { Serial.println("Render: No departures to display."); tft.setCursor(10, yPos); // Position cursor tft.print("No departures found."); } else { // Serial.printf("Render: Processing %d departures.\n", departures.size()); // Less verbose logging int count = 0; for (JsonVariantConst departure_v : departures) { if (count >= api_cfg.limit) break; // Stop if we've reached the display limit if (!departure_v.is<JsonObjectConst>()) continue; // Skip if not a valid JSON object JsonObjectConst departure = departure_v.as<JsonObjectConst>(); // --- Extract Data --- const char *route_cstr = departure["route"]["short_name"] | "N/A"; // Bus/Tram number const char *time_sched_cstr = departure["departure_timestamp"]["scheduled"] | ""; // Scheduled ISO time string const char *headsign_raw = departure["trip"]["headsign"] | ""; // Raw destination headsign bool cancelled = departure["trip"]["is_canceled"] | false; // Is the trip cancelled? // --- Time Calculations --- time_t scheduledEpoch = 0; time_t actualDepartureEpoch = 0; long minutesUntil = -1; // -1: Unknown, -2: Gone, 0: <1 min, >0: Minutes until String departureTimeStr = " ???"; // String to display (e.g., " 15", " <1", " Gone") int delaySeconds = 0; bool delayAvailable = false; // Only calculate 'minutes until' if we have a valid current time from the server if (currentEpochTime > 0 && time_sched_cstr[0] != '\0') { if (parseScheduledTime(time_sched_cstr, scheduledEpoch)) { actualDepartureEpoch = scheduledEpoch; // Start with scheduled time // Check for delay information JsonVariantConst delayInfo = departure["delay"]; if (delayInfo.is<JsonObjectConst>() && (delayInfo["is_available"] | false)) { delayAvailable = true; delaySeconds = (delayInfo["minutes"] | 0) * 60 + (delayInfo["seconds"] | 0); actualDepartureEpoch += delaySeconds; // Add delay to get actual departure time } // Calculate difference in seconds long diffSeconds = actualDepartureEpoch - currentEpochTime; if (cancelled) { // Override if cancelled minutesUntil = -1; // Not relevant departureTimeStr = " XXX"; // Indicate cancelled } else if (diffSeconds < -30) { // Allow for slight clock skew, considered gone if > 30s past minutesUntil = -2; departureTimeStr = " Gone"; } else if (diffSeconds < 60) { // Departing within the next minute minutesUntil = 0; departureTimeStr = " <1"; } else { // Calculate minutes until departure minutesUntil = (diffSeconds + 30) / 60; // Round to nearest minute departureTimeStr = " "; if (minutesUntil < 10) departureTimeStr += " "; // Pad for alignment departureTimeStr += String(minutesUntil); } } else { // Failed to parse scheduled time, maybe show raw time? For now, leave as ??? } } else if (time_sched_cstr[0] != '\0' && parseScheduledTime(time_sched_cstr, scheduledEpoch)) { // If we don't have current time, at least show the scheduled time HH:MM struct tm timeinfo_sched; localtime_r(&scheduledEpoch, &timeinfo_sched); // Convert epoch to local time struct char buf[6]; strftime(buf, sizeof(buf), "%H:%M", &timeinfo_sched); departureTimeStr = String(buf); } // --- Format Headsign --- String headsign_processed = removePrahaPrefix(headsign_raw); headsign_processed = transliterateCzech(headsign_processed); // Convert Czech chars // --- Truncate Headsign if too long for the display area --- int maxWidth = display_cfg.col_x_status - display_cfg.col_x_info_start - 4; // Available width int16_t text_w = tft.textWidth(headsign_processed); if (text_w > maxWidth) { String truncated = headsign_processed; while (truncated.length() > 0 && tft.textWidth(truncated + "..") > maxWidth) { truncated.remove(truncated.length() - 1); // Remove last char } headsign_processed = (truncated.length() > 0) ? truncated + ".." : ".."; // Add ellipsis } // --- Determine Status String and Color --- String statusStr = ""; uint16_t statusColor = display_cfg.default_text_color; if (cancelled) { statusStr = " X"; // Simple 'X' for cancelled statusColor = TFT_RED; departureTimeStr = " XXX"; // Overwrite time too } else if (delayAvailable && delaySeconds > 59) { // Show delay only if >= 1 minute int rounded_delay_min = (delaySeconds + 30) / 60; // Round delay to nearest minute if (rounded_delay_min > 0) { statusStr = "+"; statusStr += String(rounded_delay_min); // Color code delay: Orange for small, Red for large statusColor = (rounded_delay_min <= 3) ? TFT_ORANGE : TFT_RED; } } // --- Set Route Color (Example for specific lines) --- uint16_t routeColor = TFT_WHITE; // Default if (strcmp(route_cstr, "375") == 0) routeColor = TFT_YELLOW; else if (strcmp(route_cstr, "302") == 0) routeColor = tft.color565(255, 165, 0); // Orange else if (strcmp(route_cstr, "378") == 0) routeColor = TFT_MAGENTA; // Add more else if clauses for other routes if desired // --- Draw Data Row --- // Route Number (Left Aligned in its column) tft.setTextColor(routeColor, display_cfg.bg_color); tft.setCursor(display_cfg.col_x_route, yPos); char routeBuffer[6]; // Buffer to format route number (e.g., left-align) snprintf(routeBuffer, sizeof(routeBuffer), "%-4s", route_cstr); // Left align in 4 spaces tft.print(routeBuffer); // Departure Time/Minutes (Left Aligned in its column) tft.setTextColor(display_cfg.default_text_color, display_cfg.bg_color); tft.setCursor(display_cfg.col_x_depart + 1, yPos); // Add slight padding tft.print(departureTimeStr); // Destination/Headsign (Left Aligned in its column) tft.setTextColor(display_cfg.default_text_color, display_cfg.bg_color); tft.setCursor(display_cfg.col_x_info_start, yPos); tft.print(headsign_processed); // Status/Delay (Right Aligned in its column) tft.setTextColor(statusColor, display_cfg.bg_color); int statusColWidth = tft.width() - display_cfg.col_x_status - 2; // Width of status column text_w = tft.textWidth(statusStr); // Width of the actual status text int statusX = display_cfg.col_x_status + statusColWidth - text_w; // Calculate X for right alignment if (statusX < display_cfg.col_x_status) statusX = display_cfg.col_x_status; // Prevent going left of column start tft.setCursor(statusX, yPos); tft.print(statusStr); // --- Move to next line --- yPos += display_cfg.line_height; count++; } // End for loop } // End else (departures available) // --- Draw Current Time at Bottom RIGHT --- // Clear previous time int timeTextHeight = tft.fontHeight(1); // Using default font size 1 for time int timeBoxWidth = tft.textWidth("00:00") + 6; // Estimated width int timeBoxHeight = timeTextHeight + 2; int timeBoxX = tft.width() - timeBoxWidth - 1; // X position of the box int timeBoxY = tft.height() - timeBoxHeight - 1; // Y position of the box tft.fillRect(timeBoxX, timeBoxY, timeBoxWidth, timeBoxHeight, display_cfg.bg_color); char timeStr[6] = "--:--"; // Default if time unknown if (currentEpochTime > 0) { struct tm timeinfo_now; // Convert the *server-provided* epoch time to our *local* timezone struct localtime_r(¤tEpochTime, &timeinfo_now); strftime(timeStr, sizeof(timeStr), "%H:%M", &timeinfo_now); // Format as HH:MM } tft.setTextDatum(BR_DATUM); // Bottom Right Datum for alignment tft.setTextColor(TFT_CYAN, display_cfg.bg_color); // Use a different color for time tft.drawString(timeStr, tft.width() - 3, tft.height() - 2); // Draw time at bottom right tft.setTextDatum(TL_DATUM); // Reset datum tft.setTextColor(display_cfg.default_text_color, display_cfg.bg_color); // Reset color } // End of renderTftDisplay //------------------------------------------------------------------------------ // Display General Message/Error on TFT //------------------------------------------------------------------------------ void displayTftMessage(const char *message, uint16_t textColor) { tft.fillScreen(display_cfg.bg_color); // Clear screen tft.setTextColor(textColor, display_cfg.bg_color); tft.setTextDatum(MC_DATUM); // Middle Center Datum tft.drawString(message, tft.width() / 2, tft.height() / 2); // Draw centered text tft.setTextDatum(TL_DATUM); // Reset datum Serial.printf("TFT_MSG: %s\n", message); // Log message to serial } // Helper function specifically for errors (uses red text) void displayTftError(const char *message) { displayTftMessage(message, TFT_RED); } //------------------------------------------------------------------------------ // Placeholder for Power Saving (Not implemented in this version) //------------------------------------------------------------------------------ // void goToSleep() // { // Serial.println(F("Entering deep sleep (not implemented)...")); // setBacklight(display_cfg, 0); // Turn off backlight // // esp_deep_sleep_start(); // Actual sleep command // }

Result

Success! The LILYGO T-Display now connects to WiFi, securely fetches departure data from the Golemio API every minute, parses the JSON, calculates the time until departure (using the server's time for accuracy), handles delays and cancellations, and displays the next few buses for my stop. The display shows the route number, minutes until departure (or scheduled time if sync fails), the destination, and any delay status.

Will this actually make me less late? Probably not (hehe). But it was a fantastic exercise in integrating hardware with web APIs, handling HTTPS, parsing data, managing time zones, and configuring a specific board using PlatformIO. Plus, it looks pretty cool.

PixelTag System
14/05/2025
PixelTag: E-Paper Badge & Flutter Controller App

Week 10

Weekly Update

This week's task, focusing on interface and application programming, formed a major part of completing my final project: the PixelTag. The goal was to develop the mobile application needed to control the custom e-paper badge, allowing wireless updates of personal information and QR codes via Bluetooth Low Energy (BLE).

The specific assignment goals were:

  1. Developing an application/interface for a device.
  2. Implementing communication between the interface and the device.

PixelTag Badge: Firmware Development (ESP32)

The core of the badge is an ESP32 microcontroller connected to a small e-paper display (LILYGO® T5-2.13 inch). The firmware, written using the Arduino framework for ESP32, handles several key tasks:

  • BLE Communication: Establishes a secure BLE connection (using bonding) with a central device (the mobile app). It exposes characteristics for writing commands/data and reading back current information (Name, Email/Title, Phone, QR URL) and battery level.
  • Display Management: Uses the GxEPD2 library to control the e-paper display. It supports two main modes: displaying personal information (3 lines) or displaying a QR code. Full updates are used for clarity when switching modes or data.
  • Data Persistence: Stores the last received personal info, QR code URL, and display mode in Non-Volatile Storage (NVS) using the Preferences library. This ensures the badge remembers its state even after a reboot or deep sleep.
  • QR Code Generation: Utilizes the ESP-IDF QR Code Generator library (wrapped for Arduino) to create QR codes from the provided URL data.
  • User Interaction: A physical button (using the OneButton library) allows cycling through the display modes (Info -> QR -> Blank -> Info) without needing the app.
  • Power Management: Implements deep sleep to conserve battery, waking up on a button press or automatically after a timeout when disconnected from BLE.

The firmware structure involves setting up BLE services/characteristics, handling callbacks for connection events and data writes/reads, managing display updates, and implementing the state machine for different display modes and button interactions. The text layout on the badge itself is quite basic for now, focusing on functionality over aesthetics – definitely an area for future improvement.

ESP32 Firmware Code Snippet (Conceptual Overview)

Below is a simplified overview of the structure. The full code handles BLE setup, security, display drawing, NVS storage, button input, and power management.

// Include Libraries (GxEPD2, BLE, Preferences, OneButton, QRCode) #include <GxEPD2_BW.h> #include <BLEDevice.h> #include <Preferences.h> #include <OneButton.h> #include <qrcode.h> #include "GxEPD2_display_selection_new_style.h" // Display configuration // --- Defines --- // UUIDs, Pins, Max Lengths, Display Modes enum... // --- Globals --- // BLE pointers, display object, current data strings, state variables... DisplayMode currentMode = INFO; String personalInfo = ""; String qrCodeData = ""; bool deviceConnected = false; OneButton button(BUTTON_PIN, true, true); Preferences preferences; // --- BLE Callbacks --- // MyServerCallbacks (onConnect/onDisconnect) // DataCharacteristicCallbacks (onWrite: parse commands "command:clear", "display:info", "display:qr", "data:personal:...", "data:qr:...") // ReadCharacteristicCallbacks (onRead: provide name, email, phone, qr url, battery) // --- Setup --- void setup() { // Serial init, NVS init (load saved data/mode) // Display init // Button init (attachClick) // setupBLE() -> Initialize BLE server, services, characteristics, security // Configure deep sleep wakeup (button) // Initial display update based on loaded data/mode // Start advertising if needed } // --- Loop --- void loop() { button.tick(); // Handle button presses if (deviceConnected) { // Process BLE commands/data received in callbacks // Handle mode change requests (BLE or button) // Update display if data/mode changed (updateDisplay()) // Send battery notifications periodically } else { // Handle mode change requests from button // Update display if mode changed // Check for deep sleep timeout // Stop advertising and enter deep sleep if timeout reached } delay(10); // Small delay to avoid busy loop } // --- Core Functions --- void setupBLE(); // Creates services, characteristics (Write, Read Name/Email/Phone/QR, Battery Notify) void updateDisplay(); // Full e-paper refresh based on currentMode void drawInfoScreen(); // Draws centered multi-line text void drawQrScreen(); // Generates and draws QR code void performFullClear(); // Clears the display void handleButtonClick(); // Cycles through modes (INFO->QR->BLANK->INFO), handles cooldown uint8_t readBatteryLevel(); // Reads ADC pin for battery % void sendBatteryNotification(); // Sends battery level over BLE if connected // ... other helper functions ...

Note: This is a simplified representation. The actual code involves detailed error handling, buffer management, and specific BLE configurations. The full source code will be available in the repository linked below.


PixelTag Controller: Mobile Application (Flutter)

To interact with the badge, I developed a mobile application using Flutter. Flutter is Google's UI toolkit for building beautiful, natively compiled applications for mobile (Android & iOS), web, and desktop from a single codebase, using the Dart programming language. This allowed for rapid development and the potential for cross-platform deployment.

While Flutter allows for building iOS apps from the same codebase, I was unable to test or compile for iOS due to the requirement of a macOS machine for the necessary build tools and deployment. Therefore, the current testing and available build are focused on Android.

Key features of the Flutter app include:

  • BLE Scanning & Connection: Scans for nearby BLE devices using packages like flutter_blue_plus (requires location permission on Android). Allows filtering by device name (set in settings). Connects securely to the selected PixelTag using bonding.
  • Device Status: Once connected, displays the device name, connection status (Bonded), and the badge's battery level (read from the standard BLE Battery Service).
  • Data Refresh & Disconnect: Buttons to manually refresh the data read from the badge (pulling current Name, Email, Phone, QR URL) and to disconnect from the device.
  • Personal Information Update: Input fields for Name, Email/Title, and Phone Number (optional). Character limits are enforced based on settings. A "Send Personal Info" button transmits the data to the badge via the write characteristic.
  • QR Code Update: An input field for a URL. A "Send QR Code" button generates the appropriate command and sends the URL to the badge.
  • Settings: A separate screen allows users to configure:
    • Dark Mode toggle.
    • Unpairing the currently bonded device.
    • Device Name Filter for scanning.
    • Maximum character length limits for each field (Name, Email, Phone, URL) - these should match the limits expected by the firmware.

The application provides a straightforward interface for managing the badge's content, leveraging Flutter's Material widget set to create a familiar look and feel. It's currently a first version prototype, so the UI/UX design isn't polished, and there might be some undiscovered bugs or areas for improvement in robustness and user feedback.

System Interaction

The system works as follows:

  1. The user opens the Flutter mobile app and scans for devices.
  2. The app lists nearby PixelTags (optionally filtered).
  3. The user selects a device, initiating a secure BLE connection and bonding process.
  4. Once connected, the app can read the current badge data (Name, Email, etc.) and battery level using specific BLE characteristics.
  5. The user inputs new Personal Info or a QR URL in the app and presses the corresponding "Send" button.
  6. The app formats the data into a specific string format (e.g., "data:personal:Name\nEmail\nPhone" or "data:qr:URL") and writes it to the badge's main data characteristic.
  7. The ESP32 firmware receives the string, parses it, updates its internal data variables, saves the data to NVS, sets the requested display mode, and triggers a full refresh of the e-paper display.
  8. The physical button on the badge can also be used to cycle between the last sent Info screen, QR screen, and a blank screen, even when disconnected from the app.

Challenges & Future Work

Developing this system involved tackling BLE complexities within the Flutter ecosystem, especially secure bonding and managing different characteristics across platforms (even if only Android was tested). Debugging communication between the app and the ESP32 required careful logging on both ends. Power optimization for the badge is ongoing, relying currently on deep sleep and infrequent display updates.

Future improvements could include:

  • Implementing partial updates for the e-paper display for faster, less visually disruptive changes.
  • Improving the text layout and font choices on the badge display.
  • Adding more sophisticated error handling and user feedback in the Flutter app.
  • Testing and distributing an iOS build (requires access to macOS).
  • Exploring image display capabilities on the badge.
  • Refining the power management strategy further.

Downloads & Links

Conclusion

This week successfully integrated the hardware badge with a functional mobile interface built using Flutter. The PixelTag system now allows for easy wireless updates of the e-paper display, demonstrating a practical application of embedded systems design, BLE communication, and cross-platform mobile app development (with current focus on Android). While still a prototype with room for refinement, it fulfills the core requirements of the final project.