Initial release v1.1.0 — ESP32 automated telescope flat panel

Firmware (Arduino Nano ESP32 / PlatformIO):
- Native ASCOM Alpaca CoverCalibrator REST API on port 11111
- Alnitak serial protocol on USB at 9600 baud (simultaneous with WiFi)
- MG995 servo cover mechanism with non-blocking state machine (D9)
- LED brightness PWM via IRLZ44N MOSFET, LEDC channel 0 (D3)
- 12V dew heater PWM via IRLZ44N MOSFET, LEDC channel 1 (D5)
- mDNS + UDP Alpaca discovery, WiFi watchdog reconnect
- SERIAL_DEBUG flag to silence debug output in USB-only mode

INDI driver (C++ / libcurl / nlohmann-json):
- WiFi mode: HTTP Alpaca via libcurl
- USB mode: Alnitak serial via POSIX termios
- LightBoxInterface + DustCapInterface + dew heater number property

Python controller (PyQt6):
- Dark-themed desktop app for direct manual control
- AlpacaBackend (requests) + AlnitakBackend (pyserial)
- PollWorker QThread; cover, brightness, dew heater panels
- QSettings persistence; auto serial port discovery

Docs:
- system-diagram.svg, wiring-diagram.svg (browser-renderable SVG)
- BUILD_NOTES.md with BOM, LM2596 calibration, power-on checklist
- WIRING.md quick-reference, README.md, CHANGELOG.md
This commit is contained in:
Laurence 2026-05-17 08:51:29 +01:00
commit c32f00a2be
15 changed files with 3653 additions and 0 deletions

58
firmware/src/config.h Normal file
View file

@ -0,0 +1,58 @@
#pragma once
// ---------------------------------------------------------------------------
// WiFi
// ---------------------------------------------------------------------------
#define WIFI_SSID "your_wifi_ssid"
#define WIFI_PASSWORD "your_wifi_password"
#define HOSTNAME "esp32-flatpanel" // accessible as esp32-flatpanel.local
// ---------------------------------------------------------------------------
// Pin assignments (Arduino Nano ESP32 D-pin numbers)
// ---------------------------------------------------------------------------
#define SERVO_PIN 9 // MG995 signal wire → D9
#define LED_PWM_PIN 3 // MOSFET gate → D3
// ---------------------------------------------------------------------------
// Servo angles (degrees) tune to your mechanical stop positions
// ---------------------------------------------------------------------------
#define SERVO_CLOSED 0 // panel in image path (cover closed / panel active)
#define SERVO_OPEN 90 // panel rotated clear of telescope
// Time (ms) to wait for the MG995 to reach its target before marking done.
// MG995 spec: ~0.2 s / 60° no-load; 1 500 ms covers 90° + margin.
#define COVER_MOVE_TIMEOUT_MS 1500
// ---------------------------------------------------------------------------
// LED PWM (LEDC channel 0, 8-bit → 0-255)
// ---------------------------------------------------------------------------
#define LED_PWM_CHANNEL 0
#define LED_PWM_FREQ 5000 // 5 kHz above visible flicker
#define LED_PWM_RESOLUTION 8
#define MAX_BRIGHTNESS 255
// ---------------------------------------------------------------------------
// Dew heater (12 V via N-channel MOSFET, same circuit as LED)
// ---------------------------------------------------------------------------
#define DEW_HEATER_PIN 5 // MOSFET gate → D5
#define DEW_PWM_CHANNEL 1 // LEDC channel 1 (channel 0 used for LED)
#define DEW_PWM_FREQ 1000 // 1 kHz fine for resistive heating element
#define DEW_PWM_RESOLUTION 8 // 8-bit → 0-255 maps to 0-100 %
// ---------------------------------------------------------------------------
// USB / Alnitak serial protocol
// Set SERIAL_DEBUG to false when using USB mode so the Alnitak driver
// receives only properly-formatted responses. Debug output is harmless in
// WiFi-only mode because Alnitak parsers ignore lines not starting with '*'.
// ---------------------------------------------------------------------------
#define SERIAL_DEBUG true
// Device type 19 = Flip-Flat (cover + calibrator). Recognised natively by
// N.I.N.A., SGPro, APT, and the INDI indi_flipflat driver.
#define ALNITAK_DEVICE_ID 19
// ---------------------------------------------------------------------------
// ASCOM Alpaca
// ---------------------------------------------------------------------------
#define ALPACA_PORT 11111
// Generate a fresh UUID at https://www.uuidgenerator.net/ and paste it here.
#define DEVICE_UUID "a2ba5c7e-4b3f-4f98-a7d0-1c5b8e2f9d3a"

515
firmware/src/main.cpp Normal file
View file

@ -0,0 +1,515 @@
#include <Arduino.h>
#include <WiFi.h>
#include <ESPmDNS.h>
#include <AsyncUDP.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <ArduinoJson.h>
#include <ESP32Servo.h>
#include "config.h"
// ---------------------------------------------------------------------------
// Conditional debug output disabled in USB/Alnitak mode to keep the serial
// line clean for the Alnitak protocol parser.
// ---------------------------------------------------------------------------
#if SERIAL_DEBUG
#define DBG(...) Serial.print(__VA_ARGS__)
#define DBGF(...) Serial.printf(__VA_ARGS__)
#define DBGLN(...) Serial.println(__VA_ARGS__)
#else
#define DBG(...)
#define DBGF(...)
#define DBGLN(...)
#endif
// ---------------------------------------------------------------------------
// ASCOM Alpaca state enumerations
// ---------------------------------------------------------------------------
enum CalibratorStatus : int {
CalNotPresent = 0,
CalOff = 1,
CalNotReady = 2,
CalReady = 3,
CalUnknown = 4,
CalError = 5
};
enum CoverStatus : int {
CovNotPresent = 0,
CovUnknown = 1,
CovOpen = 2,
CovClosed = 3,
CovMoving = 4,
CovError = 5
};
// ---------------------------------------------------------------------------
// Global device state
// ---------------------------------------------------------------------------
static int g_brightness = 0;
static int g_dewPercent = 0;
static CalibratorStatus g_calState = CalOff;
static CoverStatus g_covState = CovClosed;
static bool g_connected = false;
static uint32_t g_txID = 0;
// Cover-movement state machine
static CoverStatus g_moveTarget = CovClosed;
static unsigned long g_moveStartMs = 0;
static bool g_moving = false;
AsyncWebServer server(ALPACA_PORT);
AsyncUDP udp;
Servo coverServo;
// ---------------------------------------------------------------------------
// Hardware helpers
// ---------------------------------------------------------------------------
static void applyBrightness(int b) {
b = constrain(b, 0, MAX_BRIGHTNESS);
g_brightness = b;
ledcWrite(LED_PWM_CHANNEL, b);
g_calState = (b > 0) ? CalReady : CalOff;
}
static void applyDewHeater(int pct) {
g_dewPercent = constrain(pct, 0, 100);
// map 0-100 % to 0-255 PWM, with full off at 0 % and full on at 100 %
ledcWrite(DEW_PWM_CHANNEL, map(g_dewPercent, 0, 100, 0, 255));
}
static void startCoverMove(CoverStatus target) {
coverServo.write(target == CovOpen ? SERVO_OPEN : SERVO_CLOSED);
g_covState = CovMoving;
g_moveTarget = target;
g_moveStartMs = millis();
g_moving = true;
}
// ---------------------------------------------------------------------------
// Alnitak serial protocol (USB mode)
//
// Format: command >CXXX\r (C = command char, XXX = 3-digit value)
// response *CDDVVV\r (DD = 2-digit device type, VVV = 3-digit value)
//
// Standard commands understood by N.I.N.A., SGPro, APT, indi_flipflat:
// P Ping B Set brightness J Get brightness
// L Light on D Light off
// O Open C Close H Halt
// S State (motorStatus + lightStatus)
//
// Custom extensions (for our INDI/ASCOM driver when using USB mode):
// T Set dew heater % U Get dew heater %
// ---------------------------------------------------------------------------
static int alnitakMotorState() {
switch (g_covState) {
case CovOpen: return 2; // Open
case CovClosed: return 1; // Closed
default: return 0; // NotOpenOrClosed / moving / unknown
}
}
static void processAlnitak(const char* cmd) {
if (cmd[0] != '>') return;
char ch = cmd[1];
int val = (strlen(cmd) > 2) ? atoi(cmd + 2) : 0;
char resp[20];
switch (ch) {
case 'P': // Ping
snprintf(resp, sizeof(resp), "*P%02d000\r", ALNITAK_DEVICE_ID);
break;
case 'S': // State: *SDD<motor><light>00
snprintf(resp, sizeof(resp), "*S%02d%d%d00\r",
ALNITAK_DEVICE_ID,
alnitakMotorState(),
(g_calState == CalReady) ? 1 : 0);
break;
case 'B': // Set brightness 0-255
applyBrightness(constrain(val, 0, MAX_BRIGHTNESS));
snprintf(resp, sizeof(resp), "*B%02d%03d\r", ALNITAK_DEVICE_ID, g_brightness);
break;
case 'J': // Get brightness
snprintf(resp, sizeof(resp), "*J%02d%03d\r", ALNITAK_DEVICE_ID, g_brightness);
break;
case 'L': // Light on (keep existing brightness; default 100 if zero)
if (g_brightness == 0) applyBrightness(100);
else applyBrightness(g_brightness);
snprintf(resp, sizeof(resp), "*L%02d000\r", ALNITAK_DEVICE_ID);
break;
case 'D': // Light off
applyBrightness(0);
snprintf(resp, sizeof(resp), "*D%02d000\r", ALNITAK_DEVICE_ID);
break;
case 'O': // Open cover
if (!g_moving) startCoverMove(CovOpen);
snprintf(resp, sizeof(resp), "*O%02d000\r", ALNITAK_DEVICE_ID);
break;
case 'C': // Close cover
if (!g_moving) startCoverMove(CovClosed);
snprintf(resp, sizeof(resp), "*C%02d000\r", ALNITAK_DEVICE_ID);
break;
case 'H': // Halt
if (g_moving) {
coverServo.write(coverServo.read());
g_covState = CovUnknown;
g_moving = false;
}
snprintf(resp, sizeof(resp), "*H%02d000\r", ALNITAK_DEVICE_ID);
break;
case 'T': // (custom) Set dew heater percent 0-100
applyDewHeater(constrain(val, 0, 100));
snprintf(resp, sizeof(resp), "*T%02d%03d\r", ALNITAK_DEVICE_ID, g_dewPercent);
break;
case 'U': // (custom) Get dew heater percent
snprintf(resp, sizeof(resp), "*U%02d%03d\r", ALNITAK_DEVICE_ID, g_dewPercent);
break;
default:
return; // unknown command send nothing
}
Serial.print(resp);
}
static void handleAlnitak() {
static char buf[20];
static uint8_t pos = 0;
while (Serial.available()) {
char c = static_cast<char>(Serial.read());
if (c == '\r' || c == '\n') {
if (pos > 0) {
buf[pos] = '\0';
processAlnitak(buf);
pos = 0;
}
} else if (pos < sizeof(buf) - 1) {
buf[pos++] = c;
} else {
pos = 0; // buffer overflow guard
}
}
}
// ---------------------------------------------------------------------------
// Alpaca response helpers
// ---------------------------------------------------------------------------
static int clientTxID(AsyncWebServerRequest* req) {
const AsyncWebParameter* p = req->getParam("ClientTransactionID");
if (!p) p = req->getParam("ClientTransactionID", true);
return p ? p->value().toInt() : 0;
}
static void fillCommon(StaticJsonDocument<512>& doc, AsyncWebServerRequest* req,
int errNum = 0, const char* errMsg = "") {
doc["ClientTransactionID"] = clientTxID(req);
doc["ServerTransactionID"] = ++g_txID;
doc["ErrorNumber"] = errNum;
doc["ErrorMessage"] = errMsg;
}
static void sendInt(AsyncWebServerRequest* req, int value) {
StaticJsonDocument<256> doc;
doc["Value"] = value;
fillCommon(doc, req);
String out; serializeJson(doc, out);
req->send(200, "application/json", out);
}
static void sendBool(AsyncWebServerRequest* req, bool value) {
StaticJsonDocument<256> doc;
doc["Value"] = value;
fillCommon(doc, req);
String out; serializeJson(doc, out);
req->send(200, "application/json", out);
}
static void sendStr(AsyncWebServerRequest* req, const char* value) {
StaticJsonDocument<384> doc;
doc["Value"] = value;
fillCommon(doc, req);
String out; serializeJson(doc, out);
req->send(200, "application/json", out);
}
static void sendOK(AsyncWebServerRequest* req) {
StaticJsonDocument<192> doc;
fillCommon(doc, req);
String out; serializeJson(doc, out);
req->send(200, "application/json", out);
}
static void sendErr(AsyncWebServerRequest* req, int errNum, const char* errMsg) {
StaticJsonDocument<256> doc;
fillCommon(doc, req, errNum, errMsg);
String out; serializeJson(doc, out);
req->send(200, "application/json", out);
}
static void sendIntArray(AsyncWebServerRequest* req, std::initializer_list<int> values) {
StaticJsonDocument<256> doc;
JsonArray arr = doc.createNestedArray("Value");
for (int v : values) arr.add(v);
fillCommon(doc, req);
String out; serializeJson(doc, out);
req->send(200, "application/json", out);
}
// ---------------------------------------------------------------------------
// Route setup
// ---------------------------------------------------------------------------
static void setupRoutes() {
// ---- Alpaca Management API -----------------------------------------------
server.on("/management/apiversions", HTTP_GET, [](AsyncWebServerRequest* req) {
sendIntArray(req, {1});
});
server.on("/management/v1/description", HTTP_GET, [](AsyncWebServerRequest* req) {
StaticJsonDocument<512> doc;
JsonObject val = doc.createNestedObject("Value");
val["ServerName"] = "ESP32 FlatPanel";
val["Manufacturer"] = "DIY";
val["ManufacturerVersion"] = "1.0.0";
val["Location"] = "Telescope";
fillCommon(doc, req);
String out; serializeJson(doc, out);
req->send(200, "application/json", out);
});
server.on("/management/v1/configureddevices", HTTP_GET, [](AsyncWebServerRequest* req) {
StaticJsonDocument<512> doc;
JsonArray arr = doc.createNestedArray("Value");
JsonObject dev = arr.createNestedObject();
dev["DeviceName"] = "ESP32 FlatPanel";
dev["DeviceType"] = "CoverCalibrator";
dev["DeviceNumber"] = 0;
dev["UniqueID"] = DEVICE_UUID;
fillCommon(doc, req);
String out; serializeJson(doc, out);
req->send(200, "application/json", out);
});
// ---- CoverCalibrator common device properties -------------------------
server.on("/api/v1/covercalibrator/0/name", HTTP_GET, [](AsyncWebServerRequest* req) {
sendStr(req, "ESP32 FlatPanel");
});
server.on("/api/v1/covercalibrator/0/description", HTTP_GET, [](AsyncWebServerRequest* req) {
sendStr(req, "Automated telescope flat panel with motorised cover and dew heater");
});
server.on("/api/v1/covercalibrator/0/driverinfo", HTTP_GET, [](AsyncWebServerRequest* req) {
sendStr(req, "ESP32 FlatPanel Alpaca driver v1.1");
});
server.on("/api/v1/covercalibrator/0/driverversion", HTTP_GET, [](AsyncWebServerRequest* req) {
sendStr(req, "1.1.0");
});
server.on("/api/v1/covercalibrator/0/interfaceversion", HTTP_GET, [](AsyncWebServerRequest* req) {
sendInt(req, 1);
});
// SetDewHeater / GetDewHeater exposed as Alpaca Actions
server.on("/api/v1/covercalibrator/0/supportedactions", HTTP_GET, [](AsyncWebServerRequest* req) {
StaticJsonDocument<256> doc;
JsonArray arr = doc.createNestedArray("Value");
arr.add("SetDewHeater");
arr.add("GetDewHeater");
fillCommon(doc, req);
String out; serializeJson(doc, out);
req->send(200, "application/json", out);
});
server.on("/api/v1/covercalibrator/0/action", HTTP_PUT, [](AsyncWebServerRequest* req) {
const AsyncWebParameter* aP = req->getParam("Action", true);
const AsyncWebParameter* pP = req->getParam("Parameters", true);
if (!aP) { sendErr(req, 0x400, "Action parameter required"); return; }
String action = aP->value();
String params = pP ? pP->value() : "";
action.toLowerCase();
if (action == "setdewheater") {
applyDewHeater(constrain(params.toInt(), 0, 100));
sendStr(req, "");
} else if (action == "getdewheater") {
sendStr(req, String(g_dewPercent).c_str());
} else {
sendErr(req, 0x40C, "Action not implemented");
}
});
// ---- CoverCalibrator state properties (GET) ----------------------------
server.on("/api/v1/covercalibrator/0/connected", HTTP_GET, [](AsyncWebServerRequest* req) {
sendBool(req, g_connected);
});
server.on("/api/v1/covercalibrator/0/brightness", HTTP_GET, [](AsyncWebServerRequest* req) {
sendInt(req, g_brightness);
});
server.on("/api/v1/covercalibrator/0/maxbrightness", HTTP_GET, [](AsyncWebServerRequest* req) {
sendInt(req, MAX_BRIGHTNESS);
});
server.on("/api/v1/covercalibrator/0/calibratorstate", HTTP_GET, [](AsyncWebServerRequest* req) {
sendInt(req, static_cast<int>(g_calState));
});
server.on("/api/v1/covercalibrator/0/coverstate", HTTP_GET, [](AsyncWebServerRequest* req) {
sendInt(req, static_cast<int>(g_covState));
});
// Non-standard but polled by our Python controller and INDI driver
server.on("/api/v1/covercalibrator/0/dewheater", HTTP_GET, [](AsyncWebServerRequest* req) {
sendInt(req, g_dewPercent);
});
server.on("/api/v1/covercalibrator/0/dewheater", HTTP_PUT, [](AsyncWebServerRequest* req) {
const AsyncWebParameter* p = req->getParam("Percentage", true);
if (!p) { sendErr(req, 0x400, "Percentage parameter required"); return; }
applyDewHeater(constrain(p->value().toInt(), 0, 100));
sendOK(req);
});
// ---- CoverCalibrator commands (PUT) ------------------------------------
server.on("/api/v1/covercalibrator/0/connected", HTTP_PUT, [](AsyncWebServerRequest* req) {
const AsyncWebParameter* p = req->getParam("Connected", true);
if (p) {
String v = p->value(); v.toLowerCase();
g_connected = (v == "true");
}
sendOK(req);
});
server.on("/api/v1/covercalibrator/0/calibratoron", HTTP_PUT, [](AsyncWebServerRequest* req) {
const AsyncWebParameter* p = req->getParam("Brightness", true);
if (!p) { sendErr(req, 0x400, "Brightness parameter required"); return; }
int b = p->value().toInt();
if (b < 0 || b > MAX_BRIGHTNESS) { sendErr(req, 0x401, "Brightness out of range 0-255"); return; }
applyBrightness(b);
sendOK(req);
});
server.on("/api/v1/covercalibrator/0/calibratoroff", HTTP_PUT, [](AsyncWebServerRequest* req) {
applyBrightness(0);
sendOK(req);
});
server.on("/api/v1/covercalibrator/0/opencover", HTTP_PUT, [](AsyncWebServerRequest* req) {
if (g_covState != CovOpen && !g_moving) startCoverMove(CovOpen);
sendOK(req);
});
server.on("/api/v1/covercalibrator/0/closecover", HTTP_PUT, [](AsyncWebServerRequest* req) {
if (g_covState != CovClosed && !g_moving) startCoverMove(CovClosed);
sendOK(req);
});
server.on("/api/v1/covercalibrator/0/haltcover", HTTP_PUT, [](AsyncWebServerRequest* req) {
if (g_moving) {
coverServo.write(coverServo.read());
g_covState = CovUnknown;
g_moving = false;
}
sendOK(req);
});
server.onNotFound([](AsyncWebServerRequest* req) {
req->send(404, "text/plain", "Not found");
});
}
// ---------------------------------------------------------------------------
// Alpaca UDP discovery (port 32227)
// Respond to "alpacadiscovery1" broadcasts with {"AlpacaPort":11111}
// ---------------------------------------------------------------------------
static void setupDiscovery() {
if (udp.listen(32227)) {
udp.onPacket([](AsyncUDPPacket pkt) {
if (pkt.length() == 16 &&
memcmp(pkt.data(), "alpacadiscovery1", 16) == 0) {
String resp = "{\"AlpacaPort\":" + String(ALPACA_PORT) + "}";
pkt.print(resp);
}
});
}
}
// ---------------------------------------------------------------------------
// Arduino entry points
// ---------------------------------------------------------------------------
void setup() {
Serial.begin(9600); // 9600 matches indi_flipflat / N.I.N.A. default for Alnitak
DBGLN("\nESP32 FlatPanel booting…");
// Servo start in closed position
coverServo.attach(SERVO_PIN);
coverServo.write(SERVO_CLOSED);
delay(500);
// LED PWM (LEDC ch 0)
ledcSetup(LED_PWM_CHANNEL, LED_PWM_FREQ, LED_PWM_RESOLUTION);
ledcAttachPin(LED_PWM_PIN, LED_PWM_CHANNEL);
ledcWrite(LED_PWM_CHANNEL, 0);
// Dew heater PWM (LEDC ch 1, 12 V via MOSFET off at boot)
ledcSetup(DEW_PWM_CHANNEL, DEW_PWM_FREQ, DEW_PWM_RESOLUTION);
ledcAttachPin(DEW_HEATER_PIN, DEW_PWM_CHANNEL);
ledcWrite(DEW_PWM_CHANNEL, 0);
// WiFi
WiFi.setHostname(HOSTNAME);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
DBGF("Connecting to WiFi");
unsigned long t0 = millis();
while (WiFi.status() != WL_CONNECTED) {
if (millis() - t0 > 30000) { DBGLN("\nWiFi timeout restarting"); ESP.restart(); }
delay(500);
DBG('.');
}
DBGF("\nIP: %s\n", WiFi.localIP().toString().c_str());
// mDNS (_alpaca._tcp)
if (MDNS.begin(HOSTNAME)) {
MDNS.addService("alpaca", "tcp", ALPACA_PORT);
MDNS.addServiceTxt("alpaca", "tcp", "txtvers", "1");
DBGF("mDNS: http://%s.local:%d\n", HOSTNAME, ALPACA_PORT);
}
setupRoutes();
setupDiscovery();
server.begin();
DBGLN("Alpaca server started. USB/Alnitak also active on Serial.");
}
void loop() {
// USB Alnitak protocol always active regardless of SERIAL_DEBUG
handleAlnitak();
// Cover movement state machine
if (g_moving && (millis() - g_moveStartMs >= COVER_MOVE_TIMEOUT_MS)) {
g_covState = g_moveTarget;
g_moving = false;
DBGF("Cover reached: %s\n", g_covState == CovOpen ? "OPEN" : "CLOSED");
}
// WiFi watchdog
static unsigned long lastWifiCheck = 0;
if (millis() - lastWifiCheck > 10000) {
lastWifiCheck = millis();
if (WiFi.status() != WL_CONNECTED) {
DBGLN("WiFi lost reconnecting");
WiFi.reconnect();
}
}
}