From f7475cdba6e9af0c80037ded2d5ee853fa112c53 Mon Sep 17 00:00:00 2001 From: Michel Moriniaux Date: Wed, 22 Jul 2026 17:53:05 -0700 Subject: [PATCH 1/4] Initial commit of Alpaca server --- SmartWebServer.ino | 30 +- src/Config.defaults.h | 7 + src/lib/ethernet/webServer/WebServer.cpp | 36 +- src/libApp/alpaca/Alpaca.cpp | 147 +++++ src/libApp/alpaca/Alpaca.h | 42 ++ src/libApp/alpaca/AlpacaCommon.cpp | 63 ++ src/libApp/alpaca/AlpacaCommon.h | 25 + src/libApp/alpaca/AlpacaDiscovery.cpp | 50 ++ src/libApp/alpaca/AlpacaDiscovery.h | 27 + src/libApp/alpaca/AlpacaError.h | 20 + src/libApp/alpaca/AlpacaJson.cpp | 87 +++ src/libApp/alpaca/AlpacaJson.h | 46 ++ src/libApp/alpaca/AlpacaManagement.cpp | 54 ++ src/libApp/alpaca/AlpacaManagement.h | 16 + .../alpaca/AlpacaObservingConditions.cpp | 156 +++++ src/libApp/alpaca/AlpacaObservingConditions.h | 18 + src/libApp/alpaca/AlpacaTelescope.cpp | 569 ++++++++++++++++++ src/libApp/alpaca/AlpacaTelescope.h | 18 + 18 files changed, 1402 insertions(+), 9 deletions(-) create mode 100644 src/libApp/alpaca/Alpaca.cpp create mode 100644 src/libApp/alpaca/Alpaca.h create mode 100644 src/libApp/alpaca/AlpacaCommon.cpp create mode 100644 src/libApp/alpaca/AlpacaCommon.h create mode 100644 src/libApp/alpaca/AlpacaDiscovery.cpp create mode 100644 src/libApp/alpaca/AlpacaDiscovery.h create mode 100644 src/libApp/alpaca/AlpacaError.h create mode 100644 src/libApp/alpaca/AlpacaJson.cpp create mode 100644 src/libApp/alpaca/AlpacaJson.h create mode 100644 src/libApp/alpaca/AlpacaManagement.cpp create mode 100644 src/libApp/alpaca/AlpacaManagement.h create mode 100644 src/libApp/alpaca/AlpacaObservingConditions.cpp create mode 100644 src/libApp/alpaca/AlpacaObservingConditions.h create mode 100644 src/libApp/alpaca/AlpacaTelescope.cpp create mode 100644 src/libApp/alpaca/AlpacaTelescope.h diff --git a/SmartWebServer.ino b/SmartWebServer.ino index 3c9f0a4..144857a 100644 --- a/SmartWebServer.ino +++ b/SmartWebServer.ino @@ -63,6 +63,9 @@ bool otaEnabled = false; #include "src/pages/Pages.h" +#include "src/libApp/alpaca/Alpaca.h" +#include "src/libApp/alpaca/AlpacaDiscovery.h" + #if DEBUG == PROFILER extern void profiler(); #endif @@ -83,6 +86,12 @@ void pollWebSvr() { www.handleClient(); } +#if ALPACA_SERVER == ON + void pollAlpacaDiscovery() { + alpacaDiscovery.poll(); + } +#endif + void pollCmdSvr() { if (otaEnabled) return; @@ -310,8 +319,12 @@ Again: www.on("/net.htm", handleNetwork); www.on("/", handleRoot); - - www.onNotFound(handleNotFound); + + #if ALPACA_SERVER == ON + www.onNotFound(alpacaOrNotFound); + #else + www.onNotFound(handleNotFound); + #endif #ifdef OTA_PRESENT OtaSettings otaSettings = {"", false}; @@ -360,6 +373,13 @@ Again: VLF("MSG: Starting port 80 web server"); www.begin(); + #if ALPACA_SERVER == ON + VLF("MSG: Starting ASCOM Alpaca server"); + alpaca.init(); + VF("MSG: Starting ASCOM Alpaca UDP discovery on port "); VL((long)ALPACA_DISCOVERY_PORT); + alpacaDiscovery.init(); + #endif + // allow time for the background servers to come up delay(2000); @@ -381,6 +401,12 @@ Again: VF(" task (rate 20ms priority 3)... "); if (tasks.add(20, 0, true, 3, pollWebSvr, "webPoll")) { VL("success"); } else { VL("FAILED!"); } + #if ALPACA_SERVER == ON + VF("MSG: Setup, starting Alpaca discovery polling"); + VF(" task (rate 200ms priority 6)... "); + if (tasks.add(200, 0, true, 6, pollAlpacaDiscovery, "alpcDsc")) { VL("success"); } else { VL("FAILED!"); } + #endif + // start task manager debug events #if DEBUG == PROFILER tasks.add(142, 0, true, 7, profiler, "Profilr"); diff --git a/src/Config.defaults.h b/src/Config.defaults.h index ea52205..40ff7fb 100644 --- a/src/Config.defaults.h +++ b/src/Config.defaults.h @@ -64,6 +64,13 @@ #define COMMAND_SERVER BOTH // BOTH, for STANDARD (port 9999) and PERSISTENT (ports 9996 to 9998) #endif // or disable with OFF +#ifndef ALPACA_SERVER +#define ALPACA_SERVER OFF // OFF, ON to enable the ASCOM Alpaca REST/JSON device server + discovery +#endif +#ifndef ALPACA_DISCOVERY_PORT +#define ALPACA_DISCOVERY_PORT 32227 // standard ASCOM Alpaca UDP discovery port, don't change unless you must +#endif + #ifndef STA_AP_FALLBACK #define STA_AP_FALLBACK true // activate SoftAP if station fails to connect #endif diff --git a/src/lib/ethernet/webServer/WebServer.cpp b/src/lib/ethernet/webServer/WebServer.cpp index 25f5f6c..f713e82 100644 --- a/src/lib/ethernet/webServer/WebServer.cpp +++ b/src/lib/ethernet/webServer/WebServer.cpp @@ -53,13 +53,27 @@ // scan the header if (currentSection == 1) { if (!modifiedSinceFound && line.indexOf("If-Modified-Since:") >= 0) modifiedSinceFound = true; + // some HTTP/1.1 clients (e.g. .NET's HttpClient, used by ASCOM Conform) send + // "Expect: 100-continue" on PUT/POST requests with a body and then wait (~1s by + // default) for this interim response before actually sending the body -- well past + // WEB_SOCKET_TIMEOUT (250ms) below, which covers reading the *whole* request. Left + // unanswered, this server gives up and responds before the body ever arrives, then + // the body lands on an already-closed socket -- seen as "response ended prematurely" + // by the client. Replying immediately makes the client send the body right away. + if (line.indexOf("Expect:") >= 0 && line.indexOf("100-continue") >= 0) { + client.print(F("HTTP/1.1 100 Continue\r\n\r\n")); + } if (lastMethod == HTTP_UNKNOWN) { int index = line.indexOf("GET "); if (index >= 0) { lastMethod = HTTP_GET; line = line.substring(index + 4); handler_number = getHandler(&line); - if (handler_number >= 0) processGet(&line); + // parse query-string arguments regardless of whether an exact-match route was + // found -- www.on() only covers a small fixed table of routes, but a handler + // reached via onNotFound() (e.g. the Alpaca API's dynamic paths) still needs + // www.args()/arg()/argName() to work + processGet(&line); break; } else { index = line.indexOf("POST "); @@ -73,15 +87,18 @@ lastMethod = HTTP_PUT; line = line.substring(index + 4); handler_number = getHandler(&line); - if (handler_number >= 0) processPut(&line); + // see the processGet() comment above -- same reasoning applies here + processPut(&line); } } } } } else - // scan the request - if (currentSection == 2 && handler_number >= 0) { + // scan the request body (see the processGet() comment above: parsed regardless of + // whether an exact-match route was found, so onNotFound()-reached handlers still see + // PUT/POST form arguments) + if (currentSection == 2) { if (lastMethod == HTTP_PUT) processPut(&line); else if (lastMethod == HTTP_POST) processPost(&line); } @@ -306,12 +323,17 @@ void WebServer::send(int code, const char* content_type, const String& content) { header = "HTTP/1.1 " + String(code) + " OK\r\n" + header; header += "Content-Type: "; header += content_type; header += "\r\n"; - if (length == CONTENT_LENGTH_UNKNOWN) { - header += "Connection: close\r\n"; - } else { + if (length != CONTENT_LENGTH_UNKNOWN) { if (length == CONTENT_LENGTH_NOT_SET) length = content.length(); header += "content-length: " + String(length) + "\r\n"; } + // handleClient() unconditionally calls client.stop() after every request (this server + // never keeps a connection open across requests), so every response must say so -- omitting + // this let HTTP/1.1 clients that default to keep-alive (e.g. .NET's HttpClient, used by + // ASCOM Conform) assume the socket stays open and try to reuse it for their next request, + // landing on an already-closed connection roughly every other call ("An error occurred + // while sending the request" / "the response ended prematurely" on the client side). + header += "Connection: close\r\n"; header += "\r\n"; client.print(header); diff --git a/src/libApp/alpaca/Alpaca.cpp b/src/libApp/alpaca/Alpaca.cpp new file mode 100644 index 0000000..374b431 --- /dev/null +++ b/src/libApp/alpaca/Alpaca.cpp @@ -0,0 +1,147 @@ +// ----------------------------------------------------------------------------------- +// ASCOM Alpaca REST/JSON device server +#include "Alpaca.h" + +#if ALPACA_SERVER == ON + +#include "../../lib/ethernet/webServer/WebServer.h" +#include "../../lib/wifi/webServer/WebServer.h" + +#include "AlpacaError.h" +#include "AlpacaManagement.h" +#include "AlpacaTelescope.h" +#include "AlpacaObservingConditions.h" + +void handleNotFound(); + +#define ALPACA_MAX_SEGMENTS 6 + +bool alpacaArg(const char *name, String &out) { + int n = www.args(); + for (int i = 0; i < n; i++) { + if (www.argName(i).equalsIgnoreCase(name)) { out = www.arg(i); return true; } + } + return false; +} + +bool alpacaArgBool(const char *name, bool defaultValue) { + String v; + if (!alpacaArg(name, v)) return defaultValue; + return v.equalsIgnoreCase("true") || v.equals("1"); +} + +bool alpacaArgDouble(const char *name, double &out) { + String v; + if (!alpacaArg(name, v)) return false; + out = atof(v.c_str()); + return true; +} + +uint32_t alpacaArgUint32(const char *name, uint32_t defaultValue) { + String v; + if (!alpacaArg(name, v)) return defaultValue; + long l = v.toInt(); + // don't coerce a negative (invalid) value to defaultValue -- when defaultValue is itself a + // valid enum member (e.g. TrackingRate's 0 = sidereal), that silently accepted bad input + // instead of rejecting it (confirmed via ASCOM Conform: "No error generated when TrackingRate + // is set to an invalid value (-1)"). Casting to unsigned instead wraps negative values to a + // huge number that every caller's existing range/equality check already rejects correctly. + return (uint32_t)l; +} + +// split a URI path into up to ALPACA_MAX_SEGMENTS lower-cased, non-empty '/'-separated parts +static int splitPath(const String &uri, String segments[]) { + int count = 0; + int start = 0; + int len = uri.length(); + while (start <= len && count < ALPACA_MAX_SEGMENTS) { + int slash = uri.indexOf('/', start); + if (slash == -1) slash = len; + if (slash > start) { + segments[count] = uri.substring(start, slash); + segments[count].toLowerCase(); + count++; + } + start = slash + 1; + } + return count; +} + +void Alpaca::init() { + // nothing to do yet: management/device tables are computed live from status.* +} + +void Alpaca::handleManagementRequest(String segments[], int count, AlpacaResponse &resp) { + // segments[0] == "management" + if (count == 2 && segments[1] == "apiversions") { alpacaManagementApiVersions(resp); return; } + if (count == 3 && segments[1] == "v1" && segments[2] == "description") { alpacaManagementDescription(resp); return; } + if (count == 3 && segments[1] == "v1" && segments[2] == "configureddevices") { alpacaManagementConfiguredDevices(resp); return; } + + resp.setError(ALPACA_ERR_NOT_IMPLEMENTED, "Unrecognized management endpoint"); +} + +void Alpaca::handleApiRequest(String segments[], int count, AlpacaResponse &resp) { + // segments: "api", "v1", {devicetype}, {devicenumber}, {method} + if (count != 5 || segments[1] != "v1") { + resp.setError(ALPACA_ERR_NOT_IMPLEMENTED, "Malformed Alpaca device API request"); + return; + } + + const String &deviceType = segments[2]; + int deviceNumber = segments[3].toInt(); + const String &method = segments[4]; + int httpMethod = www.method(); + + if (deviceType == "telescope") { + alpacaTelescope.handleRequest(deviceNumber, method, httpMethod, resp); + return; + } + + if (deviceType == "observingconditions") { + alpacaObservingConditions.handleRequest(deviceNumber, method, httpMethod, resp); + return; + } + + resp.setError(ALPACA_ERR_NOT_IMPLEMENTED, "Unknown or unconfigured Alpaca device type"); +} + +void Alpaca::handleRequest() { + String uri = www.uri(); + + String segments[ALPACA_MAX_SEGMENTS]; + int count = splitPath(uri, segments); + + uint32_t clientTransactionId = alpacaArgUint32("ClientTransactionID", 0); + + AlpacaResponse resp; + resp.begin(clientTransactionId); + + if (count == 0) { + resp.setError(ALPACA_ERR_NOT_IMPLEMENTED, "Empty Alpaca request"); + } else if (segments[0] == "management") { + handleManagementRequest(segments, count, resp); + } else if (segments[0] == "api") { + handleApiRequest(segments, count, resp); + } else { + resp.setError(ALPACA_ERR_NOT_IMPLEMENTED, "Not an Alpaca request"); + } + + // explicit reset (rather than relying on a prior www.send() call somewhere else having + // already left this in the right state) since an Alpaca client -- unlike a browser -- may + // well make the very first HTTP request this device sees after boot + www.setContentLength(CONTENT_LENGTH_NOT_SET); + www.send(200, "application/json", resp.toJson()); +} + +Alpaca alpaca; + +void alpacaOrNotFound() { + String uri = www.uri(); + if (uri.startsWith("/api/") || uri.startsWith("/management/") || uri.equals("/management")) { + alpaca.handleRequest(); + } else { + handleNotFound(); + } +} + +#endif diff --git a/src/libApp/alpaca/Alpaca.h b/src/libApp/alpaca/Alpaca.h new file mode 100644 index 0000000..d072e4a --- /dev/null +++ b/src/libApp/alpaca/Alpaca.h @@ -0,0 +1,42 @@ +// ----------------------------------------------------------------------------------- +// ASCOM Alpaca REST/JSON device server +// +// Dispatched from the existing www.onNotFound() handler (see SmartWebServer.ino) since +// Alpaca's /api/v1/{devicetype}/{devicenumber}/{method} URLs are dynamic and the shared +// WebServer wrapper (src/lib/wifi/webServer, src/lib/ethernet/webServer) only supports a +// small fixed table of exact-match routes. +#pragma once + +#include "../../Common.h" + +#if ALPACA_SERVER == ON + +#include "AlpacaJson.h" + +// case-insensitive request parameter lookup (Alpaca requires parameter name matching to +// be case-insensitive; real client libraries are inconsistent about e.g. ClientID vs +// clientid) -- built only on the cross-platform www.args()/argName()/arg() accessors. +bool alpacaArg(const char *name, String &out); +bool alpacaArgBool(const char *name, bool defaultValue = false); +bool alpacaArgDouble(const char *name, double &out); +uint32_t alpacaArgUint32(const char *name, uint32_t defaultValue = 0); + +class Alpaca { + public: + void init(); + + // entry point for any request under /api/ or /management/ + void handleRequest(); + + private: + void handleManagementRequest(String segments[], int count, AlpacaResponse &resp); + void handleApiRequest(String segments[], int count, AlpacaResponse &resp); +}; + +extern Alpaca alpaca; + +// registered as the www onNotFound handler when ALPACA_SERVER == ON; routes /api/ and +// /management/ requests to Alpaca, everything else falls through to the normal 404 page +void alpacaOrNotFound(); + +#endif diff --git a/src/libApp/alpaca/AlpacaCommon.cpp b/src/libApp/alpaca/AlpacaCommon.cpp new file mode 100644 index 0000000..ab69941 --- /dev/null +++ b/src/libApp/alpaca/AlpacaCommon.cpp @@ -0,0 +1,63 @@ +// ----------------------------------------------------------------------------------- +// Shared ASCOM "common device" members +#include "AlpacaCommon.h" + +#if ALPACA_SERVER == ON + +#include "Alpaca.h" +#include "AlpacaError.h" +#include "../status/Version.h" +#include "../../lib/ethernet/webServer/WebServer.h" +#include "../../lib/wifi/webServer/WebServer.h" + +bool alpacaHandleCommonMember(const String &member, int httpMethod, AlpacaResponse &resp, + const char *name, const char *description, int interfaceVersion, + bool connected, String (*deviceStateJson)()) { + + if (member == "connected") { + if (httpMethod == HTTP_GET) { + resp.setValue(jsonBool(connected)); + } else { + // accepted but a no-op: this device is a shared always-on gateway to a single OnStep + // controller, one Alpaca client disconnecting must not affect the web UI, LX200 + // command ports, or any other concurrent Alpaca client + String v; + if (!alpacaArg("Connected", v)) resp.setError(ALPACA_ERR_INVALID_VALUE, "Connected value missing"); + } + return true; + } + + if (member == "connecting") { + resp.setValue(jsonBool(false)); + return true; + } + + if (member == "connect" || member == "disconnect") { + // async connect/disconnect (ITelescopeV4 etc.) -- resolves immediately, see "connected" above + return true; + } + + if (member == "name") { resp.setValue(jsonString(name)); return true; } + if (member == "description") { resp.setValue(jsonString(description)); return true; } + if (member == "driverinfo") { resp.setValue(jsonString("SmartWebServer ASCOM Alpaca gateway for OnStep/OnStepX")); return true; } + if (member == "driverversion") { resp.setValue(jsonString(firmwareVersion.str)); return true; } + if (member == "interfaceversion") { resp.setValue(jsonInt(interfaceVersion)); return true; } + if (member == "supportedactions") { resp.setValue("[]"); return true; } + + if (member == "action" || member == "commandblind" || member == "commandbool" || member == "commandstring") { + resp.setError(ALPACA_ERR_ACTION_NOT_IMPLEMENTED, "Not implemented"); + return true; + } + + if (member == "devicestate") { + String out = "["; + out += deviceStateJson(); + out += "]"; + resp.setValue(out); + return true; + } + + return false; +} + +#endif diff --git a/src/libApp/alpaca/AlpacaCommon.h b/src/libApp/alpaca/AlpacaCommon.h new file mode 100644 index 0000000..d7f7e9d --- /dev/null +++ b/src/libApp/alpaca/AlpacaCommon.h @@ -0,0 +1,25 @@ +// ----------------------------------------------------------------------------------- +// Shared ASCOM "common device" members (present, with identical semantics, on every +// Alpaca interface: Connected/Connecting/Connect()/Disconnect(), Name, Description, +// DriverInfo, DriverVersion, InterfaceVersion, SupportedActions/Action/Command*, and the +// DeviceState property (the IStateValue mechanism -- an array of {Name,Value} pairs)). +#pragma once + +#include "../../Common.h" + +#if ALPACA_SERVER == ON + +#include "AlpacaJson.h" + +// Attempts to handle `member` as one of the common members above. +// Returns true if it was recognized and `resp` has been fully populated -- the caller +// should stop. Returns false if `member` isn't a common member, so the caller should +// continue checking its own device-specific member list. +// +// `deviceStateJson` supplies the device-specific DeviceState entries as already-valid, +// comma-joined {"Name":...,"Value":...} JSON object fragments (no wrapping brackets). +bool alpacaHandleCommonMember(const String &member, int httpMethod, AlpacaResponse &resp, + const char *name, const char *description, int interfaceVersion, + bool connected, String (*deviceStateJson)()); + +#endif diff --git a/src/libApp/alpaca/AlpacaDiscovery.cpp b/src/libApp/alpaca/AlpacaDiscovery.cpp new file mode 100644 index 0000000..2080626 --- /dev/null +++ b/src/libApp/alpaca/AlpacaDiscovery.cpp @@ -0,0 +1,50 @@ +// ----------------------------------------------------------------------------------- +// ASCOM Alpaca UDP discovery responder +#include "AlpacaDiscovery.h" + +#if ALPACA_SERVER == ON + +#if OPERATIONAL_MODE == WIFI + #include + WiFiUDP alpacaUdp; +#elif OPERATIONAL_MODE == ETHERNET_TEENSY41 + #include + EthernetUDP alpacaUdp; +#else + #include + EthernetUDP alpacaUdp; +#endif + +// the ASCOM Alpaca spec fixes this exact request string and JSON reply shape +#define ALPACA_DISCOVERY_REQUEST "alpacadiscovery1" + +// the port src/lib/wifi/webServer + src/lib/ethernet/webServer listen HTTP on (SmartWebServer.ino: www.begin()) +#define ALPACA_HTTP_PORT 80 + +void AlpacaDiscovery::init() { + active = alpacaUdp.begin(ALPACA_DISCOVERY_PORT); +} + +void AlpacaDiscovery::poll() { + if (!active) return; + + int size = alpacaUdp.parsePacket(); + if (size <= 0) return; + + char buf[32]; + int len = alpacaUdp.read(buf, sizeof(buf) - 1); + if (len < 0) len = 0; + buf[len] = 0; + + if (len == (int)strlen(ALPACA_DISCOVERY_REQUEST) && strcmp(buf, ALPACA_DISCOVERY_REQUEST) == 0) { + char reply[24]; + snprintf(reply, sizeof(reply), "{\"AlpacaPort\":%d}", ALPACA_HTTP_PORT); + alpacaUdp.beginPacket(alpacaUdp.remoteIP(), alpacaUdp.remotePort()); + alpacaUdp.write((const uint8_t *)reply, strlen(reply)); + alpacaUdp.endPacket(); + } +} + +AlpacaDiscovery alpacaDiscovery; + +#endif diff --git a/src/libApp/alpaca/AlpacaDiscovery.h b/src/libApp/alpaca/AlpacaDiscovery.h new file mode 100644 index 0000000..0cf4dfa --- /dev/null +++ b/src/libApp/alpaca/AlpacaDiscovery.h @@ -0,0 +1,27 @@ +// ----------------------------------------------------------------------------------- +// ASCOM Alpaca UDP discovery responder (port ALPACA_DISCOVERY_PORT, default 32227) +// +// A single file with a WiFiUDP/EthernetUDP conditional include, following the exact +// pattern already used for NTP (src/lib/tls/ntp/NTP.h) -- unlike WebServer/CmdServer +// (which have substantially different WiFi vs. Ethernet implementations warranting +// separate files per src/lib/wifi/* + src/lib/ethernet/*), the discovery responder's +// logic is identical either way; only the UDP class differs, and WiFiUDP/EthernetUDP +// share the same API. +#pragma once + +#include "../../Common.h" + +#if ALPACA_SERVER == ON + +class AlpacaDiscovery { + public: + void init(); + void poll(); + + private: + bool active = false; +}; + +extern AlpacaDiscovery alpacaDiscovery; + +#endif diff --git a/src/libApp/alpaca/AlpacaError.h b/src/libApp/alpaca/AlpacaError.h new file mode 100644 index 0000000..87a7761 --- /dev/null +++ b/src/libApp/alpaca/AlpacaError.h @@ -0,0 +1,20 @@ +// ----------------------------------------------------------------------------------- +// ASCOM Alpaca standard error/exception numbers ("Exception") +// see https://ascom-standards.org/newdocs/alpaca-errors.html +#pragma once + +#include "../../Common.h" + +#define ALPACA_OK 0x0 +#define ALPACA_ERR_NOT_IMPLEMENTED 0x400 +#define ALPACA_ERR_INVALID_VALUE 0x401 +#define ALPACA_ERR_VALUE_NOT_SET 0x402 +#define ALPACA_ERR_NOT_CONNECTED 0x407 +#define ALPACA_ERR_PARKED 0x408 +#define ALPACA_ERR_SLAVED 0x409 +#define ALPACA_ERR_INVALID_OPERATION 0x40B +#define ALPACA_ERR_ACTION_NOT_IMPLEMENTED 0x40C + +// driver-specific error range, add a small offset (0..0x7FF) for our own conditions +#define ALPACA_ERR_DRIVER_BASE 0x500 +#define ALPACA_ERR_DRIVER_COMMS_FAILURE (ALPACA_ERR_DRIVER_BASE + 1) // OnStep serial command failed/timed out diff --git a/src/libApp/alpaca/AlpacaJson.cpp b/src/libApp/alpaca/AlpacaJson.cpp new file mode 100644 index 0000000..8e7c928 --- /dev/null +++ b/src/libApp/alpaca/AlpacaJson.cpp @@ -0,0 +1,87 @@ +// ----------------------------------------------------------------------------------- +// Minimal hand-rolled JSON helpers for the Alpaca REST API responses. +#include "AlpacaJson.h" + +String jsonEscape(const String &s) { + String out; + out.reserve(s.length() + 4); + for (size_t i = 0; i < s.length(); i++) { + char c = s[i]; + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if ((uint8_t)c < 0x20) { char b[7]; snprintf(b, sizeof(b), "\\u%04x", c); out += b; } + else out += c; + } + } + return out; +} + +String jsonBool(bool v) { + return v ? "true" : "false"; +} + +String jsonNumber(double v, uint8_t decimals) { + if (isnan(v) || isinf(v)) return "null"; + char buf[32]; + dtostrf(v, 0, decimals, buf); + return String(buf); +} + +String jsonInt(long v) { + return String(v); +} + +String jsonString(const String &v) { + String out = "\""; + out += jsonEscape(v); + out += "\""; + return out; +} + +void jsonStateValueEntry(String &out, bool &first, const char *name, const String &valueJson) { + if (!first) out += ","; + out += "{\"Name\":\"" + String(name) + "\",\"Value\":" + valueJson + "}"; + first = false; +} + +// server-wide monotonically increasing transaction id, per the Alpaca spec +static uint32_t serverTransactionCounter = 0; + +void AlpacaResponse::begin(uint32_t clientTransactionId) { + this->clientTransactionId = clientTransactionId; + serverTransactionCounter++; + value = ""; + hasValue = false; + errorNumber = 0; + errorMessage = ""; +} + +void AlpacaResponse::setValue(const String &jsonValueFragment) { + value = jsonValueFragment; + hasValue = true; +} + +void AlpacaResponse::setError(int errorNumber, const String &message) { + this->errorNumber = errorNumber; + this->errorMessage = message; +} + +String AlpacaResponse::toJson() { + String out = "{"; + if (hasValue && errorNumber == 0) { + out += "\"Value\":"; + out += value; + out += ","; + } + out += "\"ClientTransactionID\":" + String(clientTransactionId) + ","; + out += "\"ServerTransactionID\":" + String(serverTransactionCounter) + ","; + out += "\"ErrorNumber\":" + String(errorNumber) + ","; + out += "\"ErrorMessage\":" + jsonString(errorMessage); + out += "}"; + return out; +} diff --git a/src/libApp/alpaca/AlpacaJson.h b/src/libApp/alpaca/AlpacaJson.h new file mode 100644 index 0000000..cb55ffc --- /dev/null +++ b/src/libApp/alpaca/AlpacaJson.h @@ -0,0 +1,46 @@ +// ----------------------------------------------------------------------------------- +// Minimal hand-rolled JSON helpers for the Alpaca REST API responses. +// No parsing is needed: Alpaca requests are query-string/form-encoded, only +// responses are JSON. Kept deliberately small to match the codebase's existing +// manual string-building style (see src/pages/KeyValue.cpp) instead of pulling +// in a JSON library. +#pragma once + +#include "../../Common.h" + +// escape a string for safe embedding inside JSON double-quotes +String jsonEscape(const String &s); + +// JSON value fragment builders (no wrapping key, caller assembles objects/arrays) +String jsonBool(bool v); +String jsonNumber(double v, uint8_t decimals = 6); +String jsonInt(long v); +String jsonString(const String &v); + +// appends one {"Name":"","Value":} entry (comma-separated) to an array +// under construction -- the IStateValue shape used by every device's DeviceState property. +// `first` tracks whether a leading comma is needed; caller wraps the result in "[" ... "]". +void jsonStateValueEntry(String &out, bool &first, const char *name, const String &valueJson); + +// builds the standard Alpaca response envelope: +// {"Value":,"ClientTransactionID":N,"ServerTransactionID":N,"ErrorNumber":N,"ErrorMessage":"..."} +// "Value" is present only when setValue() was called and no error was set (methods that +// return void, and property/method calls that errored, omit it entirely per the Alpaca spec). +class AlpacaResponse { + public: + void begin(uint32_t clientTransactionId); + + // value must already be a valid JSON fragment (number/bool/string/array/object) + void setValue(const String &jsonValueFragment); + + void setError(int errorNumber, const String &message); + + String toJson(); + + private: + String value; + bool hasValue = false; + uint32_t clientTransactionId = 0; + int errorNumber = 0; + String errorMessage; +}; diff --git a/src/libApp/alpaca/AlpacaManagement.cpp b/src/libApp/alpaca/AlpacaManagement.cpp new file mode 100644 index 0000000..039438a --- /dev/null +++ b/src/libApp/alpaca/AlpacaManagement.cpp @@ -0,0 +1,54 @@ +// ----------------------------------------------------------------------------------- +// Alpaca Management API +#include "AlpacaManagement.h" + +#if ALPACA_SERVER == ON + +#include "../status/Version.h" +#include "../status/Status.h" + +void alpacaManagementApiVersions(AlpacaResponse &resp) { + resp.setValue("[1]"); +} + +void alpacaManagementDescription(AlpacaResponse &resp) { + String out = "{"; + out += "\"ServerName\":" + jsonString(HOST_NAME) + ","; + out += "\"Manufacturer\":" + jsonString("Howard Dutton") + ","; + out += "\"ManufacturerVersion\":" + jsonString(firmwareVersion.str) + ","; + out += "\"Location\":" + jsonString(""); + out += "}"; + resp.setValue(out); +} + +static void appendDevice(String &out, bool &first, const char *deviceName, const char *deviceType, int deviceNumber) { + if (!first) out += ","; + out += "{"; + out += "\"DeviceName\":" + jsonString(deviceName) + ","; + out += "\"DeviceType\":" + jsonString(deviceType) + ","; + out += "\"DeviceNumber\":" + jsonInt(deviceNumber) + ","; + out += "\"UniqueID\":" + jsonString(String(HOST_NAME) + "-" + deviceType + "-" + String(deviceNumber)); + out += "}"; + first = false; +} + +void alpacaManagementConfiguredDevices(AlpacaResponse &resp) { + String out = "["; + bool first = true; + + if (status.mountFound == SD_TRUE) { + appendDevice(out, first, "OnStep", "Telescope", 0); + } + + // advertised whenever OnStep itself is reachable, even if no physical weather sensor is + // attached -- individual sensor properties report NotImplementedException in that case + // (see AlpacaObservingConditions.cpp), which is standard/expected ASCOM behavior + if (status.onStepFound) { + appendDevice(out, first, "OnStep Weather", "ObservingConditions", 0); + } + + out += "]"; + resp.setValue(out); +} + +#endif diff --git a/src/libApp/alpaca/AlpacaManagement.h b/src/libApp/alpaca/AlpacaManagement.h new file mode 100644 index 0000000..d1eb2a7 --- /dev/null +++ b/src/libApp/alpaca/AlpacaManagement.h @@ -0,0 +1,16 @@ +// ----------------------------------------------------------------------------------- +// Alpaca Management API: /management/apiversions, /management/v1/description, +// /management/v1/configureddevices +#pragma once + +#include "../../Common.h" + +#if ALPACA_SERVER == ON + +#include "AlpacaJson.h" + +void alpacaManagementApiVersions(AlpacaResponse &resp); +void alpacaManagementDescription(AlpacaResponse &resp); +void alpacaManagementConfiguredDevices(AlpacaResponse &resp); + +#endif diff --git a/src/libApp/alpaca/AlpacaObservingConditions.cpp b/src/libApp/alpaca/AlpacaObservingConditions.cpp new file mode 100644 index 0000000..db13526 --- /dev/null +++ b/src/libApp/alpaca/AlpacaObservingConditions.cpp @@ -0,0 +1,156 @@ +// ----------------------------------------------------------------------------------- +// ASCOM Alpaca IObservingConditionsV2 implementation, gateway to OnStep via onStep.command() +#include "AlpacaObservingConditions.h" + +#if ALPACA_SERVER == ON + +#include "Alpaca.h" +#include "AlpacaCommon.h" +#include "AlpacaError.h" +#include "../cmd/Cmd.h" +#include "../status/Status.h" +#include "../../lib/ethernet/webServer/WebServer.h" +#include "../../lib/wifi/webServer/WebServer.h" + +struct ObsCondSensor { + const char *key; // lower-case: matches both the URL property name and the SensorName argument + const char *displayName; // PascalCase: used for the DeviceState JSON "Name" field + const char *cmd; // OnStep command + const char *description; // for SensorDescription() +}; + +// confirmed against OnStepX docs/COMMAND_REFERENCE.md + src/telescope/Telescope.command.cpp: +// ":GX9A#" temperature C, ":GX9B#" pressure mbar, ":GX9C#" relative humidity %, ":GX9E#" dew +// point C -- all reply "+/-n.n#" (src/libApp/weather/Weather.h). Every other ASCOM-recognized +// weather sensor (cloud cover, rain rate, sky quality/brightness/temperature, wind, star FWHM) +// has no OnStepX data source and reports NotImplementedException, the standard/expected ASCOM +// behavior for a partial ObservingConditions implementation. +static const ObsCondSensor sensors[] = { + {"temperature", "Temperature", ":GX9A#", "OnStep ambient temperature sensor (BME280/BMP280)"}, + {"pressure", "Pressure", ":GX9B#", "OnStep barometric pressure sensor (BME280/BMP280)"}, + {"humidity", "Humidity", ":GX9C#", "OnStep relative humidity sensor (BME280)"}, + {"dewpoint", "DewPoint", ":GX9E#", "OnStep calculated dew point (from temperature and humidity)"}, +}; +static const int sensorCount = sizeof(sensors) / sizeof(sensors[0]); + +// listed explicitly so they get a proper NotImplementedException instead of falling through +// to the generic "unknown member" error +static const char *unsupportedSensors[] = { + "cloudcover", "rainrate", "skybrightness", "skyquality", "skytemperature", + "starfwhm", "winddirection", "windgust", "windspeed" +}; +static const int unsupportedSensorCount = sizeof(unsupportedSensors) / sizeof(unsupportedSensors[0]); + +// returns false only on a comms failure; a successful read of "nan" -- no physical sensor +// present, see OnStepX's Weather class defaulting every reading to NAN when WS_NONE -- is +// reported back to the caller via isnan(out), not as a failure here +static bool cmdGetSensor(const char *cmd, double &out) { + char temp[80]; + if (!onStep.command(cmd, temp)) return false; + out = atof(temp); + return true; +} + +static int findSensor(const String &key) { + for (int i = 0; i < sensorCount; i++) if (key == sensors[i].key) return i; + return -1; +} + +// per the ASCOM spec, a sensor's Value, SensorDescription, and TimeSinceLastUpdate must be +// consistently all-implemented or all-not-implemented (confirmed via ASCOM Conform: on this rig, +// with no physical BME280/BMP280 attached, Value already correctly threw NotImplementedException +// via the isnan() check below, but SensorDescription/TimeSinceLastUpdate only checked the static +// name table and succeeded anyway -- this does the same live check those two need too) +static bool sensorAvailable(int index) { + double v; + return cmdGetSensor(sensors[index].cmd, v) && !isnan(v); +} + +static String obsCondDeviceStateJson() { + String out; + bool first = true; + for (int i = 0; i < sensorCount; i++) { + double v; + if (cmdGetSensor(sensors[i].cmd, v) && !isnan(v)) { + jsonStateValueEntry(out, first, sensors[i].displayName, jsonNumber(v)); + } + } + jsonStateValueEntry(out, first, "TimeStamp", jsonString("")); + return out; +} + +void AlpacaObservingConditions::handleRequest(int deviceNumber, const String &method, int httpMethod, AlpacaResponse &resp) { + if (deviceNumber != 0) { + resp.setError(ALPACA_ERR_INVALID_VALUE, "Invalid ObservingConditions device number"); + return; + } + + bool connected = status.onStepFound; + + if (alpacaHandleCommonMember(method, httpMethod, resp, "OnStep", "OnStep ambient weather sensors, via SmartWebServer", + 2, connected, obsCondDeviceStateJson)) { + return; + } + + if (!connected) { + resp.setError(ALPACA_ERR_NOT_CONNECTED, "OnStep not found"); + return; + } + + int sensorIndex = findSensor(method); + if (sensorIndex >= 0) { + double v; + if (!cmdGetSensor(sensors[sensorIndex].cmd, v)) { resp.setError(ALPACA_ERR_DRIVER_COMMS_FAILURE, "Comms failure"); return; } + if (isnan(v)) { resp.setError(ALPACA_ERR_NOT_IMPLEMENTED, "No sensor present for this property"); return; } + resp.setValue(jsonNumber(v)); + return; + } + + for (int i = 0; i < unsupportedSensorCount; i++) { + if (method == unsupportedSensors[i]) { + resp.setError(ALPACA_ERR_NOT_IMPLEMENTED, "Not available from OnStepX"); + return; + } + } + + if (method == "averageperiod") { + if (httpMethod == HTTP_GET) { + resp.setValue(jsonNumber(0)); + } else { + double v; + if (!alpacaArgDouble("AveragePeriod", v)) { resp.setError(ALPACA_ERR_INVALID_VALUE, "Missing value"); return; } + if (v != 0) resp.setError(ALPACA_ERR_INVALID_VALUE, "Only AveragePeriod=0 (current value) is supported"); + } + return; + } + + if (method == "refresh") return; // no-op: every property above already reads OnStep live + + if (method == "sensordescription") { + String sensorName; + if (!alpacaArg("SensorName", sensorName)) { resp.setError(ALPACA_ERR_INVALID_VALUE, "Missing SensorName"); return; } + sensorName.toLowerCase(); + int i = findSensor(sensorName); + if (i < 0 || !sensorAvailable(i)) { resp.setError(ALPACA_ERR_NOT_IMPLEMENTED, "Unknown or unsupported sensor"); return; } + resp.setValue(jsonString(sensors[i].description)); + return; + } + + if (method == "timesincelastupdate") { + String sensorName; + alpacaArg("SensorName", sensorName); // optional -- "" means "most recently updated sensor" + sensorName.toLowerCase(); + if (sensorName.length() > 0) { + int i = findSensor(sensorName); + if (i < 0 || !sensorAvailable(i)) { resp.setError(ALPACA_ERR_NOT_IMPLEMENTED, "Unknown or unsupported sensor"); return; } + } + resp.setValue(jsonNumber(0)); // always freshly read from OnStep, per request + return; + } + + resp.setError(ALPACA_ERR_NOT_IMPLEMENTED, "Unknown ObservingConditions member: " + method); +} + +AlpacaObservingConditions alpacaObservingConditions; + +#endif diff --git a/src/libApp/alpaca/AlpacaObservingConditions.h b/src/libApp/alpaca/AlpacaObservingConditions.h new file mode 100644 index 0000000..fd8a184 --- /dev/null +++ b/src/libApp/alpaca/AlpacaObservingConditions.h @@ -0,0 +1,18 @@ +// ----------------------------------------------------------------------------------- +// ASCOM Alpaca IObservingConditionsV2 implementation, gateway to OnStep via onStep.command() +#pragma once + +#include "../../Common.h" + +#if ALPACA_SERVER == ON + +#include "AlpacaJson.h" + +class AlpacaObservingConditions { + public: + void handleRequest(int deviceNumber, const String &method, int httpMethod, AlpacaResponse &resp); +}; + +extern AlpacaObservingConditions alpacaObservingConditions; + +#endif diff --git a/src/libApp/alpaca/AlpacaTelescope.cpp b/src/libApp/alpaca/AlpacaTelescope.cpp new file mode 100644 index 0000000..5033989 --- /dev/null +++ b/src/libApp/alpaca/AlpacaTelescope.cpp @@ -0,0 +1,569 @@ +// ----------------------------------------------------------------------------------- +// ASCOM Alpaca ITelescopeV4 implementation, gateway to OnStep via onStep.command() +// +// Command mappings not already exercised by this repo's existing src/pages/mount/*.cpp were +// cross-checked against the OnStepX firmware source (github.com/hjd1964/OnStepX) -- see the +// "confirmed against OnStepX" comments below. That caught three real bugs versus my initial +// LX200-convention guesses: SiteLongitude sign was inverted (OnStepX's ":Gg#"/":Sg#" are +// "east is negative" i.e. +West, the opposite of ASCOM's +East), PulseGuide's ":Mg" direction +// letter must be lowercase (uppercase silently falls through to CE_CMD_UNKNOWN), and ":MS#"'s +// 0-9 goto-result digit is OnStepX's own numbering, not this codebase's CommandErrors ordinal. +// Still not verified against real hardware: none of this has actually been run against a live +// OnStep controller or an Alpaca client/Conform tool. +#include "AlpacaTelescope.h" + +#if ALPACA_SERVER == ON + +#include "Alpaca.h" +#include "AlpacaCommon.h" +#include "AlpacaError.h" +#include "../cmd/Cmd.h" +#include "../status/Status.h" +#include "../../lib/convert/Convert.h" +#include "../../lib/ethernet/webServer/WebServer.h" +#include "../../lib/wifi/webServer/WebServer.h" + +// x-sidereal guide rate table, index matches status.guideRate / GuideRatesStr (Status.h) +// and the :R0#..:R9# OnStep commands (src/pages/mount/GuideTile.cpp) +static const double guideRateXSidereal[10] = {0.25, 0.5, 1, 2, 4, 8, 20, 48, 24, 48}; // 8,9 ("1/2 Max","Max") approximated +static const double siderealDegPerSec = 360.0 / 86164.0905; // 0.0041780746.../s + +static bool cmdGetHours(const char *cmd, double &out) { + char temp[80]; + if (!onStep.command(cmd, temp)) return false; + return convert.hmsToDouble(&out, temp); +} + +static bool cmdGetDegrees(const char *cmd, bool signPresent, double &out) { + char temp[80]; + if (!onStep.command(cmd, temp)) return false; + return convert.dmsToDouble(&out, temp, signPresent); +} + +static bool getRaDec(double &ra, double &dec) { + #if DISPLAY_HIGH_PRECISION_COORDS == ON + return cmdGetHours(":GRa#", ra) && cmdGetDegrees(":GDe#", true, dec); + #else + return cmdGetHours(":GR#", ra) && cmdGetDegrees(":GD#", true, dec); + #endif +} + +static bool getTargetRaDec(double &ra, double &dec) { + #if DISPLAY_HIGH_PRECISION_COORDS == ON + return cmdGetHours(":Gra#", ra) && cmdGetDegrees(":Gde#", true, dec); + #else + return cmdGetHours(":Gr#", ra) && cmdGetDegrees(":Gd#", true, dec); + #endif +} + +static bool getAzAlt(double &az, double &alt) { + #if DISPLAY_HIGH_PRECISION_COORDS == ON + return cmdGetDegrees(":GZH#", false, az) && cmdGetDegrees(":GAH#", true, alt); + #else + return cmdGetDegrees(":GZ#", false, az) && cmdGetDegrees(":GA#", true, alt); + #endif +} + +static bool getSiteLatLon(double &lat, double &lon) { + // confirmed against OnStepX src/telescope/mount/site/Site.command.cpp: ":Gt#"/":GtH#" are + // "positive for North" (matches ASCOM SiteLatitude directly), but ":Gg#"/":GgH#" are + // "east is negative" i.e. +West (the classic Meade LX200 convention) -- the opposite sign + // of ASCOM's SiteLongitude (+East), so the parsed value must be negated here. + bool highPrecision = status.getVersionMajor() > 3; + bool ok1 = cmdGetDegrees(highPrecision ? ":GtH#" : ":Gt#", true, lat); + + // Confirmed against real hardware: longitude's reply is signed AND up to 3-digit degrees + // (e.g. "+116*23#"), which dmsToDouble()'s signPresent=true mode can't parse -- that mode + // only reads 2 degree digits (it's designed for <=90 degree quantities like latitude and + // altitude). So the sign has to be stripped and reapplied manually around a signPresent=false + // (unsigned, 3-digit) parse, exactly mirroring how OnStepX's own Site.command.cpp parses the + // ":Sg#" *input* parameter the same way. + char temp[80]; + bool ok2 = onStep.command(highPrecision ? ":GgH#" : ":Gg#", temp); + if (ok2) { + bool negative = (temp[0] == '-'); + char *numPart = (temp[0] == '+' || temp[0] == '-') ? &temp[1] : temp; + ok2 = convert.dmsToDouble(&lon, numPart, false); + if (ok2 && negative) lon = -lon; + } + if (ok2) lon = -lon; // OnStepX "east is negative" -> ASCOM's +East, per the comment above + return ok1 && ok2; +} + +// starts a goto via ":MS#" (single-digit reply per Cmd.cpp's shortResponse handling for the +// "S" sub-command). Confirmed against OnStepX src/telescope/mount/goto/Goto.command.cpp: the +// digit is OnStepX's own 0..9 goto-result code (0=possible, 1=below horizon, ... 9=unspecified), +// NOT this codebase's CommandErrors ordinal -- but Cmd.h's enum was deliberately laid out with +// CE_GOTO_ERR_BELOW_HORIZON..CE_GOTO_ERR_UNSPECIFIED as nine consecutive values in that same +// order, so offsetting from CE_GOTO_ERR_BELOW_HORIZON converts correctly. +static bool startGoto(AlpacaResponse &resp) { + char temp[80]; + if (!onStep.command(":MS#", temp)) { + resp.setError(ALPACA_ERR_DRIVER_COMMS_FAILURE, "Comms failure"); + return false; + } + if (temp[0] != 0 && temp[0] != '0') { + int digit = temp[0] - '0'; // 1..9 + int errEnum = CE_GOTO_ERR_BELOW_HORIZON + (digit - 1); + resp.setError(ALPACA_ERR_INVALID_OPERATION, onStep.commandErrorToStr(errEnum)); + return false; + } + return true; +} + +// OnStep's ":Gr#"/":Gd#" always return *some* parseable value (its own last/default target), +// so unlike most of this file's "did the command succeed" checks, "has a target ever actually +// been set" has to be tracked here in software -- confirmed via ASCOM Conform: reading +// TargetRightAscension/TargetDeclination before ever writing them must throw +// ValueNotSetException, and previously didn't since OnStep's reply parsed successfully anyway. +static bool targetRaSet = false; +static bool targetDecSet = false; + +static void setRaDecTarget(double ra, double dec) { + char buf[16]; + convert.doubleToHms(buf, ra, false, PM_HIGH); + char cmd[24]; + snprintf(cmd, sizeof(cmd), ":Sr%s#", buf); + onStep.commandBool(cmd); + convert.doubleToDms(buf, dec, false, true, PM_HIGH); + snprintf(cmd, sizeof(cmd), ":Sd%s#", buf); + onStep.commandBool(cmd); + targetRaSet = true; + targetDecSet = true; +} + +// AlignmentModes: algAltAz=0, algPolar=1, algGermanPolar=2 +static int alignmentMode() { + if (status.mountType == MT_GEM) return 2; + if (status.mountType == MT_FORK || status.mountType == MT_FORKALT) return 1; + return 0; +} + +// PierSide: pierUnknown=-1, pierEast=0, pierWest=1 +static int sideOfPier() { + if (status.pierSide == PierSideEast) return 0; + if (status.pierSide == PierSideWest) return 1; + return -1; +} + +// DriveRates: driveSidereal=0, driveLunar=1, driveSolar=2, driveKing=3 +static int trackingRateFromHz(double hz) { + if (fabs(hz - 57.900) < 0.05) return 1; + if (fabs(hz - 60.000) < 0.05) return 2; + if (fabs(hz - 60.136) < 0.05) return 3; + return 0; // default/closest match is sidereal +} + +static double guideRateDegPerSec(int index) { + if (index < 0 || index > 9) index = 2; + return guideRateXSidereal[index] * siderealDegPerSec; +} + +// picks the nearest guide-rate index (0..7, the fixed x-sidereal presets) for a requested deg/s rate +static int nearestGuideRateIndex(double degPerSec) { + double best = 1e9; int bestIndex = 2; + for (int i = 0; i <= 7; i++) { + double d = fabs(guideRateXSidereal[i] * siderealDegPerSec - fabs(degPerSec)); + if (d < best) { best = d; bestIndex = i; } + } + return bestIndex; +} + +static String telescopeDeviceStateJson() { + String out; + bool first = true; + double ra, dec, az, alt, lst; + bool haveRaDec = getRaDec(ra, dec); + bool haveAzAlt = getAzAlt(az, alt); + bool haveLst = cmdGetHours(":GS#", lst); + + if (haveRaDec) { + jsonStateValueEntry(out, first, "RightAscension", jsonNumber(ra)); + jsonStateValueEntry(out, first, "Declination", jsonNumber(dec)); + } + if (haveAzAlt) { + jsonStateValueEntry(out, first, "Azimuth", jsonNumber(az)); + jsonStateValueEntry(out, first, "Altitude", jsonNumber(alt)); + } + if (haveLst) jsonStateValueEntry(out, first, "SiderealTime", jsonNumber(lst)); + jsonStateValueEntry(out, first, "AtHome", jsonBool(status.atHome)); + jsonStateValueEntry(out, first, "AtPark", jsonBool(status.parked)); + jsonStateValueEntry(out, first, "Slewing", jsonBool(status.inGoto)); + jsonStateValueEntry(out, first, "Tracking", jsonBool(status.tracking)); + jsonStateValueEntry(out, first, "SideOfPier", jsonInt(sideOfPier())); + jsonStateValueEntry(out, first, "IsPulseGuiding", jsonBool(status.pulseGuiding)); + jsonStateValueEntry(out, first, "TimeStamp", jsonString("")); + return out; +} + +// remembers which continuous-motion direction command is active per axis so MoveAxis(axis,0) knows what to stop +static char axis0Dir = 0; // 'e', 'w', or 0 +static char axis1Dir = 0; // 'n', 's', or 0 + +void AlpacaTelescope::handleRequest(int deviceNumber, const String &method, int httpMethod, AlpacaResponse &resp) { + if (deviceNumber != 0) { + resp.setError(ALPACA_ERR_INVALID_VALUE, "Invalid Telescope device number"); + return; + } + + bool connected = status.onStepFound; + + if (alpacaHandleCommonMember(method, httpMethod, resp, "OnStep", "OnStep telescope mount, via SmartWebServer", + 4, connected, telescopeDeviceStateJson)) { + return; + } + + // every member reaching this point is telescope-specific (the shared "connected" member + // itself was already handled above), so it's safe to require a live OnStep link here + if (!connected) { + resp.setError(ALPACA_ERR_NOT_CONNECTED, "OnStep not found"); + return; + } + + // ---- read/write properties ---- + + if (method == "alignmentmode") { resp.setValue(jsonInt(alignmentMode())); return; } + + if (method == "aperturearea" || method == "aperturediameter" || method == "focallength" || + method == "siteelevation") { + resp.setError(ALPACA_ERR_NOT_IMPLEMENTED, "Not known to OnStep/SmartWebServer"); + return; + } + + // per the ASCOM spec these two GETs are mandatory and must return 0 when not settable + // (CanSetDeclinationRate/CanSetRightAscensionRate are both false) rather than throw -- + // confirmed against real hardware via the ASCOM Conform Universal tool + if (method == "declinationrate" || method == "rightascensionrate") { + if (httpMethod == HTTP_GET) resp.setValue(jsonNumber(0)); + else resp.setError(ALPACA_ERR_NOT_IMPLEMENTED, "Not adjustable"); + return; + } + + if (method == "altitude") { + double az, alt; + if (getAzAlt(az, alt)) resp.setValue(jsonNumber(alt)); else resp.setError(ALPACA_ERR_DRIVER_COMMS_FAILURE, "Comms failure"); + return; + } + if (method == "azimuth") { + double az, alt; + if (getAzAlt(az, alt)) resp.setValue(jsonNumber(az)); else resp.setError(ALPACA_ERR_DRIVER_COMMS_FAILURE, "Comms failure"); + return; + } + if (method == "rightascension") { + double ra, dec; + if (getRaDec(ra, dec)) resp.setValue(jsonNumber(ra)); else resp.setError(ALPACA_ERR_DRIVER_COMMS_FAILURE, "Comms failure"); + return; + } + if (method == "declination") { + double ra, dec; + if (getRaDec(ra, dec)) resp.setValue(jsonNumber(dec)); else resp.setError(ALPACA_ERR_DRIVER_COMMS_FAILURE, "Comms failure"); + return; + } + + if (method == "targetrightascension") { + if (httpMethod == HTTP_GET) { + double ra, dec; + if (!targetRaSet) { resp.setError(ALPACA_ERR_VALUE_NOT_SET, "Target has not been set"); return; } + if (getTargetRaDec(ra, dec)) resp.setValue(jsonNumber(ra)); else resp.setError(ALPACA_ERR_VALUE_NOT_SET, "Target not set"); + } else { + double ra; + if (!alpacaArgDouble("TargetRightAscension", ra) || ra < 0 || ra >= 24) { resp.setError(ALPACA_ERR_INVALID_VALUE, "Bad RA"); return; } + char buf[16]; convert.doubleToHms(buf, ra, false, PM_HIGH); + char cmd[24]; snprintf(cmd, sizeof(cmd), ":Sr%s#", buf); + if (!onStep.commandBool(cmd)) resp.setError(ALPACA_ERR_INVALID_VALUE, "OnStep rejected RA"); else targetRaSet = true; + } + return; + } + if (method == "targetdeclination") { + if (httpMethod == HTTP_GET) { + double ra, dec; + if (!targetDecSet) { resp.setError(ALPACA_ERR_VALUE_NOT_SET, "Target has not been set"); return; } + if (getTargetRaDec(ra, dec)) resp.setValue(jsonNumber(dec)); else resp.setError(ALPACA_ERR_VALUE_NOT_SET, "Target not set"); + } else { + double dec; + if (!alpacaArgDouble("TargetDeclination", dec) || dec < -90 || dec > 90) { resp.setError(ALPACA_ERR_INVALID_VALUE, "Bad Dec"); return; } + char buf[16]; convert.doubleToDms(buf, dec, false, true, PM_HIGH); + char cmd[24]; snprintf(cmd, sizeof(cmd), ":Sd%s#", buf); + if (!onStep.commandBool(cmd)) resp.setError(ALPACA_ERR_INVALID_VALUE, "OnStep rejected Dec"); else targetDecSet = true; + } + return; + } + + if (method == "sitelatitude" || method == "sitelongitude") { + if (httpMethod == HTTP_GET) { + double lat, lon; + if (!getSiteLatLon(lat, lon)) { resp.setError(ALPACA_ERR_DRIVER_COMMS_FAILURE, "Comms failure"); return; } + resp.setValue(jsonNumber(method == "sitelatitude" ? lat : lon)); + } else { + double v; + if (!alpacaArgDouble(method == "sitelatitude" ? "SiteLatitude" : "SiteLongitude", v)) { resp.setError(ALPACA_ERR_INVALID_VALUE, "Missing value"); return; } + char buf[16]; + char cmd[24]; + if (method == "sitelatitude") { + if (v < -90 || v > 90) { resp.setError(ALPACA_ERR_INVALID_VALUE, "Bad latitude"); return; } + convert.doubleToDms(buf, v, false, true, PM_HIGH); + snprintf(cmd, sizeof(cmd), ":St%s#", buf); + } else { + if (v < -180 || v > 180) { resp.setError(ALPACA_ERR_INVALID_VALUE, "Bad longitude"); return; } + // negate: ASCOM SiteLongitude is +East, OnStepX's ":Sg#" is +West ("east is negative"), see getSiteLatLon() + convert.doubleToDms(buf, -v, true, true, PM_HIGH); + snprintf(cmd, sizeof(cmd), ":Sg%s#", buf); + } + if (!onStep.commandBool(cmd)) resp.setError(ALPACA_ERR_INVALID_VALUE, "OnStep rejected value"); + } + return; + } + + if (method == "siderealtime") { + double lst; + if (cmdGetHours(":GS#", lst)) resp.setValue(jsonNumber(lst)); else resp.setError(ALPACA_ERR_DRIVER_COMMS_FAILURE, "Comms failure"); + return; + } + + if (method == "equatorialsystem") { resp.setValue(jsonInt(1)); return; } // equTopocentric (JNow) + + if (method == "athome") { resp.setValue(jsonBool(status.atHome)); return; } + if (method == "atpark") { resp.setValue(jsonBool(status.parked)); return; } + if (method == "slewing") { resp.setValue(jsonBool(status.inGoto)); return; } + if (method == "ispulseguiding") { resp.setValue(jsonBool(status.pulseGuiding)); return; } + if (method == "sideofpier") { + if (httpMethod == HTTP_GET) resp.setValue(jsonInt(sideOfPier())); + else resp.setError(ALPACA_ERR_NOT_IMPLEMENTED, "SideOfPier cannot be set"); + return; + } + + if (method == "slewsettletime") { + if (httpMethod == HTTP_GET) resp.setValue(jsonInt(0)); + else resp.setError(ALPACA_ERR_NOT_IMPLEMENTED, "Not adjustable"); + return; + } + + if (method == "doesrefraction") { + if (httpMethod == HTTP_GET) resp.setValue(jsonBool(status.rateCompensation != RC_NONE)); + else resp.setError(ALPACA_ERR_NOT_IMPLEMENTED, "Use tracking rate compensation controls instead"); + return; + } + + if (method == "tracking") { + if (httpMethod == HTTP_GET) { + resp.setValue(jsonBool(status.tracking)); + } else { + bool on = alpacaArgBool("Tracking", false); + if (!onStep.commandBool(on ? ":Te#" : ":Td#")) resp.setError(ALPACA_ERR_INVALID_OPERATION, "OnStep rejected tracking change"); + } + return; + } + + if (method == "trackingrate") { + if (httpMethod == HTTP_GET) { + char temp[80]; + double hz = 0; + if (onStep.command(":GT#", temp)) hz = atof(temp); + resp.setValue(jsonInt(trackingRateFromHz(hz))); + } else { + uint32_t rate = alpacaArgUint32("TrackingRate", 0); + const char *cmd = ":TQ#"; + if (rate == 1) cmd = ":TL#"; else if (rate == 2) cmd = ":TS#"; else if (rate == 3) cmd = ":TK#"; + else if (rate != 0) { resp.setError(ALPACA_ERR_INVALID_VALUE, "Unsupported tracking rate"); return; } + onStep.commandBlind(cmd); + } + return; + } + if (method == "trackingrates") { resp.setValue("[0,1,2,3]"); return; } + + if (method == "utcdate") { + if (httpMethod == HTTP_GET) { + char timeStr[80], dateStr[80]; + if (!onStep.command(":GX80#", timeStr) || !onStep.command(":GX81#", dateStr)) { + resp.setError(ALPACA_ERR_DRIVER_COMMS_FAILURE, "Comms failure"); + return; + } + // confirmed against OnStepX docs/COMMAND_REFERENCE.md: ":GX81#" -> "MM/DD/YY#", + // ":GX80#" -> "HH:MM:SS.ss#" (fractional seconds are valid trailing digits in ISO 8601) + int mo = 0, dy = 0, yr = 0; + sscanf(dateStr, "%d/%d/%d", &mo, &dy, &yr); + char iso[32]; + snprintf(iso, sizeof(iso), "20%02d-%02d-%02dT%sZ", yr, mo, dy, timeStr); + resp.setValue(jsonString(iso)); + } else { + resp.setError(ALPACA_ERR_NOT_IMPLEMENTED, "Setting the mount clock via Alpaca isn't supported yet, use the web UI"); + } + return; + } + + if (method == "guideraterightascension" || method == "guideratedeclination") { + if (httpMethod == HTTP_GET) { + resp.setValue(jsonNumber(guideRateDegPerSec(status.guideRate))); + } else { + double v; + const char *argName = (method == "guideraterightascension") ? "GuideRateRightAscension" : "GuideRateDeclination"; + if (!alpacaArgDouble(argName, v)) { resp.setError(ALPACA_ERR_INVALID_VALUE, "Missing value"); return; } + int idx = nearestGuideRateIndex(v); + char cmd[8]; snprintf(cmd, sizeof(cmd), ":R%d#", idx); + onStep.commandBlind(cmd); + } + return; + } + + // ---- capability flags ---- + if (method == "canfindhome") { resp.setValue(jsonBool(true)); return; } + if (method == "canpark") { resp.setValue(jsonBool(true)); return; } + if (method == "canunpark") { resp.setValue(jsonBool(true)); return; } + if (method == "cansetpark") { resp.setValue(jsonBool(true)); return; } + if (method == "canpulseguide") { resp.setValue(jsonBool(true)); return; } + if (method == "cansettracking") { resp.setValue(jsonBool(true)); return; } + if (method == "cansetguiderates") { resp.setValue(jsonBool(true)); return; } + if (method == "canslew") { resp.setValue(jsonBool(false)); return; } // synchronous slew not implemented, see slewtocoordinates + if (method == "canslewasync") { resp.setValue(jsonBool(true)); return; } + if (method == "canslewaltaz") { resp.setValue(jsonBool(false)); return; } + if (method == "canslewaltazasync") { resp.setValue(jsonBool(true)); return; } + if (method == "cansync") { resp.setValue(jsonBool(true)); return; } + if (method == "cansyncaltaz") { resp.setValue(jsonBool(false)); return; } + if (method == "canmoveaxis") { + uint32_t axis = alpacaArgUint32("Axis", 0); + resp.setValue(jsonBool(axis == 0 || axis == 1)); + return; + } + if (method == "cansetpierside") { resp.setValue(jsonBool(false)); return; } + if (method == "cansetdeclinationrate") { resp.setValue(jsonBool(false)); return; } + if (method == "cansetrightascensionrate") { resp.setValue(jsonBool(false)); return; } + + // ---- methods ---- + + if (method == "abortslew") { onStep.commandBlind(":Q#"); return; } + if (method == "findhome") { onStep.commandBlind(":hC#"); return; } + if (method == "park") { if (!onStep.commandBool(":hP#")) resp.setError(ALPACA_ERR_INVALID_OPERATION, "Park failed"); return; } + if (method == "unpark") { if (!onStep.commandBool(":hR#")) resp.setError(ALPACA_ERR_INVALID_OPERATION, "Unpark failed"); return; } + if (method == "setpark") { if (!onStep.commandBool(":hQ#")) resp.setError(ALPACA_ERR_INVALID_OPERATION, "SetPark failed"); return; } + + if (method == "slewtocoordinates" || method == "slewtotarget" || method == "slewtoaltaz") { + resp.setError(ALPACA_ERR_NOT_IMPLEMENTED, "Synchronous slewing isn't supported, use the Async method"); + return; + } + + if (method == "slewtocoordinatesasync") { + double ra, dec; + if (!alpacaArgDouble("RightAscension", ra) || !alpacaArgDouble("Declination", dec) || + ra < 0 || ra >= 24 || dec < -90 || dec > 90) { + resp.setError(ALPACA_ERR_INVALID_VALUE, "Bad coordinates"); + return; + } + setRaDecTarget(ra, dec); + startGoto(resp); + return; + } + if (method == "slewtotargetasync") { + if (!targetRaSet || !targetDecSet) { resp.setError(ALPACA_ERR_VALUE_NOT_SET, "Target has not been set"); return; } + startGoto(resp); + return; + } + if (method == "slewtoaltazasync") { + // confirmed against OnStepX src/telescope/mount/goto/Goto.command.cpp: ":Sz#" takes an + // unsigned DDD*MM[:SS[.SSS]] (dmsToDouble signPresent=false, 0..360) and ":Sa#" a signed + // sDD*MM[:SS[.SSS]] (signPresent=true, -90..90) -- matches doubleToDms(fullRange, signPresent) + // below exactly. (Their docs show a "'" seconds separator; the shared Convert::dmsToDouble + // parser accepts ':' equally, which is what doubleToDms here actually emits.) + double az, alt; + if (!alpacaArgDouble("Azimuth", az) || !alpacaArgDouble("Altitude", alt) || + az < 0 || az > 360 || alt < -90 || alt > 90) { + resp.setError(ALPACA_ERR_INVALID_VALUE, "Bad coordinates"); + return; + } + char buf[16], cmd[24]; + convert.doubleToDms(buf, az, true, false, PM_HIGH); + snprintf(cmd, sizeof(cmd), ":Sz%s#", buf); + onStep.commandBool(cmd); + convert.doubleToDms(buf, alt, false, true, PM_HIGH); + snprintf(cmd, sizeof(cmd), ":Sa%s#", buf); + onStep.commandBool(cmd); + startGoto(resp); + return; + } + + if (method == "synctocoordinates") { + double ra, dec; + if (!alpacaArgDouble("RightAscension", ra) || !alpacaArgDouble("Declination", dec) || + ra < 0 || ra >= 24 || dec < -90 || dec > 90) { + resp.setError(ALPACA_ERR_INVALID_VALUE, "Bad coordinates"); + return; + } + setRaDecTarget(ra, dec); + if (!onStep.commandBool(":CS#")) resp.setError(ALPACA_ERR_INVALID_OPERATION, "Sync failed"); + return; + } + if (method == "synctotarget") { + if (!targetRaSet || !targetDecSet) { resp.setError(ALPACA_ERR_VALUE_NOT_SET, "Target has not been set"); return; } + if (!onStep.commandBool(":CS#")) resp.setError(ALPACA_ERR_INVALID_OPERATION, "Sync failed"); + return; + } + if (method == "synctoaltaz") { + resp.setError(ALPACA_ERR_NOT_IMPLEMENTED, "SyncToAltAz isn't supported by OnStep"); + return; + } + + if (method == "destinationsideofpier") { + resp.setError(ALPACA_ERR_NOT_IMPLEMENTED, "Not available"); + return; + } + + if (method == "axisrates") { + uint32_t axis = alpacaArgUint32("Axis", 0); + if (axis != 0 && axis != 1) { resp.setValue("[]"); return; } + String out = "["; + for (int i = 0; i <= 7; i++) { + if (i) out += ","; + double r = guideRateXSidereal[i] * siderealDegPerSec; + out += "{\"Minimum\":" + jsonNumber(r) + ",\"Maximum\":" + jsonNumber(r) + "}"; + } + out += "]"; + resp.setValue(out); + return; + } + + if (method == "moveaxis") { + uint32_t axis = alpacaArgUint32("Axis", 99); + double rate; + if ((axis != 0 && axis != 1) || !alpacaArgDouble("Rate", rate)) { resp.setError(ALPACA_ERR_INVALID_VALUE, "Bad axis/rate"); return; } + + if (axis == 0) { + if (axis0Dir) { char c[6]; snprintf(c, sizeof(c), ":Q%c#", axis0Dir); onStep.commandBlind(c); axis0Dir = 0; } + if (rate != 0) { + int idx = nearestGuideRateIndex(rate); + char c[8]; snprintf(c, sizeof(c), ":R%d#", idx); onStep.commandBlind(c); + axis0Dir = (rate > 0) ? 'e' : 'w'; + snprintf(c, sizeof(c), ":M%c#", axis0Dir); onStep.commandBlind(c); + } + } else { + if (axis1Dir) { char c[6]; snprintf(c, sizeof(c), ":Q%c#", axis1Dir); onStep.commandBlind(c); axis1Dir = 0; } + if (rate != 0) { + int idx = nearestGuideRateIndex(rate); + char c[8]; snprintf(c, sizeof(c), ":R%d#", idx); onStep.commandBlind(c); + axis1Dir = (rate > 0) ? 'n' : 's'; + snprintf(c, sizeof(c), ":M%c#", axis1Dir); onStep.commandBlind(c); + } + } + return; + } + + if (method == "pulseguide") { + // confirmed against OnStepX src/telescope/mount/guide/Guide.command.cpp: ":Mgd[n]#" where d + // is lowercase 'w','e','n', or 's' (uppercase falls through to CE_CMD_UNKNOWN there) and n is + // a plain (not zero-padded) millisecond count parsed with convert.atoi2(); "g" (vs. "G") means + // no reply is sent, matching commandBlind() below, and OnStep self-times/executes it async. + uint32_t direction = alpacaArgUint32("Direction", 99); + uint32_t duration = alpacaArgUint32("Duration", 0); + if (direction > 3) { resp.setError(ALPACA_ERR_INVALID_VALUE, "Bad direction"); return; } + if (duration > 9999) duration = 9999; + char dirChar = "nsew"[direction]; // guideNorth=0, guideSouth=1, guideEast=2, guideWest=3 + char cmd[16]; + snprintf(cmd, sizeof(cmd), ":Mg%c%d#", dirChar, (int)duration); + onStep.commandBlind(cmd); + return; + } + + resp.setError(ALPACA_ERR_NOT_IMPLEMENTED, "Unknown Telescope member: " + method); +} + +AlpacaTelescope alpacaTelescope; + +#endif diff --git a/src/libApp/alpaca/AlpacaTelescope.h b/src/libApp/alpaca/AlpacaTelescope.h new file mode 100644 index 0000000..8b11d0f --- /dev/null +++ b/src/libApp/alpaca/AlpacaTelescope.h @@ -0,0 +1,18 @@ +// ----------------------------------------------------------------------------------- +// ASCOM Alpaca ITelescopeV4 implementation, gateway to OnStep via onStep.command() +#pragma once + +#include "../../Common.h" + +#if ALPACA_SERVER == ON + +#include "AlpacaJson.h" + +class AlpacaTelescope { + public: + void handleRequest(int deviceNumber, const String &method, int httpMethod, AlpacaResponse &resp); +}; + +extern AlpacaTelescope alpacaTelescope; + +#endif From 9ac484960fa6ebcceaaaf8d4a44d082e6b03b18f Mon Sep 17 00:00:00 2001 From: Michel Moriniaux Date: Wed, 22 Jul 2026 18:03:35 -0700 Subject: [PATCH 2/4] Added Rotator --- src/libApp/alpaca/Alpaca.cpp | 6 + src/libApp/alpaca/AlpacaManagement.cpp | 7 ++ src/libApp/alpaca/AlpacaRotator.cpp | 166 +++++++++++++++++++++++++ src/libApp/alpaca/AlpacaRotator.h | 18 +++ 4 files changed, 197 insertions(+) create mode 100644 src/libApp/alpaca/AlpacaRotator.cpp create mode 100644 src/libApp/alpaca/AlpacaRotator.h diff --git a/src/libApp/alpaca/Alpaca.cpp b/src/libApp/alpaca/Alpaca.cpp index 374b431..3cc831f 100644 --- a/src/libApp/alpaca/Alpaca.cpp +++ b/src/libApp/alpaca/Alpaca.cpp @@ -11,6 +11,7 @@ #include "AlpacaManagement.h" #include "AlpacaTelescope.h" #include "AlpacaObservingConditions.h" +#include "AlpacaRotator.h" void handleNotFound(); @@ -102,6 +103,11 @@ void Alpaca::handleApiRequest(String segments[], int count, AlpacaResponse &resp return; } + if (deviceType == "rotator") { + alpacaRotator.handleRequest(deviceNumber, method, httpMethod, resp); + return; + } + resp.setError(ALPACA_ERR_NOT_IMPLEMENTED, "Unknown or unconfigured Alpaca device type"); } diff --git a/src/libApp/alpaca/AlpacaManagement.cpp b/src/libApp/alpaca/AlpacaManagement.cpp index 039438a..a6787a7 100644 --- a/src/libApp/alpaca/AlpacaManagement.cpp +++ b/src/libApp/alpaca/AlpacaManagement.cpp @@ -47,6 +47,13 @@ void alpacaManagementConfiguredDevices(AlpacaResponse &resp) { appendDevice(out, first, "OnStep Weather", "ObservingConditions", 0); } + // a rotator is fundamentally an axis 3 configured at compile time on the OnStepX side, and + // its presence is decided once at boot by Status::rotatorScan() (Status.cpp), which probes + // ":GX98#" -- reuse that same decision here rather than probing again + if (status.rotatorFound == SD_TRUE) { + appendDevice(out, first, "OnStep", "Rotator", 0); + } + out += "]"; resp.setValue(out); } diff --git a/src/libApp/alpaca/AlpacaRotator.cpp b/src/libApp/alpaca/AlpacaRotator.cpp new file mode 100644 index 0000000..770c115 --- /dev/null +++ b/src/libApp/alpaca/AlpacaRotator.cpp @@ -0,0 +1,166 @@ +// ----------------------------------------------------------------------------------- +// ASCOM Alpaca IRotatorV4 implementation, gateway to OnStep via onStep.command() +// +// Command mappings confirmed against OnStepX src/telescope/rotator/local/Rotator.command.cpp +// (fetched directly, not just the docs table -- see AlpacaTelescope.cpp's header comment for +// why that mattered elsewhere in this session). +#include "AlpacaRotator.h" + +#if ALPACA_SERVER == ON + +#include "Alpaca.h" +#include "AlpacaCommon.h" +#include "AlpacaError.h" +#include "../cmd/Cmd.h" +#include "../status/Status.h" +#include "../../lib/convert/Convert.h" +#include "../../lib/ethernet/webServer/WebServer.h" +#include "../../lib/wifi/webServer/WebServer.h" + +// ":rG#" replies "sDDD*MM#" (signed, 3-digit degrees, no seconds -- PM_LOW). dmsToDouble's +// signPresent=true mode only reads 2 degree digits (matches AlpacaTelescope.cpp's +// getSiteLatLon() longitude fix -- the same OnStep decode gap), so the sign has to be stripped +// and reapplied manually around an unsigned parse. Result normalized to ASCOM's required 0-360 +// range (OnStepX can report a small negative angle after a CCW move through zero). +static bool getPositionDeg(double &out) { + char temp[80]; + if (!onStep.command(":rG#", temp)) return false; + bool negative = (temp[0] == '-'); + char *numPart = (temp[0] == '+' || temp[0] == '-') ? &temp[1] : temp; + if (!convert.dmsToDouble(&out, numPart, false)) return false; + if (negative) out = -out; + if (out < 0) out += 360; + if (out >= 360) out -= 360; + return true; +} + +static bool getIsMoving(bool &out) { + char temp[80]; + if (!onStep.command(":rT#", temp)) return false; + out = strchr(temp, 'M') != NULL; + return true; +} + +// OnStepX has no "get target" rotator command (only the internal axis3.getTargetCoordinate() +// used by ":rr#"), so -- matching how AlpacaTelescope.cpp tracks TargetRightAscension/ +// TargetDeclination -- TargetPosition is tracked here in software, throwing ValueNotSetException +// if Move()/MoveAbsolute()/MoveMechanical() was never called. +static bool targetPositionSet = false; +static double targetPositionValue = 0; + +static String rotatorDeviceStateJson() { + String out; + bool first = true; + double pos; + bool moving; + if (getPositionDeg(pos)) jsonStateValueEntry(out, first, "Position", jsonNumber(pos)); + if (getIsMoving(moving)) jsonStateValueEntry(out, first, "IsMoving", jsonBool(moving)); + jsonStateValueEntry(out, first, "TimeStamp", jsonString("")); + return out; +} + +void AlpacaRotator::handleRequest(int deviceNumber, const String &method, int httpMethod, AlpacaResponse &resp) { + if (deviceNumber != 0) { + resp.setError(ALPACA_ERR_INVALID_VALUE, "Invalid Rotator device number"); + return; + } + + // rotator presence is decided once at boot by Status::rotatorScan() (Status.cpp), which probes + // ":GX98#" and sets status.rotatorFound -- gate live "connected" state on it the same way + // AlpacaManagement.cpp gates whether the device is advertised at all + bool connected = status.onStepFound && status.rotatorFound == SD_TRUE; + + if (alpacaHandleCommonMember(method, httpMethod, resp, "OnStep", "OnStep rotator, via SmartWebServer", + 4, connected, rotatorDeviceStateJson)) { + return; + } + + if (!connected) { + resp.setError(ALPACA_ERR_NOT_CONNECTED, "OnStep or rotator not found"); + return; + } + + if (method == "canreverse") { resp.setValue(jsonBool(false)); return; } + + if (method == "reverse") { + if (httpMethod == HTTP_GET) resp.setValue(jsonBool(false)); + // OnStepX's ":rR#" only reverses the optional alt-az derotator sub-feature, not the rotator's + // own sense of direction -- there's no general "flip which way Position increases" primitive + else resp.setError(ALPACA_ERR_NOT_IMPLEMENTED, "Not adjustable"); + return; + } + + if (method == "ismoving") { + bool moving; + if (getIsMoving(moving)) resp.setValue(jsonBool(moving)); else resp.setError(ALPACA_ERR_DRIVER_COMMS_FAILURE, "Comms failure"); + return; + } + + if (method == "position" || method == "mechanicalposition") { + // OnStepX's rotator doesn't expose a separate raw/mechanical vs. sky-adjusted position via + // LX200 (no sync-to-arbitrary-value command exists either, see "sync" below), so both ASCOM + // properties map to the same ":rG#" reading + double pos; + if (getPositionDeg(pos)) resp.setValue(jsonNumber(pos)); else resp.setError(ALPACA_ERR_DRIVER_COMMS_FAILURE, "Comms failure"); + return; + } + + if (method == "stepsize") { + char temp[80]; + if (!onStep.command(":rD#", temp)) { resp.setError(ALPACA_ERR_DRIVER_COMMS_FAILURE, "Comms failure"); return; } + resp.setValue(jsonNumber(atof(temp))); + return; + } + + if (method == "targetposition") { + if (!targetPositionSet) { resp.setError(ALPACA_ERR_VALUE_NOT_SET, "Target has not been set"); return; } + resp.setValue(jsonNumber(targetPositionValue)); + return; + } + + if (method == "halt") { onStep.commandBlind(":rQ#"); return; } + + if (method == "move") { + double relDeg; + if (!alpacaArgDouble("Position", relDeg)) { resp.setError(ALPACA_ERR_INVALID_VALUE, "Missing Position"); return; } + // OnStepX's ":rr#" parses its parameter with dmsToDouble(signPresent=true), which -- like + // ":rG#" above -- only reads 2 degree digits, capping the relative offset at +/-99 degrees + if (relDeg <= -100 || relDeg >= 100) { resp.setError(ALPACA_ERR_INVALID_VALUE, "Relative move must be within +/-99 degrees"); return; } + double base; + if (targetPositionSet) base = targetPositionValue; else if (!getPositionDeg(base)) { resp.setError(ALPACA_ERR_DRIVER_COMMS_FAILURE, "Comms failure"); return; } + char buf[16], cmd[24]; + convert.doubleToDms(buf, relDeg, false, true, PM_LOW); + snprintf(cmd, sizeof(cmd), ":rr%s#", buf); + onStep.commandBlind(cmd); + targetPositionValue = base + relDeg; + targetPositionSet = true; + return; + } + + if (method == "moveabsolute" || method == "movemechanical") { + // no separate mechanical-position command exists (see "position" above), so MoveMechanical + // is implemented identically to MoveAbsolute + double pos; + if (!alpacaArgDouble("Position", pos) || pos < 0 || pos >= 360) { resp.setError(ALPACA_ERR_INVALID_VALUE, "Position must be 0 <= Position < 360"); return; } + char buf[16], cmd[24]; + convert.doubleToDms(buf, pos, true, true, PM_LOW); + snprintf(cmd, sizeof(cmd), ":rS%s#", buf); + if (!onStep.commandBool(cmd)) { resp.setError(ALPACA_ERR_INVALID_OPERATION, "OnStep rejected the move"); return; } + targetPositionValue = pos; + targetPositionSet = true; + return; + } + + if (method == "sync") { + // no general "set current position to this arbitrary value" primitive exists in OnStepX's + // rotator LX200 command set (only ":rZ#" sync-to-zero and ":rF#" sync-to-half-travel do) + resp.setError(ALPACA_ERR_NOT_IMPLEMENTED, "Sync to an arbitrary position isn't supported by OnStepX"); + return; + } + + resp.setError(ALPACA_ERR_NOT_IMPLEMENTED, "Unknown Rotator member: " + method); +} + +AlpacaRotator alpacaRotator; + +#endif diff --git a/src/libApp/alpaca/AlpacaRotator.h b/src/libApp/alpaca/AlpacaRotator.h new file mode 100644 index 0000000..b66640c --- /dev/null +++ b/src/libApp/alpaca/AlpacaRotator.h @@ -0,0 +1,18 @@ +// ----------------------------------------------------------------------------------- +// ASCOM Alpaca IRotatorV4 implementation, gateway to OnStep via onStep.command() +#pragma once + +#include "../../Common.h" + +#if ALPACA_SERVER == ON + +#include "AlpacaJson.h" + +class AlpacaRotator { + public: + void handleRequest(int deviceNumber, const String &method, int httpMethod, AlpacaResponse &resp); +}; + +extern AlpacaRotator alpacaRotator; + +#endif From 4d4ec7248c3c19493d323ded5d37d3fbb22f53a0 Mon Sep 17 00:00:00 2001 From: Michel Moriniaux Date: Wed, 22 Jul 2026 18:22:28 -0700 Subject: [PATCH 3/4] Added Focuser --- src/libApp/alpaca/Alpaca.cpp | 6 + src/libApp/alpaca/AlpacaCommon.cpp | 4 +- src/libApp/alpaca/AlpacaCommon.h | 4 +- src/libApp/alpaca/AlpacaFocuser.cpp | 179 ++++++++++++++++++ src/libApp/alpaca/AlpacaFocuser.h | 20 ++ src/libApp/alpaca/AlpacaManagement.cpp | 12 ++ .../alpaca/AlpacaObservingConditions.cpp | 5 +- src/libApp/alpaca/AlpacaRotator.cpp | 5 +- src/libApp/alpaca/AlpacaTelescope.cpp | 5 +- 9 files changed, 231 insertions(+), 9 deletions(-) create mode 100644 src/libApp/alpaca/AlpacaFocuser.cpp create mode 100644 src/libApp/alpaca/AlpacaFocuser.h diff --git a/src/libApp/alpaca/Alpaca.cpp b/src/libApp/alpaca/Alpaca.cpp index 3cc831f..963d7b9 100644 --- a/src/libApp/alpaca/Alpaca.cpp +++ b/src/libApp/alpaca/Alpaca.cpp @@ -12,6 +12,7 @@ #include "AlpacaTelescope.h" #include "AlpacaObservingConditions.h" #include "AlpacaRotator.h" +#include "AlpacaFocuser.h" void handleNotFound(); @@ -108,6 +109,11 @@ void Alpaca::handleApiRequest(String segments[], int count, AlpacaResponse &resp return; } + if (deviceType == "focuser") { + alpacaFocuser.handleRequest(deviceNumber, method, httpMethod, resp); + return; + } + resp.setError(ALPACA_ERR_NOT_IMPLEMENTED, "Unknown or unconfigured Alpaca device type"); } diff --git a/src/libApp/alpaca/AlpacaCommon.cpp b/src/libApp/alpaca/AlpacaCommon.cpp index ab69941..fc212b6 100644 --- a/src/libApp/alpaca/AlpacaCommon.cpp +++ b/src/libApp/alpaca/AlpacaCommon.cpp @@ -12,7 +12,7 @@ bool alpacaHandleCommonMember(const String &member, int httpMethod, AlpacaResponse &resp, const char *name, const char *description, int interfaceVersion, - bool connected, String (*deviceStateJson)()) { + bool connected, int deviceNumber, String (*deviceStateJson)(int)) { if (member == "connected") { if (httpMethod == HTTP_GET) { @@ -51,7 +51,7 @@ bool alpacaHandleCommonMember(const String &member, int httpMethod, AlpacaRespon if (member == "devicestate") { String out = "["; - out += deviceStateJson(); + out += deviceStateJson(deviceNumber); out += "]"; resp.setValue(out); return true; diff --git a/src/libApp/alpaca/AlpacaCommon.h b/src/libApp/alpaca/AlpacaCommon.h index d7f7e9d..22bdc69 100644 --- a/src/libApp/alpaca/AlpacaCommon.h +++ b/src/libApp/alpaca/AlpacaCommon.h @@ -18,8 +18,10 @@ // // `deviceStateJson` supplies the device-specific DeviceState entries as already-valid, // comma-joined {"Name":...,"Value":...} JSON object fragments (no wrapping brackets). +// It's passed the same deviceNumber as the request -- needed for multi-instance device types +// (e.g. Focuser, one instance per OnStep axis 4-9); single-instance callbacks just ignore it. bool alpacaHandleCommonMember(const String &member, int httpMethod, AlpacaResponse &resp, const char *name, const char *description, int interfaceVersion, - bool connected, String (*deviceStateJson)()); + bool connected, int deviceNumber, String (*deviceStateJson)(int)); #endif diff --git a/src/libApp/alpaca/AlpacaFocuser.cpp b/src/libApp/alpaca/AlpacaFocuser.cpp new file mode 100644 index 0000000..1ba78af --- /dev/null +++ b/src/libApp/alpaca/AlpacaFocuser.cpp @@ -0,0 +1,179 @@ +// ----------------------------------------------------------------------------------- +// ASCOM Alpaca IFocuserV4 implementation, gateway to OnStep via onStep.command() +// +// Command mappings confirmed against OnStepX src/telescope/focuser/local/Focuser.command.cpp +// (fetched directly, not just the docs table -- see AlpacaTelescope.cpp's header comment for +// why that mattered elsewhere in this session). +// +// OnStep supports up to 6 focusers (OnStepX axes 4-9); Alpaca device number N maps directly to +// OnStep focuser slot N+1 (device 0 = focuser 1/axis4, ... device 5 = focuser 6/axis9), matching +// how status.focuserPresent[6] (Status.cpp's Status::focuserScan(), probed once at boot) already +// tracks presence per slot -- device numbers for absent slots simply aren't advertised, rather +// than compacting the numbering. +// +// Every command below directly addresses its focuser (":F...#", n = 1-6) rather than +// using the stateful ":FA#" select-then-act pattern the existing web UI uses (see +// src/pages/focuser/SelectTile.cpp) -- OnStepX's own command parser supports this (it rewrites +// ":F#" into an internally-selected command before dispatch), and it avoids a +// select/act race between concurrent Alpaca requests for different focusers entirely. +#include "AlpacaFocuser.h" + +#if ALPACA_SERVER == ON + +#include "Alpaca.h" +#include "AlpacaCommon.h" +#include "AlpacaError.h" +#include "../cmd/Cmd.h" +#include "../status/Status.h" +#include "../../lib/ethernet/webServer/WebServer.h" +#include "../../lib/wifi/webServer/WebServer.h" + +// all commands use the UPPERCASE sub-command letter (micron units, matching the existing web +// UI) except temperature/TCF-enable, which OnStepX only exposes in one (lowercase) form +static bool cmdGetLong(int deviceNumber, char subCmd, long &out) { + char cmd[8], temp[80]; + snprintf(cmd, sizeof(cmd), ":F%d%c#", deviceNumber + 1, subCmd); + if (!onStep.command(cmd, temp)) return false; + out = atol(temp); + return true; +} + +static bool cmdSetLong(int deviceNumber, char subCmd, long value) { + char cmd[24]; + snprintf(cmd, sizeof(cmd), ":F%d%c%ld#", deviceNumber + 1, subCmd, value); + return onStep.commandBool(cmd); +} + +static bool getIsMoving(int deviceNumber, bool &out) { + char cmd[8], temp[80]; + snprintf(cmd, sizeof(cmd), ":F%dT#", deviceNumber + 1); + if (!onStep.command(cmd, temp)) return false; + out = temp[0] == 'M'; + return true; +} + +static bool getTemperature(int deviceNumber, double &out) { + char cmd[8], temp[80]; + snprintf(cmd, sizeof(cmd), ":F%dt#", deviceNumber + 1); + if (!onStep.command(cmd, temp)) return false; + out = atof(temp); + return true; +} + +static String focuserDeviceStateJson(int deviceNumber) { + String out; + bool first = true; + long pos; + bool moving; + double temperature; + if (cmdGetLong(deviceNumber, 'G', pos)) jsonStateValueEntry(out, first, "Position", jsonInt(pos)); + if (getIsMoving(deviceNumber, moving)) jsonStateValueEntry(out, first, "IsMoving", jsonBool(moving)); + if (getTemperature(deviceNumber, temperature)) jsonStateValueEntry(out, first, "Temperature", jsonNumber(temperature)); + jsonStateValueEntry(out, first, "TimeStamp", jsonString("")); + return out; +} + +void AlpacaFocuser::handleRequest(int deviceNumber, const String &method, int httpMethod, AlpacaResponse &resp) { + // presence is decided once at boot by Status::focuserScan() (Status.cpp), which probes + // ":F1a#".."F6a#" -- reuse that decision (and AlpacaManagement.cpp only ever advertises + // device numbers where it's true) rather than probing again per-request + bool present = deviceNumber >= 0 && deviceNumber < 6 && status.focuserPresent[deviceNumber]; + bool connected = status.onStepFound && present; + + if (alpacaHandleCommonMember(method, httpMethod, resp, "OnStep", "OnStep focuser, via SmartWebServer", + 4, connected, deviceNumber, focuserDeviceStateJson)) { + return; + } + + if (!connected) { + resp.setError(ALPACA_ERR_NOT_CONNECTED, present ? "OnStep not found" : "No focuser at this device number"); + return; + } + + // both stepper (true absolute position/limit switches) and DC-motor (":Fp#" reports "pseudo + // absolute", no limit switches) focusers still track and report a position and accept + // absolute-position moves the same way through ":FS#", so both are reported as Absolute here + if (method == "absolute") { resp.setValue(jsonBool(true)); return; } + + if (method == "ismoving") { + bool moving; + if (getIsMoving(deviceNumber, moving)) resp.setValue(jsonBool(moving)); else resp.setError(ALPACA_ERR_DRIVER_COMMS_FAILURE, "Comms failure"); + return; + } + + if (method == "maxstep" || method == "maxincrement") { + // no separate "maximum single move" limit is exposed, so MaxIncrement (largest move allowed + // in one Move() call) is reported the same as MaxStep (the full travel range), allowing + // full-range moves in a single command -- a common, reasonable default for this property + long maxPos; + if (cmdGetLong(deviceNumber, 'M', maxPos)) resp.setValue(jsonInt(maxPos)); else resp.setError(ALPACA_ERR_DRIVER_COMMS_FAILURE, "Comms failure"); + return; + } + + if (method == "position") { + long pos; + if (cmdGetLong(deviceNumber, 'G', pos)) resp.setValue(jsonInt(pos)); else resp.setError(ALPACA_ERR_DRIVER_COMMS_FAILURE, "Comms failure"); + return; + } + + if (method == "stepsize") { + // Position above is reported in the same microns ":FG#" itself returns (matching the + // existing web UI, src/pages/focuser/*.cpp), not raw motor steps -- so StepSize must be + // 1.0 here to stay internally consistent (Position * StepSize must equal real microns). + // Reporting OnStepX's actual microns-per-step ratio (":Fu#") instead would silently corrupt + // any client math (e.g. autofocus routines) that combines the two. + resp.setValue(jsonNumber(1.0)); + return; + } + + if (method == "temperature") { + double t; + if (getTemperature(deviceNumber, t)) resp.setValue(jsonNumber(t)); else resp.setError(ALPACA_ERR_DRIVER_COMMS_FAILURE, "Comms failure"); + return; + } + + if (method == "tempcompavailable") { resp.setValue(jsonBool(true)); return; } + + if (method == "tempcomp") { + if (httpMethod == HTTP_GET) { + char cmd[8]; + snprintf(cmd, sizeof(cmd), ":F%dc#", deviceNumber + 1); + resp.setValue(jsonBool(onStep.commandBool(cmd))); + } else { + bool on = alpacaArgBool("TempComp", false); + char cmd[8]; + snprintf(cmd, sizeof(cmd), ":F%dc%d#", deviceNumber + 1, on ? 1 : 0); + if (!onStep.commandBool(cmd)) resp.setError(ALPACA_ERR_INVALID_OPERATION, "OnStep rejected the change"); + } + return; + } + + if (method == "halt") { + char cmd[8]; + snprintf(cmd, sizeof(cmd), ":F%dQ#", deviceNumber + 1); + onStep.commandBlind(cmd); + return; + } + + if (method == "move") { + // Absolute is always true above, so per the ASCOM spec Move(Position) always targets an + // absolute position (never a relative offset) + double posD; + if (!alpacaArgDouble("Position", posD)) { resp.setError(ALPACA_ERR_INVALID_VALUE, "Missing Position"); return; } + long pos = lround(posD); + long minPos, maxPos; + if (!cmdGetLong(deviceNumber, 'I', minPos) || !cmdGetLong(deviceNumber, 'M', maxPos)) { + resp.setError(ALPACA_ERR_DRIVER_COMMS_FAILURE, "Comms failure"); + return; + } + if (pos < minPos || pos > maxPos) { resp.setError(ALPACA_ERR_INVALID_VALUE, "Position outside the focuser's travel range"); return; } + if (!cmdSetLong(deviceNumber, 'S', pos)) resp.setError(ALPACA_ERR_INVALID_OPERATION, "OnStep rejected the move"); + return; + } + + resp.setError(ALPACA_ERR_NOT_IMPLEMENTED, "Unknown Focuser member: " + method); +} + +AlpacaFocuser alpacaFocuser; + +#endif diff --git a/src/libApp/alpaca/AlpacaFocuser.h b/src/libApp/alpaca/AlpacaFocuser.h new file mode 100644 index 0000000..320b9be --- /dev/null +++ b/src/libApp/alpaca/AlpacaFocuser.h @@ -0,0 +1,20 @@ +// ----------------------------------------------------------------------------------- +// ASCOM Alpaca IFocuserV4 implementation, gateway to OnStep via onStep.command() +// One instance covers all (up to 6) focusers -- see AlpacaFocuser.cpp for how device +// number maps to the underlying OnStep focuser/axis. +#pragma once + +#include "../../Common.h" + +#if ALPACA_SERVER == ON + +#include "AlpacaJson.h" + +class AlpacaFocuser { + public: + void handleRequest(int deviceNumber, const String &method, int httpMethod, AlpacaResponse &resp); +}; + +extern AlpacaFocuser alpacaFocuser; + +#endif diff --git a/src/libApp/alpaca/AlpacaManagement.cpp b/src/libApp/alpaca/AlpacaManagement.cpp index a6787a7..42c466b 100644 --- a/src/libApp/alpaca/AlpacaManagement.cpp +++ b/src/libApp/alpaca/AlpacaManagement.cpp @@ -54,6 +54,18 @@ void alpacaManagementConfiguredDevices(AlpacaResponse &resp) { appendDevice(out, first, "OnStep", "Rotator", 0); } + // focusers are axes 4-9 configured at compile time on the OnStepX side; presence per slot is + // decided once at boot by Status::focuserScan() (Status.cpp, ":F1a#".."F6a#") -- device number + // N is advertised only when slot N is actually present, so gaps in OnStep's own focuser + // numbering show up as gaps in the Alpaca device numbers too rather than being compacted + for (int i = 0; i < 6; i++) { + if (status.focuserPresent[i]) { + char name[16]; + snprintf(name, sizeof(name), "OnStep %d", i + 1); + appendDevice(out, first, name, "Focuser", i); + } + } + out += "]"; resp.setValue(out); } diff --git a/src/libApp/alpaca/AlpacaObservingConditions.cpp b/src/libApp/alpaca/AlpacaObservingConditions.cpp index db13526..61abfd9 100644 --- a/src/libApp/alpaca/AlpacaObservingConditions.cpp +++ b/src/libApp/alpaca/AlpacaObservingConditions.cpp @@ -66,7 +66,8 @@ static bool sensorAvailable(int index) { return cmdGetSensor(sensors[index].cmd, v) && !isnan(v); } -static String obsCondDeviceStateJson() { +static String obsCondDeviceStateJson(int deviceNumber) { + UNUSED(deviceNumber); // single-instance device String out; bool first = true; for (int i = 0; i < sensorCount; i++) { @@ -88,7 +89,7 @@ void AlpacaObservingConditions::handleRequest(int deviceNumber, const String &me bool connected = status.onStepFound; if (alpacaHandleCommonMember(method, httpMethod, resp, "OnStep", "OnStep ambient weather sensors, via SmartWebServer", - 2, connected, obsCondDeviceStateJson)) { + 2, connected, deviceNumber, obsCondDeviceStateJson)) { return; } diff --git a/src/libApp/alpaca/AlpacaRotator.cpp b/src/libApp/alpaca/AlpacaRotator.cpp index 770c115..90ba661 100644 --- a/src/libApp/alpaca/AlpacaRotator.cpp +++ b/src/libApp/alpaca/AlpacaRotator.cpp @@ -48,7 +48,8 @@ static bool getIsMoving(bool &out) { static bool targetPositionSet = false; static double targetPositionValue = 0; -static String rotatorDeviceStateJson() { +static String rotatorDeviceStateJson(int deviceNumber) { + UNUSED(deviceNumber); // single-instance device String out; bool first = true; double pos; @@ -71,7 +72,7 @@ void AlpacaRotator::handleRequest(int deviceNumber, const String &method, int ht bool connected = status.onStepFound && status.rotatorFound == SD_TRUE; if (alpacaHandleCommonMember(method, httpMethod, resp, "OnStep", "OnStep rotator, via SmartWebServer", - 4, connected, rotatorDeviceStateJson)) { + 4, connected, deviceNumber, rotatorDeviceStateJson)) { return; } diff --git a/src/libApp/alpaca/AlpacaTelescope.cpp b/src/libApp/alpaca/AlpacaTelescope.cpp index 5033989..09c8ade 100644 --- a/src/libApp/alpaca/AlpacaTelescope.cpp +++ b/src/libApp/alpaca/AlpacaTelescope.cpp @@ -169,7 +169,8 @@ static int nearestGuideRateIndex(double degPerSec) { return bestIndex; } -static String telescopeDeviceStateJson() { +static String telescopeDeviceStateJson(int deviceNumber) { + UNUSED(deviceNumber); // single-instance device String out; bool first = true; double ra, dec, az, alt, lst; @@ -209,7 +210,7 @@ void AlpacaTelescope::handleRequest(int deviceNumber, const String &method, int bool connected = status.onStepFound; if (alpacaHandleCommonMember(method, httpMethod, resp, "OnStep", "OnStep telescope mount, via SmartWebServer", - 4, connected, telescopeDeviceStateJson)) { + 4, connected, deviceNumber, telescopeDeviceStateJson)) { return; } From 55220a377ebe7e9dc1239ea4efcaba720b8d8310 Mon Sep 17 00:00:00 2001 From: Michel Moriniaux Date: Tue, 28 Jul 2026 22:04:49 -0700 Subject: [PATCH 4/4] bug fixes following conformance tests --- Config.h | 5 + src/libApp/alpaca/Alpaca.cpp | 11 ++ src/libApp/alpaca/AlpacaTelescope.cpp | 220 ++++++++++++++++++++++---- src/libApp/cmd/Cmd.cpp | 7 +- 4 files changed, 210 insertions(+), 33 deletions(-) diff --git a/Config.h b/Config.h index 88bfd50..07151fd 100644 --- a/Config.h +++ b/Config.h @@ -38,6 +38,11 @@ #define SERIAL_SWAP AUTO // AUTO, Automatic check both, ON for swapped port or OFF for default port only. Infreq // this option is ignored in ETHERNET modes +// ASCOM ALPACA -------------------------------------------------------------------------------------------------------------------- +#define ALPACA_SERVER OFF // ON, Exposes OnStep as an ASCOM Alpaca Telescope over HTTP/JSON on port 80. Option + // Also starts the Alpaca UDP discovery responder (see ALPACA_DISCOVERY_PORT + // in Config.defaults.h, default 32227.) OFF to disable both. + // BLE GAMEPAD SETTINGS (ESP32 ONLY) ------------------------------------------------ see https://onstep.groups.io/g/main/wiki/26762 #define BLE_GAMEPAD OFF // OFF, ON to allow BLE gamepad connection for ESP32 only. Option #define BLE_GP_ADDR "ff:ff:de:09:f5:cf" // f5:cf", GamePad MAC address #1 Adjust diff --git a/src/libApp/alpaca/Alpaca.cpp b/src/libApp/alpaca/Alpaca.cpp index 963d7b9..aaf6fe5 100644 --- a/src/libApp/alpaca/Alpaca.cpp +++ b/src/libApp/alpaca/Alpaca.cpp @@ -149,6 +149,17 @@ Alpaca alpaca; void alpacaOrNotFound() { String uri = www.uri(); + + // the Alpaca spec requires a browser-facing setup page at "/setup" (server-wide) and at + // "/setup/v1/{devicetype}/{devicenumber}/setup" (per-device) -- rather than building a + // bespoke configuration UI for each device class, both simply redirect to the existing + // SmartWebServer root page, which already covers the same ground (network/mount/etc. setup) + if (uri.equals("/setup") || uri.startsWith("/setup/")) { + www.sendHeader("Location", "/"); + www.send(302, "text/html", ""); + return; + } + if (uri.startsWith("/api/") || uri.startsWith("/management/") || uri.equals("/management")) { alpaca.handleRequest(); } else { diff --git a/src/libApp/alpaca/AlpacaTelescope.cpp b/src/libApp/alpaca/AlpacaTelescope.cpp index 09c8ade..533ca65 100644 --- a/src/libApp/alpaca/AlpacaTelescope.cpp +++ b/src/libApp/alpaca/AlpacaTelescope.cpp @@ -90,6 +90,40 @@ static bool getSiteLatLon(double &lat, double &lon) { return ok1 && ok2; } +// converts Alt/Az to RA/Dec so SlewToAltAzAsync can go through the (already hardware-confirmed) +// ":Sr#"/":Sd#"+":MS#" RA/Dec goto path instead of ":Sz#"/":Sa#" -- confirmed against OnStepX +// src/telescope/mount/goto/Goto.cpp's setTarget(): for any mount type other than ALTAZM/ALTALT +// (i.e. every GEM/Fork equatorial mount), ":MS#" ignores whatever ":Sz#"/":Sa#" set and instead +// *overwrites* the goto target's Alt/Az from its existing (possibly stale, e.g. left over from +// the previous test's RA/Dec target) equatorial target -- confirmed via ASCOM Conform against +// real hardware: SlewToAltAzAsync landed ~100 degrees away in Azimuth and ~28 degrees away in +// Altitude, matching where the mount's *previous* RA/Dec target converted to, not the requested +// Alt/Az. The math below mirrors OnStepX's own Transform::horToEqu (src/telescope/mount/ +// coordinates/Transform.cpp) exactly, so it round-trips correctly against OnStepX's own +// equToHor() used for the AltAz GET properties -- and works regardless of the mount's physical +// type, since horizontal<->equatorial conversion is invertible either way. +static bool altAzToRaDec(double az, double alt, double &ra, double &dec) { + double lat, lon, lst; + if (!getSiteLatLon(lat, lon) || !cmdGetHours(":GS#", lst)) return false; + + const double d2r = 0.017453292519943295; + double latRad = lat * d2r, azRad = az * d2r, altRad = alt * d2r; + double cosAz = cos(azRad); + double sinDec = sin(altRad) * sin(latRad) + cos(altRad) * cos(latRad) * cosAz; + dec = asin(sinDec) / d2r; + + double t1 = sin(azRad); + double t2 = cosAz * sin(latRad) - tan(altRad) * cos(latRad); + double ha = atan2(t1, t2) / d2r; + ha += 180.0; + if (ha > 180.0) ha -= 360.0; + + ra = lst - ha / 15.0; + while (ra < 0) ra += 24.0; + while (ra >= 24.0) ra -= 24.0; + return true; +} + // starts a goto via ":MS#" (single-digit reply per Cmd.cpp's shortResponse handling for the // "S" sub-command). Confirmed against OnStepX src/telescope/mount/goto/Goto.command.cpp: the // digit is OnStepX's own 0..9 goto-result code (0=possible, 1=below horizon, ... 9=unspecified), @@ -111,6 +145,20 @@ static bool startGoto(AlpacaResponse &resp) { return true; } +// per the ASCOM spec, AbortSlew/FindHome/MoveAxis/PulseGuide/SyncToCoordinates/SyncToTarget (and, +// defensively, the SlewTo*Async methods -- see startGoto()'s CE_SLEW_ERR_IN_PARK handling, which +// already covers those via OnStep's own rejection) must throw ParkedException while parked -- +// confirmed via ASCOM Conform against real hardware: several of these use commandBlind() or a +// "fails silently" OnStep reply (":CS#"), so nothing would otherwise surface OnStep's own +// rejection (if any) as an Alpaca error +static bool rejectIfParked(AlpacaResponse &resp) { + if (status.parked) { + resp.setError(ALPACA_ERR_PARKED, "Parked"); + return true; + } + return false; +} + // OnStep's ":Gr#"/":Gd#" always return *some* parseable value (its own last/default target), // so unlike most of this file's "did the command succeed" checks, "has a target ever actually // been set" has to be tracked here in software -- confirmed via ASCOM Conform: reading @@ -147,11 +195,22 @@ static int sideOfPier() { } // DriveRates: driveSidereal=0, driveLunar=1, driveSolar=2, driveKing=3 +// index 0=Sidereal,1=Lunar,2=Solar,3=King, matching Alpaca's DriveRate enum and this file's +// trackingrate PUT handler below. Picks the *nearest* of OnStepX's four known rate constants +// (src/lib/Constants.h: SIDEREAL_RATE_HZ=60.16427..., LUNAR_RATE_HZ=57.9, SOLAR_RATE_HZ=60.0, +// KING_RATE_HZ=60.136) instead of fixed +/-0.05 thresholds -- confirmed via ASCOM Conform +// against real hardware: Sidereal and King are only 0.028Hz apart, so a fixed-order threshold +// check matched true Sidereal readings against the King branch first, misreporting the rate +// right after setting it and making Conform think the Sidereal write had failed. static int trackingRateFromHz(double hz) { - if (fabs(hz - 57.900) < 0.05) return 1; - if (fabs(hz - 60.000) < 0.05) return 2; - if (fabs(hz - 60.136) < 0.05) return 3; - return 0; // default/closest match is sidereal + static const double rateHz[4] = {60.16427456104770, 57.9, 60.0, 60.136}; + int best = 0; + double bestDiff = fabs(hz - rateHz[0]); + for (int i = 1; i < 4; i++) { + double diff = fabs(hz - rateHz[i]); + if (diff < bestDiff) { bestDiff = diff; best = i; } + } + return best; } static double guideRateDegPerSec(int index) { @@ -169,6 +228,36 @@ static int nearestGuideRateIndex(double degPerSec) { return bestIndex; } +// remembers which continuous-motion direction command is active per axis so MoveAxis(axis,0) knows what to stop +static char axis0Dir = 0; // 'e', 'w', or 0 +static char axis1Dir = 0; // 'n', 's', or 0 + +// live ":GU#" query for goto-in-progress/pulse-guiding state, bypassing status.inGoto/ +// status.pulseGuiding, which are only refreshed by the periodic background status poll (up to +// ~1s stale) -- confirmed via ASCOM Conform against real hardware: IsPulseGuiding read +// immediately after PulseGuide() started a 2s pulse returned FALSE, because the cached flag +// hadn't been refreshed yet since the pulse began. Falls back to the cached status.* fields on +// comms failure. +static void queryGotoPulseState(bool &inGoto, bool &pulseGuiding) { + inGoto = status.inGoto; + pulseGuiding = status.pulseGuiding; + char temp[80]; + if (!onStep.command(":GU#", temp)) return; + inGoto = strstr(temp, "N") == NULL; + pulseGuiding = strstr(temp, "G") != NULL; +} + +// Slewing must be true during MoveAxis-driven jogging, not just during a goto -- confirmed via +// ASCOM Conform against real hardware ("Slewing is not true immediately/after 2 seconds moving"). +// The live goto flag only reflects OnStepX's goto engine; the ":Me#"/":Mw#"/":Mn#"/":Ms#" jog +// commands MoveAxis uses are a different subsystem that doesn't set it, so this also has to +// track the jog state kept above. +static bool isSlewing() { + bool inGoto, pulseGuiding; + queryGotoPulseState(inGoto, pulseGuiding); + return inGoto || axis0Dir != 0 || axis1Dir != 0; +} + static String telescopeDeviceStateJson(int deviceNumber) { UNUSED(deviceNumber); // single-instance device String out; @@ -187,20 +276,18 @@ static String telescopeDeviceStateJson(int deviceNumber) { jsonStateValueEntry(out, first, "Altitude", jsonNumber(alt)); } if (haveLst) jsonStateValueEntry(out, first, "SiderealTime", jsonNumber(lst)); + bool inGoto, pulseGuiding; + queryGotoPulseState(inGoto, pulseGuiding); jsonStateValueEntry(out, first, "AtHome", jsonBool(status.atHome)); jsonStateValueEntry(out, first, "AtPark", jsonBool(status.parked)); - jsonStateValueEntry(out, first, "Slewing", jsonBool(status.inGoto)); + jsonStateValueEntry(out, first, "Slewing", jsonBool(inGoto || axis0Dir != 0 || axis1Dir != 0)); jsonStateValueEntry(out, first, "Tracking", jsonBool(status.tracking)); jsonStateValueEntry(out, first, "SideOfPier", jsonInt(sideOfPier())); - jsonStateValueEntry(out, first, "IsPulseGuiding", jsonBool(status.pulseGuiding)); + jsonStateValueEntry(out, first, "IsPulseGuiding", jsonBool(pulseGuiding)); jsonStateValueEntry(out, first, "TimeStamp", jsonString("")); return out; } -// remembers which continuous-motion direction command is active per axis so MoveAxis(axis,0) knows what to stop -static char axis0Dir = 0; // 'e', 'w', or 0 -static char axis1Dir = 0; // 'n', 's', or 0 - void AlpacaTelescope::handleRequest(int deviceNumber, const String &method, int httpMethod, AlpacaResponse &resp) { if (deviceNumber != 0) { resp.setError(ALPACA_ERR_INVALID_VALUE, "Invalid Telescope device number"); @@ -225,12 +312,42 @@ void AlpacaTelescope::handleRequest(int deviceNumber, const String &method, int if (method == "alignmentmode") { resp.setValue(jsonInt(alignmentMode())); return; } - if (method == "aperturearea" || method == "aperturediameter" || method == "focallength" || - method == "siteelevation") { + if (method == "aperturearea" || method == "aperturediameter" || method == "focallength") { resp.setError(ALPACA_ERR_NOT_IMPLEMENTED, "Not known to OnStep/SmartWebServer"); return; } + if (method == "siteelevation") { + // confirmed against OnStepX src/telescope/mount/site/Site.command.cpp: ":Gv#"/":Sv#" exist + // (meters, simple signed decimal, no dms/hms conversion needed) -- previously left as + // NotImplementedException, which turned out to be the root cause of an ASCOM Conform + // "CheckMethods" abort (some other test apparently needs elevation internally and didn't + // handle that exception gracefully) + if (httpMethod == HTTP_GET) { + char temp[80]; + if (!onStep.command(":Gv#", temp)) { resp.setError(ALPACA_ERR_DRIVER_COMMS_FAILURE, "Comms failure"); return; } + resp.setValue(jsonNumber(atof(temp))); + } else { + // validated against the *intersection* of two different valid ranges, confirmed via ASCOM + // Conform against real hardware both ways: + // - ASCOM's own spec documents SiteElevation as valid from -300 to +10,000m, and Conform + // enforces that ceiling regardless of what the hardware can actually do (a value above + // 10,000 must be rejected here even though OnStep would happily accept it) + // - OnStepX's Site::setElevation() (src/telescope/mount/site/Site.cpp) only accepts + // [-100, 20000) -- narrower than ASCOM's floor of -300, so a value between -300 and + // -100 would pass a check against the ASCOM range alone and then genuinely get + // rejected by OnStep (surfaced as a confusing "OnStep rejected value" rather than a + // clean InvalidValueException) + // the intersection (-100 to 10,000) guarantees anything accepted here is valid on both counts + double v; + if (!alpacaArgDouble("SiteElevation", v) || v < -100 || v > 10000) { resp.setError(ALPACA_ERR_INVALID_VALUE, "SiteElevation out of range"); return; } + char cmd[24]; + snprintf(cmd, sizeof(cmd), ":Sv%+.1f#", v); + if (!onStep.commandBool(cmd)) resp.setError(ALPACA_ERR_INVALID_VALUE, "OnStep rejected value"); + } + return; + } + // per the ASCOM spec these two GETs are mandatory and must return 0 when not settable // (CanSetDeclinationRate/CanSetRightAscensionRate are both false) rather than throw -- // confirmed against real hardware via the ASCOM Conform Universal tool @@ -302,12 +419,17 @@ void AlpacaTelescope::handleRequest(int deviceNumber, const String &method, int char cmd[24]; if (method == "sitelatitude") { if (v < -90 || v > 90) { resp.setError(ALPACA_ERR_INVALID_VALUE, "Bad latitude"); return; } - convert.doubleToDms(buf, v, false, true, PM_HIGH); + // PM_HIGHEST (fractional arcseconds), not PM_HIGH -- confirmed via ASCOM Conform that + // PM_HIGH's whole-second precision was losing the fractional part on round-trip (Conform + // writes e.g. +44:17:27.4 and expects to read the same value back); OnStepX's ":St#" + // documents the ".SSS" fractional form as valid input + convert.doubleToDms(buf, v, false, true, PM_HIGHEST); snprintf(cmd, sizeof(cmd), ":St%s#", buf); } else { if (v < -180 || v > 180) { resp.setError(ALPACA_ERR_INVALID_VALUE, "Bad longitude"); return; } // negate: ASCOM SiteLongitude is +East, OnStepX's ":Sg#" is +West ("east is negative"), see getSiteLatLon() - convert.doubleToDms(buf, -v, true, true, PM_HIGH); + // PM_HIGHEST: see the sitelatitude comment above, same round-trip precision fix + convert.doubleToDms(buf, -v, true, true, PM_HIGHEST); snprintf(cmd, sizeof(cmd), ":Sg%s#", buf); } if (!onStep.commandBool(cmd)) resp.setError(ALPACA_ERR_INVALID_VALUE, "OnStep rejected value"); @@ -325,8 +447,13 @@ void AlpacaTelescope::handleRequest(int deviceNumber, const String &method, int if (method == "athome") { resp.setValue(jsonBool(status.atHome)); return; } if (method == "atpark") { resp.setValue(jsonBool(status.parked)); return; } - if (method == "slewing") { resp.setValue(jsonBool(status.inGoto)); return; } - if (method == "ispulseguiding") { resp.setValue(jsonBool(status.pulseGuiding)); return; } + if (method == "slewing") { resp.setValue(jsonBool(isSlewing())); return; } + if (method == "ispulseguiding") { + bool inGoto, pulseGuiding; + queryGotoPulseState(inGoto, pulseGuiding); + resp.setValue(jsonBool(pulseGuiding)); + return; + } if (method == "sideofpier") { if (httpMethod == HTTP_GET) resp.setValue(jsonInt(sideOfPier())); else resp.setError(ALPACA_ERR_NOT_IMPLEMENTED, "SideOfPier cannot be set"); @@ -334,7 +461,12 @@ void AlpacaTelescope::handleRequest(int deviceNumber, const String &method, int } if (method == "slewsettletime") { - if (httpMethod == HTTP_GET) resp.setValue(jsonInt(0)); + // OnStepX's goto engine does a final slow-speed "near destination" refine/backlash-takeup + // pass right before it clears its in-goto flag (src/telescope/mount/goto/Goto.cpp, + // nearDestinationRefineStages) -- confirmed via ASCOM Conform against real hardware: reading + // RA/Dec/Az/Alt immediately after Slewing goes false was consistently ~10-25 arcseconds off + // target. 2s is a heuristic buffer for that settling, not a measured minimum. + if (httpMethod == HTTP_GET) resp.setValue(jsonInt(2)); else resp.setError(ALPACA_ERR_NOT_IMPLEMENTED, "Not adjustable"); return; } @@ -431,10 +563,17 @@ void AlpacaTelescope::handleRequest(int deviceNumber, const String &method, int // ---- methods ---- - if (method == "abortslew") { onStep.commandBlind(":Q#"); return; } - if (method == "findhome") { onStep.commandBlind(":hC#"); return; } + if (method == "abortslew") { if (rejectIfParked(resp)) return; onStep.commandBlind(":Q#"); return; } + if (method == "findhome") { if (rejectIfParked(resp)) return; onStep.commandBlind(":hC#"); return; } if (method == "park") { if (!onStep.commandBool(":hP#")) resp.setError(ALPACA_ERR_INVALID_OPERATION, "Park failed"); return; } - if (method == "unpark") { if (!onStep.commandBool(":hR#")) resp.setError(ALPACA_ERR_INVALID_OPERATION, "Unpark failed"); return; } + if (method == "unpark") { + // per the ASCOM spec, unparking an already-unparked scope must be a harmless no-op, not an + // error -- confirmed via ASCOM Conform: OnStep's ":hR#" isn't idempotent the way ":hP#" is + // (it fails when there's nothing to unpark), so that has to be handled here instead + if (!status.parked) return; + if (!onStep.commandBool(":hR#")) resp.setError(ALPACA_ERR_INVALID_OPERATION, "Unpark failed"); + return; + } if (method == "setpark") { if (!onStep.commandBool(":hQ#")) resp.setError(ALPACA_ERR_INVALID_OPERATION, "SetPark failed"); return; } if (method == "slewtocoordinates" || method == "slewtotarget" || method == "slewtoaltaz") { @@ -443,6 +582,7 @@ void AlpacaTelescope::handleRequest(int deviceNumber, const String &method, int } if (method == "slewtocoordinatesasync") { + if (rejectIfParked(resp)) return; double ra, dec; if (!alpacaArgDouble("RightAscension", ra) || !alpacaArgDouble("Declination", dec) || ra < 0 || ra >= 24 || dec < -90 || dec > 90) { @@ -454,34 +594,29 @@ void AlpacaTelescope::handleRequest(int deviceNumber, const String &method, int return; } if (method == "slewtotargetasync") { + if (rejectIfParked(resp)) return; if (!targetRaSet || !targetDecSet) { resp.setError(ALPACA_ERR_VALUE_NOT_SET, "Target has not been set"); return; } startGoto(resp); return; } if (method == "slewtoaltazasync") { - // confirmed against OnStepX src/telescope/mount/goto/Goto.command.cpp: ":Sz#" takes an - // unsigned DDD*MM[:SS[.SSS]] (dmsToDouble signPresent=false, 0..360) and ":Sa#" a signed - // sDD*MM[:SS[.SSS]] (signPresent=true, -90..90) -- matches doubleToDms(fullRange, signPresent) - // below exactly. (Their docs show a "'" seconds separator; the shared Convert::dmsToDouble - // parser accepts ':' equally, which is what doubleToDms here actually emits.) + if (rejectIfParked(resp)) return; double az, alt; if (!alpacaArgDouble("Azimuth", az) || !alpacaArgDouble("Altitude", alt) || az < 0 || az > 360 || alt < -90 || alt > 90) { resp.setError(ALPACA_ERR_INVALID_VALUE, "Bad coordinates"); return; } - char buf[16], cmd[24]; - convert.doubleToDms(buf, az, true, false, PM_HIGH); - snprintf(cmd, sizeof(cmd), ":Sz%s#", buf); - onStep.commandBool(cmd); - convert.doubleToDms(buf, alt, false, true, PM_HIGH); - snprintf(cmd, sizeof(cmd), ":Sa%s#", buf); - onStep.commandBool(cmd); + // goes via RA/Dec, not OnStepX's ":Sz#"/":Sa#" -- see altAzToRaDec()'s comment for why + double ra, dec; + if (!altAzToRaDec(az, alt, ra, dec)) { resp.setError(ALPACA_ERR_DRIVER_COMMS_FAILURE, "Comms failure"); return; } + setRaDecTarget(ra, dec); startGoto(resp); return; } if (method == "synctocoordinates") { + if (rejectIfParked(resp)) return; double ra, dec; if (!alpacaArgDouble("RightAscension", ra) || !alpacaArgDouble("Declination", dec) || ra < 0 || ra >= 24 || dec < -90 || dec > 90) { @@ -493,6 +628,7 @@ void AlpacaTelescope::handleRequest(int deviceNumber, const String &method, int return; } if (method == "synctotarget") { + if (rejectIfParked(resp)) return; if (!targetRaSet || !targetDecSet) { resp.setError(ALPACA_ERR_VALUE_NOT_SET, "Target has not been set"); return; } if (!onStep.commandBool(":CS#")) resp.setError(ALPACA_ERR_INVALID_OPERATION, "Sync failed"); return; @@ -522,10 +658,29 @@ void AlpacaTelescope::handleRequest(int deviceNumber, const String &method, int } if (method == "moveaxis") { + if (rejectIfParked(resp)) return; uint32_t axis = alpacaArgUint32("Axis", 99); double rate; if ((axis != 0 && axis != 1) || !alpacaArgDouble("Rate", rate)) { resp.setError(ALPACA_ERR_INVALID_VALUE, "Bad axis/rate"); return; } + // per the ASCOM spec, a non-zero Rate outside the range AxisRates() advertises (indices 0..7 + // of guideRateXSidereal, see "axisrates" above) must throw InvalidValueException -- confirmed + // via ASCOM Conform, which deliberately probes just outside both ends + if (rate != 0) { + double minRate = guideRateXSidereal[0] * siderealDegPerSec; + double maxRate = guideRateXSidereal[7] * siderealDegPerSec; + // tolerance: the client only ever sees these bounds after they've round-tripped through + // AxisRates()'s 6-decimal JSON (jsonNumber()), so a client re-sending exactly the + // advertised boundary value produces a double that differs from this freshly-recomputed + // maxRate by a tiny rounding amount -- confirmed via ASCOM Conform, which does exactly + // that and had the true boundary rate spuriously rejected by a strict comparison here + const double rateEpsilon = 0.0001; + if (fabs(rate) < minRate - rateEpsilon || fabs(rate) > maxRate + rateEpsilon) { + resp.setError(ALPACA_ERR_INVALID_VALUE, "Rate outside the range AxisRates() advertises"); + return; + } + } + if (axis == 0) { if (axis0Dir) { char c[6]; snprintf(c, sizeof(c), ":Q%c#", axis0Dir); onStep.commandBlind(c); axis0Dir = 0; } if (rate != 0) { @@ -547,6 +702,7 @@ void AlpacaTelescope::handleRequest(int deviceNumber, const String &method, int } if (method == "pulseguide") { + if (rejectIfParked(resp)) return; // confirmed against OnStepX src/telescope/mount/guide/Guide.command.cpp: ":Mgd[n]#" where d // is lowercase 'w','e','n', or 's' (uppercase falls through to CE_CMD_UNKNOWN there) and n is // a plain (not zero-padded) millisecond count parsed with convert.atoi2(); "g" (vs. "G") means diff --git a/src/libApp/cmd/Cmd.cpp b/src/libApp/cmd/Cmd.cpp index 29ad89f..c621470 100644 --- a/src/libApp/cmd/Cmd.cpp +++ b/src/libApp/cmd/Cmd.cpp @@ -141,7 +141,12 @@ bool OnStepCmd::processCommand(const char* cmd, char* response, long timeOutMs) if (strchr("AEGCMS0123456789", cmd[2])) noResponse = true; } else if (cmd[1] == 'S') { - if (strchr("CLSGtgMNOPrdhoTBXza", cmd[2])) shortResponse = true; + // "v" (":Sv#" set site elevation) added after confirming against real hardware: its reply + // is a bare, unterminated "0"/"1" like the rest of this set, not '#'-framed -- without this + // classification the default full-frame-wait path in the else branch below always times + // out waiting for a '#' that never arrives, reporting failure even when OnStep accepted + // the value (used by src/libApp/alpaca/AlpacaTelescope.cpp; no existing page used ":Sv#" before) + if (strchr("CLSGtgMNOPrdhoTBXzav", cmd[2])) shortResponse = true; } else if (cmd[1] == 'L') { if (strchr("BNCDL!",cmd[2])) noResponse = true;