I took an ESP32 board, with a camera and LCD, and turned it into an autoguiding component for my spectroheliograph setup. Below’s the source code — before you judge it, think about it as an as-is release, half of it written while recording the Sun itself, mostly for fun. Some day in the future I may clean it up. PS: form9 from the printscreen got a decent title.
Note that, for this particular setup featured here:
- the ESP32 / OV2640 / ST7789 development board in the picture doesn’t have remaining free pins (maybe if the SPI/I2C buses are tapped into). My solution reports the center of gravity through the serial port.
- the EQ3 hand controller originally doesn’t have an ST4 connector
Both limitations from above have been overcome in my particular setup: I rebuilt the EQ3’s controller from the ground up, only the stepper motors are from factory, and it includes a special spectroheliograph scan mode. Both the autoguider and the otherwise standalone embedded device EQ3 controller connect to a desktop app, to the The Soapbox MountPusher Guider assembly. Thus the control is centralized there and Sharpcap, camera cooling, autoguiding etc are all orchestrated in a concert. See the family portrait below.
Some quick photos
#include "esp_camera.h"
#include "soc/soc.h"
#include "soc/rtc_cntl_reg.h"
#include "driver/rtc_io.h"
#include <stdint.h>
#include <Adafruit_ST7789.h> // Include Adafruit Hardware-specific library for ST7789
#define TFT_WIDTH_PX 240
#define TFT_HEIGHT_PX 240
#define JPG_QUALITY 16
#define COG_PIXEL_STEP 4
//const framesize_t DEFINED_FRAMESIZE = FRAMESIZE_XGA;
const framesize_t DEFINED_FRAMESIZE = FRAMESIZE_SXGA;
#define asMegaHz(X) (1000UL * 1000UL * X)
#define APPLY_HORIZONTAL_FLIP 0
#define APPLY_VERTICAL_FLIP 0
//docs: https://www.atomic14.com/2023/08/31/esp32-s3-adafruit-st7789-hardware-spi
#define CAMERA_MODEL_WROVER_KIT // Has PSRAM
#define MAGIC_LEVEL 3
#define OBSERVE_MAGIC_LEVEL 0
int lastCogIterations;
typedef struct TSumma {
int where;
uint64_t summa;
} TSumma;
typedef struct TcenterOfGravity {
TSumma row;
TSumma col;
uint32_t updatedAtMillis;
uint32_t index;
int markedX;
int markedY;
} TcenterOfGravity;
typedef struct TNormalizedLumin {
uint16_t brightest;
uint16_t faintest;
uint16_t originalBrightest;
uint16_t originalFaintest;
uint16_t range;
} TNormalizedLumin;
TcenterOfGravity mycog;
#define OVERRIDE_PINS 1
#if OVERRIDE_PINS
#define Y2_GPIO_NUM_OVER 34
#define Y3_GPIO_NUM_OVER 13
#define Y4_GPIO_NUM_OVER 26
#define Y5_GPIO_NUM_OVER 35
#define Y6_GPIO_NUM_OVER 39
#define Y7_GPIO_NUM_OVER 38
#define Y8_GPIO_NUM_OVER 37
#define Y9_GPIO_NUM_OVER 36
#define XCLK_GPIO_NUM_OVER 4
#define PCLK_GPIO_NUM_OVER 25
#define VSYNC_GPIO_NUM_OVER 5
#define HREF_GPIO_NUM_OVER 27
#define SIOD_GPIO_NUM_OVER 18
#define SIOC_GPIO_NUM_OVER 23
#define PWDN_GPIO_NUM_OVER -1
#define RESET_GPIO_NUM_OVER -1
#else
#define Y2_GPIO_NUM_OVER Y2_GPIO_NUM
#define Y3_GPIO_NUM_OVER Y3_GPIO_NUM
#define Y4_GPIO_NUM_OVER Y4_GPIO_NUM
#define Y5_GPIO_NUM_OVER Y5_GPIO_NUM
#define Y6_GPIO_NUM_OVER Y6_GPIO_NUM
#define Y7_GPIO_NUM_OVER Y7_GPIO_NUM
#define Y8_GPIO_NUM_OVER Y8_GPIO_NUM
#define Y9_GPIO_NUM_OVER Y9_GPIO_NUM
#define XCLK_GPIO_NUM_OVER XCLK_GPIO_NUM
#define PCLK_GPIO_NUM_OVER PCLK_GPIO_NUM
#define VSYNC_GPIO_NUM_OVER VSYNC_GPIO_NUM
#define HREF_GPIO_NUM_OVER HREF_GPIO_NUM
#define SIOD_GPIO_NUM_OVER SIOD_GPIO_NUM
#define SIOC_GPIO_NUM_OVER SIOC_GPIO_NUM
#define PWDN_GPIO_NUM_OVER PWDN_GPIO_NUM
#define RESET_GPIO_NUM_OVER RESET_GPIO_NUM
#endif
// Define ST7789 display pin connection
#define TFT_CS 12
#define TFT_RST -1 // Or set to -1 and connect to Arduino RESET pin
#define TFT_DC 15
#define TFT_BK 2
#define TFT_MOSI 19
#define TFT_MISO 22
#define TFT_SCLK 21
Adafruit_ST7789 *tft = NULL;
void tftSetup(){
pinMode(TFT_BK, OUTPUT);
digitalWrite(TFT_BK, HIGH);
int hardware_spi = 1;
if (hardware_spi){
SPIClass *spi = new SPIClass(VSPI);
spi->begin(TFT_SCLK, TFT_MISO, TFT_MOSI, TFT_CS);
tft = new Adafruit_ST7789(spi, TFT_CS, TFT_DC, TFT_RST);
// 80MHz should work, but you may need lower speeds
tft->init(TFT_WIDTH_PX, TFT_HEIGHT_PX, SPI_MODE3); // Init ST7789 240x240
tft->setSPISpeed(asMegaHz(80));
}else{
tft = new Adafruit_ST7789(TFT_CS, TFT_DC, TFT_RST);
tft->init(TFT_WIDTH_PX, TFT_HEIGHT_PX, SPI_MODE3); // Init ST7789 240x240
}
}
void IRAM_ATTR DebugPrint(const char *s){
return ;
static uint32_t lastPrinted;
uint32_t present = millis();
uint32_t age = present - lastPrinted;
Serial.printf("\r\n%s [%d ms]\r\n", s, age);
lastPrinted = present;
};
TwoWire myI2C = TwoWire(1);
void Camera_setConfig() {
camera_config_t config;
memset(&config, 0, sizeof(config));
int force_hardware_i2c = 1;
int sda;
int scl;
if (force_hardware_i2c){
if (myI2C.begin(SIOD_GPIO_NUM_OVER, SIOC_GPIO_NUM_OVER, 400000)){
Serial.println("hardware i2c up");
}else{
Serial.println("hardware i2c down");
};
sda = -1;
scl = -1;
config.sccb_i2c_port = 1;
}else{
sda = SIOD_GPIO_NUM_OVER;
scl = SIOC_GPIO_NUM_OVER;
}
config.ledc_channel = LEDC_CHANNEL_0;
config.ledc_timer = LEDC_TIMER_0;
config.pin_d0 = Y2_GPIO_NUM_OVER;
config.pin_d1 = Y3_GPIO_NUM_OVER;
config.pin_d2 = Y4_GPIO_NUM_OVER;
config.pin_d3 = Y5_GPIO_NUM_OVER;
config.pin_d4 = Y6_GPIO_NUM_OVER;
config.pin_d5 = Y7_GPIO_NUM_OVER;
config.pin_d6 = Y8_GPIO_NUM_OVER;
config.pin_d7 = Y9_GPIO_NUM_OVER;
config.pin_xclk = XCLK_GPIO_NUM_OVER;
config.pin_pclk = PCLK_GPIO_NUM_OVER;
config.pin_vsync = VSYNC_GPIO_NUM_OVER;
config.pin_href = HREF_GPIO_NUM_OVER;
config.pin_sscb_sda = sda;
config.pin_sscb_scl = scl;
config.pin_pwdn = PWDN_GPIO_NUM_OVER;
config.pin_reset = RESET_GPIO_NUM_OVER;
config.xclk_freq_hz = asMegaHz(20);
config.pixel_format = PIXFORMAT_GRAYSCALE;
config.frame_size = DEFINED_FRAMESIZE;
//config.frame_size = FRAMESIZE_SVGA;
//config.frame_size = FRAMESIZE_XGA;
//config.frame_size = FRAMESIZE_QQVGA;
config.jpeg_quality = JPG_QUALITY;
config.fb_count = 3;
// config.grab_mode = CAMERA_GRAB_LATEST;
//config.fb_location = CAMERA_FB_IN_DRAM;
esp_err_t err = esp_camera_init(&config);
if (err != ESP_OK) {
Serial.printf("Failed to init camera, error 0x%x", err);
return;
}
sensor_t * cameraModule = esp_camera_sensor_get();
// (-2 to 2)
cameraModule->set_brightness(cameraModule, 0);
// (-2 to 2)
cameraModule->set_contrast(cameraModule, 0);
// (-2 to 2)
cameraModule->set_saturation(cameraModule, 0);
cameraModule->set_special_effect(cameraModule, 0);
cameraModule->set_whitebal(cameraModule, 1);
cameraModule->set_awb_gain(cameraModule, 1);
cameraModule->set_wb_mode(cameraModule, 0);
cameraModule->set_exposure_ctrl(cameraModule, 1);
cameraModule->set_aec2(cameraModule, 0);
cameraModule->set_ae_level(cameraModule, 0);
// (0 to 1200)
cameraModule->set_aec_value(cameraModule, 300);
cameraModule->set_gain_ctrl(cameraModule, 1);
// (0 to 30)
cameraModule->set_agc_gain(cameraModule, 0);
// (0 to 6)step
cameraModule->set_gainceiling(cameraModule, (gainceiling_t)0);
cameraModule->set_bpc(cameraModule, 0);
cameraModule->set_wpc(cameraModule, 1);
cameraModule->set_raw_gma(cameraModule, 1);
cameraModule->set_lenc(cameraModule, 0);
cameraModule->set_hmirror(cameraModule, APPLY_HORIZONTAL_FLIP);
cameraModule->set_vflip(cameraModule, APPLY_VERTICAL_FLIP);
cameraModule->set_dcw(cameraModule, 1);
cameraModule->set_colorbar(cameraModule, 0);
}
void printToSerialAsAsciiArt(camera_fb_t * fb){
int step = (fb->height / 50);
if (step < 1){
step = 1;
}
int lineX = mycog.markedX;
int lineY = mycog.markedY;
int drawnX = 0;
int drawnY = 0;
for (int y=0; y<fb->height; y += step){
for (int x=0; x<fb->width; x += step){
int py = y;
int px = x;
if ((abs(y - lineY) <= step)&&(0 == drawnY)){
py = lineY;
//drawnY = 1;
}
if ((abs(x - lineX) <= step)&&(0 == drawnX)){
px = lineX;
//drawnX = 1;
}
uint8_t b = fb->buf[py*fb->width + px];
char levels[] = { '@', 'B', 'h', ':', '.', ' ', 0};
int i = b / (256 / strlen(levels));
if (i == strlen(levels)){
i--;
}
char n[2] = { levels[i], 0 };
if (OBSERVE_MAGIC_LEVEL){
if (MAGIC_LEVEL == b){
n[0] = '#';
}
}
char m[16];
strcpy(m, n);
// double it, for the serial monitor's character width/height
strcat(m, n);
Serial.print(m);
}
Serial.println();
}
}
uint8_t scanLine[8192];
void IRAM_ATTR NormalizeLuminosities_init(camera_fb_t * fb, TNormalizedLumin *n, uint8_t *actualFrameBuf){
n->brightest = 0;
n->faintest = 255;
uint32_t brightestLum = 50;
uint32_t faintestLum = 256;
size_t step = COG_PIXEL_STEP;
uint32_t linesFittingIntoTheScanlineBuffer = 1; //sizeof(scanLine) / fb->width;
uint32_t linesInTheFramebuffer = 0;
uint32_t linesOffset = 0;
for (size_t y=0; y < fb->height; y+= step){
if (0 == linesInTheFramebuffer){
memcpy(scanLine, actualFrameBuf + (y*fb->width), fb->width * linesFittingIntoTheScanlineBuffer);
linesInTheFramebuffer = linesFittingIntoTheScanlineBuffer;
linesOffset = 0;
}
for (size_t x=0; x < fb->width; x+= step){
uint8_t lum = scanLine[linesOffset*fb->width + x];
if (lum > brightestLum){
brightestLum = lum;
}
if (lum < faintestLum){
faintestLum = lum;
}
}
linesOffset++;
linesInTheFramebuffer--;
};
n->originalBrightest = brightestLum;
n->originalFaintest = faintestLum;
// linear stretch
int range = (brightestLum - faintestLum);
if (0 == range){
range = 1;
}
n->range = range;
/*
for (size_t y=0; y < fb->height; y++){
memcpy(scanLine, actualFrameBuf + (y*fb->width), fb->width);
int changes = 0;
for (size_t x=0; x < fb->width; x++){
int lum = scanLine[x];
lum -= faintestLum;
int f = 256 / range;
lum *= f;
if (lum > 255){
lum = 255;
}
if (lum != scanLine[x]){
scanLine[x] = lum;
changes++;
}
}
if (changes > 0){
memcpy(actualFrameBuf + (y*fb->width), scanLine, fb->width);
}
};
*/
n->brightest = 255;
n->faintest = 0;
}
uint8_t IRAM_ATTR NormalizeLuminosities_getScaledLuminance(uint8_t originalLum, TNormalizedLumin *n){
int lum = originalLum;
lum -= n->originalFaintest;
int f = 256 / n->range;
lum *= f;
if (lum > 255){
lum = 255;
}
return (uint8_t) lum;
}
void IRAM_ATTR findCenterOfMass(camera_fb_t * fb, TcenterOfGravity *cog, uint8_t *actualFrameBuf){
static uint32_t lastEvalDone = 0;
if (millis() - lastEvalDone < 500){
return ;
}
size_t step = COG_PIXEL_STEP;
size_t x_start;
size_t y_start;
size_t x_end;
size_t y_end;
//take a square
if (fb->width > fb->height){
y_start = 0;
y_end = fb->height;
x_start = (fb->width - fb->height) / 2;
x_end = x_start + y_end;
}else{
x_start = 0;
x_end = fb->width;
y_start = (fb->height - fb->width) / 2;
y_end = y_start + x_end;
}
TNormalizedLumin n;
NormalizeLuminosities_init(fb, &n, actualFrameBuf);
DebugPrint("normalize init done");
uint32_t brightestLum = n.brightest;
int32_t moment_x = 0;
int32_t moment_y = 0;
int32_t count = 0;
int retries = 5;
lastCogIterations = 0;
while (0 == count){
lastCogIterations++;
brightestLum *= 8;
brightestLum /= 10;
for (size_t y=y_start; y < y_end; y += step){
memcpy(scanLine, actualFrameBuf + (y*fb->width), x_end);
int changes = 0;
int firstChange = -1;
int lastChange = 0;
for (size_t x=x_start; x < x_end; x += step){
uint8_t lum = NormalizeLuminosities_getScaledLuminance(scanLine[x], &n);
if (lum >= brightestLum){
count++;
moment_x += (x - x_start);
moment_y += (y - y_start);
}
if (OBSERVE_MAGIC_LEVEL){
if (MAGIC_LEVEL == scanLine[x]){
scanLine[x]++;
changes++;
if (-1 == firstChange){
firstChange = x;
}
lastChange = x;
}
}
}
if (changes > 0){
//prevent the magic level from showing up
size_t changeSize = lastChange - firstChange + 1;
memcpy(actualFrameBuf + (y*fb->width)+firstChange, scanLine+firstChange, changeSize);
}
};
if (0 == retries){
break;
}
retries--;
}
DebugPrint("cog scan done");
if (count > 0){
cog->col.where = (moment_x / count) - ((x_end - x_start) / 2);
cog->row.where = (moment_y / count) - ((y_end - y_start) / 2);
cog->updatedAtMillis = millis();
cog->index++;
// mark it with a cross
cog->markedY = (cog->row.where + fb->height /2);
cog->markedX = (cog->col.where + fb->width/2);
if (OBSERVE_MAGIC_LEVEL){
memcpy(scanLine, actualFrameBuf + (cog->markedY*fb->width), fb->width);
for (int x=0; x<fb->width; x++){
scanLine[x] = MAGIC_LEVEL;
}
memcpy(actualFrameBuf + (cog->markedY*fb->width), scanLine, fb->width);
for (int y=0; y<fb->height; y++){
actualFrameBuf[y *fb->width + cog->markedX] = MAGIC_LEVEL;
}
DebugPrint("cog cross drawn");
}
}
lastEvalDone = millis();
}
uint16_t tftBuffer[TFT_WIDTH_PX];
uint16_t IRAM_ATTR rgbToRGB565(uint8_t r, uint8_t g, uint8_t b){
uint16_t red = r >> 3;
uint16_t blue = b >> 3;
uint16_t green = g >> 2;
uint16_t ret = (red << 6+5) | (green << 5) | blue;
return ret;
}
void IRAM_ATTR printToTft(camera_fb_t * fb){
int step = (fb->height / TFT_WIDTH_PX);
if (step < 1){
step = 1;
}
int lineX = mycog.markedX;
int lineY = mycog.markedY;
int destX = 0;
int destY = 0;
int lastPy = -1;
for (int y=0; y<TFT_HEIGHT_PX; y++){
memset(tftBuffer, 0, sizeof(tftBuffer));
int py = y * fb->height / TFT_HEIGHT_PX;
int py_next = (y+1) * fb->height / TFT_HEIGHT_PX;
if ((lineY >= py)&&(lineY < py_next)){
py = lineY;
}
if (py != lastPy){
memcpy(scanLine, fb->buf + (py*fb->width), fb->width);
lastPy = py;
}
for (int x=0; x<TFT_WIDTH_PX; x++){
int px = x * fb->width / TFT_WIDTH_PX;
int px_next = (x+1) * fb->width / TFT_WIDTH_PX;
if ((lineX >= px)&&(lineX<px_next)){
px = lineX;
}
uint8_t grey = scanLine[px];
uint16_t col = rgbToRGB565(grey, grey, grey);
if ((py == lineY) || (px == lineX)){
col = rgbToRGB565(0, 255, 0);
}
if ((MAGIC_LEVEL == grey)&&(OBSERVE_MAGIC_LEVEL)){
col = rgbToRGB565(0, 255, 0);
}
tftBuffer[ x] = col;
}
destX = 0;
tft->drawRGBBitmap(destX, destY, (uint16_t *)&tftBuffer, TFT_WIDTH_PX, 1);
destY++;
}
}
void printOut(camera_fb_t * fb){
static uint32_t lastPrint = 0;
if (millis() - lastPrint > 1000){
//printToSerialAsAsciiArt(fb);
DebugPrint("ascii art done");
printToTft(fb);
DebugPrint("tft done");
lastPrint = millis();
}
};
void takeNewPhoto(void) {
DebugPrint("getting frame");
camera_fb_t * fb = esp_camera_fb_get();
if (!fb) {
Serial.println("failed to get image frame");
delay(1000);
return;
}
DebugPrint("frame gotten");
Serial.print("f");
Serial.printf("we have a photo %d %d %d\r\n", fb->len, fb->width, fb->height);
findCenterOfMass(fb, &mycog, fb->buf);
Serial.printf("##mass[%d]: %d %d\r\n", mycog.index, mycog.col.where, mycog.row.where);
char lci[32];
strcpy(lci, "cog found ");
itoa(lastCogIterations, lci+strlen(lci), 10);
DebugPrint(lci);
printOut(fb);
DebugPrint("printout done");
esp_camera_fb_return(fb);
DebugPrint("framevuffer returned");
delay(100);
}
void setup() {
memset(&mycog, 0, sizeof(mycog));
Serial.begin(115200);
tftSetup();
Serial.print("init camera...");
Camera_setConfig();
Serial.println("Camera OK!");
}
void loop() {
takeNewPhoto();
}








