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

View file

@ -0,0 +1,581 @@
#include "indi_esp32_flatpanel.h"
#include <libindi/indicom.h>
#include <curl/curl.h>
#include <nlohmann/json.hpp>
// POSIX serial port (USB/Alnitak mode)
#include <fcntl.h>
#include <termios.h>
#include <unistd.h>
#include <sys/select.h>
#include <cstring>
#include <sstream>
using json = nlohmann::json;
// Alpaca CoverCalibrator cover-state values
static constexpr int COVER_OPEN = 2;
static constexpr int COVER_CLOSED = 3;
static constexpr int COVER_MOVING = 4;
// Alpaca calibrator-state values
static constexpr int CAL_OFF = 1;
static constexpr int CAL_READY = 3;
// Alnitak device ID embedded in responses (19 = Flip-Flat)
static constexpr int ALNITAK_DEVID = 19;
// ---------------------------------------------------------------------------
// Registration / factory
// ---------------------------------------------------------------------------
static std::unique_ptr<ESP32FlatPanel> g_device;
void ISGetProperties(const char* dev) { g_device->ISGetProperties(dev); }
void ISNewSwitch(const char* dev, const char* name, ISState* s, char* n[], int num)
{ g_device->ISNewSwitch(dev, name, s, n, num); }
void ISNewText(const char* dev, const char* name, char* t[], char* n[], int num)
{ g_device->ISNewText(dev, name, t, n, num); }
void ISNewNumber(const char* dev, const char* name, double v[], char* n[], int num)
{ g_device->ISNewNumber(dev, name, v, n, num); }
void ISNewBLOB(const char* dev, const char* name, int s, int b,
char* f[], char* n[], int num)
{ INDI_UNUSED(dev); INDI_UNUSED(name); INDI_UNUSED(s); INDI_UNUSED(b);
INDI_UNUSED(f); INDI_UNUSED(n); INDI_UNUSED(num); }
void ISSnoopDevice(XMLEle* root) { g_device->ISSnoopDevice(root); }
int main(int /*argc*/, char* /*argv*/[]) {
curl_global_init(CURL_GLOBAL_ALL);
g_device = std::make_unique<ESP32FlatPanel>();
g_device->ISGetProperties(nullptr);
return 0;
}
// ---------------------------------------------------------------------------
// Constructor / destructor
// ---------------------------------------------------------------------------
ESP32FlatPanel::ESP32FlatPanel()
: LightBoxInterface(this, true),
DustCapInterface(this)
{
setVersion(1, 1);
}
ESP32FlatPanel::~ESP32FlatPanel() {
closeSerial();
curl_global_cleanup();
}
// ---------------------------------------------------------------------------
// DefaultDevice interface
// ---------------------------------------------------------------------------
const char* ESP32FlatPanel::getDefaultName() {
return "ESP32 FlatPanel";
}
bool ESP32FlatPanel::initProperties() {
INDI::DefaultDevice::initProperties();
// Connection mode
IUFillSwitch(&ConnModeS[0], "WIFI", "WiFi / Alpaca", ISS_ON);
IUFillSwitch(&ConnModeS[1], "USB", "USB Serial (Alnitak)", ISS_OFF);
IUFillSwitchVector(&ConnModeSP, ConnModeS, 2, getDeviceName(),
"CONN_MODE", "Connection Mode", CONNECTION_TAB,
IP_RW, ISR_1OFMANY, 60, IPS_IDLE);
// WiFi host / port
IUFillText(&HostT[0], "HOST", "Host / IP", "esp32-flatpanel.local");
IUFillText(&HostT[1], "PORT", "Port", "11111");
IUFillTextVector(&HostTP, HostT, 2, getDeviceName(),
"ALPACA_HOST", "Alpaca Server", CONNECTION_TAB,
IP_RW, 60, IPS_IDLE);
// USB serial port path
IUFillText(&SerialPortT[0], "PORT", "Serial Port", "/dev/ttyUSB0");
IUFillTextVector(&SerialPortTP, SerialPortT, 1, getDeviceName(),
"SERIAL_PORT", "Serial Port", CONNECTION_TAB,
IP_RW, 60, IPS_IDLE);
// Dew heater 0-100 %
IUFillNumber(&DewHeaterN[0], "DEW_HEATER_PCT", "Dew Heater (%)", "%.0f", 0, 100, 5, 0);
IUFillNumberVector(&DewHeaterNP, DewHeaterN, 1, getDeviceName(),
"DEW_HEATER", "Dew Heater", MAIN_CONTROL_TAB,
IP_RW, 60, IPS_IDLE);
LightBoxInterface::initProperties(MAIN_CONTROL_TAB, MAIN_CONTROL_TAB);
DustCapInterface::initProperties(MAIN_CONTROL_TAB);
addAuxControls();
setDriverInterface(AUX_INTERFACE | LIGHTBOX_INTERFACE | DUSTCAP_INTERFACE);
return true;
}
bool ESP32FlatPanel::updateProperties() {
INDI::DefaultDevice::updateProperties();
if (isConnected()) {
defineProperty(&ConnModeSP);
if (m_connMode == CONN_WIFI) {
defineProperty(&HostTP);
} else {
defineProperty(&SerialPortTP);
}
defineProperty(&DewHeaterNP);
LightBoxInterface::updateProperties();
DustCapInterface::updateProperties();
// Sync initial state from device
pollState();
SetTimer(1000);
} else {
deleteProperty(ConnModeSP.name);
deleteProperty(HostTP.name);
deleteProperty(SerialPortTP.name);
deleteProperty(DewHeaterNP.name);
LightBoxInterface::updateProperties();
DustCapInterface::updateProperties();
}
return true;
}
bool ESP32FlatPanel::Connect() {
// Read connection mode
m_connMode = (ConnModeS[1].s == ISS_ON) ? CONN_USB : CONN_WIFI;
if (m_connMode == CONN_WIFI) {
m_host = HostT[0].text;
m_port = std::atoi(HostT[1].text);
int iface = alpacaGetInt("/api/v1/covercalibrator/0/interfaceversion", -1);
if (iface < 0) {
LOG_ERROR("Cannot reach ESP32 at the specified host/port.");
return false;
}
alpacaPutVoid("/api/v1/covercalibrator/0/connected", "Connected=true");
LOGF_INFO("Connected via WiFi to %s:%d", m_host.c_str(), m_port);
} else {
if (!openSerial(SerialPortT[0].text)) {
LOGF_ERROR("Cannot open serial port %s", SerialPortT[0].text);
return false;
}
// Ping to verify
std::string resp = sendAlnitak(">P000");
if (resp.empty() || resp[0] != '*') {
LOG_ERROR("No valid Alnitak ping response.");
closeSerial();
return false;
}
LOGF_INFO("Connected via USB/Alnitak on %s", SerialPortT[0].text);
}
return true;
}
bool ESP32FlatPanel::Disconnect() {
if (m_connMode == CONN_WIFI)
alpacaPutVoid("/api/v1/covercalibrator/0/connected", "Connected=false");
else
closeSerial();
LOG_INFO("Disconnected from ESP32 FlatPanel.");
return true;
}
void ESP32FlatPanel::TimerHit() {
if (!isConnected()) return;
pollState();
SetTimer(1000);
}
// ---------------------------------------------------------------------------
// Property change handlers
// ---------------------------------------------------------------------------
bool ESP32FlatPanel::ISNewSwitch(const char* dev, const char* name, ISState* states, char* names[], int n) {
if (!strcmp(dev, getDeviceName()) && !strcmp(name, ConnModeSP.name)) {
IUUpdateSwitch(&ConnModeSP, states, names, n);
ConnModeSP.s = IPS_OK;
IDSetSwitch(&ConnModeSP, nullptr);
return true;
}
if (LightBoxInterface::ISNewSwitch(dev, name, states, names, n)) return true;
if (DustCapInterface::ISNewSwitch(dev, name, states, names, n)) return true;
return INDI::DefaultDevice::ISNewSwitch(dev, name, states, names, n);
}
bool ESP32FlatPanel::ISNewText(const char* dev, const char* name, char* texts[], char* names[], int n) {
if (!strcmp(dev, getDeviceName())) {
if (!strcmp(name, HostTP.name)) {
IUUpdateText(&HostTP, texts, names, n);
HostTP.s = IPS_OK;
IDSetText(&HostTP, nullptr);
return true;
}
if (!strcmp(name, SerialPortTP.name)) {
IUUpdateText(&SerialPortTP, texts, names, n);
SerialPortTP.s = IPS_OK;
IDSetText(&SerialPortTP, nullptr);
return true;
}
}
if (LightBoxInterface::ISNewText(dev, name, texts, names, n)) return true;
return INDI::DefaultDevice::ISNewText(dev, name, texts, names, n);
}
bool ESP32FlatPanel::ISNewNumber(const char* dev, const char* name, double values[], char* names[], int n) {
if (!strcmp(dev, getDeviceName()) && !strcmp(name, DewHeaterNP.name)) {
IUUpdateNumber(&DewHeaterNP, values, names, n);
setDewHeater(static_cast<int>(DewHeaterN[0].value));
return true;
}
if (LightBoxInterface::ISNewNumber(dev, name, values, names, n)) return true;
return INDI::DefaultDevice::ISNewNumber(dev, name, values, names, n);
}
bool ESP32FlatPanel::ISSnoopDevice(XMLEle* root) {
LightBoxInterface::snoop(root);
return INDI::DefaultDevice::ISSnoopDevice(root);
}
// ---------------------------------------------------------------------------
// LightBoxInterface implementation
// ---------------------------------------------------------------------------
bool ESP32FlatPanel::EnableLightBox(bool enable) {
if (m_connMode == CONN_WIFI) {
if (enable) {
int b = static_cast<int>(LightIntensityN[0].value);
if (b == 0) b = 100;
std::string body = commonPutBody() + "&Brightness=" + std::to_string(b);
return alpacaPutVoid("/api/v1/covercalibrator/0/calibratoron", body);
}
return alpacaPutVoid("/api/v1/covercalibrator/0/calibratoroff");
} else {
if (enable) {
int b = static_cast<int>(LightIntensityN[0].value);
if (b == 0) b = 100;
return alnitakSetBrightness(b);
}
return alnitakSetBrightness(0);
}
}
bool ESP32FlatPanel::SetLightBoxBrightness(uint16_t value) {
if (m_connMode == CONN_WIFI) {
std::string body = commonPutBody() + "&Brightness=" + std::to_string(static_cast<int>(value));
return alpacaPutVoid("/api/v1/covercalibrator/0/calibratoron", body);
}
return alnitakSetBrightness(static_cast<int>(value));
}
// ---------------------------------------------------------------------------
// DustCapInterface implementation
// ParkCap = close cover (panel over telescope aperture, ready for flats)
// UnParkCap = open cover (panel rotated clear, telescope free for imaging)
// ---------------------------------------------------------------------------
IPState ESP32FlatPanel::ParkCap() {
bool ok = (m_connMode == CONN_WIFI)
? alpacaPutVoid("/api/v1/covercalibrator/0/closecover")
: !sendAlnitak(">C000").empty();
if (!ok) return IPS_ALERT;
LOG_INFO("Closing cover…");
return IPS_BUSY;
}
IPState ESP32FlatPanel::UnParkCap() {
bool ok = (m_connMode == CONN_WIFI)
? alpacaPutVoid("/api/v1/covercalibrator/0/opencover")
: !sendAlnitak(">O000").empty();
if (!ok) return IPS_ALERT;
LOG_INFO("Opening cover…");
return IPS_BUSY;
}
// ---------------------------------------------------------------------------
// Dew heater control
// ---------------------------------------------------------------------------
void ESP32FlatPanel::setDewHeater(int pct) {
pct = std::max(0, std::min(100, pct));
bool ok;
if (m_connMode == CONN_WIFI) {
std::string body = commonPutBody() +
"&Action=SetDewHeater&Parameters=" + std::to_string(pct);
ok = alpacaPutVoid("/api/v1/covercalibrator/0/action", body);
} else {
ok = alnitakSetDewPercent(pct);
}
DewHeaterN[0].value = pct;
DewHeaterNP.s = ok ? IPS_OK : IPS_ALERT;
IDSetNumber(&DewHeaterNP, nullptr);
}
// ---------------------------------------------------------------------------
// State polling
// ---------------------------------------------------------------------------
void ESP32FlatPanel::pollState() {
int covState, calState, bright, dew;
if (m_connMode == CONN_WIFI) {
covState = alpacaGetInt("/api/v1/covercalibrator/0/coverstate", COVER_CLOSED);
calState = alpacaGetInt("/api/v1/covercalibrator/0/calibratorstate", CAL_OFF);
bright = alpacaGetInt("/api/v1/covercalibrator/0/brightness", 0);
// Dew heater via custom Alpaca action
std::string resp = httpGet("/api/v1/covercalibrator/0/action");
dew = 0;
try {
auto j = json::parse(httpGet("/api/v1/covercalibrator/0/brightness"));
// Poll dew via supportedactions action isn't a GET — just keep last known value
dew = static_cast<int>(DewHeaterN[0].value);
} catch (...) {}
} else {
covState = alnitakGetCoverState();
bright = alnitakGetBrightness();
calState = (bright > 0) ? CAL_READY : CAL_OFF;
dew = alnitakGetDewPercent();
}
// Update cover
IPState newCovState = IPS_IDLE;
if (covState == COVER_MOVING) {
newCovState = IPS_BUSY;
} else if (covState == COVER_OPEN || covState == COVER_CLOSED) {
if (ParkCapSP.s == IPS_BUSY) {
LOG_INFO(covState == COVER_OPEN ? "Cover fully open." : "Cover fully closed.");
}
newCovState = IPS_OK;
ParkCapSP[CAP_PARK].s = (covState == COVER_CLOSED) ? ISS_ON : ISS_OFF;
ParkCapSP[CAP_UNPARK].s = (covState == COVER_OPEN) ? ISS_ON : ISS_OFF;
} else {
newCovState = IPS_ALERT;
}
if (newCovState != ParkCapSP.s) {
ParkCapSP.s = newCovState;
IDSetSwitch(&ParkCapSP, nullptr);
}
// Update brightness
LightIntensityN[0].value = bright;
LightIntensityNP.s = (calState == CAL_READY) ? IPS_OK : IPS_IDLE;
IDSetNumber(&LightIntensityNP, nullptr);
LightSP[FLAT_LIGHT_ON].s = (calState == CAL_READY) ? ISS_ON : ISS_OFF;
LightSP[FLAT_LIGHT_OFF].s = (calState != CAL_READY) ? ISS_ON : ISS_OFF;
LightSP.s = IPS_OK;
IDSetSwitch(&LightSP, nullptr);
// Update dew heater
if (dew >= 0) {
DewHeaterN[0].value = dew;
DewHeaterNP.s = IPS_OK;
IDSetNumber(&DewHeaterNP, nullptr);
}
}
// ---------------------------------------------------------------------------
// USB / Alnitak serial helpers
// ---------------------------------------------------------------------------
bool ESP32FlatPanel::openSerial(const std::string& device) {
m_serialFd = open(device.c_str(), O_RDWR | O_NOCTTY | O_NONBLOCK);
if (m_serialFd < 0) return false;
struct termios tty{};
tcgetattr(m_serialFd, &tty);
cfsetspeed(&tty, B9600); // standard Alnitak / N.I.N.A. baud rate
tty.c_cflag = CS8 | CREAD | CLOCAL;
tty.c_iflag = IGNBRK | IGNPAR;
tty.c_oflag = 0;
tty.c_lflag = 0;
tty.c_cc[VMIN] = 0;
tty.c_cc[VTIME] = 20; // 2 second read timeout (units of 0.1 s)
tcsetattr(m_serialFd, TCSANOW, &tty);
tcflush(m_serialFd, TCIOFLUSH);
return true;
}
void ESP32FlatPanel::closeSerial() {
if (m_serialFd >= 0) {
close(m_serialFd);
m_serialFd = -1;
}
}
std::string ESP32FlatPanel::sendAlnitak(const std::string& cmd) {
if (m_serialFd < 0) return {};
std::string full = cmd + "\r";
if (write(m_serialFd, full.c_str(), full.size()) < 0) return {};
// Read response up to '\r', with 2-second timeout
char buf[32];
int pos = 0;
char c;
unsigned long deadline_ms = 2000;
struct timeval tv;
fd_set readset;
auto remaining = [&]() -> struct timeval {
return {static_cast<time_t>(deadline_ms / 1000),
static_cast<suseconds_t>((deadline_ms % 1000) * 1000)};
};
while (true) {
FD_ZERO(&readset);
FD_SET(m_serialFd, &readset);
tv = remaining();
int ret = select(m_serialFd + 1, &readset, nullptr, nullptr, &tv);
if (ret <= 0) break;
if (read(m_serialFd, &c, 1) != 1) break;
if (c == '\r' || c == '\n') {
if (pos > 0) break;
} else if (pos < 30) {
buf[pos++] = c;
}
}
buf[pos] = '\0';
return std::string(buf);
}
int ESP32FlatPanel::alnitakGetBrightness() {
std::string r = sendAlnitak(">J000");
if (r.size() < 5 || r[0] != '*' || r[1] != 'J') return 0;
// *JDD BBB (DD = device id, BBB = brightness)
int devId = 0, bright = 0;
sscanf(r.c_str() + 2, "%2d%3d", &devId, &bright);
return bright;
}
bool ESP32FlatPanel::alnitakSetBrightness(int b) {
char cmd[8];
snprintf(cmd, sizeof(cmd), ">B%03d", b);
std::string r = sendAlnitak(cmd);
return !r.empty() && r[0] == '*' && r[1] == 'B';
}
int ESP32FlatPanel::alnitakGetCoverState() {
// Returns Alpaca CoverStatus int
std::string r = sendAlnitak(">S000");
if (r.size() < 7 || r[0] != '*' || r[1] != 'S') return COVER_CLOSED;
int devId = 0, motorState = 0, lightState = 0, extra = 0;
sscanf(r.c_str() + 2, "%2d%1d%1d%2d", &devId, &motorState, &lightState, &extra);
// Alnitak motor: 0=unknown 1=Closed 2=Open
switch (motorState) {
case 2: return COVER_OPEN;
case 1: return COVER_CLOSED;
default: return COVER_MOVING;
}
}
int ESP32FlatPanel::alnitakGetDewPercent() {
// Custom command >U000 → *UDDppp
std::string r = sendAlnitak(">U000");
if (r.size() < 5 || r[0] != '*' || r[1] != 'U') return -1;
int devId = 0, pct = 0;
sscanf(r.c_str() + 2, "%2d%3d", &devId, &pct);
return pct;
}
bool ESP32FlatPanel::alnitakSetDewPercent(int pct) {
char cmd[8];
snprintf(cmd, sizeof(cmd), ">T%03d", pct);
std::string r = sendAlnitak(cmd);
return !r.empty() && r[0] == '*' && r[1] == 'T';
}
// ---------------------------------------------------------------------------
// HTTP / Alpaca helpers (WiFi mode)
// ---------------------------------------------------------------------------
size_t ESP32FlatPanel::writeCallback(void* ptr, size_t size, size_t nmemb, std::string* out) {
out->append(static_cast<char*>(ptr), size * nmemb);
return size * nmemb;
}
std::string ESP32FlatPanel::alpacaURL(const std::string& endpoint) const {
return "http://" + m_host + ":" + std::to_string(m_port) + endpoint;
}
std::string ESP32FlatPanel::commonPutBody() {
return "ClientID=1&ClientTransactionID=" + std::to_string(++m_txID);
}
std::string ESP32FlatPanel::httpGet(const std::string& endpoint) {
std::string url = alpacaURL(endpoint) +
"?ClientID=1&ClientTransactionID=" + std::to_string(++m_txID);
std::string response;
CURL* curl = curl_easy_init();
if (!curl) return {};
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, &ESP32FlatPanel::writeCallback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 5L);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 3L);
curl_easy_perform(curl);
curl_easy_cleanup(curl);
return response;
}
bool ESP32FlatPanel::httpPut(const std::string& endpoint, const std::string& body) {
std::string url = alpacaURL(endpoint);
std::string response;
CURL* curl = curl_easy_init();
if (!curl) return false;
curl_slist* headers = curl_slist_append(nullptr,
"Content-Type: application/x-www-form-urlencoded");
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "PUT");
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str());
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, &ESP32FlatPanel::writeCallback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 5L);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 3L);
CURLcode res = curl_easy_perform(curl);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
if (res != CURLE_OK) return false;
try {
return json::parse(response).value("ErrorNumber", -1) == 0;
} catch (...) {
return false;
}
}
int ESP32FlatPanel::alpacaGetInt(const std::string& endpoint, int defaultVal) {
std::string resp = httpGet(endpoint);
if (resp.empty()) return defaultVal;
try {
auto j = json::parse(resp);
if (j.value("ErrorNumber", -1) != 0) return defaultVal;
return j.value("Value", defaultVal);
} catch (...) {
return defaultVal;
}
}
bool ESP32FlatPanel::alpacaGetBool(const std::string& endpoint, bool defaultVal) {
std::string resp = httpGet(endpoint);
if (resp.empty()) return defaultVal;
try {
auto j = json::parse(resp);
if (j.value("ErrorNumber", -1) != 0) return defaultVal;
return j.value("Value", defaultVal);
} catch (...) {
return defaultVal;
}
}
bool ESP32FlatPanel::alpacaPutVoid(const std::string& endpoint, const std::string& extraBody) {
std::string body = commonPutBody();
if (!extraBody.empty()) body += "&" + extraBody;
return httpPut(endpoint, body);
}