diff --git a/Makefile b/Makefile index 868b9a6..d2cec86 100644 --- a/Makefile +++ b/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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f18ec2 --- /dev/null +++ b/README.md @@ -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 + +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://: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 `: + +```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. diff --git a/examples/alpaca_server/alpaca_server.ino b/examples/alpaca_server/alpaca_server.ino new file mode 100644 index 0000000..6ccd48f --- /dev/null +++ b/examples/alpaca_server/alpaca_server.ino @@ -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 + +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(); +} diff --git a/include/ST4.h b/include/ST4.h index fd5eae4..fe39f3e 100644 --- a/include/ST4.h +++ b/include/ST4.h @@ -16,3 +16,7 @@ #ifdef ST4_WIFI_ENABLED #include "ST4WiFi.h" #endif + +#ifdef ST4_ALPACA_ENABLED +#include "ST4Alpaca.h" +#endif diff --git a/include/ST4Alpaca.h b/include/ST4Alpaca.h new file mode 100644 index 0000000..02e93b0 --- /dev/null +++ b/include/ST4Alpaca.h @@ -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 +#include +#include +#include +#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 diff --git a/library.json b/library.json index 82e7fdf..3ff6d57 100644 --- a/library.json +++ b/library.json @@ -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" ] } diff --git a/platformio.ini b/platformio.ini index 4b06c6e..b10d158 100644 --- a/platformio.ini +++ b/platformio.ini @@ -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> diff --git a/src/ST4Alpaca.cpp b/src/ST4Alpaca.cpp new file mode 100644 index 0000000..2457456 --- /dev/null +++ b/src/ST4Alpaca.cpp @@ -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()) { + 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(); + arr.add(1); + sendValue(req, doc); + }); + + server_->on("/management/v1/description", HTTP_GET, + [this](AsyncWebServerRequest* req) { + JsonDocument doc; + auto desc = doc["Value"].to(); + 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(); + auto dev = arr.add(); + 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(); + 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(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 diff --git a/test/mocks/Arduino.h b/test/mocks/Arduino.h new file mode 100644 index 0000000..52932b7 --- /dev/null +++ b/test/mocks/Arduino.h @@ -0,0 +1,230 @@ +// Arduino.h mock for native testing +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#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(MockState::mockTimeMicros / 1000); +} + +inline void delay(unsigned long ms) { + MockState::advanceTime(static_cast(ms) * 1000); +} + +inline bool isDigit(char c) { + return std::isdigit(static_cast(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(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(pos); + } + + int indexOf(char ch, int from) const { + if (from < 0) from = 0; + auto pos = s_.find(ch, static_cast(from)); + return (pos == std::string::npos) ? -1 : static_cast(pos); + } + + String substring(int from) const { + if (from < 0) from = 0; + if (static_cast(from) >= s_.length()) return String(""); + return String(s_.substr(static_cast(from))); + } + + String substring(int from, int to) const { + if (from < 0) from = 0; + if (to < from) return String(""); + if (static_cast(from) >= s_.length()) return String(""); + size_t len = static_cast(to - from); + return String(s_.substr(static_cast(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(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(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(buf[i]); + } + return size; + } +}; + +extern HardwareSerial Serial; diff --git a/test/mocks/ST4Pulse_stub.cpp b/test/mocks/ST4Pulse_stub.cpp new file mode 100644 index 0000000..833e826 --- /dev/null +++ b/test/mocks/ST4Pulse_stub.cpp @@ -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*) {} diff --git a/test/mocks/esp_timer.h b/test/mocks/esp_timer.h new file mode 100644 index 0000000..689a648 --- /dev/null +++ b/test/mocks/esp_timer.h @@ -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; } diff --git a/test/mocks/freertos/FreeRTOS.h b/test/mocks/freertos/FreeRTOS.h new file mode 100644 index 0000000..2cbf4db --- /dev/null +++ b/test/mocks/freertos/FreeRTOS.h @@ -0,0 +1,20 @@ +// FreeRTOS mock for native testing +#pragma once +#include +#include + +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 diff --git a/test/mocks/freertos/semphr.h b/test/mocks/freertos/semphr.h new file mode 100644 index 0000000..fe30576 --- /dev/null +++ b/test/mocks/freertos/semphr.h @@ -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) {} diff --git a/test/mocks/freertos/task.h b/test/mocks/freertos/task.h new file mode 100644 index 0000000..40473bf --- /dev/null +++ b/test/mocks/freertos/task.h @@ -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; } diff --git a/test/mocks/mock_state.cpp b/test/mocks/mock_state.cpp new file mode 100644 index 0000000..ec95814 --- /dev/null +++ b/test/mocks/mock_state.cpp @@ -0,0 +1,30 @@ +// Mock state implementation +#include "mock_state.h" +#include "Arduino.h" + +namespace MockState { + int64_t mockTimeMicros = 0; + std::map gpioStates; + std::map 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; diff --git a/test/mocks/mock_state.h b/test/mocks/mock_state.h new file mode 100644 index 0000000..84aedb7 --- /dev/null +++ b/test/mocks/mock_state.h @@ -0,0 +1,17 @@ +// Mock state shared across all mock implementations for native testing +#pragma once +#include +#include +#include + +namespace MockState { + extern int64_t mockTimeMicros; + extern std::map gpioStates; + extern std::map gpioModes; + extern std::string serialInput; + extern std::string serialOutput; + + void reset(); + void advanceTime(int64_t microseconds); + void setSerialInput(const std::string& input); +} diff --git a/test/test_axis/test_axis.cpp b/test/test_axis/test_axis.cpp new file mode 100644 index 0000000..d080927 --- /dev/null +++ b/test/test_axis/test_axis.cpp @@ -0,0 +1,105 @@ +// ST4Axis unit tests +#include +#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(); +} diff --git a/test/test_controller/test_controller.cpp b/test/test_controller/test_controller.cpp new file mode 100644 index 0000000..ca666d2 --- /dev/null +++ b/test/test_controller/test_controller.cpp @@ -0,0 +1,159 @@ +// ST4Controller unit tests +#include +#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(); +} diff --git a/test/test_pin/test_pin.cpp b/test/test_pin/test_pin.cpp new file mode 100644 index 0000000..7f7e03d --- /dev/null +++ b/test/test_pin/test_pin.cpp @@ -0,0 +1,92 @@ +// ST4Pin unit tests +#include +#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(); +} diff --git a/test/test_serial/test_serial.cpp b/test/test_serial/test_serial.cpp new file mode 100644 index 0000000..5139f99 --- /dev/null +++ b/test/test_serial/test_serial.cpp @@ -0,0 +1,185 @@ +// ST4Serial unit tests +#include +#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(); +} diff --git a/test/test_tracker/test_tracker.cpp b/test/test_tracker/test_tracker.cpp new file mode 100644 index 0000000..179f921 --- /dev/null +++ b/test/test_tracker/test_tracker.cpp @@ -0,0 +1,124 @@ +// ST4Tracker unit tests +#include +#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(); +}