yasuhiro yamashita
Published © MIT

Pale Blue Dot

I created a stroboscope with M5Stack to represent the Earth floating in space.

IntermediateFull instructions provided15 hours87
Pale Blue Dot

Things used in this project

Hardware components

M5Stack Core2 ESP32 IoT Development Kit
M5Stack Core2 ESP32 IoT Development Kit
×1
Base26 Proto Industrial Board Module
M5Stack Base26 Proto Industrial Board Module
×1
Arduino vanira shiel
×2
Perf+ 2
Crowd Supply Perf+ 2
×1
Peristatic Pump DC12V 2x4
×1
Rotary potentiometer (generic)
Rotary potentiometer (generic)
×2
Toggle Switch, On-On
Toggle Switch, On-On
×1
High Brightness LED, White
High Brightness LED, White
×1
Nch MOSFET IRLZ44N
×1
Through Hole Resistor, 10 ohm
Through Hole Resistor, 10 ohm
×9
Resistor 10k ohm
Resistor 10k ohm
×1
Resistor 1k ohm
Resistor 1k ohm
×1
silicone rubber tube I.D=2.0 O.D=3
×1
Machine Screw, M3
Machine Screw, M3
×18

Software apps and online services

Arduino IDE
Arduino IDE

Hand tools and fabrication machines

3D Printer (generic)
3D Printer (generic)
Soldering iron (generic)
Soldering iron (generic)

Story

Read more

Custom parts and enclosures

stl files

for 3d printer

Sketchfab still processing.

Schematics

schematic

Here is the connection diagram of the components.

Code

M5Stack_core2

Arduino
This code includes both a function to drive the stroboscope and a function to display arbitrary animations on the M5Stack’s display.
#include <M5Unified.h>
#include <SD.h>
#include <esp_timer.h>

// ===== Playback Settings =====
static int  FRAME_COUNT      = 199;   // Number of frames to play (initially 199)
static int  FRAME_DELAY_MS   = 0;     // Delay between frames (0 = as fast as possible)
static const int START_Y     = 0;     // Fixed vertical position
static const int LCD_W       = 320;   // Core2: 320x240

// ===== Strobe (independent from frame playback) =====
// Initial values (based on reference code)
static volatile uint32_t g_on_us  = 100;     // ON time (µs)
static volatile uint32_t g_off_us = 30000;   // OFF time (µs) → period = on + off

// Knobs (Core2 ADC inputs)
static const int ADC_OFF_PIN = 36;  // Cycle (=off) input (input-only pin)
static const int ADC_ON_PIN  = 35;  // ON time input (input-only pin)

// Mapping ranges (adjust as needed)
static const uint32_t OFF_MIN_US = 1000;     // 1ms
static const uint32_t OFF_MAX_US = 100000;   // 100ms
static const uint32_t ON_MIN_US  = 10;       // 10µs
static const uint32_t ON_MAX_US  = 10000;    // 10ms

static const float ADC_EMA_ALPHA = 0.20f;    // EMA smoothing factor (0<alpha<=1)

// Strobe output (a pin not conflicting with SD on Core2)
static const int STROBE_PIN = 26;

// Timers
static esp_timer_handle_t g_periodic_timer     = nullptr;
static esp_timer_handle_t g_one_shot_off_timer = nullptr;

// ===== Image buffer: reuse PSRAM (avoid malloc/free each time) =====
static uint8_t* g_buf = nullptr;
static size_t   g_cap = 0;

// ===== Utility =====
static inline uint32_t umap(uint32_t x, uint32_t in_min, uint32_t in_max,
                            uint32_t out_min, uint32_t out_max) {
  if (x <= in_min) return out_min;
  if (x >= in_max) return out_max;
  uint64_t num = (uint64_t)(x - in_min) * (out_max - out_min);
  return out_min + (uint32_t)(num / (in_max - in_min));
}

// Get BMP width from file header (little endian / 4 bytes from offset 18)
// * Currently unused since draw coordinates are fixed, but kept for future variable-width support
static int getBmpWidth(File &f) {
  uint8_t hdr[26];
  if (!f.seek(0)) return -1;
  size_t r = f.read(hdr, sizeof(hdr));
  if (r < 22) return -1;
  int32_t w = (int32_t)( (uint32_t)hdr[18]
                       | ((uint32_t)hdr[19] << 8)
                       | ((uint32_t)hdr[20] << 16)
                       | ((uint32_t)hdr[21] << 24) );
  if (w < 0) w = -w;  // Top-down BMP → take absolute value
  f.seek(0);
  return w;
}

// ===== Strobe interrupt handlers =====
static void IRAM_ATTR one_shot_off_cb(void*) {
  gpio_set_level((gpio_num_t)STROBE_PIN, 0);
}

static void IRAM_ATTR periodic_cb(void*) {
  gpio_set_level((gpio_num_t)STROBE_PIN, 1);
  uint32_t on_us = g_on_us;
  if (g_one_shot_off_timer) {
    esp_timer_stop(g_one_shot_off_timer);
    esp_timer_start_once(g_one_shot_off_timer, on_us);
  }
}

static void update_periodic_timer(uint32_t on_us, uint32_t off_us) {
  // Range guard
  if (on_us  < ON_MIN_US)  on_us  = ON_MIN_US;
  if (on_us  > ON_MAX_US)  on_us  = ON_MAX_US;
  if (off_us < OFF_MIN_US) off_us = OFF_MIN_US;
  if (off_us > OFF_MAX_US) off_us = OFF_MAX_US;

  g_on_us  = on_us;
  g_off_us = off_us;

  uint32_t period_us = on_us + off_us;
  if (g_periodic_timer) {
    esp_timer_stop(g_periodic_timer);
    esp_timer_start_periodic(g_periodic_timer, period_us); // µs period
  }

  // Basic log (always output here)
  Serial.printf("Strobe set: on=%u us, off=%u us (period=%u us)\n", on_us, off_us, period_us);
}

// ===== Setup =====
void setup() {
  Serial.begin(115200);
  delay(200);
  Serial.println("=== Setup ===");

  auto cfg = M5.config();
  M5.begin(cfg);
  M5.Display.setRotation(0);
  M5.Display.fillScreen(TFT_BLACK);

  // SD (Core2 TF slot)
  if (!SD.begin(GPIO_NUM_4)) {
    Serial.println("SD init failed");
    while (true) delay(1000);
  }
  Serial.println("SD OK");

  // Strobe output
  pinMode(STROBE_PIN, OUTPUT);
  digitalWrite(STROBE_PIN, LOW);

  // ADC
  analogReadResolution(12);

  // Timer creation
  esp_timer_create_args_t periodic_args = {};
  periodic_args.callback = &periodic_cb;
  periodic_args.name     = "strobe_periodic";
  ESP_ERROR_CHECK(esp_timer_create(&periodic_args, &g_periodic_timer));

  esp_timer_create_args_t off_args = {};
  off_args.callback = &one_shot_off_cb;
  off_args.name     = "strobe_off";
  ESP_ERROR_CHECK(esp_timer_create(&off_args, &g_one_shot_off_timer));

  // Initial strobe setting (based on reference code)
  update_periodic_timer(g_on_us, g_off_us);

  Serial.println("=== Ready ===");
}

// ===== Main loop =====
void loop() {
  // 1) Update ON/OFF from analog input (kept independently)
  static float ema_off = 2000.0f, ema_on = 500.0f;
  int raw_off = analogRead(ADC_OFF_PIN);   // 0..4095
  int raw_on  = analogRead(ADC_ON_PIN);    // 0..4095
  ema_off = (1.0f - ADC_EMA_ALPHA) * ema_off + ADC_EMA_ALPHA * raw_off;
  ema_on  = (1.0f - ADC_EMA_ALPHA) * ema_on  + ADC_EMA_ALPHA * raw_on;

  uint32_t new_off_us = umap((uint32_t)ema_off, 0, 4095, OFF_MIN_US, OFF_MAX_US);
  uint32_t new_on_us  = umap((uint32_t)ema_on,  0, 4095, ON_MIN_US,  ON_MAX_US);

  // Decide whether to update depending on difference from last values (threshold adjustable)
  static uint32_t last_off_us = 0, last_on_us = 0;
  if (abs((int)new_off_us - (int)last_off_us) > 50 ||   // Change ≥ 50µs
      abs((int)new_on_us  - (int)last_on_us)  > 10) {   // Change ≥ 10µs

    // First update timer (µs period change)
    update_periodic_timer(new_on_us, new_off_us);

    // Debug output: analog → EMA → mapping → final setting
    uint32_t period_us = new_on_us + new_off_us;
    float    freq_hz   = (period_us > 0) ? (1000000.0f / (float)period_us) : 0.0f;
    float    duty_pct  = (period_us > 0) ? (100.0f * (float)new_on_us / (float)period_us) : 0.0f;

    Serial.printf(
      "[STROBE] raw(off,on)=(%4d,%4d)  EMA(off,on)=(%6.1f,%6.1f)  "
      "on=%u us  off=%u us  period=%u us  freq=%.2f Hz  duty=%.2f%%  (GPIO=%d)\n",
      raw_off, raw_on, ema_off, ema_on,
      new_on_us, new_off_us, period_us, freq_hz, duty_pct, STROBE_PIN
    );

    last_off_us = new_off_us;
    last_on_us  = new_on_us;
  }

  // 2) Draw one frame (non-blocking)
  static int frame_i = 1;  // Current frame index

  char filename[32];
  sprintf(filename, "/%04d_240.bmp", frame_i);

  File f = SD.open(filename, FILE_READ);
  if (f) {
    size_t len = f.size();
    if (len > g_cap) {
      if (g_buf) { free(g_buf); g_buf = nullptr; g_cap = 0; }
      g_buf = (uint8_t*)ps_malloc(len);
      if (!g_buf) {
        f.close();
        Serial.println("ps_malloc failed");
        delay(1);
        return;
      }
      g_cap = len;
    }

    f.read(g_buf, len);
    f.close();

    M5.Display.drawBmp(g_buf, len, 0, 40);  // Fixed coordinates as specified
  }

  // Move to the next frame
  frame_i++;
  if (frame_i > FRAME_COUNT) frame_i = 1;

  // Frame delay
  if (FRAME_DELAY_MS > 0) delay(FRAME_DELAY_MS);
  else delay(1);  // Minimal breathing space
}

Credits

yasuhiro yamashita
3 projects • 3 followers

Comments