#include "indi_esp32_flatpanel.h" #include #include #include // POSIX serial port (USB/Alnitak mode) #include #include #include #include #include #include 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 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(); 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(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(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(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(value)); return alpacaPutVoid("/api/v1/covercalibrator/0/calibratoron", body); } return alnitakSetBrightness(static_cast(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(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(deadline_ms / 1000), static_cast((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(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); }