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:
parent
35ec6dfdb5
commit
71e5484507
12
Makefile
12
Makefile
@ -1,9 +1,9 @@
|
||||
.PHONY: all basic serial pulse wifi clean
|
||||
.PHONY: all basic serial pulse wifi alpaca clean test
|
||||
|
||||
BOARD = esp32dev
|
||||
LIB = .
|
||||
|
||||
all: basic serial pulse wifi
|
||||
all: basic serial pulse wifi alpaca
|
||||
@echo "All examples built successfully"
|
||||
|
||||
basic:
|
||||
@ -20,5 +20,13 @@ wifi:
|
||||
--project-option="build_flags=-DST4_WIFI_ENABLED" \
|
||||
--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:
|
||||
rm -rf .pio
|
||||
|
||||
test:
|
||||
pio test -e native -v
|
||||
|
||||
279
README.md
Normal file
279
README.md
Normal 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.
|
||||
53
examples/alpaca_server/alpaca_server.ino
Normal file
53
examples/alpaca_server/alpaca_server.ino
Normal 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();
|
||||
}
|
||||
@ -16,3 +16,7 @@
|
||||
#ifdef ST4_WIFI_ENABLED
|
||||
#include "ST4WiFi.h"
|
||||
#endif
|
||||
|
||||
#ifdef ST4_ALPACA_ENABLED
|
||||
#include "ST4Alpaca.h"
|
||||
#endif
|
||||
|
||||
59
include/ST4Alpaca.h
Normal file
59
include/ST4Alpaca.h
Normal 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
|
||||
@ -38,6 +38,7 @@
|
||||
"examples/basic_gpio/basic_gpio.ino",
|
||||
"examples/serial_compatible/serial_compatible.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"
|
||||
]
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
[platformio]
|
||||
default_envs = serial_compatible
|
||||
|
||||
[env]
|
||||
[base_esp32]
|
||||
platform = espressif32
|
||||
board = esp32dev
|
||||
framework = arduino
|
||||
@ -13,18 +13,52 @@ monitor_speed = 115200
|
||||
lib_deps = symlink://.
|
||||
|
||||
[env:basic_gpio]
|
||||
extends = base_esp32
|
||||
build_src_filter = +<../examples/basic_gpio/>
|
||||
|
||||
[env:serial_compatible]
|
||||
extends = base_esp32
|
||||
build_src_filter = +<../examples/serial_compatible/>
|
||||
|
||||
[env:pulse_guide]
|
||||
extends = base_esp32
|
||||
build_src_filter = +<../examples/pulse_guide/>
|
||||
|
||||
[env:wifi_control]
|
||||
extends = base_esp32
|
||||
build_flags = -DST4_WIFI_ENABLED
|
||||
lib_deps =
|
||||
symlink://.
|
||||
bblanchon/ArduinoJson@^7.0.0
|
||||
mathieucarbou/ESPAsyncWebServer@^3.6.0
|
||||
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
543
src/ST4Alpaca.cpp
Normal 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
230
test/mocks/Arduino.h
Normal 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;
|
||||
43
test/mocks/ST4Pulse_stub.cpp
Normal file
43
test/mocks/ST4Pulse_stub.cpp
Normal 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
24
test/mocks/esp_timer.h
Normal 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; }
|
||||
20
test/mocks/freertos/FreeRTOS.h
Normal file
20
test/mocks/freertos/FreeRTOS.h
Normal 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
|
||||
12
test/mocks/freertos/semphr.h
Normal file
12
test/mocks/freertos/semphr.h
Normal 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) {}
|
||||
8
test/mocks/freertos/task.h
Normal file
8
test/mocks/freertos/task.h
Normal 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
30
test/mocks/mock_state.cpp
Normal 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
17
test/mocks/mock_state.h
Normal 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);
|
||||
}
|
||||
105
test/test_axis/test_axis.cpp
Normal file
105
test/test_axis/test_axis.cpp
Normal 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();
|
||||
}
|
||||
159
test/test_controller/test_controller.cpp
Normal file
159
test/test_controller/test_controller.cpp
Normal 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();
|
||||
}
|
||||
92
test/test_pin/test_pin.cpp
Normal file
92
test/test_pin/test_pin.cpp
Normal 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();
|
||||
}
|
||||
185
test/test_serial/test_serial.cpp
Normal file
185
test/test_serial/test_serial.cpp
Normal 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();
|
||||
}
|
||||
124
test/test_tracker/test_tracker.cpp
Normal file
124
test/test_tracker/test_tracker.cpp
Normal 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();
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user