From 4c91fd48115efbf9a59048e366b536c7c548bc5f Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Tue, 17 Feb 2026 19:46:03 -0700 Subject: [PATCH] ESP32 ST-4 autoguider library with thread-safe pulse guiding Port of arduino-st4 (Kevin Ferrare) to ESP32/PlatformIO with: - FreeRTOS mutex protection at every layer (Pin, Axis, Pulse, Controller) - Hardware timer pulse guiding with ISR-safe deferred stop pattern - Backward-compatible serial protocol (57600 baud, #-terminated) - Extended commands: PULSE, POS?, SYNC, STATUS?, VERSION? - Optional WiFi/WebSocket control (gated by ST4_WIFI_ENABLED) - Dead-reckoning position tracker using esp_timer microsecond precision All 4 examples build clean against esp32dev target. --- LICENSE | 165 ++++++++++++++++ Makefile | 24 +++ examples/basic_gpio/basic_gpio.ino | 52 +++++ examples/pulse_guide/pulse_guide.ino | 48 +++++ .../serial_compatible/serial_compatible.ino | 27 +++ examples/wifi_control/wifi_control.ino | 59 ++++++ include/ST4.h | 18 ++ include/ST4Axis.h | 31 +++ include/ST4Config.h | 28 +++ include/ST4Controller.h | 63 ++++++ include/ST4Pin.h | 22 +++ include/ST4Pulse.h | 43 +++++ include/ST4Serial.h | 28 +++ include/ST4Tracker.h | 31 +++ include/ST4Types.h | 63 ++++++ include/ST4WiFi.h | 49 +++++ library.json | 43 +++++ platformio.ini | 30 +++ src/ST4Axis.cpp | 59 ++++++ src/ST4Controller.cpp | 180 +++++++++++++++++ src/ST4Pin.cpp | 33 ++++ src/ST4Pulse.cpp | 139 +++++++++++++ src/ST4Serial.cpp | 151 +++++++++++++++ src/ST4Tracker.cpp | 70 +++++++ src/ST4WiFi.cpp | 182 ++++++++++++++++++ 25 files changed, 1638 insertions(+) create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 examples/basic_gpio/basic_gpio.ino create mode 100644 examples/pulse_guide/pulse_guide.ino create mode 100644 examples/serial_compatible/serial_compatible.ino create mode 100644 examples/wifi_control/wifi_control.ino create mode 100644 include/ST4.h create mode 100644 include/ST4Axis.h create mode 100644 include/ST4Config.h create mode 100644 include/ST4Controller.h create mode 100644 include/ST4Pin.h create mode 100644 include/ST4Pulse.h create mode 100644 include/ST4Serial.h create mode 100644 include/ST4Tracker.h create mode 100644 include/ST4Types.h create mode 100644 include/ST4WiFi.h create mode 100644 library.json create mode 100644 platformio.ini create mode 100644 src/ST4Axis.cpp create mode 100644 src/ST4Controller.cpp create mode 100644 src/ST4Pin.cpp create mode 100644 src/ST4Pulse.cpp create mode 100644 src/ST4Serial.cpp create mode 100644 src/ST4Tracker.cpp create mode 100644 src/ST4WiFi.cpp diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0a04128 --- /dev/null +++ b/LICENSE @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..868b9a6 --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +.PHONY: all basic serial pulse wifi clean + +BOARD = esp32dev +LIB = . + +all: basic serial pulse wifi + @echo "All examples built successfully" + +basic: + pio ci examples/basic_gpio/basic_gpio.ino --lib="$(LIB)" --board=$(BOARD) + +serial: + pio ci examples/serial_compatible/serial_compatible.ino --lib="$(LIB)" --board=$(BOARD) + +pulse: + pio ci examples/pulse_guide/pulse_guide.ino --lib="$(LIB)" --board=$(BOARD) + +wifi: + pio ci examples/wifi_control/wifi_control.ino --lib="$(LIB)" --board=$(BOARD) \ + --project-option="build_flags=-DST4_WIFI_ENABLED" \ + --project-option="lib_deps=bblanchon/ArduinoJson@^7.0.0, mathieucarbou/ESPAsyncWebServer@^3.6.0" + +clean: + rm -rf .pio diff --git a/examples/basic_gpio/basic_gpio.ino b/examples/basic_gpio/basic_gpio.ino new file mode 100644 index 0000000..155a0ff --- /dev/null +++ b/examples/basic_gpio/basic_gpio.ino @@ -0,0 +1,52 @@ +// ST4-ESP32 Basic GPIO Example +// Cycles through all axis directions to verify wiring +// Use multimeter or logic analyzer on TLP521-4 outputs to verify + +#include + +ST4Axis ra; +ST4Axis dec; + +void setup() { + Serial.begin(115200); + Serial.println("ST4 Basic GPIO Test"); + + ra.begin(ST4_PIN_RA_PLUS, ST4_PIN_RA_MINUS); + dec.begin(ST4_PIN_DEC_PLUS, ST4_PIN_DEC_MINUS); + + Serial.println("Pins initialized. Cycling directions..."); +} + +void loop() { + Serial.println("RA+"); + ra.plus(); + delay(2000); + + Serial.println("RA stop"); + ra.stop(); + delay(1000); + + Serial.println("RA-"); + ra.minus(); + delay(2000); + + Serial.println("RA stop"); + ra.stop(); + delay(1000); + + Serial.println("DEC+"); + dec.plus(); + delay(2000); + + Serial.println("DEC stop"); + dec.stop(); + delay(1000); + + Serial.println("DEC-"); + dec.minus(); + delay(2000); + + Serial.println("All stop"); + dec.stop(); + delay(3000); +} diff --git a/examples/pulse_guide/pulse_guide.ino b/examples/pulse_guide/pulse_guide.ino new file mode 100644 index 0000000..0eb8529 --- /dev/null +++ b/examples/pulse_guide/pulse_guide.ino @@ -0,0 +1,48 @@ +// ST4-ESP32 Pulse Guide Example +// Demonstrates hardware-timer pulse guiding with position tracking +// +// Send via serial (57600 baud): +// CONNECT# - connect to mount +// PULSE RA+ 500# - pulse RA+ for 500ms +// PULSE DEC- 1000# - pulse DEC- for 1 second +// POS?# - read current position +// STATUS?# - full status report + +#include + +ST4Controller controller; +ST4Serial st4Serial; + +void setup() { + Serial.begin(115200); + Serial.println("ST4 Pulse Guide Example"); + + controller.begin( + ST4_PIN_RA_PLUS, ST4_PIN_RA_MINUS, + ST4_PIN_DEC_PLUS, ST4_PIN_DEC_MINUS, + ST4_PIN_LED + ); + + st4Serial.begin(controller, Serial); + controller.connect(); + + Serial.println("Ready. Try: PULSE RA+ 500#"); +} + +void loop() { + st4Serial.update(); + + // Print position every 5 seconds while moving + static uint32_t lastPrint = 0; + if (millis() - lastPrint > 5000) { + lastPrint = millis(); + if (controller.axisActive(ST4AxisId::RA) || + controller.axisActive(ST4AxisId::DECLINATION) || + controller.isPulseActive()) { + Serial.print("RA: "); + Serial.print(controller.position(ST4AxisId::RA), 6); + Serial.print(" DEC: "); + Serial.println(controller.position(ST4AxisId::DECLINATION), 6); + } + } +} diff --git a/examples/serial_compatible/serial_compatible.ino b/examples/serial_compatible/serial_compatible.ino new file mode 100644 index 0000000..c6efef9 --- /dev/null +++ b/examples/serial_compatible/serial_compatible.ino @@ -0,0 +1,27 @@ +// ST4-ESP32 Serial Compatible Example +// Drop-in replacement for original ArduinoCode.ino +// Works with the ASCOM ArduinoST4 driver at 57600 baud +// +// Original commands: CONNECT# DISCONNECT# RA+# RA-# RA0# DEC+# DEC-# DEC0# +// Extended commands: PULSE RA+ 500# POS?# SYNC 12.345 45.678# STATUS?# VERSION?# + +#include + +ST4Controller controller; +ST4Serial st4Serial; + +void setup() { + controller.begin( + ST4_PIN_RA_PLUS, ST4_PIN_RA_MINUS, + ST4_PIN_DEC_PLUS, ST4_PIN_DEC_MINUS, + ST4_PIN_LED + ); + + // Extended mode adds PULSE, POS?, SYNC, STATUS?, VERSION? + // Set to false for strict original protocol compatibility + st4Serial.begin(controller, Serial, true); +} + +void loop() { + st4Serial.update(); +} diff --git a/examples/wifi_control/wifi_control.ino b/examples/wifi_control/wifi_control.ino new file mode 100644 index 0000000..bdcf664 --- /dev/null +++ b/examples/wifi_control/wifi_control.ino @@ -0,0 +1,59 @@ +// ST4-ESP32 WiFi Control Example +// Creates a WiFi AP with WebSocket server for wireless autoguiding +// +// Connect to WiFi AP, then open WebSocket at ws:///ws +// JSON commands: +// {"cmd":"move","axis":"ra","dir":"+"} +// {"cmd":"pulse","axis":"dec","dir":"-","ms":500} +// {"cmd":"stop"} +// {"cmd":"sync","ra":12.345,"dec":45.678} +// {"cmd":"status"} +// +// State broadcasts are sent automatically on direction changes +// and periodically during active slew + +#ifndef ST4_WIFI_ENABLED +#define ST4_WIFI_ENABLED +#endif +#include + +ST4Controller controller; +ST4Serial st4Serial; +ST4WiFi st4WiFi; + +const char* WIFI_SSID = "ST4-Guider"; +const char* WIFI_PASS = "st4guide!"; + +void setup() { + Serial.begin(115200); + Serial.println("ST4 WiFi Control"); + + controller.begin( + ST4_PIN_RA_PLUS, ST4_PIN_RA_MINUS, + ST4_PIN_DEC_PLUS, ST4_PIN_DEC_MINUS, + ST4_PIN_LED + ); + + st4Serial.begin(controller, Serial); + + ST4WiFiConfig wifiConfig = { + .ssid = WIFI_SSID, + .password = WIFI_PASS, + .apMode = true, + .httpPort = 80, + .broadcastIntervalMs = 250 + }; + st4WiFi.begin(controller, wifiConfig); + + controller.connect(); + + Serial.println("WiFi AP: " + String(WIFI_SSID)); + Serial.print("WebSocket: ws://"); + Serial.print(WiFi.softAPIP()); + Serial.println("/ws"); +} + +void loop() { + st4Serial.update(); + st4WiFi.update(); +} diff --git a/include/ST4.h b/include/ST4.h new file mode 100644 index 0000000..fd5eae4 --- /dev/null +++ b/include/ST4.h @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// ST4-ESP32: ESP32 ST-4 autoguider port controller +// Facade header - includes all library components + +#pragma once + +#include "ST4Types.h" +#include "ST4Config.h" +#include "ST4Pin.h" +#include "ST4Axis.h" +#include "ST4Tracker.h" +#include "ST4Pulse.h" +#include "ST4Controller.h" +#include "ST4Serial.h" + +#ifdef ST4_WIFI_ENABLED +#include "ST4WiFi.h" +#endif diff --git a/include/ST4Axis.h b/include/ST4Axis.h new file mode 100644 index 0000000..32fe524 --- /dev/null +++ b/include/ST4Axis.h @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// ST4-ESP32: Dual-pin axis with mutual exclusion +// Deactivates opposing pin before activating (matches original safety pattern) + +#pragma once + +#include +#include +#include "ST4Pin.h" +#include "ST4Types.h" + +class ST4Axis { + ST4Pin plusPin_; + ST4Pin minusPin_; + volatile ST4Direction direction_; + SemaphoreHandle_t mutex_; + +public: + ST4Axis(); + ~ST4Axis(); + + void begin(int plusPin, int minusPin, + ST4PinLogic logic = ST4PinLogic::ACTIVE_HIGH); + void plus(); + void minus(); + void stop(); + void move(ST4Direction dir); + + ST4Direction direction() const; + bool isActive() const; +}; diff --git a/include/ST4Config.h b/include/ST4Config.h new file mode 100644 index 0000000..2add942 --- /dev/null +++ b/include/ST4Config.h @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// ST4-ESP32: Default pin configuration for ESP32 + +#pragma once + +#include + +// Default GPIO assignments for ESP32 with TLP521-4 optocoupler +// Override by defining before including ST4.h +#ifndef ST4_PIN_RA_PLUS +#define ST4_PIN_RA_PLUS 16 +#endif + +#ifndef ST4_PIN_RA_MINUS +#define ST4_PIN_RA_MINUS 17 +#endif + +#ifndef ST4_PIN_DEC_PLUS +#define ST4_PIN_DEC_PLUS 18 +#endif + +#ifndef ST4_PIN_DEC_MINUS +#define ST4_PIN_DEC_MINUS 19 +#endif + +#ifndef ST4_PIN_LED +#define ST4_PIN_LED 2 +#endif diff --git a/include/ST4Controller.h b/include/ST4Controller.h new file mode 100644 index 0000000..a691f39 --- /dev/null +++ b/include/ST4Controller.h @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// ST4-ESP32: High-level mount controller +// Owns axes, trackers, and pulse engine with configurable sidereal rates +// Controller-level mutex ensures composite operations are atomic across cores + +#pragma once + +#include +#include +#include "ST4Types.h" +#include "ST4Config.h" +#include "ST4Axis.h" +#include "ST4Tracker.h" +#include "ST4Pulse.h" + +class ST4Controller { + ST4Axis axes_[2]; + ST4Tracker trackers_[2]; + ST4Pulse pulse_; + + ST4RateConfig rateConfig_; + bool connected_; + int ledPin_; + mutable SemaphoreHandle_t mutex_; + + double calculateSlewRate(ST4AxisId axis, ST4Direction dir) const; + +public: + ST4Controller(); + ~ST4Controller(); + + void begin(int raPlusPin = ST4_PIN_RA_PLUS, + int raMinusPin = ST4_PIN_RA_MINUS, + int decPlusPin = ST4_PIN_DEC_PLUS, + int decMinusPin = ST4_PIN_DEC_MINUS, + int ledPin = ST4_PIN_LED, + ST4PinLogic logic = ST4PinLogic::ACTIVE_HIGH); + + void setRates(const ST4RateConfig& config); + + void connect(); + void disconnect(); + bool isConnected() const; + + void move(ST4AxisId axis, ST4Direction dir); + void stopAxis(ST4AxisId axis); + void stopAll(); + + bool pulseGuide(ST4AxisId axis, ST4Direction dir, uint32_t ms); + bool isPulseActive() const; + + double position(ST4AxisId axis) const; + void setPosition(ST4AxisId axis, double pos); + void sync(double ra, double dec); + + ST4Direction axisDirection(ST4AxisId axis) const; + bool axisActive(ST4AxisId axis) const; + + ST4State state() const; + + ST4Axis& axis(ST4AxisId id); + ST4Tracker& tracker(ST4AxisId id); +}; diff --git a/include/ST4Pin.h b/include/ST4Pin.h new file mode 100644 index 0000000..3aa6b7a --- /dev/null +++ b/include/ST4Pin.h @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// ST4-ESP32: Single GPIO pin abstraction with configurable active logic + +#pragma once + +#include +#include "ST4Types.h" + +class ST4Pin { + int pin_; + ST4PinLogic logic_; + bool active_; + +public: + ST4Pin(); + + void begin(int pin, ST4PinLogic logic = ST4PinLogic::ACTIVE_HIGH); + void activate(); + void deactivate(); + bool isActive() const; + int pin() const; +}; diff --git a/include/ST4Pulse.h b/include/ST4Pulse.h new file mode 100644 index 0000000..683e4eb --- /dev/null +++ b/include/ST4Pulse.h @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// ST4-ESP32: Hardware timer pulse guiding (non-blocking) +// Uses esp_timer one-shot with deferred stop via FreeRTOS task + +#pragma once + +#include +#include +#include +#include +#include "ST4Axis.h" +#include "ST4Tracker.h" + +class ST4Pulse { + // Static instance for timer callback (standard embedded ISR pattern) + static ST4Pulse* instance_; + + esp_timer_handle_t timer_; + SemaphoreHandle_t pulseDoneSem_; + SemaphoreHandle_t mutex_; + TaskHandle_t pulseTaskHandle_; + + ST4Axis* activeAxis_; + ST4Tracker* activeTracker_; + volatile bool active_; + volatile bool shutdown_; + + static constexpr uint32_t MAX_PULSE_MS = 10000; + + void cancelLocked(); + static void timerCallback(void* arg); + static void pulseTaskFunc(void* arg); + +public: + ST4Pulse(); + ~ST4Pulse(); + + void begin(); + bool pulse(ST4Axis& axis, ST4Tracker& tracker, + ST4Direction dir, double slewRate, uint32_t ms); + bool isActive() const; + void cancel(); +}; diff --git a/include/ST4Serial.h b/include/ST4Serial.h new file mode 100644 index 0000000..bccbb20 --- /dev/null +++ b/include/ST4Serial.h @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// ST4-ESP32: Serial protocol handler (ASCOM/INDI compatible) +// Drop-in replacement for original ArduinoCode.ino serial protocol +// Extended mode adds PULSE, POS?, SYNC, STATUS?, VERSION? + +#pragma once + +#include +#include "ST4Controller.h" + +class ST4Serial { + ST4Controller* controller_; + HardwareSerial* serial_; + String buffer_; + bool extendedMode_; + + void processCommand(const String& cmd); + void processExtendedCommand(const String& cmd); + String directionStr(ST4Direction dir) const; + +public: + ST4Serial(); + + void begin(ST4Controller& controller, + HardwareSerial& serial = Serial, + bool extendedMode = true); + void update(); +}; diff --git a/include/ST4Tracker.h b/include/ST4Tracker.h new file mode 100644 index 0000000..001bc2e --- /dev/null +++ b/include/ST4Tracker.h @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// ST4-ESP32: Dead-reckoning position tracker +// Port of ASCOM AxisMovementTracker.cs using esp_timer microsecond precision + +#pragma once + +#include +#include +#include "ST4Types.h" + +class ST4Tracker { + double position_; + double slewRate_; + int64_t startTime_; + mutable SemaphoreHandle_t mutex_; + + double calculateDelta() const; + +public: + ST4Tracker(); + ~ST4Tracker(); + + void begin(); + void start(double slewRate); + void stop(); + + double position() const; + void setPosition(double pos); + double slewRate() const; + bool isMoving() const; +}; diff --git a/include/ST4Types.h b/include/ST4Types.h new file mode 100644 index 0000000..fbf8038 --- /dev/null +++ b/include/ST4Types.h @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// ST4-ESP32: ESP32 ST-4 autoguider port controller +// Based on arduino-st4 by Kevin Ferrare + +#pragma once + +#include + +enum class ST4Direction : uint8_t { + PLUS, + MINUS, + STOP +}; + +enum class ST4AxisId : uint8_t { + RA = 0, + DECLINATION = 1 +}; + +enum class ST4PinLogic : uint8_t { + ACTIVE_HIGH, + ACTIVE_LOW +}; + +// Sidereal rate constants (from ASCOM ArduinoST4 Constants.cs) +namespace ST4Constants { + constexpr double DEGREES_PER_SECOND = 360.0 / (24.0 * 3600.0); + constexpr double RA_PER_SECOND = 1.0 / 3600.0; + + // Default sidereal rate multipliers (from ASCOM Driver.cs) + // RA+: 8x slew + 1x earth rotation = 9x + // RA-: 8x slew - 1x earth rotation = 7x + // DEC: symmetric 8x + constexpr double DEFAULT_RA_RATE_PLUS = 9.0; + constexpr double DEFAULT_RA_RATE_MINUS = 7.0; + constexpr double DEFAULT_DEC_RATE_PLUS = 8.0; + constexpr double DEFAULT_DEC_RATE_MINUS = 8.0; + + constexpr uint32_t DEFAULT_BAUD_RATE = 57600; + constexpr uint16_t DEFAULT_WS_PORT = 81; + constexpr uint16_t DEFAULT_HTTP_PORT = 80; + + constexpr char VERSION[] = "2026.02.17"; +} + +struct ST4AxisState { + bool active; + ST4Direction direction; + double position; +}; + +struct ST4State { + bool connected; + ST4AxisState ra; + ST4AxisState dec; +}; + +struct ST4RateConfig { + double raPlusMultiplier; + double raMinusMultiplier; + double decPlusMultiplier; + double decMinusMultiplier; +}; diff --git a/include/ST4WiFi.h b/include/ST4WiFi.h new file mode 100644 index 0000000..fb910b6 --- /dev/null +++ b/include/ST4WiFi.h @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// ST4-ESP32: WiFi + WebSocket server (optional) +// Enable by defining ST4_WIFI_ENABLED before including ST4.h + +#pragma once + +#ifdef ST4_WIFI_ENABLED + +#include +#include +#include +#include "ST4Controller.h" + +struct ST4WiFiConfig { + const char* ssid; + const char* password; + bool apMode; + uint16_t httpPort; + uint32_t broadcastIntervalMs; +}; + +class ST4WiFi { + ST4Controller* controller_; + AsyncWebServer* server_; + AsyncWebSocket* ws_; + ST4WiFiConfig config_; + + uint32_t lastBroadcast_; + ST4Direction lastRaDir_; + ST4Direction lastDecDir_; + + void handleWebSocketEvent(AsyncWebSocket* server, + AsyncWebSocketClient* client, + AwsEventType type, void* arg, + uint8_t* data, size_t len); + void processCommand(AsyncWebSocketClient* client, + uint8_t* data, size_t len); + void broadcastState(); + String stateJson() const; + +public: + ST4WiFi(); + ~ST4WiFi(); + + void begin(ST4Controller& controller, const ST4WiFiConfig& config); + void update(); +}; + +#endif // ST4_WIFI_ENABLED diff --git a/library.json b/library.json new file mode 100644 index 0000000..82e7fdf --- /dev/null +++ b/library.json @@ -0,0 +1,43 @@ +{ + "name": "ST4-ESP32", + "version": "2026.02.17", + "description": "ESP32 ST-4 autoguider port controller with WiFi/WebSocket support, hardware-timer pulse guiding, and FreeRTOS thread safety", + "keywords": [ + "telescope", + "autoguider", + "st-4", + "mount-control", + "pulse-guiding", + "satellite-tracking", + "ham-radio", + "esp32" + ], + "repository": { + "type": "git", + "url": "https://git.supported.systems/rpm/st-4-esp32" + }, + "authors": [ + { + "name": "Ryan Malloy", + "email": "ryan@supported.systems" + } + ], + "license": "LGPL-3.0-or-later", + "frameworks": "arduino", + "platforms": "espressif32", + "headers": "ST4.h", + "dependencies": { + "bblanchon/ArduinoJson": "^7.0.0" + }, + "build": { + "flags": [ + "-I include" + ] + }, + "examples": [ + "examples/basic_gpio/basic_gpio.ino", + "examples/serial_compatible/serial_compatible.ino", + "examples/pulse_guide/pulse_guide.ino", + "examples/wifi_control/wifi_control.ino" + ] +} diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..4b06c6e --- /dev/null +++ b/platformio.ini @@ -0,0 +1,30 @@ +; ST4-ESP32 PlatformIO library +; For building examples: use 'make' targets (uses pio ci) +; For development/upload: pio run -e (uses symlink lib_deps) + +[platformio] +default_envs = serial_compatible + +[env] +platform = espressif32 +board = esp32dev +framework = arduino +monitor_speed = 115200 +lib_deps = symlink://. + +[env:basic_gpio] +build_src_filter = +<../examples/basic_gpio/> + +[env:serial_compatible] +build_src_filter = +<../examples/serial_compatible/> + +[env:pulse_guide] +build_src_filter = +<../examples/pulse_guide/> + +[env:wifi_control] +build_flags = -DST4_WIFI_ENABLED +lib_deps = + symlink://. + bblanchon/ArduinoJson@^7.0.0 + mathieucarbou/ESPAsyncWebServer@^3.6.0 +build_src_filter = +<../examples/wifi_control/> diff --git a/src/ST4Axis.cpp b/src/ST4Axis.cpp new file mode 100644 index 0000000..bff699c --- /dev/null +++ b/src/ST4Axis.cpp @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +#include "ST4Axis.h" + +ST4Axis::ST4Axis() + : direction_(ST4Direction::STOP), mutex_(nullptr) {} + +ST4Axis::~ST4Axis() { + if (mutex_) vSemaphoreDelete(mutex_); +} + +void ST4Axis::begin(int plusPin, int minusPin, ST4PinLogic logic) { + mutex_ = xSemaphoreCreateMutex(); + configASSERT(mutex_); + plusPin_.begin(plusPin, logic); + minusPin_.begin(minusPin, logic); + stop(); +} + +void ST4Axis::plus() { + xSemaphoreTake(mutex_, portMAX_DELAY); + // Safety: deactivate opposing direction first (prevents optocoupler short) + minusPin_.deactivate(); + plusPin_.activate(); + direction_ = ST4Direction::PLUS; + xSemaphoreGive(mutex_); +} + +void ST4Axis::minus() { + xSemaphoreTake(mutex_, portMAX_DELAY); + plusPin_.deactivate(); + minusPin_.activate(); + direction_ = ST4Direction::MINUS; + xSemaphoreGive(mutex_); +} + +void ST4Axis::stop() { + xSemaphoreTake(mutex_, portMAX_DELAY); + plusPin_.deactivate(); + minusPin_.deactivate(); + direction_ = ST4Direction::STOP; + xSemaphoreGive(mutex_); +} + +void ST4Axis::move(ST4Direction dir) { + switch (dir) { + case ST4Direction::PLUS: plus(); break; + case ST4Direction::MINUS: minus(); break; + case ST4Direction::STOP: stop(); break; + } +} + +ST4Direction ST4Axis::direction() const { + // Atomic read on ESP32 (uint8_t enum) + return direction_; +} + +bool ST4Axis::isActive() const { + return direction_ != ST4Direction::STOP; +} diff --git a/src/ST4Controller.cpp b/src/ST4Controller.cpp new file mode 100644 index 0000000..cae024f --- /dev/null +++ b/src/ST4Controller.cpp @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +#include "ST4Controller.h" +#include + +ST4Controller::ST4Controller() + : connected_(false), ledPin_(-1), mutex_(nullptr) { + rateConfig_ = { + ST4Constants::DEFAULT_RA_RATE_PLUS, + ST4Constants::DEFAULT_RA_RATE_MINUS, + ST4Constants::DEFAULT_DEC_RATE_PLUS, + ST4Constants::DEFAULT_DEC_RATE_MINUS + }; +} + +ST4Controller::~ST4Controller() { + if (mutex_) vSemaphoreDelete(mutex_); +} + +void ST4Controller::begin(int raPlusPin, int raMinusPin, + int decPlusPin, int decMinusPin, + int ledPin, ST4PinLogic logic) { + mutex_ = xSemaphoreCreateMutex(); + configASSERT(mutex_); + + ledPin_ = ledPin; + if (ledPin_ >= 0) { + pinMode(ledPin_, OUTPUT); + digitalWrite(ledPin_, LOW); + } + + axes_[static_cast(ST4AxisId::RA)].begin(raPlusPin, raMinusPin, logic); + axes_[static_cast(ST4AxisId::DECLINATION)].begin(decPlusPin, decMinusPin, logic); + trackers_[0].begin(); + trackers_[1].begin(); + pulse_.begin(); +} + +void ST4Controller::setRates(const ST4RateConfig& config) { + configASSERT(config.raPlusMultiplier > 0); + configASSERT(config.raMinusMultiplier > 0); + configASSERT(config.decPlusMultiplier > 0); + configASSERT(config.decMinusMultiplier > 0); + + xSemaphoreTake(mutex_, portMAX_DELAY); + rateConfig_ = config; + xSemaphoreGive(mutex_); +} + +double ST4Controller::calculateSlewRate(ST4AxisId axis, ST4Direction dir) const { + if (dir == ST4Direction::STOP) return 0; + + double baseRate = (axis == ST4AxisId::RA) + ? ST4Constants::RA_PER_SECOND + : ST4Constants::DEGREES_PER_SECOND; + + double multiplier; + if (axis == ST4AxisId::RA) { + multiplier = (dir == ST4Direction::PLUS) + ? rateConfig_.raPlusMultiplier + : rateConfig_.raMinusMultiplier; + } else { + multiplier = (dir == ST4Direction::PLUS) + ? rateConfig_.decPlusMultiplier + : rateConfig_.decMinusMultiplier; + } + + double sign = (dir == ST4Direction::MINUS) ? -1.0 : 1.0; + return sign * baseRate * multiplier; +} + +void ST4Controller::connect() { + xSemaphoreTake(mutex_, portMAX_DELAY); + connected_ = true; + if (ledPin_ >= 0) digitalWrite(ledPin_, HIGH); + xSemaphoreGive(mutex_); + stopAll(); +} + +void ST4Controller::disconnect() { + stopAll(); + xSemaphoreTake(mutex_, portMAX_DELAY); + connected_ = false; + if (ledPin_ >= 0) digitalWrite(ledPin_, LOW); + xSemaphoreGive(mutex_); +} + +bool ST4Controller::isConnected() const { + return connected_; +} + +void ST4Controller::move(ST4AxisId axis, ST4Direction dir) { + if (!connected_) return; + + xSemaphoreTake(mutex_, portMAX_DELAY); + int idx = static_cast(axis); + if (dir == ST4Direction::STOP) { + axes_[idx].stop(); + trackers_[idx].stop(); + } else { + axes_[idx].move(dir); + trackers_[idx].start(calculateSlewRate(axis, dir)); + } + xSemaphoreGive(mutex_); +} + +void ST4Controller::stopAxis(ST4AxisId axis) { + move(axis, ST4Direction::STOP); +} + +void ST4Controller::stopAll() { + xSemaphoreTake(mutex_, portMAX_DELAY); + int raIdx = static_cast(ST4AxisId::RA); + int decIdx = static_cast(ST4AxisId::DECLINATION); + axes_[raIdx].stop(); + trackers_[raIdx].stop(); + axes_[decIdx].stop(); + trackers_[decIdx].stop(); + xSemaphoreGive(mutex_); + if (pulse_.isActive()) pulse_.cancel(); +} + +bool ST4Controller::pulseGuide(ST4AxisId axis, ST4Direction dir, uint32_t ms) { + if (!connected_) return false; + + xSemaphoreTake(mutex_, portMAX_DELAY); + int idx = static_cast(axis); + double rate = calculateSlewRate(axis, dir); + bool result = pulse_.pulse(axes_[idx], trackers_[idx], dir, rate, ms); + xSemaphoreGive(mutex_); + return result; +} + +bool ST4Controller::isPulseActive() const { + return pulse_.isActive(); +} + +double ST4Controller::position(ST4AxisId axis) const { + return trackers_[static_cast(axis)].position(); +} + +void ST4Controller::setPosition(ST4AxisId axis, double pos) { + trackers_[static_cast(axis)].setPosition(pos); +} + +void ST4Controller::sync(double ra, double dec) { + xSemaphoreTake(mutex_, portMAX_DELAY); + setPosition(ST4AxisId::RA, ra); + setPosition(ST4AxisId::DECLINATION, dec); + xSemaphoreGive(mutex_); +} + +ST4Direction ST4Controller::axisDirection(ST4AxisId axis) const { + return axes_[static_cast(axis)].direction(); +} + +bool ST4Controller::axisActive(ST4AxisId axis) const { + return axes_[static_cast(axis)].isActive(); +} + +ST4State ST4Controller::state() const { + xSemaphoreTake(mutex_, portMAX_DELAY); + ST4State s; + s.connected = connected_; + s.ra.active = axes_[0].isActive(); + s.ra.direction = axes_[0].direction(); + s.ra.position = trackers_[0].position(); + s.dec.active = axes_[1].isActive(); + s.dec.direction = axes_[1].direction(); + s.dec.position = trackers_[1].position(); + xSemaphoreGive(mutex_); + return s; +} + +ST4Axis& ST4Controller::axis(ST4AxisId id) { + return axes_[static_cast(id)]; +} + +ST4Tracker& ST4Controller::tracker(ST4AxisId id) { + return trackers_[static_cast(id)]; +} diff --git a/src/ST4Pin.cpp b/src/ST4Pin.cpp new file mode 100644 index 0000000..3f3a65e --- /dev/null +++ b/src/ST4Pin.cpp @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +#include "ST4Pin.h" + +ST4Pin::ST4Pin() + : pin_(-1), logic_(ST4PinLogic::ACTIVE_HIGH), active_(false) {} + +void ST4Pin::begin(int pin, ST4PinLogic logic) { + pin_ = pin; + logic_ = logic; + active_ = false; + pinMode(pin_, OUTPUT); + deactivate(); +} + +void ST4Pin::activate() { + if (pin_ < 0) return; + active_ = true; + digitalWrite(pin_, (logic_ == ST4PinLogic::ACTIVE_HIGH) ? HIGH : LOW); +} + +void ST4Pin::deactivate() { + if (pin_ < 0) return; + active_ = false; + digitalWrite(pin_, (logic_ == ST4PinLogic::ACTIVE_HIGH) ? LOW : HIGH); +} + +bool ST4Pin::isActive() const { + return active_; +} + +int ST4Pin::pin() const { + return pin_; +} diff --git a/src/ST4Pulse.cpp b/src/ST4Pulse.cpp new file mode 100644 index 0000000..5aece3f --- /dev/null +++ b/src/ST4Pulse.cpp @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Hardware timer pulse guiding with deferred ISR-safe stop +// +// Pattern: esp_timer callback -> semaphore -> high-priority task -> mutex -> axis.stop() +// The mutex protects the multi-field state (activeAxis_, activeTracker_, active_) +// from concurrent pulse()/cancel() calls across ESP32 cores. +#include "ST4Pulse.h" + +ST4Pulse* ST4Pulse::instance_ = nullptr; + +static const int PULSE_TASK_PRIORITY = configMAX_PRIORITIES - 2; +static const int PULSE_TASK_STACK = 2048; + +ST4Pulse::ST4Pulse() + : timer_(nullptr), pulseDoneSem_(nullptr), mutex_(nullptr), + pulseTaskHandle_(nullptr), + activeAxis_(nullptr), activeTracker_(nullptr), + active_(false), shutdown_(false) {} + +ST4Pulse::~ST4Pulse() { + // 1. Stop and delete timer (prevents new semaphore gives) + if (timer_) { + esp_timer_stop(timer_); + esp_timer_delete(timer_); + timer_ = nullptr; + } + // 2. Signal task to exit and unblock it + shutdown_ = true; + if (pulseDoneSem_) xSemaphoreGive(pulseDoneSem_); + // 3. Wait for task to see shutdown flag + if (pulseTaskHandle_) { + vTaskDelay(pdMS_TO_TICKS(10)); + vTaskDelete(pulseTaskHandle_); + pulseTaskHandle_ = nullptr; + } + // 4. Delete synchronization primitives last + if (pulseDoneSem_) vSemaphoreDelete(pulseDoneSem_); + if (mutex_) vSemaphoreDelete(mutex_); + instance_ = nullptr; +} + +void ST4Pulse::timerCallback(void* arg) { + ST4Pulse* self = static_cast(arg); + // Give semaphore to wake the pulse-stop task + // Safe for ESP_TIMER_TASK dispatch (the default) + xSemaphoreGive(self->pulseDoneSem_); +} + +void ST4Pulse::pulseTaskFunc(void* arg) { + ST4Pulse* self = static_cast(arg); + for (;;) { + if (xSemaphoreTake(self->pulseDoneSem_, portMAX_DELAY) == pdTRUE) { + if (self->shutdown_) break; + + xSemaphoreTake(self->mutex_, portMAX_DELAY); + if (self->active_) { + if (self->activeAxis_) self->activeAxis_->stop(); + if (self->activeTracker_) self->activeTracker_->stop(); + self->active_ = false; + } + xSemaphoreGive(self->mutex_); + } + } + // Task deletes itself on shutdown + vTaskDelete(nullptr); +} + +void ST4Pulse::begin() { + instance_ = this; + + mutex_ = xSemaphoreCreateMutex(); + configASSERT(mutex_); + + pulseDoneSem_ = xSemaphoreCreateBinary(); + configASSERT(pulseDoneSem_); + + esp_timer_create_args_t timerArgs = {}; + timerArgs.callback = timerCallback; + timerArgs.arg = this; + timerArgs.name = "st4_pulse"; + esp_err_t err = esp_timer_create(&timerArgs, &timer_); + configASSERT(err == ESP_OK); + + BaseType_t created = xTaskCreatePinnedToCore( + pulseTaskFunc, "st4_pulse", PULSE_TASK_STACK, + this, PULSE_TASK_PRIORITY, &pulseTaskHandle_, 1 + ); + configASSERT(created == pdPASS); +} + +void ST4Pulse::cancelLocked() { + // Caller must hold mutex_ + esp_timer_stop(timer_); + // Drain any pending semaphore from a just-fired timer + xSemaphoreTake(pulseDoneSem_, 0); + + if (activeAxis_) activeAxis_->stop(); + if (activeTracker_) activeTracker_->stop(); + active_ = false; + activeAxis_ = nullptr; + activeTracker_ = nullptr; +} + +bool ST4Pulse::pulse(ST4Axis& axis, ST4Tracker& tracker, + ST4Direction dir, double slewRate, uint32_t ms) { + xSemaphoreTake(mutex_, portMAX_DELAY); + + if (active_) cancelLocked(); + + if (dir == ST4Direction::STOP || ms == 0) { + xSemaphoreGive(mutex_); + return false; + } + + if (ms > MAX_PULSE_MS) ms = MAX_PULSE_MS; + + activeAxis_ = &axis; + activeTracker_ = &tracker; + active_ = true; + + axis.move(dir); + tracker.start(slewRate); + + // Start one-shot timer (microseconds) + esp_timer_start_once(timer_, static_cast(ms) * 1000); + + xSemaphoreGive(mutex_); + return true; +} + +bool ST4Pulse::isActive() const { + return active_; +} + +void ST4Pulse::cancel() { + xSemaphoreTake(mutex_, portMAX_DELAY); + cancelLocked(); + xSemaphoreGive(mutex_); +} diff --git a/src/ST4Serial.cpp b/src/ST4Serial.cpp new file mode 100644 index 0000000..0c1d2e7 --- /dev/null +++ b/src/ST4Serial.cpp @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Serial protocol: backward compatible with original ArduinoCode.ino +// Original commands: CONNECT#, DISCONNECT#, RA+#, RA-#, RA0#, DEC+#, DEC-#, DEC0# +// Extended commands: PULSE RA+ 500#, POS?#, SYNC 12.345 45.678#, STATUS?#, VERSION?# +#include "ST4Serial.h" + +ST4Serial::ST4Serial() + : controller_(nullptr), serial_(nullptr), extendedMode_(true) {} + +void ST4Serial::begin(ST4Controller& controller, HardwareSerial& serial, + bool extendedMode) { + controller_ = &controller; + serial_ = &serial; + extendedMode_ = extendedMode; + buffer_.reserve(64); + serial_->begin(ST4Constants::DEFAULT_BAUD_RATE, SERIAL_8N1); + serial_->println("INITIALIZED#"); +} + +String ST4Serial::directionStr(ST4Direction dir) const { + switch (dir) { + case ST4Direction::PLUS: return "+"; + case ST4Direction::MINUS: return "-"; + default: return "0"; + } +} + +void ST4Serial::processCommand(const String& cmd) { + bool valid = true; + + if (cmd == "CONNECT") { + controller_->connect(); + } else if (cmd == "DISCONNECT") { + controller_->disconnect(); + } else if (cmd == "RA0") { + controller_->stopAxis(ST4AxisId::RA); + } else if (cmd == "RA+") { + controller_->move(ST4AxisId::RA, ST4Direction::PLUS); + } else if (cmd == "RA-") { + controller_->move(ST4AxisId::RA, ST4Direction::MINUS); + } else if (cmd == "DEC0") { + controller_->stopAxis(ST4AxisId::DECLINATION); + } else if (cmd == "DEC+") { + controller_->move(ST4AxisId::DECLINATION, ST4Direction::PLUS); + } else if (cmd == "DEC-") { + controller_->move(ST4AxisId::DECLINATION, ST4Direction::MINUS); + } else if (extendedMode_) { + processExtendedCommand(cmd); + return; + } else { + valid = false; + } + + if (valid) { + serial_->println("OK#"); + } +} + +void ST4Serial::processExtendedCommand(const String& cmd) { + if (cmd.startsWith("PULSE ")) { + // Format: PULSE RA+ 500 or PULSE DEC- 1000 + String rest = cmd.substring(6); + rest.trim(); + + ST4AxisId axis; + if (rest.startsWith("RA")) { + axis = ST4AxisId::RA; + rest = rest.substring(2); + } else if (rest.startsWith("DEC")) { + axis = ST4AxisId::DECLINATION; + rest = rest.substring(3); + } else { + serial_->println("ERR:INVALID_AXIS#"); + return; + } + + ST4Direction dir; + if (rest.startsWith("+")) { + dir = ST4Direction::PLUS; + rest = rest.substring(1); + } else if (rest.startsWith("-")) { + dir = ST4Direction::MINUS; + rest = rest.substring(1); + } else { + serial_->println("ERR:INVALID_DIR#"); + return; + } + + rest.trim(); + uint32_t ms = rest.toInt(); + if (ms > 0) { + controller_->pulseGuide(axis, dir, ms); + serial_->println("OK#"); + } else { + serial_->println("ERR:INVALID_DURATION#"); + } + } else if (cmd == "POS?") { + serial_->print("POS "); + serial_->print(controller_->position(ST4AxisId::RA), 6); + serial_->print(" "); + serial_->print(controller_->position(ST4AxisId::DECLINATION), 6); + serial_->println("#"); + } else if (cmd.startsWith("SYNC ")) { + // Format: SYNC 12.345 45.678 + String rest = cmd.substring(5); + int spaceIdx = rest.indexOf(' '); + if (spaceIdx > 0) { + double ra = rest.substring(0, spaceIdx).toDouble(); + double dec = rest.substring(spaceIdx + 1).toDouble(); + controller_->sync(ra, dec); + serial_->println("OK#"); + } else { + serial_->println("ERR:INVALID_COORDS#"); + } + } else if (cmd == "STATUS?") { + ST4State s = controller_->state(); + serial_->print("STATUS "); + serial_->print(s.connected ? "CONNECTED" : "DISCONNECTED"); + serial_->print(" RA:"); + serial_->print(directionStr(s.ra.direction)); + serial_->print(":"); + serial_->print(s.ra.position, 6); + serial_->print(" DEC:"); + serial_->print(directionStr(s.dec.direction)); + serial_->print(":"); + serial_->print(s.dec.position, 6); + serial_->println("#"); + } else if (cmd == "VERSION?") { + serial_->print("VERSION "); + serial_->print(ST4Constants::VERSION); + serial_->println("#"); + } + // Unknown extended commands are silently ignored (matches original behavior) +} + +void ST4Serial::update() { + while (serial_->available()) { + char c = serial_->read(); + if (c == '#') { + buffer_.trim(); + if (buffer_.length() > 0) { + processCommand(buffer_); + } + buffer_ = ""; + } else if (c != '\r' && c != '\n') { + if (buffer_.length() < 64) { + buffer_ += c; + } + } + } +} diff --git a/src/ST4Tracker.cpp b/src/ST4Tracker.cpp new file mode 100644 index 0000000..39d5385 --- /dev/null +++ b/src/ST4Tracker.cpp @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Port of ASCOM AxisMovementTracker.cs +// Uses esp_timer_get_time() for microsecond precision (vs DateTime.Now ~15ms) +#include "ST4Tracker.h" +#include + +ST4Tracker::ST4Tracker() + : position_(0), slewRate_(0), startTime_(0), mutex_(nullptr) {} + +ST4Tracker::~ST4Tracker() { + if (mutex_) vSemaphoreDelete(mutex_); +} + +void ST4Tracker::begin() { + mutex_ = xSemaphoreCreateMutex(); + configASSERT(mutex_); +} + +double ST4Tracker::calculateDelta() const { + // Equivalent to ASCOM's: slewRate * (DateTime.Now - slewStartTime).TotalSeconds + if (slewRate_ == 0) return 0; + int64_t elapsed_us = esp_timer_get_time() - startTime_; + return slewRate_ * (elapsed_us / 1000000.0); +} + +void ST4Tracker::start(double slewRate) { + xSemaphoreTake(mutex_, portMAX_DELAY); + // Accumulate pending delta before changing rate (matches ASCOM Stop+Start pattern) + if (slewRate_ != 0) { + position_ += calculateDelta(); + } + slewRate_ = slewRate; + startTime_ = esp_timer_get_time(); + xSemaphoreGive(mutex_); +} + +void ST4Tracker::stop() { + xSemaphoreTake(mutex_, portMAX_DELAY); + position_ += calculateDelta(); + slewRate_ = 0; + xSemaphoreGive(mutex_); +} + +double ST4Tracker::position() const { + xSemaphoreTake(mutex_, portMAX_DELAY); + double pos = position_ + calculateDelta(); + xSemaphoreGive(mutex_); + return pos; +} + +void ST4Tracker::setPosition(double pos) { + xSemaphoreTake(mutex_, portMAX_DELAY); + position_ = pos; + slewRate_ = 0; + xSemaphoreGive(mutex_); +} + +double ST4Tracker::slewRate() const { + xSemaphoreTake(mutex_, portMAX_DELAY); + double rate = slewRate_; + xSemaphoreGive(mutex_); + return rate; +} + +bool ST4Tracker::isMoving() const { + xSemaphoreTake(mutex_, portMAX_DELAY); + bool moving = slewRate_ != 0; + xSemaphoreGive(mutex_); + return moving; +} diff --git a/src/ST4WiFi.cpp b/src/ST4WiFi.cpp new file mode 100644 index 0000000..b91e0d0 --- /dev/null +++ b/src/ST4WiFi.cpp @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +#ifdef ST4_WIFI_ENABLED + +#include "ST4WiFi.h" + +ST4WiFi::ST4WiFi() + : controller_(nullptr), server_(nullptr), ws_(nullptr), + lastBroadcast_(0), + lastRaDir_(ST4Direction::STOP), lastDecDir_(ST4Direction::STOP) {} + +ST4WiFi::~ST4WiFi() { + delete ws_; + delete server_; +} + +void ST4WiFi::begin(ST4Controller& controller, const ST4WiFiConfig& config) { + controller_ = &controller; + config_ = config; + + if (config_.apMode) { + WiFi.softAP(config_.ssid, config_.password); + } else { + WiFi.begin(config_.ssid, config_.password); + uint32_t startMs = millis(); + while (WiFi.status() != WL_CONNECTED) { + if (millis() - startMs > 15000) { + log_w("WiFi connection timeout -- running serial-only"); + return; + } + delay(500); + } + } + + server_ = new AsyncWebServer(config_.httpPort); + ws_ = new AsyncWebSocket("/ws"); + + ws_->onEvent([this](AsyncWebSocket* server, AsyncWebSocketClient* client, + AwsEventType type, void* arg, + uint8_t* data, size_t len) { + handleWebSocketEvent(server, client, type, arg, data, len); + }); + + server_->addHandler(ws_); + server_->begin(); +} + +void ST4WiFi::handleWebSocketEvent(AsyncWebSocket* server, + AsyncWebSocketClient* client, + AwsEventType type, void* arg, + uint8_t* data, size_t len) { + switch (type) { + case WS_EVT_CONNECT: + // Send current state to newly connected client + client->text(stateJson()); + break; + case WS_EVT_DATA: { + AwsFrameInfo* info = static_cast(arg); + if (info->final && info->index == 0 && + info->len == len && info->opcode == WS_TEXT) { + processCommand(client, data, len); + } + break; + } + default: + break; + } +} + +void ST4WiFi::processCommand(AsyncWebSocketClient* client, + uint8_t* data, size_t len) { + JsonDocument doc; + DeserializationError err = deserializeJson(doc, data, len); + if (err) return; + + const char* cmd = doc["cmd"]; + if (!cmd) return; + + if (strcmp(cmd, "move") == 0) { + const char* axisStr = doc["axis"]; + const char* dirStr = doc["dir"]; + if (!axisStr || !dirStr) return; + + ST4AxisId axis; + if (strcmp(axisStr, "ra") == 0) axis = ST4AxisId::RA; + else if (strcmp(axisStr, "dec") == 0) axis = ST4AxisId::DECLINATION; + else return; + + ST4Direction dir; + if (strcmp(dirStr, "+") == 0) dir = ST4Direction::PLUS; + else if (strcmp(dirStr, "-") == 0) dir = ST4Direction::MINUS; + else dir = ST4Direction::STOP; + + controller_->move(axis, dir); + broadcastState(); + + } else if (strcmp(cmd, "pulse") == 0) { + const char* axisStr = doc["axis"]; + const char* dirStr = doc["dir"]; + uint32_t ms = doc["ms"] | 0; + if (!axisStr || !dirStr || ms == 0) return; + + ST4AxisId axis; + if (strcmp(axisStr, "ra") == 0) axis = ST4AxisId::RA; + else if (strcmp(axisStr, "dec") == 0) axis = ST4AxisId::DECLINATION; + else return; + + ST4Direction dir; + if (strcmp(dirStr, "+") == 0) dir = ST4Direction::PLUS; + else if (strcmp(dirStr, "-") == 0) dir = ST4Direction::MINUS; + else return; + + controller_->pulseGuide(axis, dir, ms); + broadcastState(); + + } else if (strcmp(cmd, "stop") == 0) { + controller_->stopAll(); + broadcastState(); + + } else if (strcmp(cmd, "sync") == 0) { + double ra = doc["ra"] | 0.0; + double dec = doc["dec"] | 0.0; + controller_->sync(ra, dec); + broadcastState(); + + } else if (strcmp(cmd, "status") == 0) { + client->text(stateJson()); + } +} + +void ST4WiFi::broadcastState() { + if (ws_->count() > 0) { + ws_->textAll(stateJson()); + } + lastBroadcast_ = millis(); +} + +String ST4WiFi::stateJson() const { + ST4State s = controller_->state(); + + JsonDocument doc; + doc["type"] = "state"; + doc["connected"] = s.connected; + + auto ra = doc["ra"].to(); + ra["active"] = s.ra.active; + ra["dir"] = (s.ra.direction == ST4Direction::PLUS) ? "+" + : (s.ra.direction == ST4Direction::MINUS) ? "-" : "0"; + ra["pos"] = s.ra.position; + + auto dec = doc["dec"].to(); + dec["active"] = s.dec.active; + dec["dir"] = (s.dec.direction == ST4Direction::PLUS) ? "+" + : (s.dec.direction == ST4Direction::MINUS) ? "-" : "0"; + dec["pos"] = s.dec.position; + + String output; + serializeJson(doc, output); + return output; +} + +void ST4WiFi::update() { + ws_->cleanupClients(); + + ST4Direction currentRa = controller_->axisDirection(ST4AxisId::RA); + ST4Direction currentDec = controller_->axisDirection(ST4AxisId::DECLINATION); + + bool changed = (currentRa != lastRaDir_) || (currentDec != lastDecDir_); + lastRaDir_ = currentRa; + lastDecDir_ = currentDec; + + // Broadcast on direction change or periodically during active slew + bool anyActive = controller_->axisActive(ST4AxisId::RA) || + controller_->axisActive(ST4AxisId::DECLINATION); + bool periodicUpdate = anyActive && + (millis() - lastBroadcast_ >= config_.broadcastIntervalMs); + + if (changed || periodicUpdate) { + broadcastState(); + } +} + +#endif // ST4_WIFI_ENABLED