Add native tests, ASCOM Alpaca API, and README

Native test suite (61 tests, 5 suites) with thin mock layer for
Arduino/FreeRTOS/esp_timer enabling host-side testing without hardware.

ASCOM Alpaca REST API on port 32323 with UDP discovery, implementing
Telescope v3 interface for N.I.N.A., PHD2, and compatible software.
Follows existing ST4WiFi conditional compilation pattern.

README documents wiring, all three protocols (serial, WebSocket, Alpaca),
pin/rate configuration, and build instructions.
This commit is contained in:
Ryan Malloy 2026-02-18 13:40:34 -07:00
parent 35ec6dfdb5
commit 71e5484507
21 changed files with 2034 additions and 4 deletions

View File

@ -1,9 +1,9 @@
.PHONY: all basic serial pulse wifi clean .PHONY: all basic serial pulse wifi alpaca clean test
BOARD = esp32dev BOARD = esp32dev
LIB = . LIB = .
all: basic serial pulse wifi all: basic serial pulse wifi alpaca
@echo "All examples built successfully" @echo "All examples built successfully"
basic: basic:
@ -20,5 +20,13 @@ wifi:
--project-option="build_flags=-DST4_WIFI_ENABLED" \ --project-option="build_flags=-DST4_WIFI_ENABLED" \
--project-option="lib_deps=bblanchon/ArduinoJson@^7.0.0, mathieucarbou/ESPAsyncWebServer@^3.6.0" --project-option="lib_deps=bblanchon/ArduinoJson@^7.0.0, mathieucarbou/ESPAsyncWebServer@^3.6.0"
alpaca:
pio ci examples/alpaca_server/alpaca_server.ino --lib="$(LIB)" --board=$(BOARD) \
--project-option="build_flags=-DST4_ALPACA_ENABLED" \
--project-option="lib_deps=bblanchon/ArduinoJson@^7.0.0, mathieucarbou/ESPAsyncWebServer@^3.6.0"
clean: clean:
rm -rf .pio rm -rf .pio
test:
pio test -e native -v

279
README.md Normal file
View File

@ -0,0 +1,279 @@
# ST4-ESP32
ESP32 library for controlling telescope mounts via the ST-4 autoguider port. Drives optocoupler-isolated GPIO pins with hardware-timer pulse guiding, microsecond position tracking, and FreeRTOS thread safety.
Supports serial, WebSocket, and [ASCOM Alpaca](https://ascom-standards.org/api/) interfaces -- drop-in compatible with the original [arduino-st4](https://github.com/kevinferrare/arduino-st4) protocol while adding pulse guiding, position tracking, and network control.
## Wiring
```
ESP32 TLP521-4 (Quad Optocoupler) RJ12 (ST-4)
┌──────────────────────┐
GPIO 16 (RA+) ──┤1 Anode Collector 8├── Pin 3 (RA+)
GND ──┤2 Cathode Emitter 7├── Pin 2 (GND)
GPIO 17 (RA-) ──┤3 Anode Collector 6├── Pin 5 (RA-)
GND ──┤4 Cathode Emitter 5├── Pin 4 (DEC-)
└──────────────────────┘
┌──────────────────────┐
GPIO 18 (DEC+) ──┤1 Anode Collector 8├── Pin 6 (DEC+)
GND ──┤2 Cathode Emitter 7├── Pin 2 (GND)
GPIO 19 (DEC-) ──┤3 Anode Collector 6├── Pin 4 (DEC-)
GND ──┤4 Cathode Emitter 5├── Pin 2 (GND)
└──────────────────────┘
GPIO 2 (LED) ── 220R ── LED ── GND
```
Each GPIO drives an optocoupler LED through a current-limiting resistor (220-470 ohm). The optocoupler transistor shorts the corresponding ST-4 signal to ground, simulating a button press on the hand controller. The optical isolation protects the ESP32 from the mount's electrical domain.
See `arduino-st4/Hardware/` for reference photos of the original build.
## Quick Start
```cpp
#include <ST4.h>
ST4Controller controller;
ST4Serial serial;
void setup() {
controller.begin(); // Default pins: 16, 17, 18, 19, LED=2
serial.begin(controller); // 57600 baud, extended protocol
}
void loop() {
serial.update();
}
```
Flash with PlatformIO:
```bash
pio run -e serial_compatible -t upload -t monitor
```
## Features
- **GPIO safety** -- mutual exclusion prevents simultaneous plus/minus activation on the same axis (optocoupler short protection)
- **Hardware-timer pulse guiding** -- `esp_timer` one-shot with FreeRTOS task handoff, non-blocking microsecond precision
- **Dead-reckoning position tracking** -- port of ASCOM `AxisMovementTracker.cs` using `esp_timer_get_time()` for microsecond resolution
- **Configurable sidereal rates** -- default 9x/7x RA (accounts for Earth rotation), 8x symmetric DEC
- **Serial protocol** -- backward compatible with original arduino-st4 ASCOM driver, plus extended commands
- **WebSocket server** -- JSON command/state protocol with automatic state broadcasting
- **ASCOM Alpaca REST API** -- standard telescope interface for N.I.N.A., PHD2, and any ASCOM-compatible software
- **FreeRTOS thread safety** -- layered mutex hierarchy (Controller > Pulse > Axis) prevents deadlocks across ESP32 dual cores
- **Conditional compilation** -- WiFi and Alpaca are opt-in via `#define`, keeping the core lean
## Serial Protocol
Connect at **57600 baud, 8N1**. Commands are terminated with `#`.
### Basic Commands (arduino-st4 compatible)
| Command | Action |
|---------|--------|
| `CONNECT#` | Enable mount control, turn on LED |
| `DISCONNECT#` | Stop all axes, turn off LED |
| `RA+#` | Slew RA positive |
| `RA-#` | Slew RA negative |
| `RA0#` | Stop RA axis |
| `DEC+#` | Slew DEC positive |
| `DEC-#` | Slew DEC negative |
| `DEC0#` | Stop DEC axis |
### Extended Commands (enabled by default)
| Command | Response |
|---------|----------|
| `PULSE RA+ 500#` | Pulse guide RA+ for 500 ms |
| `PULSE DEC- 1000#` | Pulse guide DEC- for 1000 ms |
| `POS?#` | `POS 12.345678 45.678901#` |
| `SYNC 12.345 45.678#` | Set position to given RA/DEC |
| `STATUS?#` | `STATUS CONNECTED RA:+:12.345678 DEC:0:45.678901#` |
| `VERSION?#` | `VERSION 2026.02.17#` |
## WebSocket Protocol
Connect to `ws://<ip>:81/ws`. Commands and state are JSON.
### Client to Server
```json
{"cmd":"move","axis":"ra","dir":"+"}
{"cmd":"move","axis":"dec","dir":"-"}
{"cmd":"pulse","axis":"ra","dir":"+","ms":500}
{"cmd":"stop"}
{"cmd":"sync","ra":12.345,"dec":45.678}
{"cmd":"status"}
```
### Server to Client (broadcast)
```json
{
"type": "state",
"connected": true,
"ra": {"active": false, "dir": "+", "pos": 12.345678},
"dec": {"active": true, "dir": "-", "pos": 45.678901}
}
```
State broadcasts on direction change and every 250 ms during active slew.
## ASCOM Alpaca
The Alpaca interface implements the [ASCOM Telescope v3](https://ascom-standards.org/api/) specification, allowing any compatible software to control the mount over HTTP.
- **REST API** on port 32323 (configurable)
- **UDP discovery** on port 32227 -- clients auto-detect the device
- **CORS enabled** for web-based Alpaca clients
### Supported Operations
| Operation | Endpoint | Method |
|-----------|----------|--------|
| Connect/Disconnect | `/api/v1/telescope/0/connected` | PUT |
| Pulse Guide | `/api/v1/telescope/0/pulseguide` | PUT |
| Move Axis | `/api/v1/telescope/0/moveaxis` | PUT |
| Abort Slew | `/api/v1/telescope/0/abortslew` | PUT |
| Sync Position | `/api/v1/telescope/0/synctocoordinates` | PUT |
| Get Position | `/api/v1/telescope/0/rightascension`, `declination` | GET |
| Slewing State | `/api/v1/telescope/0/slewing` | GET |
| Pulse Active | `/api/v1/telescope/0/ispulseguiding` | GET |
### PulseGuide Direction Mapping
| Alpaca Direction | Value | ST-4 Mapping |
|------------------|-------|--------------|
| North | 0 | DEC+ |
| South | 1 | DEC- |
| East | 2 | RA+ |
| West | 3 | RA- |
Enable with `#define ST4_ALPACA_ENABLED` before including `ST4.h`, or use the `alpaca_server` example.
## Pin Configuration
Override defaults by defining before `#include <ST4.h>`:
```cpp
#define ST4_PIN_RA_PLUS 16 // GPIO for RA positive
#define ST4_PIN_RA_MINUS 17 // GPIO for RA negative
#define ST4_PIN_DEC_PLUS 18 // GPIO for DEC positive
#define ST4_PIN_DEC_MINUS 19 // GPIO for DEC negative
#define ST4_PIN_LED 2 // Status LED
```
Active logic is configurable per-axis (`ACTIVE_HIGH` default, `ACTIVE_LOW` for inverted drivers).
## Rate Configuration
Default sidereal rate multipliers (from the original ASCOM driver):
| Axis | Direction | Multiplier | Effective Rate |
|------|-----------|------------|----------------|
| RA | Plus | 9x | 8x slew + 1x Earth rotation |
| RA | Minus | 7x | 8x slew - 1x Earth rotation |
| DEC | Plus | 8x | Symmetric |
| DEC | Minus | 8x | Symmetric |
Override at runtime:
```cpp
ST4RateConfig rates = {9.0, 7.0, 8.0, 8.0};
controller.setRates(rates);
```
## Examples
| Example | Features | Build |
|---------|----------|-------|
| `basic_gpio` | Raw axis control, wiring verification | `make basic` |
| `serial_compatible` | Serial protocol, ASCOM driver compatible | `make serial` |
| `pulse_guide` | Hardware-timer pulse guiding, position tracking | `make pulse` |
| `wifi_control` | WebSocket server, JSON state broadcasting | `make wifi` |
| `alpaca_server` | ASCOM Alpaca REST API, UDP discovery | `make alpaca` |
## Dependencies
| Library | Version | Used By |
|---------|---------|---------|
| [ArduinoJson](https://arduinojson.org/) | ^7.0.0 | WiFi, Alpaca |
| [ESPAsyncWebServer](https://github.com/mathieucarbou/ESPAsyncWebServer) | ^3.6.0 | WiFi, Alpaca |
Core library (serial/GPIO/pulse) has no external dependencies beyond the Arduino ESP32 framework.
## Building
Requires [PlatformIO](https://platformio.org/).
```bash
# Build all examples
make all
# Build specific example
make serial
make wifi
make alpaca
# Run native unit tests (no hardware needed)
make test
# Upload and monitor
pio run -e serial_compatible -t upload -t monitor
# Clean build artifacts
make clean
```
### Native Tests
61 tests across 5 suites run on the host machine without ESP32 hardware. A thin mock layer in `test/mocks/` replaces Arduino, FreeRTOS, and esp_timer APIs:
```
test_pin 9 tests GPIO logic, active-high/low, pin validation
test_axis 10 tests Mutual exclusion, direction control, safety
test_tracker 11 tests Position accumulation, rate changes, timing
test_serial 16 tests Protocol parsing, all commands, edge cases
test_controller 15 tests Rate calculation, connection guard, state
```
```bash
pio test -e native -v
```
## Project Structure
```
st-4-esp32/
├── include/ Header files
│ ├── ST4.h Facade (includes everything)
│ ├── ST4Types.h Enums, constants, state structs
│ ├── ST4Config.h Default pin assignments
│ ├── ST4Pin.h Single GPIO abstraction
│ ├── ST4Axis.h Dual-pin axis with mutual exclusion
│ ├── ST4Tracker.h Dead-reckoning position tracker
│ ├── ST4Pulse.h Hardware-timer pulse engine
│ ├── ST4Controller.h High-level mount controller
│ ├── ST4Serial.h Serial protocol handler
│ ├── ST4WiFi.h WebSocket server (optional)
│ └── ST4Alpaca.h ASCOM Alpaca REST API (optional)
├── src/ Implementations
├── examples/ 5 Arduino sketches
├── test/
│ ├── mocks/ Arduino/FreeRTOS mock layer
│ └── test_*/ Unity test suites
├── arduino-st4/ Original project reference
├── platformio.ini
├── Makefile
└── library.json
```
## License
LGPL-3.0-or-later
## Credits
Based on [arduino-st4](https://github.com/kevinferrare/arduino-st4) by Kevin Ferrare. Sidereal rate constants and ASCOM protocol from the original ArduinoST4 ASCOM driver.

View File

@ -0,0 +1,53 @@
// ST4-ESP32 ASCOM Alpaca Server Example
// Combines serial control with ASCOM Alpaca REST API
// Any ASCOM-compatible software (N.I.N.A., PHD2, etc.) can connect
//
// Configure WiFi credentials below, then flash to ESP32.
// The device will appear on your network as an Alpaca telescope.
#ifndef ST4_ALPACA_ENABLED
#define ST4_ALPACA_ENABLED
#endif
#include <ST4.h>
ST4Controller controller;
ST4Serial serial;
ST4Alpaca alpaca;
// WiFi credentials
const char* WIFI_SSID = "YOUR_WIFI_SSID";
const char* WIFI_PASS = "YOUR_WIFI_PASS";
void setup() {
Serial.begin(115200);
Serial.println("ST4 Alpaca Server");
controller.begin(
ST4_PIN_RA_PLUS, ST4_PIN_RA_MINUS,
ST4_PIN_DEC_PLUS, ST4_PIN_DEC_MINUS,
ST4_PIN_LED
);
serial.begin(controller, Serial);
// Connect to WiFi (required for Alpaca)
WiFi.begin(WIFI_SSID, WIFI_PASS);
Serial.print("Connecting to WiFi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println();
Serial.print("IP: ");
Serial.println(WiFi.localIP());
// Start Alpaca server (default port 32323, discovery on 32227)
alpaca.begin(controller);
Serial.println("Alpaca server started on port 32323");
Serial.println("Discovery broadcast on port 32227");
}
void loop() {
serial.update();
alpaca.update();
}

View File

@ -16,3 +16,7 @@
#ifdef ST4_WIFI_ENABLED #ifdef ST4_WIFI_ENABLED
#include "ST4WiFi.h" #include "ST4WiFi.h"
#endif #endif
#ifdef ST4_ALPACA_ENABLED
#include "ST4Alpaca.h"
#endif

59
include/ST4Alpaca.h Normal file
View File

@ -0,0 +1,59 @@
// SPDX-License-Identifier: LGPL-3.0-or-later
// ST4-ESP32: ASCOM Alpaca REST API (optional)
// Enable by defining ST4_ALPACA_ENABLED before including ST4.h
// Implements ASCOM Alpaca Telescope interface v3
#pragma once
#ifdef ST4_ALPACA_ENABLED
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <AsyncUDP.h>
#include <ArduinoJson.h>
#include "ST4Controller.h"
struct ST4AlpacaConfig {
uint16_t httpPort = 32323;
uint16_t discoveryPort = 32227;
};
class ST4Alpaca {
ST4Controller* controller_;
AsyncWebServer* server_;
AsyncUDP udp_;
ST4AlpacaConfig config_;
uint32_t serverTransactionId_;
// Response helpers
void sendValue(AsyncWebServerRequest* req, JsonDocument& doc);
void sendBool(AsyncWebServerRequest* req, bool value);
void sendDouble(AsyncWebServerRequest* req, double value);
void sendString(AsyncWebServerRequest* req, const char* value);
void sendInt(AsyncWebServerRequest* req, int value);
void sendError(AsyncWebServerRequest* req, int errNum, const char* errMsg);
void addCors(AsyncWebServerResponse* resp);
// Parameter parsing (PUT uses form-encoded body)
int clientTransactionId(AsyncWebServerRequest* req);
bool paramBool(AsyncWebServerRequest* req, const char* name, bool& out);
bool paramInt(AsyncWebServerRequest* req, const char* name, int& out);
bool paramDouble(AsyncWebServerRequest* req, const char* name, double& out);
String paramString(AsyncWebServerRequest* req, const char* name);
// Route registration
void registerManagement();
void registerDiscovery();
void registerCapabilities();
void registerState();
void registerActions();
public:
ST4Alpaca();
~ST4Alpaca();
void begin(ST4Controller& controller, const ST4AlpacaConfig& config = {});
void update();
};
#endif // ST4_ALPACA_ENABLED

View File

@ -38,6 +38,7 @@
"examples/basic_gpio/basic_gpio.ino", "examples/basic_gpio/basic_gpio.ino",
"examples/serial_compatible/serial_compatible.ino", "examples/serial_compatible/serial_compatible.ino",
"examples/pulse_guide/pulse_guide.ino", "examples/pulse_guide/pulse_guide.ino",
"examples/wifi_control/wifi_control.ino" "examples/wifi_control/wifi_control.ino",
"examples/alpaca_server/alpaca_server.ino"
] ]
} }

View File

@ -5,7 +5,7 @@
[platformio] [platformio]
default_envs = serial_compatible default_envs = serial_compatible
[env] [base_esp32]
platform = espressif32 platform = espressif32
board = esp32dev board = esp32dev
framework = arduino framework = arduino
@ -13,18 +13,52 @@ monitor_speed = 115200
lib_deps = symlink://. lib_deps = symlink://.
[env:basic_gpio] [env:basic_gpio]
extends = base_esp32
build_src_filter = +<../examples/basic_gpio/> build_src_filter = +<../examples/basic_gpio/>
[env:serial_compatible] [env:serial_compatible]
extends = base_esp32
build_src_filter = +<../examples/serial_compatible/> build_src_filter = +<../examples/serial_compatible/>
[env:pulse_guide] [env:pulse_guide]
extends = base_esp32
build_src_filter = +<../examples/pulse_guide/> build_src_filter = +<../examples/pulse_guide/>
[env:wifi_control] [env:wifi_control]
extends = base_esp32
build_flags = -DST4_WIFI_ENABLED build_flags = -DST4_WIFI_ENABLED
lib_deps = lib_deps =
symlink://. symlink://.
bblanchon/ArduinoJson@^7.0.0 bblanchon/ArduinoJson@^7.0.0
mathieucarbou/ESPAsyncWebServer@^3.6.0 mathieucarbou/ESPAsyncWebServer@^3.6.0
build_src_filter = +<../examples/wifi_control/> build_src_filter = +<../examples/wifi_control/>
[env:alpaca_server]
extends = base_esp32
build_flags = -DST4_ALPACA_ENABLED
lib_deps =
symlink://.
bblanchon/ArduinoJson@^7.0.0
mathieucarbou/ESPAsyncWebServer@^3.6.0
build_src_filter = +<../examples/alpaca_server/>
[env:native]
platform = native
test_framework = unity
build_flags =
-I test/mocks
-I include
-std=c++17
-DST4_NATIVE_TEST
-DUNITY_INCLUDE_DOUBLE
test_build_src = true
build_src_filter =
+<../src/ST4Pin.cpp>
+<../src/ST4Axis.cpp>
+<../src/ST4Tracker.cpp>
+<../src/ST4Controller.cpp>
+<../src/ST4Serial.cpp>
-<../src/ST4Pulse.cpp>
-<../src/ST4WiFi.cpp>
+<../test/mocks/mock_state.cpp>
+<../test/mocks/ST4Pulse_stub.cpp>

543
src/ST4Alpaca.cpp Normal file
View File

@ -0,0 +1,543 @@
// SPDX-License-Identifier: LGPL-3.0-or-later
// ST4-ESP32: ASCOM Alpaca REST API implementation
// See: https://ascom-standards.org/api/
#ifdef ST4_ALPACA_ENABLED
#include "ST4Alpaca.h"
// Alpaca error codes
static constexpr int ALPACA_OK = 0;
static constexpr int ALPACA_NOT_IMPLEMENTED = 0x400;
static constexpr int ALPACA_NOT_CONNECTED = 0x401;
static constexpr int ALPACA_INVALID_VALUE = 0x407;
// Alpaca PulseGuide direction enum
static constexpr int GUIDE_NORTH = 0;
static constexpr int GUIDE_SOUTH = 1;
static constexpr int GUIDE_EAST = 2;
static constexpr int GUIDE_WEST = 3;
static const char* API_BASE = "/api/v1/telescope/0/";
ST4Alpaca::ST4Alpaca()
: controller_(nullptr), server_(nullptr), serverTransactionId_(0) {}
ST4Alpaca::~ST4Alpaca() {
delete server_;
}
// ── Response helpers ───────────────────────────────────────────────
void ST4Alpaca::addCors(AsyncWebServerResponse* resp) {
resp->addHeader("Access-Control-Allow-Origin", "*");
resp->addHeader("Access-Control-Allow-Methods", "GET, PUT, OPTIONS");
resp->addHeader("Access-Control-Allow-Headers", "Content-Type");
}
int ST4Alpaca::clientTransactionId(AsyncWebServerRequest* req) {
// GET: query param; PUT: form body param
if (req->hasParam("ClientTransactionID")) {
return req->getParam("ClientTransactionID")->value().toInt();
}
if (req->hasParam("ClientTransactionID", true)) {
return req->getParam("ClientTransactionID", true)->value().toInt();
}
return 0;
}
void ST4Alpaca::sendValue(AsyncWebServerRequest* req, JsonDocument& doc) {
doc["ClientTransactionID"] = clientTransactionId(req);
doc["ServerTransactionID"] = ++serverTransactionId_;
if (!doc["ErrorNumber"].is<int>()) {
doc["ErrorNumber"] = 0;
doc["ErrorMessage"] = "";
}
String body;
serializeJson(doc, body);
AsyncWebServerResponse* resp = req->beginResponse(200, "application/json", body);
addCors(resp);
req->send(resp);
}
void ST4Alpaca::sendBool(AsyncWebServerRequest* req, bool value) {
JsonDocument doc;
doc["Value"] = value;
sendValue(req, doc);
}
void ST4Alpaca::sendDouble(AsyncWebServerRequest* req, double value) {
JsonDocument doc;
doc["Value"] = value;
sendValue(req, doc);
}
void ST4Alpaca::sendString(AsyncWebServerRequest* req, const char* value) {
JsonDocument doc;
doc["Value"] = value;
sendValue(req, doc);
}
void ST4Alpaca::sendInt(AsyncWebServerRequest* req, int value) {
JsonDocument doc;
doc["Value"] = value;
sendValue(req, doc);
}
void ST4Alpaca::sendError(AsyncWebServerRequest* req, int errNum, const char* errMsg) {
JsonDocument doc;
doc["ErrorNumber"] = errNum;
doc["ErrorMessage"] = errMsg;
sendValue(req, doc);
}
// ── Parameter parsing (PUT body is application/x-www-form-urlencoded) ──
bool ST4Alpaca::paramBool(AsyncWebServerRequest* req, const char* name, bool& out) {
// Try case-sensitive first, then common casings
const char* candidates[] = { name, nullptr };
for (int i = 0; candidates[i]; i++) {
if (req->hasParam(candidates[i], true)) {
String val = req->getParam(candidates[i], true)->value();
val.toLowerCase();
out = (val == "true" || val == "1");
return true;
}
}
// Try case-insensitive: iterate all params
for (size_t i = 0; i < req->params(); i++) {
const AsyncWebParameter* p = req->getParam(i);
if (p->isPost()) {
String pName = p->name();
String target = name;
pName.toLowerCase();
target.toLowerCase();
if (pName == target) {
String val = p->value();
val.toLowerCase();
out = (val == "true" || val == "1");
return true;
}
}
}
return false;
}
bool ST4Alpaca::paramInt(AsyncWebServerRequest* req, const char* name, int& out) {
if (req->hasParam(name, true)) {
out = req->getParam(name, true)->value().toInt();
return true;
}
// Case-insensitive fallback
for (size_t i = 0; i < req->params(); i++) {
const AsyncWebParameter* p = req->getParam(i);
if (p->isPost()) {
String pName = p->name();
String target = name;
pName.toLowerCase();
target.toLowerCase();
if (pName == target) {
out = p->value().toInt();
return true;
}
}
}
return false;
}
bool ST4Alpaca::paramDouble(AsyncWebServerRequest* req, const char* name, double& out) {
if (req->hasParam(name, true)) {
out = req->getParam(name, true)->value().toDouble();
return true;
}
// Case-insensitive fallback
for (size_t i = 0; i < req->params(); i++) {
const AsyncWebParameter* p = req->getParam(i);
if (p->isPost()) {
String pName = p->name();
String target = name;
pName.toLowerCase();
target.toLowerCase();
if (pName == target) {
out = p->value().toDouble();
return true;
}
}
}
return false;
}
String ST4Alpaca::paramString(AsyncWebServerRequest* req, const char* name) {
if (req->hasParam(name, true)) {
return req->getParam(name, true)->value();
}
for (size_t i = 0; i < req->params(); i++) {
const AsyncWebParameter* p = req->getParam(i);
if (p->isPost()) {
String pName = p->name();
String target = name;
pName.toLowerCase();
target.toLowerCase();
if (pName == target) {
return p->value();
}
}
}
return "";
}
// ── Initialization ─────────────────────────────────────────────────
void ST4Alpaca::begin(ST4Controller& controller, const ST4AlpacaConfig& config) {
controller_ = &controller;
config_ = config;
server_ = new AsyncWebServer(config_.httpPort);
registerManagement();
registerDiscovery();
registerCapabilities();
registerState();
registerActions();
server_->begin();
log_i("Alpaca server on port %d", config_.httpPort);
}
void ST4Alpaca::update() {
// AsyncWebServer is event-driven; nothing to poll.
// Placeholder for future periodic tasks (e.g. mDNS keepalive).
}
// ── Management API ─────────────────────────────────────────────────
void ST4Alpaca::registerManagement() {
server_->on("/management/apiversions", HTTP_GET,
[this](AsyncWebServerRequest* req) {
JsonDocument doc;
auto arr = doc["Value"].to<JsonArray>();
arr.add(1);
sendValue(req, doc);
});
server_->on("/management/v1/description", HTTP_GET,
[this](AsyncWebServerRequest* req) {
JsonDocument doc;
auto desc = doc["Value"].to<JsonObject>();
desc["ServerName"] = "ST4-ESP32";
desc["Manufacturer"] = "supported.systems";
desc["ManufacturerVersion"] = ST4Constants::VERSION;
desc["Location"] = "";
sendValue(req, doc);
});
server_->on("/management/v1/configureddevices", HTTP_GET,
[this](AsyncWebServerRequest* req) {
JsonDocument doc;
auto arr = doc["Value"].to<JsonArray>();
auto dev = arr.add<JsonObject>();
dev["DeviceName"] = "ST4-ESP32";
dev["DeviceType"] = "Telescope";
dev["DeviceNumber"] = 0;
dev["UniqueID"] = "st4-esp32-00000001";
sendValue(req, doc);
});
}
// ── Discovery (UDP broadcast on port 32227) ────────────────────────
void ST4Alpaca::registerDiscovery() {
if (udp_.listen(config_.discoveryPort)) {
udp_.onPacket([this](AsyncUDPPacket packet) {
JsonDocument doc;
doc["AlpacaPort"] = config_.httpPort;
String body;
serializeJson(doc, body);
packet.printf("%s", body.c_str());
});
log_i("Alpaca discovery on UDP port %d", config_.discoveryPort);
}
}
// ── Capability routes (all GET, static values) ─────────────────────
void ST4Alpaca::registerCapabilities() {
String base = API_BASE;
// Capabilities that return true
const char* trueProps[] = {
"canpulseguide", "canmoveaxis", "cansync"
};
for (const char* prop : trueProps) {
String path = base + prop;
server_->on(path.c_str(), HTTP_GET,
[this](AsyncWebServerRequest* req) { sendBool(req, true); });
}
// Capabilities that return false
const char* falseProps[] = {
"canslew", "canslewasync", "canpark", "canfindhome",
"cansettracking", "cansetguiderates", "cansetpierside",
"cansetdeclinationrate", "cansetrightascensionrate",
"canunpark", "canslewaltaz", "canslewaltazasync",
"cansyncaltaz"
};
for (const char* prop : falseProps) {
String path = base + prop;
server_->on(path.c_str(), HTTP_GET,
[this](AsyncWebServerRequest* req) { sendBool(req, false); });
}
}
// ── State routes (dynamic GETs) ────────────────────────────────────
void ST4Alpaca::registerState() {
String base = API_BASE;
// connected (GET)
server_->on((base + "connected").c_str(), HTTP_GET,
[this](AsyncWebServerRequest* req) {
sendBool(req, controller_->isConnected());
});
// rightascension
server_->on((base + "rightascension").c_str(), HTTP_GET,
[this](AsyncWebServerRequest* req) {
double ra = controller_->position(ST4AxisId::RA);
// ASCOM expects RA in 0..24 range
ra = fmod(fmod(ra, 24.0) + 24.0, 24.0);
sendDouble(req, ra);
});
// declination
server_->on((base + "declination").c_str(), HTTP_GET,
[this](AsyncWebServerRequest* req) {
sendDouble(req, controller_->position(ST4AxisId::DECLINATION));
});
// slewing
server_->on((base + "slewing").c_str(), HTTP_GET,
[this](AsyncWebServerRequest* req) {
bool slewing = controller_->axisActive(ST4AxisId::RA) ||
controller_->axisActive(ST4AxisId::DECLINATION);
sendBool(req, slewing);
});
// ispulseguiding
server_->on((base + "ispulseguiding").c_str(), HTTP_GET,
[this](AsyncWebServerRequest* req) {
sendBool(req, controller_->isPulseActive());
});
// Static false states
const char* falseStates[] = { "atpark", "athome" };
for (const char* prop : falseStates) {
String path = base + prop;
server_->on(path.c_str(), HTTP_GET,
[this](AsyncWebServerRequest* req) { sendBool(req, false); });
}
// tracking -- always false (ST-4 port has no tracking feedback)
server_->on((base + "tracking").c_str(), HTTP_GET,
[this](AsyncWebServerRequest* req) {
sendBool(req, false);
});
// alignmentmode -> 0 (altaz)
server_->on((base + "alignmentmode").c_str(), HTTP_GET,
[this](AsyncWebServerRequest* req) { sendInt(req, 0); });
// equatorialsystem -> 0 (local topocentric, matches original driver)
server_->on((base + "equatorialsystem").c_str(), HTTP_GET,
[this](AsyncWebServerRequest* req) { sendInt(req, 0); });
// sideofpier -> 0
server_->on((base + "sideofpier").c_str(), HTTP_GET,
[this](AsyncWebServerRequest* req) { sendInt(req, 0); });
// Device info
server_->on((base + "description").c_str(), HTTP_GET,
[this](AsyncWebServerRequest* req) {
sendString(req, "ST4-ESP32 Autoguider Interface");
});
server_->on((base + "name").c_str(), HTTP_GET,
[this](AsyncWebServerRequest* req) {
sendString(req, "ST4-ESP32");
});
server_->on((base + "driverinfo").c_str(), HTTP_GET,
[this](AsyncWebServerRequest* req) {
sendString(req, "ST4-ESP32 ASCOM Alpaca Driver");
});
server_->on((base + "driverversion").c_str(), HTTP_GET,
[this](AsyncWebServerRequest* req) {
sendString(req, ST4Constants::VERSION);
});
server_->on((base + "interfaceversion").c_str(), HTTP_GET,
[this](AsyncWebServerRequest* req) { sendInt(req, 3); });
server_->on((base + "supportedactions").c_str(), HTTP_GET,
[this](AsyncWebServerRequest* req) {
JsonDocument doc;
doc["Value"].to<JsonArray>();
sendValue(req, doc);
});
}
// ── Action routes (PUTs + CORS preflight) ──────────────────────────
void ST4Alpaca::registerActions() {
String base = API_BASE;
// ── connected (PUT) ────────────────────────────────────────────
server_->on((base + "connected").c_str(), HTTP_PUT,
[this](AsyncWebServerRequest* req) {
bool val = false;
if (!paramBool(req, "Connected", val)) {
sendError(req, ALPACA_INVALID_VALUE, "Missing Connected parameter");
return;
}
if (val) {
controller_->connect();
} else {
controller_->disconnect();
}
sendError(req, ALPACA_OK, "");
});
// ── moveaxis (PUT) ─────────────────────────────────────────────
server_->on((base + "moveaxis").c_str(), HTTP_PUT,
[this](AsyncWebServerRequest* req) {
if (!controller_->isConnected()) {
sendError(req, ALPACA_NOT_CONNECTED, "Not connected");
return;
}
int axisNum = -1;
double rate = 0.0;
if (!paramInt(req, "Axis", axisNum) || !paramDouble(req, "Rate", rate)) {
sendError(req, ALPACA_INVALID_VALUE, "Missing Axis or Rate parameter");
return;
}
if (axisNum < 0 || axisNum > 1) {
sendError(req, ALPACA_INVALID_VALUE, "Axis must be 0 (RA) or 1 (DEC)");
return;
}
ST4AxisId axis = (axisNum == 0) ? ST4AxisId::RA : ST4AxisId::DECLINATION;
if (rate == 0.0) {
controller_->stopAxis(axis);
} else {
ST4Direction dir = (rate > 0.0) ? ST4Direction::PLUS : ST4Direction::MINUS;
controller_->move(axis, dir);
}
sendError(req, ALPACA_OK, "");
});
// ── pulseguide (PUT) ───────────────────────────────────────────
server_->on((base + "pulseguide").c_str(), HTTP_PUT,
[this](AsyncWebServerRequest* req) {
if (!controller_->isConnected()) {
sendError(req, ALPACA_NOT_CONNECTED, "Not connected");
return;
}
int direction = -1;
int duration = 0;
if (!paramInt(req, "Direction", direction) || !paramInt(req, "Duration", duration)) {
sendError(req, ALPACA_INVALID_VALUE, "Missing Direction or Duration parameter");
return;
}
if (direction < GUIDE_NORTH || direction > GUIDE_WEST) {
sendError(req, ALPACA_INVALID_VALUE, "Direction must be 0-3");
return;
}
if (duration < 0) {
sendError(req, ALPACA_INVALID_VALUE, "Duration must be >= 0");
return;
}
// Map Alpaca direction to ST4 axis/direction
// (from original ASCOM Driver.cs PulseGuide method)
ST4AxisId axis;
ST4Direction dir;
switch (direction) {
case GUIDE_NORTH: axis = ST4AxisId::DECLINATION; dir = ST4Direction::PLUS; break;
case GUIDE_SOUTH: axis = ST4AxisId::DECLINATION; dir = ST4Direction::MINUS; break;
case GUIDE_EAST: axis = ST4AxisId::RA; dir = ST4Direction::PLUS; break;
case GUIDE_WEST: axis = ST4AxisId::RA; dir = ST4Direction::MINUS; break;
default:
sendError(req, ALPACA_INVALID_VALUE, "Invalid direction");
return;
}
controller_->pulseGuide(axis, dir, static_cast<uint32_t>(duration));
sendError(req, ALPACA_OK, "");
});
// ── abortslew (PUT) ────────────────────────────────────────────
server_->on((base + "abortslew").c_str(), HTTP_PUT,
[this](AsyncWebServerRequest* req) {
controller_->stopAll();
sendError(req, ALPACA_OK, "");
});
// ── synctocoordinates (PUT) ────────────────────────────────────
server_->on((base + "synctocoordinates").c_str(), HTTP_PUT,
[this](AsyncWebServerRequest* req) {
if (!controller_->isConnected()) {
sendError(req, ALPACA_NOT_CONNECTED, "Not connected");
return;
}
double ra = 0.0, dec = 0.0;
if (!paramDouble(req, "RightAscension", ra) ||
!paramDouble(req, "Declination", dec)) {
sendError(req, ALPACA_INVALID_VALUE,
"Missing RightAscension or Declination parameter");
return;
}
controller_->sync(ra, dec);
sendError(req, ALPACA_OK, "");
});
// ── tracking (PUT) -- not implemented ──────────────────────────
server_->on((base + "tracking").c_str(), HTTP_PUT,
[this](AsyncWebServerRequest* req) {
sendError(req, ALPACA_NOT_IMPLEMENTED, "Tracking control not supported");
});
// ── Not-implemented PUTs ───────────────────────────────────────
const char* notImpl[] = {
"slewtocoordinates", "slewtocoordinatesasync",
"slewtoaltaz", "slewtoaltazasync",
"park", "unpark", "findhome", "setpark"
};
for (const char* action : notImpl) {
String path = base + action;
server_->on(path.c_str(), HTTP_PUT,
[this, action](AsyncWebServerRequest* req) {
String msg = String(action) + " not supported";
sendError(req, ALPACA_NOT_IMPLEMENTED, msg.c_str());
});
}
// ── CORS preflight for all API paths ───────────────────────────
server_->on((base + "*").c_str(), HTTP_OPTIONS,
[this](AsyncWebServerRequest* req) {
AsyncWebServerResponse* resp = req->beginResponse(204);
addCors(resp);
req->send(resp);
});
// CORS preflight for management paths
server_->on("/management/*", HTTP_OPTIONS,
[this](AsyncWebServerRequest* req) {
AsyncWebServerResponse* resp = req->beginResponse(204);
addCors(resp);
req->send(resp);
});
}
#endif // ST4_ALPACA_ENABLED

230
test/mocks/Arduino.h Normal file
View File

@ -0,0 +1,230 @@
// Arduino.h mock for native testing
#pragma once
#include <cstdint>
#include <cstring>
#include <cctype>
#include <string>
#include <cstdio>
#include <cstdlib>
#include <cmath>
#include "mock_state.h"
#define HIGH 1
#define LOW 0
#define OUTPUT 1
#define INPUT 0
#define SERIAL_8N1 0x800001c
#define log_w(...)
inline void pinMode(int pin, int mode) {
MockState::gpioModes[pin] = mode;
}
inline void digitalWrite(int pin, int val) {
MockState::gpioStates[pin] = val;
}
inline int digitalRead(int pin) {
auto it = MockState::gpioStates.find(pin);
if (it != MockState::gpioStates.end()) return it->second;
return LOW;
}
inline unsigned long millis() {
return static_cast<unsigned long>(MockState::mockTimeMicros / 1000);
}
inline void delay(unsigned long ms) {
MockState::advanceTime(static_cast<int64_t>(ms) * 1000);
}
inline bool isDigit(char c) {
return std::isdigit(static_cast<unsigned char>(c));
}
// Arduino String class mock
class String {
std::string s_;
public:
String() : s_() {}
String(const char* cstr) : s_(cstr ? cstr : "") {}
String(const String& other) : s_(other.s_) {}
String(const std::string& str) : s_(str) {}
String(double val, int precision) {
char buf[64];
std::snprintf(buf, sizeof(buf), "%.*f", precision, val);
s_ = buf;
}
String(int val) {
s_ = std::to_string(val);
}
String& operator=(const String& rhs) {
if (this != &rhs) s_ = rhs.s_;
return *this;
}
String& operator=(const char* cstr) {
s_ = cstr ? cstr : "";
return *this;
}
unsigned int length() const { return static_cast<unsigned int>(s_.length()); }
void reserve(unsigned int size) { s_.reserve(size); }
const char* c_str() const { return s_.c_str(); }
void trim() {
size_t start = s_.find_first_not_of(" \t\r\n");
size_t end = s_.find_last_not_of(" \t\r\n");
if (start == std::string::npos) {
s_.clear();
} else {
s_ = s_.substr(start, end - start + 1);
}
}
bool startsWith(const char* prefix) const {
size_t plen = std::strlen(prefix);
if (s_.length() < plen) return false;
return s_.compare(0, plen, prefix) == 0;
}
int indexOf(char ch) const {
auto pos = s_.find(ch);
return (pos == std::string::npos) ? -1 : static_cast<int>(pos);
}
int indexOf(char ch, int from) const {
if (from < 0) from = 0;
auto pos = s_.find(ch, static_cast<size_t>(from));
return (pos == std::string::npos) ? -1 : static_cast<int>(pos);
}
String substring(int from) const {
if (from < 0) from = 0;
if (static_cast<size_t>(from) >= s_.length()) return String("");
return String(s_.substr(static_cast<size_t>(from)));
}
String substring(int from, int to) const {
if (from < 0) from = 0;
if (to < from) return String("");
if (static_cast<size_t>(from) >= s_.length()) return String("");
size_t len = static_cast<size_t>(to - from);
return String(s_.substr(static_cast<size_t>(from), len));
}
long toInt() const { return std::atol(s_.c_str()); }
double toDouble() const { return std::atof(s_.c_str()); }
String& operator+=(char c) {
s_ += c;
return *this;
}
String& operator+=(const char* cstr) {
if (cstr) s_ += cstr;
return *this;
}
String& operator+=(const String& rhs) {
s_ += rhs.s_;
return *this;
}
bool operator==(const String& rhs) const { return s_ == rhs.s_; }
bool operator==(const char* rhs) const { return s_ == (rhs ? rhs : ""); }
bool operator!=(const String& rhs) const { return s_ != rhs.s_; }
bool operator!=(const char* rhs) const { return s_ != (rhs ? rhs : ""); }
char operator[](unsigned int index) const {
if (index < s_.length()) return s_[index];
return '\0';
}
friend String operator+(const String& lhs, const String& rhs) {
return String(lhs.s_ + rhs.s_);
}
};
// HardwareSerial mock
class HardwareSerial {
size_t readPos_;
public:
HardwareSerial() : readPos_(0) {}
void begin(uint32_t baud, int config = 0) {
(void)baud;
(void)config;
readPos_ = 0;
}
int available() {
return static_cast<int>(MockState::serialInput.length() - readPos_);
}
char read() {
if (readPos_ < MockState::serialInput.length()) {
return MockState::serialInput[readPos_++];
}
return -1;
}
void resetReadPos() { readPos_ = 0; }
// print overloads
void print(const char* str) {
if (str) MockState::serialOutput += str;
}
void print(const String& str) {
MockState::serialOutput += str.c_str();
}
void print(double val, int decimals) {
char buf[64];
std::snprintf(buf, sizeof(buf), "%.*f", decimals, val);
MockState::serialOutput += buf;
}
void print(int val) {
MockState::serialOutput += std::to_string(val);
}
// println overloads
void println(const char* str) {
if (str) MockState::serialOutput += str;
MockState::serialOutput += "\n";
}
void println(const String& str) {
MockState::serialOutput += str.c_str();
MockState::serialOutput += "\n";
}
void println() {
MockState::serialOutput += "\n";
}
// write
size_t write(uint8_t c) {
MockState::serialOutput += static_cast<char>(c);
return 1;
}
size_t write(const uint8_t* buf, size_t size) {
for (size_t i = 0; i < size; i++) {
MockState::serialOutput += static_cast<char>(buf[i]);
}
return size;
}
};
extern HardwareSerial Serial;

View File

@ -0,0 +1,43 @@
// ST4Pulse stub for native testing
// Replaces the real ST4Pulse which uses esp_timer callbacks and FreeRTOS tasks
#include "ST4Pulse.h"
ST4Pulse* ST4Pulse::instance_ = nullptr;
ST4Pulse::ST4Pulse()
: timer_(nullptr), pulseDoneSem_(nullptr), mutex_(nullptr),
pulseTaskHandle_(nullptr), activeAxis_(nullptr), activeTracker_(nullptr),
active_(false), shutdown_(false) {}
ST4Pulse::~ST4Pulse() { instance_ = nullptr; }
void ST4Pulse::begin() { instance_ = this; }
bool ST4Pulse::pulse(ST4Axis& axis, ST4Tracker& tracker,
ST4Direction dir, double slewRate, uint32_t ms) {
if (dir == ST4Direction::STOP || ms == 0) return false;
active_ = true;
activeAxis_ = &axis;
activeTracker_ = &tracker;
axis.move(dir);
tracker.start(slewRate);
// Stub: immediately complete the pulse
axis.stop();
tracker.stop();
active_ = false;
return true;
}
bool ST4Pulse::isActive() const { return active_; }
void ST4Pulse::cancel() {
if (activeAxis_) activeAxis_->stop();
if (activeTracker_) activeTracker_->stop();
active_ = false;
activeAxis_ = nullptr;
activeTracker_ = nullptr;
}
void ST4Pulse::cancelLocked() { cancel(); }
void ST4Pulse::timerCallback(void*) {}
void ST4Pulse::pulseTaskFunc(void*) {}

24
test/mocks/esp_timer.h Normal file
View File

@ -0,0 +1,24 @@
// esp_timer mock for native testing
#pragma once
#include "freertos/FreeRTOS.h"
namespace MockState { extern int64_t mockTimeMicros; }
typedef void (*esp_timer_cb_t)(void* arg);
struct esp_timer_create_args_t {
esp_timer_cb_t callback;
void* arg;
const char* name;
};
typedef void* esp_timer_handle_t;
inline int64_t esp_timer_get_time() { return MockState::mockTimeMicros; }
inline esp_err_t esp_timer_create(const esp_timer_create_args_t*, esp_timer_handle_t* out) {
*out = (void*)0x1;
return ESP_OK;
}
inline esp_err_t esp_timer_start_once(esp_timer_handle_t, uint64_t) { return ESP_OK; }
inline esp_err_t esp_timer_stop(esp_timer_handle_t) { return ESP_OK; }
inline esp_err_t esp_timer_delete(esp_timer_handle_t) { return ESP_OK; }

View File

@ -0,0 +1,20 @@
// FreeRTOS mock for native testing
#pragma once
#include <cstdint>
#include <cassert>
typedef void* SemaphoreHandle_t;
typedef void* TaskHandle_t;
typedef int BaseType_t;
typedef uint32_t TickType_t;
#define pdTRUE 1
#define pdFALSE 0
#define pdPASS 1
#define portMAX_DELAY 0xFFFFFFFF
#define configMAX_PRIORITIES 25
#define configASSERT(x) assert(x)
#define pdMS_TO_TICKS(ms) (ms)
typedef int esp_err_t;
#define ESP_OK 0

View File

@ -0,0 +1,12 @@
// FreeRTOS semaphore mock for native testing
#pragma once
#include "FreeRTOS.h"
static void* const MOCK_MUTEX_SENTINEL = (void*)0xDEADBEEF;
static void* const MOCK_SEM_SENTINEL = (void*)0xCAFEBABE;
inline SemaphoreHandle_t xSemaphoreCreateMutex() { return MOCK_MUTEX_SENTINEL; }
inline SemaphoreHandle_t xSemaphoreCreateBinary() { return MOCK_SEM_SENTINEL; }
inline BaseType_t xSemaphoreTake(SemaphoreHandle_t, TickType_t) { return pdTRUE; }
inline void xSemaphoreGive(SemaphoreHandle_t) {}
inline void vSemaphoreDelete(SemaphoreHandle_t) {}

View File

@ -0,0 +1,8 @@
// FreeRTOS task mock for native testing
#pragma once
#include "FreeRTOS.h"
inline void vTaskDelay(TickType_t) {}
inline void vTaskDelete(TaskHandle_t) {}
inline BaseType_t xTaskCreatePinnedToCore(void (*)(void*), const char*, uint32_t,
void*, int, TaskHandle_t*, int) { return pdPASS; }

30
test/mocks/mock_state.cpp Normal file
View File

@ -0,0 +1,30 @@
// Mock state implementation
#include "mock_state.h"
#include "Arduino.h"
namespace MockState {
int64_t mockTimeMicros = 0;
std::map<int, int> gpioStates;
std::map<int, int> gpioModes;
std::string serialInput;
std::string serialOutput;
void reset() {
mockTimeMicros = 0;
gpioStates.clear();
gpioModes.clear();
serialInput.clear();
serialOutput.clear();
}
void advanceTime(int64_t microseconds) {
mockTimeMicros += microseconds;
}
void setSerialInput(const std::string& input) {
serialInput = input;
}
}
// Global Serial instance definition
HardwareSerial Serial;

17
test/mocks/mock_state.h Normal file
View File

@ -0,0 +1,17 @@
// Mock state shared across all mock implementations for native testing
#pragma once
#include <cstdint>
#include <map>
#include <string>
namespace MockState {
extern int64_t mockTimeMicros;
extern std::map<int, int> gpioStates;
extern std::map<int, int> gpioModes;
extern std::string serialInput;
extern std::string serialOutput;
void reset();
void advanceTime(int64_t microseconds);
void setSerialInput(const std::string& input);
}

View File

@ -0,0 +1,105 @@
// ST4Axis unit tests
#include <unity.h>
#include "mock_state.h"
#include "ST4Axis.h"
void setUp() { MockState::reset(); }
void tearDown() {}
void test_plus_activates_correct_pin() {
ST4Axis axis;
axis.begin(10, 11);
axis.plus();
TEST_ASSERT_EQUAL(HIGH, MockState::gpioStates[10]);
TEST_ASSERT_EQUAL(LOW, MockState::gpioStates[11]);
TEST_ASSERT_EQUAL(ST4Direction::PLUS, axis.direction());
}
void test_minus_activates_correct_pin() {
ST4Axis axis;
axis.begin(10, 11);
axis.minus();
TEST_ASSERT_EQUAL(LOW, MockState::gpioStates[10]);
TEST_ASSERT_EQUAL(HIGH, MockState::gpioStates[11]);
TEST_ASSERT_EQUAL(ST4Direction::MINUS, axis.direction());
}
void test_mutual_exclusion() {
ST4Axis axis;
axis.begin(10, 11);
axis.plus();
TEST_ASSERT_EQUAL(HIGH, MockState::gpioStates[10]);
// Switching to minus should deactivate plus first
axis.minus();
TEST_ASSERT_EQUAL(LOW, MockState::gpioStates[10]);
TEST_ASSERT_EQUAL(HIGH, MockState::gpioStates[11]);
}
void test_stop_deactivates_both() {
ST4Axis axis;
axis.begin(10, 11);
axis.plus();
axis.stop();
TEST_ASSERT_EQUAL(LOW, MockState::gpioStates[10]);
TEST_ASSERT_EQUAL(LOW, MockState::gpioStates[11]);
TEST_ASSERT_EQUAL(ST4Direction::STOP, axis.direction());
}
void test_move_direction_plus() {
ST4Axis axis;
axis.begin(10, 11);
axis.move(ST4Direction::PLUS);
TEST_ASSERT_EQUAL(ST4Direction::PLUS, axis.direction());
}
void test_move_direction_minus() {
ST4Axis axis;
axis.begin(10, 11);
axis.move(ST4Direction::MINUS);
TEST_ASSERT_EQUAL(ST4Direction::MINUS, axis.direction());
}
void test_move_direction_stop() {
ST4Axis axis;
axis.begin(10, 11);
axis.move(ST4Direction::PLUS);
axis.move(ST4Direction::STOP);
TEST_ASSERT_EQUAL(ST4Direction::STOP, axis.direction());
}
void test_is_active_plus() {
ST4Axis axis;
axis.begin(10, 11);
axis.plus();
TEST_ASSERT_TRUE(axis.isActive());
}
void test_is_active_stop() {
ST4Axis axis;
axis.begin(10, 11);
axis.plus();
axis.stop();
TEST_ASSERT_FALSE(axis.isActive());
}
void test_initial_state_stopped() {
ST4Axis axis;
axis.begin(10, 11);
TEST_ASSERT_EQUAL(ST4Direction::STOP, axis.direction());
TEST_ASSERT_FALSE(axis.isActive());
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_plus_activates_correct_pin);
RUN_TEST(test_minus_activates_correct_pin);
RUN_TEST(test_mutual_exclusion);
RUN_TEST(test_stop_deactivates_both);
RUN_TEST(test_move_direction_plus);
RUN_TEST(test_move_direction_minus);
RUN_TEST(test_move_direction_stop);
RUN_TEST(test_is_active_plus);
RUN_TEST(test_is_active_stop);
RUN_TEST(test_initial_state_stopped);
return UNITY_END();
}

View File

@ -0,0 +1,159 @@
// ST4Controller unit tests
#include <unity.h>
#include "mock_state.h"
#include "ST4Controller.h"
static ST4Controller* ctrl;
void setUp() {
MockState::reset();
ctrl = new ST4Controller();
ctrl->begin();
}
void tearDown() {
delete ctrl;
ctrl = nullptr;
}
void test_not_connected_guard() {
// Before connect(), move() should be a no-op
ctrl->move(ST4AxisId::RA, ST4Direction::PLUS);
TEST_ASSERT_EQUAL(ST4Direction::STOP, ctrl->axisDirection(ST4AxisId::RA));
TEST_ASSERT_FALSE(ctrl->axisActive(ST4AxisId::RA));
}
void test_connect_sets_led_high() {
ctrl->connect();
TEST_ASSERT_EQUAL(HIGH, MockState::gpioStates[ST4_PIN_LED]);
TEST_ASSERT_TRUE(ctrl->isConnected());
}
void test_disconnect_sets_led_low() {
ctrl->connect();
ctrl->disconnect();
TEST_ASSERT_EQUAL(LOW, MockState::gpioStates[ST4_PIN_LED]);
TEST_ASSERT_FALSE(ctrl->isConnected());
}
void test_move_ra_plus() {
ctrl->connect();
ctrl->move(ST4AxisId::RA, ST4Direction::PLUS);
TEST_ASSERT_EQUAL(ST4Direction::PLUS, ctrl->axisDirection(ST4AxisId::RA));
TEST_ASSERT_TRUE(ctrl->axisActive(ST4AxisId::RA));
}
void test_move_dec_minus() {
ctrl->connect();
ctrl->move(ST4AxisId::DECLINATION, ST4Direction::MINUS);
TEST_ASSERT_EQUAL(ST4Direction::MINUS, ctrl->axisDirection(ST4AxisId::DECLINATION));
}
void test_rate_calculation_ra_plus() {
ctrl->connect();
ctrl->move(ST4AxisId::RA, ST4Direction::PLUS);
// RA PLUS: 9 * RA_PER_SECOND = 9 / 3600
MockState::advanceTime(1000000); // 1 second
double expected = 9.0 * ST4Constants::RA_PER_SECOND;
double pos = ctrl->position(ST4AxisId::RA);
TEST_ASSERT_DOUBLE_WITHIN(1e-9, expected, pos);
}
void test_rate_calculation_ra_minus() {
ctrl->connect();
ctrl->move(ST4AxisId::RA, ST4Direction::MINUS);
MockState::advanceTime(1000000);
// RA MINUS: -7 * RA_PER_SECOND
double expected = -7.0 * ST4Constants::RA_PER_SECOND;
double pos = ctrl->position(ST4AxisId::RA);
TEST_ASSERT_DOUBLE_WITHIN(1e-9, expected, pos);
}
void test_rate_calculation_dec_plus() {
ctrl->connect();
ctrl->move(ST4AxisId::DECLINATION, ST4Direction::PLUS);
MockState::advanceTime(1000000);
double expected = 8.0 * ST4Constants::DEGREES_PER_SECOND;
double pos = ctrl->position(ST4AxisId::DECLINATION);
TEST_ASSERT_DOUBLE_WITHIN(1e-9, expected, pos);
}
void test_state_snapshot() {
ctrl->connect();
ctrl->move(ST4AxisId::RA, ST4Direction::PLUS);
ST4State s = ctrl->state();
TEST_ASSERT_TRUE(s.connected);
TEST_ASSERT_TRUE(s.ra.active);
TEST_ASSERT_EQUAL(ST4Direction::PLUS, s.ra.direction);
TEST_ASSERT_FALSE(s.dec.active);
TEST_ASSERT_EQUAL(ST4Direction::STOP, s.dec.direction);
}
void test_sync() {
ctrl->connect();
ctrl->sync(10.0, 20.0);
TEST_ASSERT_DOUBLE_WITHIN(1e-9, 10.0, ctrl->position(ST4AxisId::RA));
TEST_ASSERT_DOUBLE_WITHIN(1e-9, 20.0, ctrl->position(ST4AxisId::DECLINATION));
}
void test_stop_all() {
ctrl->connect();
ctrl->move(ST4AxisId::RA, ST4Direction::PLUS);
ctrl->move(ST4AxisId::DECLINATION, ST4Direction::MINUS);
ctrl->stopAll();
TEST_ASSERT_EQUAL(ST4Direction::STOP, ctrl->axisDirection(ST4AxisId::RA));
TEST_ASSERT_EQUAL(ST4Direction::STOP, ctrl->axisDirection(ST4AxisId::DECLINATION));
TEST_ASSERT_FALSE(ctrl->axisActive(ST4AxisId::RA));
TEST_ASSERT_FALSE(ctrl->axisActive(ST4AxisId::DECLINATION));
}
void test_stop_axis() {
ctrl->connect();
ctrl->move(ST4AxisId::RA, ST4Direction::PLUS);
ctrl->move(ST4AxisId::DECLINATION, ST4Direction::MINUS);
ctrl->stopAxis(ST4AxisId::RA);
TEST_ASSERT_EQUAL(ST4Direction::STOP, ctrl->axisDirection(ST4AxisId::RA));
TEST_ASSERT_EQUAL(ST4Direction::MINUS, ctrl->axisDirection(ST4AxisId::DECLINATION));
}
void test_pulse_guide_not_connected() {
bool result = ctrl->pulseGuide(ST4AxisId::RA, ST4Direction::PLUS, 500);
TEST_ASSERT_FALSE(result);
}
void test_pulse_guide_connected() {
ctrl->connect();
bool result = ctrl->pulseGuide(ST4AxisId::RA, ST4Direction::PLUS, 500);
TEST_ASSERT_TRUE(result);
}
void test_set_rates() {
ST4RateConfig customRates = {4.0, 3.0, 2.0, 2.0};
ctrl->setRates(customRates);
ctrl->connect();
ctrl->move(ST4AxisId::RA, ST4Direction::PLUS);
MockState::advanceTime(1000000);
double expected = 4.0 * ST4Constants::RA_PER_SECOND;
double pos = ctrl->position(ST4AxisId::RA);
TEST_ASSERT_DOUBLE_WITHIN(1e-9, expected, pos);
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_not_connected_guard);
RUN_TEST(test_connect_sets_led_high);
RUN_TEST(test_disconnect_sets_led_low);
RUN_TEST(test_move_ra_plus);
RUN_TEST(test_move_dec_minus);
RUN_TEST(test_rate_calculation_ra_plus);
RUN_TEST(test_rate_calculation_ra_minus);
RUN_TEST(test_rate_calculation_dec_plus);
RUN_TEST(test_state_snapshot);
RUN_TEST(test_sync);
RUN_TEST(test_stop_all);
RUN_TEST(test_stop_axis);
RUN_TEST(test_pulse_guide_not_connected);
RUN_TEST(test_pulse_guide_connected);
RUN_TEST(test_set_rates);
return UNITY_END();
}

View File

@ -0,0 +1,92 @@
// ST4Pin unit tests
#include <unity.h>
#include "mock_state.h"
#include "ST4Pin.h"
void setUp() { MockState::reset(); }
void tearDown() {}
void test_active_high_activate() {
ST4Pin pin;
pin.begin(5, ST4PinLogic::ACTIVE_HIGH);
pin.activate();
TEST_ASSERT_EQUAL(HIGH, MockState::gpioStates[5]);
TEST_ASSERT_TRUE(pin.isActive());
}
void test_active_high_deactivate() {
ST4Pin pin;
pin.begin(5, ST4PinLogic::ACTIVE_HIGH);
pin.activate();
pin.deactivate();
TEST_ASSERT_EQUAL(LOW, MockState::gpioStates[5]);
TEST_ASSERT_FALSE(pin.isActive());
}
void test_active_low_activate() {
ST4Pin pin;
pin.begin(5, ST4PinLogic::ACTIVE_LOW);
pin.activate();
TEST_ASSERT_EQUAL(LOW, MockState::gpioStates[5]);
TEST_ASSERT_TRUE(pin.isActive());
}
void test_active_low_deactivate() {
ST4Pin pin;
pin.begin(5, ST4PinLogic::ACTIVE_LOW);
pin.activate();
pin.deactivate();
TEST_ASSERT_EQUAL(HIGH, MockState::gpioStates[5]);
TEST_ASSERT_FALSE(pin.isActive());
}
void test_begin_sets_output() {
ST4Pin pin;
pin.begin(5, ST4PinLogic::ACTIVE_HIGH);
TEST_ASSERT_EQUAL(OUTPUT, MockState::gpioModes[5]);
}
void test_begin_deactivates() {
ST4Pin pin;
pin.begin(5, ST4PinLogic::ACTIVE_HIGH);
// ACTIVE_HIGH deactivated = LOW
TEST_ASSERT_EQUAL(LOW, MockState::gpioStates[5]);
TEST_ASSERT_FALSE(pin.isActive());
}
void test_begin_deactivates_active_low() {
ST4Pin pin;
pin.begin(5, ST4PinLogic::ACTIVE_LOW);
// ACTIVE_LOW deactivated = HIGH
TEST_ASSERT_EQUAL(HIGH, MockState::gpioStates[5]);
TEST_ASSERT_FALSE(pin.isActive());
}
void test_negative_pin_noop() {
ST4Pin pin;
// Default constructed pin has pin_=-1
pin.activate();
pin.deactivate();
TEST_ASSERT_FALSE(pin.isActive());
TEST_ASSERT_EQUAL(-1, pin.pin());
}
void test_pin_returns_assigned() {
ST4Pin pin;
pin.begin(7, ST4PinLogic::ACTIVE_HIGH);
TEST_ASSERT_EQUAL(7, pin.pin());
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_active_high_activate);
RUN_TEST(test_active_high_deactivate);
RUN_TEST(test_active_low_activate);
RUN_TEST(test_active_low_deactivate);
RUN_TEST(test_begin_sets_output);
RUN_TEST(test_begin_deactivates);
RUN_TEST(test_begin_deactivates_active_low);
RUN_TEST(test_negative_pin_noop);
RUN_TEST(test_pin_returns_assigned);
return UNITY_END();
}

View File

@ -0,0 +1,185 @@
// ST4Serial unit tests
#include <unity.h>
#include "mock_state.h"
#include "ST4Controller.h"
#include "ST4Serial.h"
static ST4Controller* ctrl;
static ST4Serial* serial;
void setUp() {
MockState::reset();
ctrl = new ST4Controller();
ctrl->begin();
serial = new ST4Serial();
serial->begin(*ctrl, Serial, true);
// Clear the INITIALIZED# output from begin()
MockState::serialOutput.clear();
}
void tearDown() {
delete serial;
delete ctrl;
serial = nullptr;
ctrl = nullptr;
}
// Helper: send a command string and process it
static void sendCommand(const char* cmd) {
MockState::setSerialInput(cmd);
Serial.resetReadPos();
serial->update();
}
void test_connect_command() {
sendCommand("CONNECT#");
TEST_ASSERT_TRUE(ctrl->isConnected());
TEST_ASSERT_NOT_EQUAL(std::string::npos, MockState::serialOutput.find("OK#"));
}
void test_disconnect_command() {
sendCommand("CONNECT#");
MockState::serialOutput.clear();
sendCommand("DISCONNECT#");
TEST_ASSERT_FALSE(ctrl->isConnected());
TEST_ASSERT_NOT_EQUAL(std::string::npos, MockState::serialOutput.find("OK#"));
}
void test_ra_plus() {
sendCommand("CONNECT#");
MockState::serialOutput.clear();
sendCommand("RA+#");
TEST_ASSERT_EQUAL(ST4Direction::PLUS, ctrl->axisDirection(ST4AxisId::RA));
TEST_ASSERT_NOT_EQUAL(std::string::npos, MockState::serialOutput.find("OK#"));
}
void test_ra_minus() {
sendCommand("CONNECT#");
MockState::serialOutput.clear();
sendCommand("RA-#");
TEST_ASSERT_EQUAL(ST4Direction::MINUS, ctrl->axisDirection(ST4AxisId::RA));
TEST_ASSERT_NOT_EQUAL(std::string::npos, MockState::serialOutput.find("OK#"));
}
void test_ra_stop() {
sendCommand("CONNECT#");
sendCommand("RA+#");
MockState::serialOutput.clear();
sendCommand("RA0#");
TEST_ASSERT_EQUAL(ST4Direction::STOP, ctrl->axisDirection(ST4AxisId::RA));
TEST_ASSERT_NOT_EQUAL(std::string::npos, MockState::serialOutput.find("OK#"));
}
void test_dec_plus() {
sendCommand("CONNECT#");
MockState::serialOutput.clear();
sendCommand("DEC+#");
TEST_ASSERT_EQUAL(ST4Direction::PLUS, ctrl->axisDirection(ST4AxisId::DECLINATION));
TEST_ASSERT_NOT_EQUAL(std::string::npos, MockState::serialOutput.find("OK#"));
}
void test_dec_minus() {
sendCommand("CONNECT#");
MockState::serialOutput.clear();
sendCommand("DEC-#");
TEST_ASSERT_EQUAL(ST4Direction::MINUS, ctrl->axisDirection(ST4AxisId::DECLINATION));
TEST_ASSERT_NOT_EQUAL(std::string::npos, MockState::serialOutput.find("OK#"));
}
void test_dec_stop() {
sendCommand("CONNECT#");
sendCommand("DEC+#");
MockState::serialOutput.clear();
sendCommand("DEC0#");
TEST_ASSERT_EQUAL(ST4Direction::STOP, ctrl->axisDirection(ST4AxisId::DECLINATION));
TEST_ASSERT_NOT_EQUAL(std::string::npos, MockState::serialOutput.find("OK#"));
}
void test_pulse_command() {
sendCommand("CONNECT#");
MockState::serialOutput.clear();
sendCommand("PULSE RA+ 500#");
TEST_ASSERT_NOT_EQUAL(std::string::npos, MockState::serialOutput.find("OK#"));
}
void test_pos_query() {
sendCommand("CONNECT#");
ctrl->sync(12.345, 45.678);
MockState::serialOutput.clear();
sendCommand("POS?#");
TEST_ASSERT_NOT_EQUAL(std::string::npos, MockState::serialOutput.find("POS"));
}
void test_sync_command() {
sendCommand("CONNECT#");
MockState::serialOutput.clear();
sendCommand("SYNC 12.345 45.678#");
TEST_ASSERT_NOT_EQUAL(std::string::npos, MockState::serialOutput.find("OK#"));
TEST_ASSERT_DOUBLE_WITHIN(1e-3, 12.345, ctrl->position(ST4AxisId::RA));
TEST_ASSERT_DOUBLE_WITHIN(1e-3, 45.678, ctrl->position(ST4AxisId::DECLINATION));
}
void test_status_query() {
sendCommand("CONNECT#");
MockState::serialOutput.clear();
sendCommand("STATUS?#");
TEST_ASSERT_NOT_EQUAL(std::string::npos, MockState::serialOutput.find("STATUS"));
}
void test_version_query() {
MockState::serialOutput.clear();
sendCommand("VERSION?#");
TEST_ASSERT_NOT_EQUAL(std::string::npos,
MockState::serialOutput.find(ST4Constants::VERSION));
}
void test_buffer_overflow() {
// Send >64 chars then '#' -- should discard the truncated command without crashing
std::string overflow(70, 'A');
overflow += '#';
sendCommand(overflow.c_str());
// No crash is the pass condition. Output should not contain "OK#" for this garbage
// (the overflow flag causes discard, so processCommand is never called)
}
void test_sync_garbage_rejected() {
sendCommand("CONNECT#");
MockState::serialOutput.clear();
sendCommand("SYNC garbage data#");
TEST_ASSERT_NOT_EQUAL(std::string::npos, MockState::serialOutput.find("ERR:INVALID_COORDS#"));
}
void test_partial_buffering() {
// Send "CON" then "NECT#" across two update() calls
MockState::setSerialInput("CON");
Serial.resetReadPos();
serial->update();
// No command processed yet
TEST_ASSERT_FALSE(ctrl->isConnected());
MockState::setSerialInput("NECT#");
Serial.resetReadPos();
serial->update();
TEST_ASSERT_TRUE(ctrl->isConnected());
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_connect_command);
RUN_TEST(test_disconnect_command);
RUN_TEST(test_ra_plus);
RUN_TEST(test_ra_minus);
RUN_TEST(test_ra_stop);
RUN_TEST(test_dec_plus);
RUN_TEST(test_dec_minus);
RUN_TEST(test_dec_stop);
RUN_TEST(test_pulse_command);
RUN_TEST(test_pos_query);
RUN_TEST(test_sync_command);
RUN_TEST(test_status_query);
RUN_TEST(test_version_query);
RUN_TEST(test_buffer_overflow);
RUN_TEST(test_sync_garbage_rejected);
RUN_TEST(test_partial_buffering);
return UNITY_END();
}

View File

@ -0,0 +1,124 @@
// ST4Tracker unit tests
#include <unity.h>
#include "mock_state.h"
#include "ST4Tracker.h"
void setUp() { MockState::reset(); }
void tearDown() {}
void test_initial_position_zero() {
ST4Tracker tracker;
tracker.begin();
TEST_ASSERT_DOUBLE_WITHIN(1e-9, 0.0, tracker.position());
}
void test_position_accumulation() {
ST4Tracker tracker;
tracker.begin();
double rate = 2.5;
tracker.start(rate);
// Advance 1 second
MockState::advanceTime(1000000);
double pos = tracker.position();
TEST_ASSERT_DOUBLE_WITHIN(1e-6, 2.5, pos);
}
void test_stop_freezes_position() {
ST4Tracker tracker;
tracker.begin();
tracker.start(3.0);
MockState::advanceTime(1000000); // 1 second -> pos = 3.0
tracker.stop();
double posAtStop = tracker.position();
// Advance more time -- position should not change
MockState::advanceTime(5000000);
double posLater = tracker.position();
TEST_ASSERT_DOUBLE_WITHIN(1e-9, posAtStop, posLater);
TEST_ASSERT_DOUBLE_WITHIN(1e-6, 3.0, posLater);
}
void test_set_position() {
ST4Tracker tracker;
tracker.begin();
tracker.setPosition(42.0);
TEST_ASSERT_DOUBLE_WITHIN(1e-9, 42.0, tracker.position());
}
void test_direction_sign_positive() {
ST4Tracker tracker;
tracker.begin();
tracker.start(1.0);
MockState::advanceTime(1000000);
TEST_ASSERT_TRUE(tracker.position() > 0.0);
}
void test_direction_sign_negative() {
ST4Tracker tracker;
tracker.begin();
tracker.start(-1.0);
MockState::advanceTime(1000000);
TEST_ASSERT_TRUE(tracker.position() < 0.0);
}
void test_slew_rate_while_moving() {
ST4Tracker tracker;
tracker.begin();
tracker.start(5.0);
TEST_ASSERT_DOUBLE_WITHIN(1e-9, 5.0, tracker.slewRate());
}
void test_slew_rate_after_stop() {
ST4Tracker tracker;
tracker.begin();
tracker.start(5.0);
tracker.stop();
TEST_ASSERT_DOUBLE_WITHIN(1e-9, 0.0, tracker.slewRate());
}
void test_is_moving() {
ST4Tracker tracker;
tracker.begin();
TEST_ASSERT_FALSE(tracker.isMoving());
tracker.start(1.0);
TEST_ASSERT_TRUE(tracker.isMoving());
tracker.stop();
TEST_ASSERT_FALSE(tracker.isMoving());
}
void test_rate_change_accumulates() {
ST4Tracker tracker;
tracker.begin();
tracker.start(2.0);
MockState::advanceTime(1000000); // 1s at rate 2.0 -> 2.0
tracker.start(4.0); // changes rate, accumulates previous delta
MockState::advanceTime(1000000); // 1s at rate 4.0 -> +4.0
tracker.stop();
double pos = tracker.position();
TEST_ASSERT_DOUBLE_WITHIN(1e-6, 6.0, pos);
}
void test_set_position_stops_movement() {
ST4Tracker tracker;
tracker.begin();
tracker.start(1.0);
MockState::advanceTime(1000000);
tracker.setPosition(100.0);
TEST_ASSERT_FALSE(tracker.isMoving());
TEST_ASSERT_DOUBLE_WITHIN(1e-9, 100.0, tracker.position());
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_initial_position_zero);
RUN_TEST(test_position_accumulation);
RUN_TEST(test_stop_freezes_position);
RUN_TEST(test_set_position);
RUN_TEST(test_direction_sign_positive);
RUN_TEST(test_direction_sign_negative);
RUN_TEST(test_slew_rate_while_moving);
RUN_TEST(test_slew_rate_after_stop);
RUN_TEST(test_is_moving);
RUN_TEST(test_rate_change_accumulates);
RUN_TEST(test_set_position_stops_movement);
return UNITY_END();
}