diff --git a/drivers.xml b/drivers.xml
index 66c271730a..39cead5168 100644
--- a/drivers.xml
+++ b/drivers.xml
@@ -923,6 +923,10 @@
indi_wanderer_eclipse
1.2
+
+ indi_wanderer_eta
+ 1.0
+
indi_dragon_light
1.0
diff --git a/drivers/auxiliary/CMakeLists.txt b/drivers/auxiliary/CMakeLists.txt
index 32f7417ad7..2cf2315f0d 100644
--- a/drivers/auxiliary/CMakeLists.txt
+++ b/drivers/auxiliary/CMakeLists.txt
@@ -26,6 +26,14 @@ add_executable(indi_wanderer_eclipse ${indi_wanderer_eclipse_SRC})
target_link_libraries(indi_wanderer_eclipse indidriver)
install(TARGETS indi_wanderer_eclipse RUNTIME DESTINATION bin)
+# ########## Wanderer ETA M54###############
+SET(indi_wanderer_eta_SRC
+ wanderer_eta.cpp)
+
+add_executable(indi_wanderer_eta ${indi_wanderer_eta_SRC})
+target_link_libraries(indi_wanderer_eta indidriver)
+install(TARGETS indi_wanderer_eta RUNTIME DESTINATION bin)
+
# ########## Astrolink-4###############
SET(indi_astrolink4_SRC
indi_astrolink4.cpp)
diff --git a/drivers/auxiliary/doc/wanderer_eta/README.md b/drivers/auxiliary/doc/wanderer_eta/README.md
new file mode 100644
index 0000000000..c24582343c
--- /dev/null
+++ b/drivers/auxiliary/doc/wanderer_eta/README.md
@@ -0,0 +1,198 @@
+# Wanderer ETA M54 — INDI Driver Testing Guide
+
+## Overview
+
+INDI driver for the WandererAstro ETA M54 Electronic Tilt Adjuster. Communicates via USB serial (CH340 chip, 19200 baud) and provides control of the three motorized tilt adjustment points.
+
+## Files Included in This Patch
+
+**New files (copy into `indi/drivers/auxiliary/`):**
+- `wanderer_eta.h`
+- `wanderer_eta.cpp`
+
+**Modified files (overwrite the upstream versions):**
+- `indi/drivers/auxiliary/CMakeLists.txt`
+- `indi/drivers.xml`
+
+## Setup on Arch Linux
+
+### 1. Install Build Dependencies
+
+```bash
+sudo pacman -S --needed base-devel cmake git \
+ cfitsio libnova libusb gsl libjpeg-turbo curl
+```
+
+### 2. Clone INDI Core
+
+```bash
+mkdir -p ~/Projects
+cd ~/Projects
+git clone https://github.com/indilib/indi.git
+cd indi
+```
+
+### 3. Apply the ETA Driver Patch
+
+Copy the patched/new files on top of the fresh clone. Assuming you extracted `eta-patch.tar.gz` into `~/Projects/`:
+
+```bash
+cd ~/Projects/indi
+
+# New driver files
+cp ~/Projects/eta-patch/wanderer_eta.h drivers/auxiliary/
+cp ~/Projects/eta-patch/wanderer_eta.cpp drivers/auxiliary/
+
+# Modified files (overwrite upstream)
+cp ~/Projects/eta-patch/CMakeLists.txt drivers/auxiliary/CMakeLists.txt
+cp ~/Projects/eta-patch/drivers.xml drivers.xml
+```
+
+Verify the files are in place:
+```bash
+ls -la ~/Projects/indi/drivers/auxiliary/wanderer_eta.*
+grep -n "wanderer_eta" ~/Projects/indi/drivers/auxiliary/CMakeLists.txt
+grep -n "Wanderer ETA" ~/Projects/indi/drivers.xml
+```
+
+### 4. Build
+
+```bash
+mkdir -p ~/Projects/build/indi-core
+cd ~/Projects/build/indi-core
+
+cmake -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE=Debug ~/Projects/indi
+
+# Build just the ETA driver (fast)
+make -j$(nproc) indi_wanderer_eta
+
+# Install
+sudo make install
+```
+
+Verify:
+```bash
+which indi_wanderer_eta
+```
+
+## Note on StellarMate / Flatpak Setups
+
+On StellarMate, KStars typically runs as a flatpak while INDI drivers run natively on the host. For testing this driver you only need to build and install INDI core natively — you do **not** need to rebuild indi-3rdparty or KStars.
+
+The workflow is:
+1. Build & install the patched INDI core natively (gives you `indi_wanderer_eta` + `indiserver`)
+2. Run `indiserver -v indi_wanderer_eta` manually on the host
+3. Connect using `indi_control_panel` or any INDI client pointing at localhost:7624
+
+**Important**: When KStars flatpak starts an Ekos profile in local mode, it launches its own `indiserver` with the drivers bundled inside the flatpak — our ETA driver won't be there. For initial testing, don't use KStars/Ekos. Instead, start `indiserver` manually on the host and connect with `indi_control_panel`. If you want to test through KStars, set the Ekos profile to **Remote** mode with the machine's **actual IP address** (e.g., `192.168.0.69`) and port `7624`, then start your native `indiserver` separately. Do NOT use `localhost` — that triggers Ekos to launch its own bundled indiserver which will conflict.
+
+If you need other INDI drivers (cameras, mount, etc.) running alongside the ETA, make sure your existing native INDI installation is compatible. If you're currently using system-packaged INDI, installing our patched build to `/usr` will overwrite `libindi`. To avoid that, you can install to a separate prefix:
+```bash
+cmake -DCMAKE_INSTALL_PREFIX=/usr/local -DCMAKE_BUILD_TYPE=Debug ~/Projects/indi
+make -j$(nproc) indi_wanderer_eta
+sudo make install
+# Then run with full path:
+/usr/local/bin/indiserver /usr/local/bin/indi_wanderer_eta
+```
+
+## Testing with Hardware
+
+### 1. Connect the ETA
+
+Plug the Wanderer ETA M54 into a USB port. Verify the CH340 serial adapter appears:
+```bash
+dmesg | tail -5
+# Should show: ch341-uart converter now attached to ttyUSB0
+
+ls /dev/ttyUSB*
+```
+
+If you get permission errors later, add yourself to the `uucp` group (Arch uses `uucp` instead of `dialout`):
+```bash
+sudo usermod -aG uucp $USER
+# Then log out and back in
+```
+
+### 2. Start the INDI Server
+
+```bash
+indiserver -v indi_wanderer_eta indi_simulator_ccd indi_simulator_telescope
+```
+
+This starts the ETA driver alongside a CCD and telescope simulator so you have a complete setup for testing in Ekos.
+
+The `-v` flag enables verbose logging so you can see serial communication.
+
+### 3. Connect with a Client
+
+**Using INDI Control Panel:**
+```bash
+indi_control_panel &
+```
+
+**Using KStars/Ekos:**
+1. Ekos → Profile Editor
+2. Set mode to **Remote**
+3. Set the host to the machine's **actual IP address** (e.g., `192.168.0.69`), port `7624`
+4. **Important**: Do NOT use `localhost` — that will make Ekos try to start its own indiserver, which will fail because one is already running on port 7624
+5. Start the profile — you should see the ETA, simulator CCD, and simulator telescope
+
+### 4. Test Sequence
+
+#### a) Connection & Handshake
+1. In the Connection tab, select the correct serial port (e.g., `/dev/ttyUSB0`)
+2. Click "Connect"
+3. **Expected**: Driver reads the continuous status stream, validates `WandererTilterM54` identifier, connects
+4. **Verify**: Firmware version appears (e.g., `20250318`)
+
+#### b) Position Readback
+1. Check the "Current Positions" fields after connecting
+2. **Expected**: Three Point values show current encoder positions (likely near 0.000 if freshly powered)
+3. **Verify**: Values update every ~2 seconds
+
+#### c) Move Points
+1. Set Point 1 target to `0.500`, click Set
+2. **Expected**: Driver sends `10.500` to device, readback updates to ~0.500
+3. Repeat for Point 2 (`0.250`) and Point 3 (`0.750`)
+4. **Verify**: Readback matches target within ~0.002mm
+
+#### d) Range Validation
+1. Try setting a point to `1.500` (above max)
+2. **Expected**: Error in log, property goes Alert, no command sent
+3. Try `-0.1` (below min) — same expected behavior
+
+#### e) Zero All
+1. With points at various positions, click "Zero All Points"
+2. **Expected**: All three points move to 0.000mm
+3. **Verify**: Both target and readback show 0.000
+
+#### f) Config Save/Load
+1. Set points to known values (e.g., 0.100, 0.200, 0.300)
+2. Options → Save Configuration
+3. Disconnect, reconnect, Options → Load Configuration
+4. **Expected**: Target positions restored
+
+### 5. Debug Logs
+
+With `-v` on indiserver you'll see:
+- `CMD: 10.500` — commands sent
+- `Data: WandererTilterM54A20250318A0.500A0.250A0.750A` — raw status received
+
+For more detail, enable Debug in the driver's Options tab.
+
+## Troubleshooting
+
+- **"Timeout reading from device"**: ETA not powered or wrong serial port. Check `dmesg`.
+- **"Unknown device: ..."**: Wrong WandererAstro device on that port (cover, rotator, etc.).
+- **Permission denied**: Add user to `uucp` group (see above).
+- **Build error "indidriver not found"**: Make sure you ran cmake from the indi root, not just the auxiliary directory.
+
+## What to Report Back
+
+1. Does the handshake succeed? (firmware version displayed)
+2. Do position readbacks update correctly?
+3. Do move commands work? (points reach requested positions)
+4. Readback accuracy — within ~0.002mm of target?
+5. Does Zero All work?
+6. Any serial errors or unexpected behavior?
+7. Exact firmware version string shown
diff --git a/drivers/auxiliary/wanderer_eta.cpp b/drivers/auxiliary/wanderer_eta.cpp
new file mode 100644
index 0000000000..41788d4abd
--- /dev/null
+++ b/drivers/auxiliary/wanderer_eta.cpp
@@ -0,0 +1,554 @@
+/*******************************************************************************
+ Copyright(c) 2025 cfuture81. All rights reserved.
+
+ Wanderer ETA M54 - Electronic Tilt Adjuster
+
+ This program is free software; you can redistribute it and/or modify it
+ under the terms of the GNU General Public License as published by the Free
+ Software Foundation; either version 2 of the License, or (at your option)
+ any later version.
+
+ This program is distributed in the hope that it will be useful, but WITHOUT
+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ more details.
+
+ You should have received a copy of the GNU Library General Public License
+ along with this library; see the file COPYING.LIB. If not, write to
+ the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
+ Boston, MA 02110-1301, USA.
+
+ The full GNU General Public License is included in this distribution in the
+ file called LICENSE.
+*******************************************************************************/
+
+#include "wanderer_eta.h"
+#include "indicom.h"
+#include "connectionplugins/connectionserial.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+static std::unique_ptr wandererETA(new WandererETA());
+
+////////////////////////////////////////////////////////////////////////////////////////////////////////
+/// Constructor
+////////////////////////////////////////////////////////////////////////////////////////////////////////
+WandererETA::WandererETA()
+{
+ setVersion(1, 0);
+}
+
+////////////////////////////////////////////////////////////////////////////////////////////////////////
+/// Device name
+////////////////////////////////////////////////////////////////////////////////////////////////////////
+const char *WandererETA::getDefaultName()
+{
+ return "Wanderer ETA M54";
+}
+
+////////////////////////////////////////////////////////////////////////////////////////////////////////
+/// Init properties
+////////////////////////////////////////////////////////////////////////////////////////////////////////
+bool WandererETA::initProperties()
+{
+ INDI::DefaultDevice::initProperties();
+
+ setDriverInterface(AUX_INTERFACE);
+ addAuxControls();
+
+ // Motor position targets (user-settable) - separate properties for independent control
+ Position1NP[0].fill("POINT_1", "Position (mm)", "%.3f", 0.000, 1.200, 0.001, 0.000);
+ Position1NP.fill(getDeviceName(), "POINT_1_TARGET", "Point 1 Target", MAIN_CONTROL_TAB, IP_RW, 60, IPS_IDLE);
+
+ Position2NP[0].fill("POINT_2", "Position (mm)", "%.3f", 0.000, 1.200, 0.001, 0.000);
+ Position2NP.fill(getDeviceName(), "POINT_2_TARGET", "Point 2 Target", MAIN_CONTROL_TAB, IP_RW, 60, IPS_IDLE);
+
+ Position3NP[0].fill("POINT_3", "Position (mm)", "%.3f", 0.000, 1.200, 0.001, 0.000);
+ Position3NP.fill(getDeviceName(), "POINT_3_TARGET", "Point 3 Target", MAIN_CONTROL_TAB, IP_RW, 60, IPS_IDLE);
+
+ // Motor position readback (encoder feedback, read-only)
+ PositionReadNP[POINT_1].fill("POINT_1_READ", "Point 1 (mm)", "%.3f", 0.000, 1.200, 0.000, 0.000);
+ PositionReadNP[POINT_2].fill("POINT_2_READ", "Point 2 (mm)", "%.3f", 0.000, 1.200, 0.000, 0.000);
+ PositionReadNP[POINT_3].fill("POINT_3_READ", "Point 3 (mm)", "%.3f", 0.000, 1.200, 0.000, 0.000);
+ PositionReadNP.fill(getDeviceName(), "POSITION_READBACK", "Current Positions", MAIN_CONTROL_TAB, IP_RO, 60, IPS_IDLE);
+
+ // Firmware information
+ FirmwareTP[FIRMWARE_VERSION].fill("FIRMWARE_VERSION", "Firmware Version", "Unknown");
+ FirmwareTP.fill(getDeviceName(), "FIRMWARE_INFO", "Firmware", MAIN_CONTROL_TAB, IP_RO, 60, IPS_IDLE);
+
+ // Zero all points
+ ZeroAllSP[ZERO_ALL].fill("ZERO_ALL", "Zero All Points", ISS_OFF);
+ ZeroAllSP.fill(getDeviceName(), "ZERO_ALL_CMD", "Zero All", MAIN_CONTROL_TAB, IP_RW, ISR_ATMOST1, 60, IPS_IDLE);
+
+ setDefaultPollingPeriod(2000);
+
+ serialConnection = new Connection::Serial(this);
+ serialConnection->setDefaultBaudRate(Connection::Serial::B_19200);
+ serialConnection->registerHandshake([&]()
+ {
+ return getData();
+ });
+ registerConnection(serialConnection);
+
+ return true;
+}
+
+////////////////////////////////////////////////////////////////////////////////////////////////////////
+/// Update properties on connect/disconnect
+////////////////////////////////////////////////////////////////////////////////////////////////////////
+bool WandererETA::updateProperties()
+{
+ INDI::DefaultDevice::updateProperties();
+
+ if (isConnected())
+ {
+ char firmwareStr[16];
+ snprintf(firmwareStr, sizeof(firmwareStr), "%d", firmware);
+ FirmwareTP[FIRMWARE_VERSION].setText(firmwareStr);
+ LOGF_INFO("Firmware version: %d", firmware);
+
+ defineProperty(Position1NP);
+ defineProperty(Position2NP);
+ defineProperty(Position3NP);
+ defineProperty(PositionReadNP);
+ defineProperty(FirmwareTP);
+ defineProperty(ZeroAllSP);
+ }
+ else
+ {
+ deleteProperty(Position1NP);
+ deleteProperty(Position2NP);
+ deleteProperty(Position3NP);
+ deleteProperty(PositionReadNP);
+ deleteProperty(FirmwareTP);
+ deleteProperty(ZeroAllSP);
+ }
+
+ return true;
+}
+
+////////////////////////////////////////////////////////////////////////////////////////////////////////
+/// Get data from device (handshake + polling)
+////////////////////////////////////////////////////////////////////////////////////////////////////////
+bool WandererETA::getData()
+{
+ // Skip reading while commands are being sent
+ if (m_SendingCommand)
+ return true;
+
+ if (!serialPortMutex.try_lock_for(std::chrono::milliseconds(500)))
+ {
+ LOG_DEBUG("Serial port is busy, skipping status update");
+ return true;
+ }
+
+ std::lock_guard lock(serialPortMutex, std::adopt_lock);
+
+ try
+ {
+ PortFD = serialConnection->getPortFD();
+
+ char buffer[512] = {0};
+ int nbytes_read = 0, rc = -1;
+
+ if ((rc = tty_read_section(PortFD, buffer, '\n', 2, &nbytes_read)) != TTY_OK)
+ {
+ if (rc == TTY_TIME_OUT)
+ {
+ LOG_DEBUG("Timeout reading from device, will try again later");
+ return true;
+ }
+
+ char errorMessage[MAXRBUF];
+ tty_error_msg(rc, errorMessage, MAXRBUF);
+ LOGF_ERROR("Failed to read data from device. Error: %s", errorMessage);
+ return false;
+ }
+
+ return parseDeviceData(buffer);
+ }
+ catch (std::exception &e)
+ {
+ LOG_ERROR("Exception occurred while reading data from device");
+ return false;
+ }
+
+ return true;
+}
+
+////////////////////////////////////////////////////////////////////////////////////////////////////////
+/// Parse device status data
+////////////////////////////////////////////////////////////////////////////////////////////////////////
+bool WandererETA::parseDeviceData(const char *data)
+{
+ try
+ {
+ std::vector tokens;
+ std::string token;
+ std::istringstream tokenStream(data);
+ LOGF_DEBUG("Data: %s", data);
+
+ // Split the data by 'A' separator
+ while (std::getline(tokenStream, token, 'A'))
+ {
+ if (!token.empty())
+ tokens.push_back(token);
+ }
+
+ // Need at least: device_id + firmware + 3 positions
+ if (tokens.size() < 5)
+ {
+ LOGF_DEBUG("Insufficient tokens: %d (need 5)", static_cast(tokens.size()));
+ return false;
+ }
+
+ // Validate device identifier
+ if (tokens[0] != "WandererTilterM54")
+ {
+ LOGF_WARN("Unknown device: %s (expected WandererTilterM54)", tokens[0].c_str());
+ return false;
+ }
+
+ // Firmware version
+ firmware = std::atoi(tokens[1].c_str());
+
+ char firmwareStr[16];
+ snprintf(firmwareStr, sizeof(firmwareStr), "%d", firmware);
+ FirmwareTP[FIRMWARE_VERSION].setText(firmwareStr);
+ FirmwareTP.setState(IPS_OK);
+ FirmwareTP.apply();
+
+ // Point positions from encoders
+ double pos1 = std::strtod(tokens[2].c_str(), nullptr);
+ double pos2 = std::strtod(tokens[3].c_str(), nullptr);
+ double pos3 = std::strtod(tokens[4].c_str(), nullptr);
+
+ PositionReadNP[POINT_1].setValue(pos1);
+ PositionReadNP[POINT_2].setValue(pos2);
+ PositionReadNP[POINT_3].setValue(pos3);
+ PositionReadNP.setState(IPS_OK);
+ PositionReadNP.apply();
+
+ return true;
+ }
+ catch (std::exception &e)
+ {
+ LOG_ERROR("Failed to parse device data");
+ return false;
+ }
+}
+
+////////////////////////////////////////////////////////////////////////////////////////////////////////
+/// Helper: update a position property by index
+////////////////////////////////////////////////////////////////////////////////////////////////////////
+void WandererETA::setPositionNP(int index, double values[], char *names[], int n, IPState state)
+{
+ switch (index)
+ {
+ case 0:
+ Position1NP.update(values, names, n);
+ Position1NP.setState(state);
+ Position1NP.apply();
+ break;
+ case 1:
+ Position2NP.update(values, names, n);
+ Position2NP.setState(state);
+ Position2NP.apply();
+ break;
+ case 2:
+ Position3NP.update(values, names, n);
+ Position3NP.setState(state);
+ Position3NP.apply();
+ break;
+ }
+}
+
+////////////////////////////////////////////////////////////////////////////////////////////////////////
+/// Helper: set state on a position property by index
+////////////////////////////////////////////////////////////////////////////////////////////////////////
+void WandererETA::setPositionState(int index, IPState state)
+{
+ switch (index)
+ {
+ case 0:
+ Position1NP.setState(state);
+ Position1NP.apply();
+ break;
+ case 1:
+ Position2NP.setState(state);
+ Position2NP.apply();
+ break;
+ case 2:
+ Position3NP.setState(state);
+ Position3NP.apply();
+ break;
+ }
+}
+
+////////////////////////////////////////////////////////////////////////////////////////////////////////
+/// Handle new number values (position targets)
+////////////////////////////////////////////////////////////////////////////////////////////////////////
+bool WandererETA::ISNewNumber(const char *dev, const char *name, double values[], char *names[], int n)
+{
+ if (dev && !strcmp(dev, getDeviceName()))
+ {
+ // Determine which point is being commanded
+ int pointIndex = -1;
+
+ if (Position1NP.isNameMatch(name))
+ pointIndex = 0;
+ else if (Position2NP.isNameMatch(name))
+ pointIndex = 1;
+ else if (Position3NP.isNameMatch(name))
+ pointIndex = 2;
+
+ if (pointIndex >= 0)
+ {
+ double target = values[0];
+
+ // During initialization (config load), just store values without moving
+ if (m_Initializing)
+ {
+ setPositionNP(pointIndex, values, names, n, IPS_OK);
+ return true;
+ }
+
+ // Validate
+ if (target < 0.0 || target > 1.2)
+ {
+ LOGF_ERROR("Position value %.3f out of range (0.000 - 1.200 mm)", target);
+ setPositionNP(pointIndex, values, names, n, IPS_ALERT);
+ return true;
+ }
+
+ setPositionNP(pointIndex, values, names, n, IPS_BUSY);
+
+ // Block polling while sending command
+ m_SendingCommand = true;
+
+ usleep(200000);
+ tcflush(PortFD, TCIOFLUSH);
+ usleep(100000);
+
+ char cmd[32];
+ snprintf(cmd, sizeof(cmd), "%d%.3f\n", pointIndex + 1, target);
+
+ tcflush(PortFD, TCIOFLUSH);
+ usleep(100000);
+
+ int nbytes_written = 0, rc = -1;
+ bool success = true;
+
+ if ((rc = tty_write_string(PortFD, cmd, &nbytes_written)) != TTY_OK)
+ {
+ char errorMessage[MAXRBUF];
+ tty_error_msg(rc, errorMessage, MAXRBUF);
+ LOGF_ERROR("Serial write error: %s", errorMessage);
+ success = false;
+ }
+ else
+ {
+ tcdrain(PortFD);
+ LOGF_INFO("Moving Point %d to %.3f mm", pointIndex + 1, target);
+
+ if (!waitForPosition(pointIndex, target, 15000))
+ {
+ LOGF_WARN("Point %d did not reach target %.3f within timeout", pointIndex + 1, target);
+ }
+ }
+
+ m_SendingCommand = false;
+
+ setPositionState(pointIndex, success ? IPS_OK : IPS_ALERT);
+ return true;
+ }
+ }
+
+ return INDI::DefaultDevice::ISNewNumber(dev, name, values, names, n);
+}
+
+////////////////////////////////////////////////////////////////////////////////////////////////////////
+/// Handle new switch values (Zero All)
+////////////////////////////////////////////////////////////////////////////////////////////////////////
+bool WandererETA::ISNewSwitch(const char *dev, const char *name, ISState *states, char *names[], int n)
+{
+ if (dev && !strcmp(dev, getDeviceName()))
+ {
+ if (ZeroAllSP.isNameMatch(name))
+ {
+ bool success = true;
+
+ m_SendingCommand = true;
+ usleep(200000);
+ tcflush(PortFD, TCIOFLUSH);
+ usleep(100000);
+
+ // Send zero command for each point with flush and wait
+ for (int i = 0; i < 3; i++)
+ {
+ char cmd[32];
+ snprintf(cmd, sizeof(cmd), "%d0.000\n", i + 1);
+
+ tcflush(PortFD, TCIOFLUSH);
+ usleep(100000);
+
+ int nbytes_written = 0, rc = -1;
+ if ((rc = tty_write_string(PortFD, cmd, &nbytes_written)) != TTY_OK)
+ {
+ success = false;
+ break;
+ }
+ tcdrain(PortFD);
+ LOGF_INFO("Zeroing Point %d", i + 1);
+
+ if (!waitForPosition(i, 0.0, 15000))
+ {
+ LOGF_WARN("Point %d did not reach zero within timeout", i + 1);
+ }
+ }
+
+ m_SendingCommand = false;
+
+ if (success)
+ {
+ LOG_INFO("Moving all points to zero position");
+ // Update targets to reflect zero
+ Position1NP[0].setValue(0.0);
+ Position1NP.setState(IPS_OK);
+ Position1NP.apply();
+ Position2NP[0].setValue(0.0);
+ Position2NP.setState(IPS_OK);
+ Position2NP.apply();
+ Position3NP[0].setValue(0.0);
+ Position3NP.setState(IPS_OK);
+ Position3NP.apply();
+ }
+
+ ZeroAllSP.setState(success ? IPS_OK : IPS_ALERT);
+ ZeroAllSP[ZERO_ALL].setState(ISS_OFF);
+ ZeroAllSP.apply();
+ return true;
+ }
+ }
+
+ return INDI::DefaultDevice::ISNewSwitch(dev, name, states, names, n);
+}
+
+////////////////////////////////////////////////////////////////////////////////////////////////////////
+/// Wait for a motor to reach target position by reading status stream
+////////////////////////////////////////////////////////////////////////////////////////////////////////
+bool WandererETA::waitForPosition(int pointIndex, double target, int timeoutMs)
+{
+ auto start = std::chrono::steady_clock::now();
+ char buffer[512] = {0};
+ int nbytes_read = 0;
+
+ while (true)
+ {
+ auto elapsed = std::chrono::duration_cast(
+ std::chrono::steady_clock::now() - start).count();
+ if (elapsed > timeoutMs)
+ return false;
+
+ nbytes_read = 0;
+ int rc = tty_read_section(PortFD, buffer, '\n', 3, &nbytes_read);
+ if (rc != TTY_OK)
+ continue;
+
+ // Parse the status line to extract positions
+ std::string data(buffer);
+ std::vector tokens;
+ std::string token;
+ std::istringstream tokenStream(data);
+ while (std::getline(tokenStream, token, 'A'))
+ {
+ if (!token.empty())
+ tokens.push_back(token);
+ }
+
+ if (tokens.size() >= 5 && tokens[0] == "WandererTilterM54")
+ {
+ double pos = std::strtod(tokens[2 + pointIndex].c_str(), nullptr);
+
+ // Update readback display
+ PositionReadNP[POINT_1].setValue(std::strtod(tokens[2].c_str(), nullptr));
+ PositionReadNP[POINT_2].setValue(std::strtod(tokens[3].c_str(), nullptr));
+ PositionReadNP[POINT_3].setValue(std::strtod(tokens[4].c_str(), nullptr));
+ PositionReadNP.setState(IPS_OK);
+ PositionReadNP.apply();
+
+ // Check if target reached (within 0.005mm tolerance)
+ if (std::abs(pos - target) < 0.005)
+ return true;
+ }
+
+ memset(buffer, 0, sizeof(buffer));
+ }
+}
+
+////////////////////////////////////////////////////////////////////////////////////////////////////////
+/// Send command to device (thread-safe)
+////////////////////////////////////////////////////////////////////////////////////////////////////////
+bool WandererETA::sendCommand(std::string command)
+{
+ std::lock_guard lock(serialPortMutex);
+
+ int nbytes_written = 0, rc = -1;
+ std::string command_termination = "\n";
+ LOGF_DEBUG("CMD: %s", command.c_str());
+
+ // Flush any pending input data before sending command.
+ // The device streams status continuously; we need a clean state.
+ tcflush(PortFD, TCIFLUSH);
+
+ if ((rc = tty_write_string(PortFD, (command + command_termination).c_str(), &nbytes_written)) != TTY_OK)
+ {
+ char errorMessage[MAXRBUF];
+ tty_error_msg(rc, errorMessage, MAXRBUF);
+ LOGF_ERROR("Serial write error: %s", errorMessage);
+ return false;
+ }
+
+ // Ensure the data is fully transmitted before releasing the port
+ tcdrain(PortFD);
+
+ return true;
+}
+
+////////////////////////////////////////////////////////////////////////////////////////////////////////
+/// Timer callback - poll device status
+////////////////////////////////////////////////////////////////////////////////////////////////////////
+void WandererETA::TimerHit()
+{
+ if (!isConnected())
+ {
+ SetTimer(getPollingPeriod());
+ return;
+ }
+
+ // Clear initialization flag on first timer tick — by now config loading is complete
+ if (m_Initializing)
+ m_Initializing = false;
+
+ getData();
+ SetTimer(getPollingPeriod());
+}
+
+////////////////////////////////////////////////////////////////////////////////////////////////////////
+/// Save config items
+////////////////////////////////////////////////////////////////////////////////////////////////////////
+bool WandererETA::saveConfigItems(FILE *fp)
+{
+ INDI::DefaultDevice::saveConfigItems(fp);
+ Position1NP.save(fp);
+ Position2NP.save(fp);
+ Position3NP.save(fp);
+ return true;
+}
diff --git a/drivers/auxiliary/wanderer_eta.h b/drivers/auxiliary/wanderer_eta.h
new file mode 100644
index 0000000000..a898345a2a
--- /dev/null
+++ b/drivers/auxiliary/wanderer_eta.h
@@ -0,0 +1,99 @@
+/*******************************************************************************
+ Copyright(c) 2025 cfuture81. All rights reserved.
+
+ Wanderer ETA M54 - Electronic Tilt Adjuster
+
+ This program is free software; you can redistribute it and/or modify it
+ under the terms of the GNU General Public License as published by the Free
+ Software Foundation; either version 2 of the License, or (at your option)
+ any later version.
+
+ This program is distributed in the hope that it will be useful, but WITHOUT
+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ more details.
+
+ You should have received a copy of the GNU Library General Public License
+ along with this library; see the file COPYING.LIB. If not, write to
+ the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
+ Boston, MA 02110-1301, USA.
+
+ The full GNU General Public License is included in this distribution in the
+ file called LICENSE.
+*******************************************************************************/
+
+#pragma once
+
+#include "defaultdevice.h"
+#include
+
+namespace Connection
+{
+class Serial;
+}
+
+class WandererETA : public INDI::DefaultDevice
+{
+ public:
+ WandererETA();
+ virtual ~WandererETA() = default;
+
+ virtual bool initProperties() override;
+ virtual bool updateProperties() override;
+ virtual bool ISNewNumber(const char *dev, const char *name, double values[], char *names[], int n) override;
+ virtual bool ISNewSwitch(const char *dev, const char *name, ISState *states, char *names[], int n) override;
+
+ protected:
+ const char *getDefaultName() override;
+ virtual void TimerHit() override;
+ virtual bool saveConfigItems(FILE *fp) override;
+
+ private:
+ // Serial communication
+ bool sendCommand(std::string command);
+ bool getData();
+ bool parseDeviceData(const char *data);
+
+ // State
+ int firmware = 0;
+
+ // Motor position targets (RW) - individual properties for independent control
+ INDI::PropertyNumber Position1NP{1};
+ INDI::PropertyNumber Position2NP{1};
+ INDI::PropertyNumber Position3NP{1};
+
+ enum
+ {
+ POINT_1,
+ POINT_2,
+ POINT_3,
+ };
+
+ // Motor position readback (RO) - what the encoders report
+ INDI::PropertyNumber PositionReadNP{3};
+
+ // Firmware information
+ INDI::PropertyText FirmwareTP{1};
+ enum
+ {
+ FIRMWARE_VERSION,
+ };
+
+ // Zero all points
+ INDI::PropertySwitch ZeroAllSP{1};
+ enum
+ {
+ ZERO_ALL,
+ };
+
+ int PortFD{ -1 };
+ Connection::Serial *serialConnection{ nullptr };
+
+ // Mutex for thread safety
+ std::timed_mutex serialPortMutex;
+ bool m_SendingCommand {false};
+ bool m_Initializing {true};
+ bool waitForPosition(int pointIndex, double target, int timeoutMs);
+ void setPositionNP(int index, double values[], char *names[], int n, IPState state);
+ void setPositionState(int index, IPState state);
+};