Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Config.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 28 additions & 2 deletions SmartWebServer.ino
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -83,6 +86,12 @@ void pollWebSvr() {
www.handleClient();
}

#if ALPACA_SERVER == ON
void pollAlpacaDiscovery() {
alpacaDiscovery.poll();
}
#endif

void pollCmdSvr() {
if (otaEnabled) return;

Expand Down Expand Up @@ -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};
Expand Down Expand Up @@ -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);

Expand All @@ -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");
Expand Down
7 changes: 7 additions & 0 deletions src/Config.defaults.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 29 additions & 7 deletions src/lib/ethernet/webServer/WebServer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 ");
Expand All @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down
170 changes: 170 additions & 0 deletions src/libApp/alpaca/Alpaca.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// -----------------------------------------------------------------------------------
// 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"
#include "AlpacaRotator.h"
#include "AlpacaFocuser.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;
}

if (deviceType == "rotator") {
alpacaRotator.handleRequest(deviceNumber, method, httpMethod, resp);
return;
}

if (deviceType == "focuser") {
alpacaFocuser.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();

// 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 {
handleNotFound();
}
}

#endif
42 changes: 42 additions & 0 deletions src/libApp/alpaca/Alpaca.h
Original file line number Diff line number Diff line change
@@ -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
Loading