Instrument Neutral Distributed Interface INDI 2.2.5
mlastro_rpa.cpp
Go to the documentation of this file.
1/*******************************************************************************
2 MLAstro Robotic Polar Alignment (RPA)
3
4 Copyright (C) 2026 Jasem Mutlaq ([email protected])
5
6 ESP32-based serial protocol (MLAstroRPA custom protocol).
7
8 This library is free software; you can redistribute it and/or
9 modify it under the terms of the GNU Lesser General Public
10 License as published by the Free Software Foundation; either
11 version 2.1 of the License, or (at your option) any later version.
12
13 This library is distributed in the hope that it will be useful,
14 but WITHOUT ANY WARRANTY; without even the implied warranty of
15 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
16 Lesser General Public License for more details.
17
18 You should have received a copy of the GNU Lesser General Public
19 License along with this library; if not, write to the Free Software
20 Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
21*******************************************************************************/
22
23#include "mlastro_rpa.h"
24
25#include "indicom.h"
26#include "indilogger.h"
28
29#include <cmath>
30#include <cstring>
31#include <memory>
32#include <termios.h>
33
34static std::unique_ptr<MLAstroRPA> rpa(new MLAstroRPA());
35
50
55{
56 return "MLAstro RPA";
57}
58
63{
65
66 // Initialise the PAC interface properties (main control tab)
68
69 // Speed: 1–5 levels as defined by the MLAstro protocol
70 SpeedNP[0].setMin(1);
71 SpeedNP[0].setMax(5);
72 SpeedNP[0].setStep(1);
73 SpeedNP[0].setValue(3);
74
75 // ── Main Control tab ──────────────────────────────────────────────────
76
77 // Current machine state (read-only text)
78 StatusTP[0].fill("STATUS", "State", "Unknown");
79 StatusTP.fill(getDeviceName(), "RPA_STATUS", "Status", MAIN_CONTROL_TAB, IP_RO, 60, IPS_IDLE);
80
81 // WiFi connection light
82 WiFiLP[0].fill("WIFI", "WiFi", IPS_IDLE);
83 WiFiLP.fill(getDeviceName(), "RPA_WIFI", "WiFi", MAIN_CONTROL_TAB, IPS_IDLE);
84
85 // Homed status light
86 HomedLP[0].fill("HOMED", "Homed", IPS_IDLE);
87 HomedLP.fill(getDeviceName(), "RPA_HOMED", "Home", MAIN_CONTROL_TAB, IPS_IDLE);
88
89 // ── Options tab ───────────────────────────────────────────────────────
90
91 // Azimuth soft limits
92 AzSoftLimitsNP[AZ_LIMIT_MIN].fill("AZ_LIMIT_MIN", "Min (deg)", "%.2f", -90, 0, 0.5, -9.0);
93 AzSoftLimitsNP[AZ_LIMIT_MAX].fill("AZ_LIMIT_MAX", "Max (deg)", "%.2f", 0, 90, 0.5, 9.0);
94 AzSoftLimitsNP.fill(getDeviceName(), "AZ_SOFT_LIMITS", "AZ Limits",
96
97 // Altitude soft limits
98 AltSoftLimitsNP[ALT_LIMIT_MIN].fill("ALT_LIMIT_MIN", "Min (deg)", "%.2f", -90, 0, 0.5, -9.0);
99 AltSoftLimitsNP[ALT_LIMIT_MAX].fill("ALT_LIMIT_MAX", "Max (deg)", "%.2f", 0, 90, 0.5, 9.0);
100 AltSoftLimitsNP.fill(getDeviceName(), "ALT_SOFT_LIMITS", "ALT Limits",
102
103 // Altitude overshoot routine — the platform settles more stably when it
104 // comes to rest moving upward. When enabled for a direction, the device
105 // overshoots the target on that leg and returns for a final upward approach.
106 AltOvershootSP[ALT_OVERSHOOT_ENABLED].fill("INDI_ENABLED", "Enable Overshoot on Firmware", ISS_OFF);
107 AltOvershootSP[ALT_OVERSHOOT_DISABLED].fill("INDI_DISABLED", "Disable", ISS_ON);
108 AltOvershootSP.fill(getDeviceName(), "ALT_OVERSHOOT", "Alt Overshoot",
110
111 AltOvershootDirSP[ALT_OVERSHOOT_UP].fill("OVERSHOOT_UP", "On Move Up", ISS_OFF);
112 AltOvershootDirSP[ALT_OVERSHOOT_DOWN].fill("OVERSHOOT_DOWN", "On Move Down", ISS_ON);
113 AltOvershootDirSP.fill(getDeviceName(), "ALT_OVERSHOOT_DIR", "Overshoot Direction",
115
116 AltOvershootAmountNP[0].fill("AMOUNT", "Amount (deg)", "%.4f", 0, 10.9997, 0.1, 2.0);
117 AltOvershootAmountNP.fill(getDeviceName(), "ALT_OVERSHOOT_AMOUNT", "Overshoot Amount",
119
120 // ── Correction tab ────────────────────────────────────────────────────
121 // Driver-side correction shaping (mirrors the MLAstro N.I.N.A. plugin):
122 // * "Correction Percentage" (default 75%, 1-100%) is applied to Azimuth on
123 // every automated correction, and to Altitude when the software overshoot
124 // is not active for the current correction direction. Applying slightly
125 // less than the full error prevents overcorrection; the client simply
126 // repeats the run until the error converges.
127 // * "Software Overshoot (ALT)": when enabled for the current correction
128 // direction, the Alt axis corrects 100% of the error and then travels the
129 // configured overshoot (0-240 arcmin) past the target, so the platform
130 // always settles from the same direction.
131 CorrectionPercentNP[0].fill("CORRECTION_PERCENT", "Correction (%)", "%.0f", 1, 100, 1, 75);
132 CorrectionPercentNP.fill(getDeviceName(), "RPA_CORRECTION_PERCENT", "Correction Percentage",
133 CORRECTION_TAB, IP_RW, 60, IPS_IDLE);
134
135 CorrectionOvershootSP[CORR_OVERSHOOT_ENABLED].fill("INDI_ENABLED", "Enable overshoot", ISS_OFF);
136 CorrectionOvershootSP[CORR_OVERSHOOT_DISABLED].fill("INDI_DISABLED", "Disable", ISS_ON);
137 CorrectionOvershootSP.fill(getDeviceName(), "RPA_CORRECTION_OVERSHOOT", "Software Overshoot (ALT)",
138 CORRECTION_TAB, IP_RW, ISR_1OFMANY, 60, IPS_IDLE);
139
140 CorrectionOvershootDirSP[CORR_OVERSHOOT_UP].fill("OVERSHOOT_UP", "Run overshoot for moving Up", ISS_OFF);
141 CorrectionOvershootDirSP[CORR_OVERSHOOT_DOWN].fill("OVERSHOOT_DOWN", "Run overshoot for moving Down",
142 ISS_ON);
143 CorrectionOvershootDirSP.fill(getDeviceName(), "RPA_CORRECTION_OVERSHOOT_DIR", "Run Overshoot Direction",
144 CORRECTION_TAB, IP_RW, ISR_NOFMANY, 60, IPS_IDLE);
145
146 CorrectionOvershootAmountNP[0].fill("AMOUNT", "Amount (arcmin)", "%.1f", 0, 240, 1, 30);
147 CorrectionOvershootAmountNP.fill(getDeviceName(), "RPA_CORRECTION_OVERSHOOT_AMOUNT",
148 "Overshoot Amount (arcmin)", CORRECTION_TAB, IP_RW, 60, IPS_IDLE);
149
150 // ── Motor Config tab ──────────────────────────────────────────────────
151
152 // Azimuth motor settings
153 AzMotorNP[AZ_RUN_CURRENT].fill("AZ_RUN_CURRENT", "Run Current (mA)", "%.0f", 100, 3000, 50, 800);
154 AzMotorNP[AZ_HOLD_CURRENT].fill("AZ_HOLD_CURRENT", "Hold Current (mA)", "%.0f", 0, 2000, 50, 200);
155 AzMotorNP[AZ_MICROSTEPS].fill("AZ_MICROSTEPS", "Microsteps", "%.0f", 1, 256, 1, 64);
156 AzMotorNP[AZ_ACCEL].fill("AZ_ACCEL", "Acceleration", "%.0f", 100, 50000, 100, 5000);
157 AzMotorNP[AZ_DECEL].fill("AZ_DECEL", "Deceleration", "%.0f", 100, 50000, 100, 5000);
158 AzMotorNP[AZ_STEPS_PER_DEG].fill("AZ_STEPS_PER_DEG", "Steps/Degree", "%.0f", 1, 100000, 1, 3200);
159 AzMotorNP[AZ_START_BOOST].fill("AZ_START_BOOST", "Start Boost (%)", "%.0f", 0, 100, 1, 10);
160 AzMotorNP[AZ_COOL_STEP].fill("AZ_COOL_STEP", "CoolStep (%)", "%.0f", 0, 100, 1, 0);
161 AzMotorNP[AZ_RUN_MODE].fill("AZ_RUN_MODE", "Mode (0=SC 1=SpC)", "%.0f", 0, 1, 1, 0);
162 AzMotorNP.fill(getDeviceName(), "AZ_MOTOR_CONFIG", "Azimuth Motor",
163 MOTOR_CONFIG_TAB, IP_RW, 60, IPS_IDLE);
164
165 // Altitude motor settings
166 AltMotorNP[ALT_RUN_CURRENT].fill("ALT_RUN_CURRENT", "Run Current (mA)", "%.0f", 100, 3000, 50, 800);
167 AltMotorNP[ALT_HOLD_CURRENT].fill("ALT_HOLD_CURRENT", "Hold Current (mA)", "%.0f", 0, 2000, 50, 200);
168 AltMotorNP[ALT_MICROSTEPS].fill("ALT_MICROSTEPS", "Microsteps", "%.0f", 1, 256, 1, 64);
169 AltMotorNP[ALT_ACCEL].fill("ALT_ACCEL", "Acceleration", "%.0f", 100, 50000, 100, 5000);
170 AltMotorNP[ALT_DECEL].fill("ALT_DECEL", "Deceleration", "%.0f", 100, 50000, 100, 5000);
171 AltMotorNP[ALT_STEPS_PER_DEG].fill("ALT_STEPS_PER_DEG", "Steps/Degree", "%.0f", 1, 100000, 1, 3200);
172 AltMotorNP[ALT_START_BOOST].fill("ALT_START_BOOST", "Start Boost (%)", "%.0f", 0, 100, 1, 10);
173 AltMotorNP[ALT_COOL_STEP].fill("ALT_COOL_STEP", "CoolStep (%)", "%.0f", 0, 100, 1, 0);
174 AltMotorNP[ALT_RUN_MODE].fill("ALT_RUN_MODE", "Mode (0=SC 1=SpC)", "%.0f", 0, 1, 1, 0);
175 AltMotorNP.fill(getDeviceName(), "ALT_MOTOR_CONFIG", "Altitude Motor",
176 MOTOR_CONFIG_TAB, IP_RW, 60, IPS_IDLE);
177
178 // Save & Reboot button
179 SaveRebootSP[0].fill("SAVE_REBOOT", "Save & Reboot", ISS_OFF);
180 SaveRebootSP.fill(getDeviceName(), "RPA_SAVE_REBOOT", "Save & Reboot",
181 MOTOR_CONFIG_TAB, IP_RW, ISR_ATMOST1, 60, IPS_IDLE);
182
183 // ── Network tab ──────────────────────────────────────────────────────
184 StationSSIDTP[0].fill("STATION_SSID", "Station SSID", "");
185 StationSSIDTP.fill(getDeviceName(), "RPA_STATION_SSID", "Station SSID",
186 NETWORK_TAB, IP_RW, 60, IPS_IDLE);
187
188 // Write-only: never read back, never saved to the local config file.
189 StationPasswordTP[0].fill("STATION_PASSWORD", "Station Password", "");
190 StationPasswordTP.fill(getDeviceName(), "RPA_STATION_PASSWORD", "Station Password",
191 NETWORK_TAB, IP_WO, 60, IPS_IDLE);
192
193 APConfigTP[AP_SSID].fill("AP_SSID", "AP SSID", "");
194 APConfigTP[AP_IP].fill("AP_IP", "AP IP Address", "");
195 APConfigTP.fill(getDeviceName(), "RPA_AP_CONFIG", "Access Point",
196 NETWORK_TAB, IP_RW, 60, IPS_IDLE);
197
198 // Write-only: never read back, never saved to the local config file.
199 APPasswordTP[0].fill("AP_PASSWORD", "AP Password", "");
200 APPasswordTP.fill(getDeviceName(), "RPA_AP_PASSWORD", "AP Password",
201 NETWORK_TAB, IP_WO, 60, IPS_IDLE);
202
203 // ── Info tab ──────────────────────────────────────────────────────────
204 FirmwareInfoTP[FIRMWARE_VERSION].fill("FIRMWARE_VERSION", "Firmware", "");
205 FirmwareInfoTP[FIRMWARE_SERIAL].fill("FIRMWARE_SERIAL", "Serial Number", "");
206 FirmwareInfoTP.fill(getDeviceName(), "RPA_FIRMWARE_INFO", "Firmware", INFO_TAB, IP_RO, 60, IPS_IDLE);
207
208 WiFiInfoTP[WIFI_INFO_AP_SSID].fill("AP_SSID", "AP SSID", "");
209 WiFiInfoTP[WIFI_INFO_AP_IP].fill("AP_IP", "AP IP", "");
210 WiFiInfoTP[WIFI_INFO_AP_MAC].fill("AP_MAC", "AP MAC", "");
211 WiFiInfoTP[WIFI_INFO_STA_SSID].fill("STA_SSID", "Station SSID", "");
212 WiFiInfoTP[WIFI_INFO_STA_IP].fill("STA_IP", "Station IP", "");
213 WiFiInfoTP[WIFI_INFO_STA_MAC].fill("STA_MAC", "Station MAC", "");
214 WiFiInfoTP.fill(getDeviceName(), "RPA_WIFI_INFO", "WiFi Info", INFO_TAB, IP_RO, 60, IPS_IDLE);
215
217
218 // Serial connection
219 serialConnection = new Connection::Serial(this);
220 serialConnection->registerHandshake([&]()
221 {
222 return Handshake();
223 });
225 registerConnection(serialConnection);
226
228 return true;
229}
230
235{
237
238 if (isConnected())
239 {
240 defineProperty(StatusTP);
241 defineProperty(WiFiLP);
242 defineProperty(HomedLP);
243 defineProperty(AzSoftLimitsNP);
244 defineProperty(AltSoftLimitsNP);
245 defineProperty(AltOvershootSP);
246 defineProperty(AltOvershootDirSP);
247 defineProperty(AltOvershootAmountNP);
248 defineProperty(CorrectionPercentNP);
249 defineProperty(CorrectionOvershootSP);
250 defineProperty(CorrectionOvershootDirSP);
251 defineProperty(CorrectionOvershootAmountNP);
252 defineProperty(AzMotorNP);
253 defineProperty(AltMotorNP);
254 defineProperty(SaveRebootSP);
255 defineProperty(StationSSIDTP);
256 defineProperty(StationPasswordTP);
257 defineProperty(APConfigTP);
258 defineProperty(APPasswordTP);
259 defineProperty(FirmwareInfoTP);
260 defineProperty(WiFiInfoTP);
261 }
262 else
263 {
264 deleteProperty(StatusTP);
265 deleteProperty(WiFiLP);
266 deleteProperty(HomedLP);
267 deleteProperty(AzSoftLimitsNP);
268 deleteProperty(AltSoftLimitsNP);
269 deleteProperty(AltOvershootSP);
270 deleteProperty(AltOvershootDirSP);
271 deleteProperty(AltOvershootAmountNP);
272 deleteProperty(CorrectionPercentNP);
273 deleteProperty(CorrectionOvershootSP);
274 deleteProperty(CorrectionOvershootDirSP);
275 deleteProperty(CorrectionOvershootAmountNP);
276 deleteProperty(AzMotorNP);
277 deleteProperty(AltMotorNP);
278 deleteProperty(SaveRebootSP);
279 deleteProperty(StationSSIDTP);
280 deleteProperty(StationPasswordTP);
281 deleteProperty(APConfigTP);
282 deleteProperty(APPasswordTP);
283 deleteProperty(FirmwareInfoTP);
284 deleteProperty(WiFiInfoTP);
285 }
286
288 return true;
289}
290
295{
298
299 // Device-specific settings. Saving these here makes them persist on the very
300 // first save as well (saveConfig(property) alone only records into an already
301 // existing config file). Password / SSID text properties are intentionally
302 // excluded: the passwords are write-only and the firmware re-reports the rest.
303 AzSoftLimitsNP.save(fp);
304 AltSoftLimitsNP.save(fp);
305 AltOvershootSP.save(fp);
306 AltOvershootDirSP.save(fp);
307 AltOvershootAmountNP.save(fp);
308 AzMotorNP.save(fp);
309 AltMotorNP.save(fp);
310 CorrectionPercentNP.save(fp);
311 CorrectionOvershootSP.save(fp);
312 CorrectionOvershootDirSP.save(fp);
313 CorrectionOvershootAmountNP.save(fp);
314 return true;
315}
316
321{
322 PortFD = serialConnection->getPortFD();
323 m_BacklashLogged = false;
324
325 // Flush any stale data
326 tcflush(PortFD, TCIOFLUSH);
327
328 // Small pause to let the device settle
329 usleep(200000);
330
331 // Send the takeover handshake command
332 char res[DRIVER_LEN] = {0};
333 if (!sendCommand("[MLAstroRPA-TC]", res))
334 return false;
335
336 if (strncmp(res, "ok", 2) != 0)
337 {
338 LOGF_ERROR("Unexpected handshake response: %s", res);
339 return false;
340 }
341
342 // Newer firmware replies "ok,firmware X.Y.Z,SN:AA:BB:CC:DD:EE:F0".
343 // Older firmware just replies "ok" with no version/serial info.
344 const char *fwStart = strchr(res, ',');
345 const char *snField = strstr(res, "SN:");
346 if (fwStart && snField && snField > fwStart)
347 {
348 fwStart++; // skip comma
349 size_t fwLen = static_cast<size_t>(snField - fwStart);
350 while (fwLen > 0 && (fwStart[fwLen - 1] == ',' || fwStart[fwLen - 1] == ' '))
351 fwLen--;
352
353 char fwVersion[64] = {0};
354 if (fwLen >= sizeof(fwVersion))
355 fwLen = sizeof(fwVersion) - 1;
356 strncpy(fwVersion, fwStart, fwLen);
357
358 FirmwareInfoTP[FIRMWARE_VERSION].setText(fwVersion);
359 FirmwareInfoTP[FIRMWARE_SERIAL].setText(snField + 3);
360 FirmwareInfoTP.setState(IPS_OK);
361 FirmwareInfoTP.apply();
362
363 LOGF_INFO("Connected to %s (Serial: %s).", fwVersion, snField + 3);
364 }
365
366 // Lock the device into relative (angle) mode for all subsequent moves
367 if (!sendCommand("JoRe:1", res))
368 {
369 LOG_WARN("Could not set relative mode – proceeding anyway.");
370 }
371
372 // Read initial telemetry to populate properties
373 char telem[DRIVER_LEN] = {0};
374 if (sendCommand("?", telem))
375 parseTelemetry(telem);
376
377 LOG_INFO("MLAstro RPA connected and ready.");
378 return true;
379}
380
385{
386 if (!isConnected())
387 return;
388
389 char res[DRIVER_LEN] = {0};
390 if (sendCommand("?", res))
391 parseTelemetry(res);
392
393 checkMotionComplete();
394
396}
397
402bool MLAstroRPA::parseTelemetry(const char *response)
403{
404 if (!response || *response == '\0')
405 return false;
406
407 // ── 1. Extract STATUS ────────────────────────────────────────────────
408 const char *lt = strchr(response, '<');
409 const char *pipe1 = strchr(response, '|');
410 if (lt && pipe1 && pipe1 > lt)
411 {
412 char status[64] = {0};
413 int len = static_cast<int>(pipe1 - lt - 1);
414 if (len > 0 && len < static_cast<int>(sizeof(status)))
415 {
416 strncpy(status, lt + 1, len);
417 status[len] = '\0';
418 if (strcmp(StatusTP[0].getText(), status) != 0)
419 {
420 StatusTP[0].setText(status);
421 StatusTP.setState(IPS_OK);
422 StatusTP.apply();
423 }
424 m_IsMoving = (strstr(status, "MOVING") != nullptr ||
425 strstr(status, "ALIGNING") != nullptr);
426 m_IsHoming = (strstr(status, "HOMING") != nullptr);
427 }
428 }
429
430 // ── 2. Extract Mpos: AZ, ALT ─────────────────────────────────────────
431 const char *mpos = strstr(response, "Mpos:");
432 if (mpos)
433 {
434 double azDeg = 0.0, altDeg = 0.0;
435 if (sscanf(mpos + 5, "%lf,%lf", &azDeg, &altDeg) == 2)
436 {
437 PositionNP[POSITION_AZ].setValue(azDeg);
438 PositionNP[POSITION_ALT].setValue(altDeg);
439 PositionNP.setState(m_IsMoving ? IPS_BUSY : IPS_OK);
441 }
442 }
443
444 // ── 3. Parse DATA_SETTING key:value pairs ────────────────────────────
445 const char *dataStart = strstr(response, "|>");
446 if (!dataStart)
447 return true; // No data section; that's OK.
448 dataStart += 2; // Skip "|>"
449
450 // Work on a mutable copy
451 char data[DRIVER_LEN] = {0};
452 strncpy(data, dataStart, DRIVER_LEN - 1);
453
454 bool sawBacklash = false;
455 char *token = strtok(data, ",");
456 while (token)
457 {
458 char key[32] = {0};
459 char val[64] = {0};
460
461 if (sscanf(token, "%31[^:]:%63s", key, val) == 2)
462 {
463 double v = atof(val);
464
465 if (strcmp(key, "SLvl") == 0)
466 {
467 SpeedNP[0].setValue(v);
469 SpeedNP.apply();
470 }
471 else if (strcmp(key, "Home") == 0)
472 {
473 bool homed = (v != 0.0);
474 if (homed != m_IsHomed)
475 {
476 m_IsHomed = homed;
477 HomedLP[0].setState(homed ? IPS_OK : IPS_IDLE);
478 HomedLP.apply();
479 }
480 }
481 else if (strcmp(key, "WSta") == 0)
482 {
483 WiFiLP[0].setState(v != 0.0 ? IPS_OK : IPS_IDLE);
484 WiFiLP.apply();
485 }
486 else if (strcmp(key, "Back") == 0)
487 {
488 sawBacklash = true;
489 int idx = (v != 0.0) ? DefaultDevice::INDI_ENABLED : DefaultDevice::INDI_DISABLED;
494 }
495 else if (strcmp(key, "AzBl") == 0)
496 {
497 sawBacklash = true;
498 BacklashNP[BACKLASH_AZ].setValue(v);
501 }
502 else if (strcmp(key, "AlBl") == 0)
503 {
504 sawBacklash = true;
505 BacklashNP[BACKLASH_ALT].setValue(v);
508 }
509 else if (strcmp(key, "AzPH") == 0)
510 {
511 // AzPH is the actual motor position — already captured via Mpos; keep in sync
512 PositionNP[POSITION_AZ].setValue(v);
513 }
514 else if (strcmp(key, "AlPH") == 0)
515 {
516 PositionNP[POSITION_ALT].setValue(v);
517 }
518 // ── Soft limits
519 else if (strcmp(key, "AzL1") == 0)
520 {
521 AzSoftLimitsNP[AZ_LIMIT_MIN].setValue(v);
522 AzSoftLimitsNP.setState(IPS_OK);
523 AzSoftLimitsNP.apply();
524 }
525 else if (strcmp(key, "AzL2") == 0)
526 {
527 AzSoftLimitsNP[AZ_LIMIT_MAX].setValue(v);
528 AzSoftLimitsNP.setState(IPS_OK);
529 AzSoftLimitsNP.apply();
530 }
531 else if (strcmp(key, "AlL1") == 0)
532 {
533 AltSoftLimitsNP[ALT_LIMIT_MIN].setValue(v);
534 AltSoftLimitsNP.setState(IPS_OK);
535 AltSoftLimitsNP.apply();
536 }
537 else if (strcmp(key, "AlL2") == 0)
538 {
539 AltSoftLimitsNP[ALT_LIMIT_MAX].setValue(v);
540 AltSoftLimitsNP.setState(IPS_OK);
541 AltSoftLimitsNP.apply();
542 }
543 // ── Altitude overshoot routine
544 else if (strcmp(key, "Over") == 0)
545 {
546 int idx = (v != 0.0) ? ALT_OVERSHOOT_ENABLED : ALT_OVERSHOOT_DISABLED;
547 AltOvershootSP.reset();
548 AltOvershootSP[idx].setState(ISS_ON);
549 AltOvershootSP.setState(IPS_OK);
550 AltOvershootSP.apply();
551 }
552 else if (strcmp(key, "OvUp") == 0)
553 {
554 AltOvershootDirSP[ALT_OVERSHOOT_UP].setState(v != 0.0 ? ISS_ON : ISS_OFF);
555 AltOvershootDirSP.setState(IPS_OK);
556 AltOvershootDirSP.apply();
557 }
558 else if (strcmp(key, "OvDn") == 0)
559 {
560 AltOvershootDirSP[ALT_OVERSHOOT_DOWN].setState(v != 0.0 ? ISS_ON : ISS_OFF);
561 AltOvershootDirSP.setState(IPS_OK);
562 AltOvershootDirSP.apply();
563 }
564 else if (strcmp(key, "OvD") == 0)
565 {
566 m_OvshD = v;
567 AltOvershootAmountNP[0].setValue(m_OvshD + m_OvshM / 60.0 + m_OvshS / 3600.0);
568 AltOvershootAmountNP.setState(IPS_OK);
569 AltOvershootAmountNP.apply();
570 }
571 else if (strcmp(key, "OvM") == 0)
572 {
573 m_OvshM = v;
574 AltOvershootAmountNP[0].setValue(m_OvshD + m_OvshM / 60.0 + m_OvshS / 3600.0);
575 AltOvershootAmountNP.apply();
576 }
577 else if (strcmp(key, "OvS") == 0)
578 {
579 m_OvshS = v;
580 AltOvershootAmountNP[0].setValue(m_OvshD + m_OvshM / 60.0 + m_OvshS / 3600.0);
581 AltOvershootAmountNP.apply();
582 }
583 // ── Azimuth motor config
584 else if (strcmp(key, "AzIR") == 0)
585 {
586 AzMotorNP[AZ_RUN_CURRENT].setValue(v);
587 AzMotorNP.apply();
588 }
589 else if (strcmp(key, "AzIH") == 0)
590 {
591 AzMotorNP[AZ_HOLD_CURRENT].setValue(v);
592 AzMotorNP.apply();
593 }
594 else if (strcmp(key, "AzMS") == 0)
595 {
596 AzMotorNP[AZ_MICROSTEPS].setValue(v);
597 AzMotorNP.apply();
598 }
599 else if (strcmp(key, "AzAc") == 0)
600 {
601 AzMotorNP[AZ_ACCEL].setValue(v);
602 AzMotorNP.apply();
603 }
604 else if (strcmp(key, "AzDec") == 0)
605 {
606 AzMotorNP[AZ_DECEL].setValue(v);
607 AzMotorNP.apply();
608 }
609 else if (strcmp(key, "AzSD") == 0)
610 {
611 AzMotorNP[AZ_STEPS_PER_DEG].setValue(v);
612 AzMotorNP.apply();
613 }
614 else if (strcmp(key, "AzSB") == 0)
615 {
616 AzMotorNP[AZ_START_BOOST].setValue(v);
617 AzMotorNP.apply();
618 }
619 else if (strcmp(key, "AzSC") == 0)
620 {
621 AzMotorNP[AZ_COOL_STEP].setValue(v);
622 AzMotorNP.apply();
623 }
624 else if (strcmp(key, "AzRM") == 0)
625 {
626 AzMotorNP[AZ_RUN_MODE].setValue(v);
627 AzMotorNP.setState(IPS_OK);
628 AzMotorNP.apply();
629 }
630 // Azimuth reverse — sync PAC reverse property
631 else if (strcmp(key, "AzRD") == 0)
632 {
633 int idx = (v != 0.0) ? DefaultDevice::INDI_ENABLED : DefaultDevice::INDI_DISABLED;
638 }
639 // ── Altitude motor config
640 else if (strcmp(key, "AlIR") == 0)
641 {
642 AltMotorNP[ALT_RUN_CURRENT].setValue(v);
643 AltMotorNP.apply();
644 }
645 else if (strcmp(key, "AlIH") == 0)
646 {
647 AltMotorNP[ALT_HOLD_CURRENT].setValue(v);
648 AltMotorNP.apply();
649 }
650 else if (strcmp(key, "AlMS") == 0)
651 {
652 AltMotorNP[ALT_MICROSTEPS].setValue(v);
653 AltMotorNP.apply();
654 }
655 else if (strcmp(key, "AlAc") == 0)
656 {
657 AltMotorNP[ALT_ACCEL].setValue(v);
658 AltMotorNP.apply();
659 }
660 else if (strcmp(key, "AlDe") == 0)
661 {
662 AltMotorNP[ALT_DECEL].setValue(v);
663 AltMotorNP.apply();
664 }
665 else if (strcmp(key, "AlSD") == 0)
666 {
667 AltMotorNP[ALT_STEPS_PER_DEG].setValue(v);
668 AltMotorNP.apply();
669 }
670 else if (strcmp(key, "AlSB") == 0)
671 {
672 AltMotorNP[ALT_START_BOOST].setValue(v);
673 AltMotorNP.apply();
674 }
675 else if (strcmp(key, "AlSC") == 0)
676 {
677 AltMotorNP[ALT_COOL_STEP].setValue(v);
678 AltMotorNP.apply();
679 }
680 else if (strcmp(key, "AlRM") == 0)
681 {
682 AltMotorNP[ALT_RUN_MODE].setValue(v);
683 AltMotorNP.setState(IPS_OK);
684 AltMotorNP.apply();
685 }
686 // Altitude reverse
687 else if (strcmp(key, "AlRD") == 0)
688 {
689 int idx = (v != 0.0) ? DefaultDevice::INDI_ENABLED : DefaultDevice::INDI_DISABLED;
694 }
695 // ── WiFi info (string values)
696 else if (strcmp(key, "APss") == 0)
697 {
698 WiFiInfoTP[WIFI_INFO_AP_SSID].setText(val);
699 WiFiInfoTP.apply();
700 }
701 else if (strcmp(key, "APip") == 0)
702 {
703 WiFiInfoTP[WIFI_INFO_AP_IP].setText(val);
704 WiFiInfoTP.apply();
705 }
706 else if (strcmp(key, "APma") == 0)
707 {
708 WiFiInfoTP[WIFI_INFO_AP_MAC].setText(val);
709 WiFiInfoTP.apply();
710 }
711 else if (strcmp(key, "STAs") == 0)
712 {
713 WiFiInfoTP[WIFI_INFO_STA_SSID].setText(val);
714 WiFiInfoTP.apply();
715 }
716 else if (strcmp(key, "STAi") == 0)
717 {
718 WiFiInfoTP[WIFI_INFO_STA_IP].setText(val);
719 WiFiInfoTP.setState(IPS_OK);
720 WiFiInfoTP.apply();
721 }
722 else if (strcmp(key, "STAm") == 0)
723 {
724 WiFiInfoTP[WIFI_INFO_STA_MAC].setText(val);
725 WiFiInfoTP.apply();
726 }
727 }
728 token = strtok(nullptr, ",");
729 }
730
731 // The firmware reports its backlash state on every poll but the driver used to
732 // parse it silently, so a client log could never show whether it was enabled.
733 // Log it once per connection to make that diagnosable.
734 if (sawBacklash && !m_BacklashLogged)
735 {
736 m_BacklashLogged = true;
737 const bool enabled = BacklashSP[DefaultDevice::INDI_ENABLED].getState() == ISS_ON;
738 LOGF_INFO("Backlash compensation %s (AZ %d, ALT %d steps).",
739 enabled ? "enabled" : "disabled",
740 static_cast<int>(BacklashNP[BACKLASH_AZ].getValue()),
741 static_cast<int>(BacklashNP[BACKLASH_ALT].getValue()));
742 }
743
744 return true;
745}
746
750void MLAstroRPA::checkMotionComplete()
751{
752 // If we were busy and are no longer moving, finalize states
753 if (!m_IsMoving)
754 {
756 {
759 LOG_INFO("MLAstro RPA motion complete.");
760 }
761
762 if (m_IsHoming && HomeSP.getState() == IPS_BUSY)
763 {
764 m_IsHoming = false;
766 HomeSP.apply();
767 LOG_INFO("MLAstro RPA homing complete.");
768 }
769 }
770}
771
775void MLAstroRPA::degreesToDMS(double degrees, int &d, int &m, int &s, bool &positive)
776{
777 positive = (degrees >= 0.0);
778 double abs_deg = std::fabs(degrees);
779 d = static_cast<int>(abs_deg);
780 double rem = (abs_deg - d) * 60.0;
781 m = static_cast<int>(rem);
782 s = static_cast<int>((rem - m) * 60.0 + 0.5); // round to nearest arcsec
783 // Handle carry-over
784 if (s >= 60)
785 {
786 s -= 60;
787 m += 1;
788 }
789 if (m >= 60)
790 {
791 m -= 60;
792 d += 1;
793 }
794}
795
799IPState MLAstroRPA::sendRelativeMove(double degrees, const char *cmdPos, const char *cmdNeg)
800{
801 if (degrees == 0.0)
802 return IPS_OK;
803
804 int d, m, s;
805 bool positive;
806 degreesToDMS(degrees, d, m, s, positive);
807
808 // Set degrees/minutes/seconds on the device, then fire the direction command
809 char cmd[DRIVER_LEN] = {0};
810 snprintf(cmd, DRIVER_LEN, "ReDe:%d,ReAM:%d,ReAS:%d,%s", d, m, s,
811 positive ? cmdPos : cmdNeg);
812
813 char res[DRIVER_LEN] = {0};
814 if (!sendCommand(cmd, res))
815 return IPS_ALERT;
816
817 if (strncmp(res, "ok", 2) != 0)
818 {
819 LOGF_ERROR("Unexpected move response: %s", res);
820 // Log protocol errors for diagnostics
821 if (strstr(res, "Soft Limit"))
822 LOG_ERROR("Motion rejected: soft limit reached.");
823 else if (strstr(res, "Hard Limit"))
824 LOG_ERROR("Motion rejected: hard limit triggered.");
825 else if (strstr(res, "Not homed"))
826 LOG_ERROR("Motion rejected: device is not homed.");
827 else if (strstr(res, "System Locked"))
828 LOG_ERROR("Motion rejected: system is locked (error state).");
829 return IPS_ALERT;
830 }
831
832 LOGF_INFO("Relative move: %.4f° → D=%d M=%d S=%d dir=%s",
833 degrees, d, m, s, positive ? "positive" : "negative");
834
835 m_IsMoving = true;
836 return IPS_BUSY;
837}
838
842double MLAstroRPA::applyCorrectionAZ(double azDegrees) const
843{
844 return azDegrees * CorrectionPercentNP[0].getValue() / 100.0;
845}
846
850double MLAstroRPA::applyCorrectionALT(double altDegrees) const
851{
852 // A zero request is never turned into an overshoot move.
853 if (altDegrees == 0.0)
854 return 0.0;
855
856 const bool master = CorrectionOvershootSP[CORR_OVERSHOOT_ENABLED].getState() == ISS_ON;
857 const bool dirEnabled = (altDegrees > 0.0)
858 ? CorrectionOvershootDirSP[CORR_OVERSHOOT_UP].getState() == ISS_ON
859 : CorrectionOvershootDirSP[CORR_OVERSHOOT_DOWN].getState() == ISS_ON;
860
861 if (master && dirEnabled)
862 {
863 // Full correction plus the configured overshoot past the target.
864 const double overshoot = CorrectionOvershootAmountNP[0].getValue() / 60.0; // arcmin → deg
865 return altDegrees + (altDegrees > 0.0 ? overshoot : -overshoot);
866 }
867
868 return altDegrees * CorrectionPercentNP[0].getValue() / 100.0;
869}
870
875{
876 const double requested = degrees;
877 degrees = applyCorrectionAZ(degrees);
878
879 if (degrees != requested)
880 LOGF_INFO("MoveAZ: correction applied — requested %.4f° → %.4f° (%.0f%%).",
881 requested, degrees, CorrectionPercentNP[0].getValue());
882
883 // positive = East → MAzR, negative = West → MAzL
884 return sendRelativeMove(degrees, "MAzR:1", "MAzL:1");
885}
886
891{
892 const double requested = degrees;
893 degrees = applyCorrectionALT(degrees);
894
895 if (degrees != requested)
896 LOGF_INFO("MoveALT: correction applied — requested %.4f° → %.4f°.",
897 requested, degrees);
898
899 // positive = Up → MAlU, negative = Down → MAlD
900 return sendRelativeMove(degrees, "MAlU:1", "MAlD:1");
901}
902
910IPState MLAstroRPA::MoveBoth(double azDegrees, double altDegrees)
911{
912 const double requestedAz = azDegrees;
913 const double requestedAlt = altDegrees;
914
915 azDegrees = applyCorrectionAZ(azDegrees);
916 altDegrees = applyCorrectionALT(altDegrees);
917
918 // If the correction percentage scaled both axes down to nothing there is no
919 // move to issue – report success so the client keeps iterating.
920 if (azDegrees == 0.0 && altDegrees == 0.0)
921 {
922 LOGF_INFO("MoveBoth: requested AZ %.4f° / ALT %.4f° scaled to zero — no move.",
923 requestedAz, requestedAlt);
924 return IPS_OK;
925 }
926
927 if (azDegrees != requestedAz || altDegrees != requestedAlt)
928 LOGF_INFO("MoveBoth: correction applied — requested AZ %.4f° / ALT %.4f° → AZ %.4f° / ALT %.4f°.",
929 requestedAz, requestedAlt, azDegrees, altDegrees);
930
931 int azD, azM, azS;
932 bool azPos;
933 degreesToDMS(azDegrees, azD, azM, azS, azPos);
934
935 int altD, altM, altS;
936 bool altPos;
937 degreesToDMS(altDegrees, altD, altM, altS, altPos);
938
939 // Chain all error params + execute command in one line.
940 // AzDi: 1 = Right/Positive, 0 = Left/Negative
941 // AlDi: 1 = Up/Positive, 0 = Down/Negative
942 char cmd[DRIVER_LEN] = {0};
943 snprintf(cmd, DRIVER_LEN,
944 "AzED:%d,AzEM:%d,AzES:%d,AzDi:%d,AlED:%d,AlEM:%d,AlES:%d,AlDi:%d,AAll:1",
945 azD, azM, azS, azPos ? 1 : 0,
946 altD, altM, altS, altPos ? 1 : 0);
947
948 char res[DRIVER_LEN] = {0};
949 if (!sendCommand(cmd, res))
950 return IPS_ALERT;
951
952 if (strncmp(res, "ok", 2) != 0)
953 {
954 LOGF_ERROR("MoveBoth: unexpected response: %s", res);
955 if (strstr(res, "Soft Limit"))
956 LOG_ERROR("Motion rejected: soft limit reached.");
957 else if (strstr(res, "Hard Limit"))
958 LOG_ERROR("Motion rejected: hard limit triggered.");
959 else if (strstr(res, "Not homed"))
960 LOG_ERROR("Motion rejected: device is not homed.");
961 else if (strstr(res, "System Locked"))
962 LOG_ERROR("Motion rejected: system is locked (error state).");
963 return IPS_ALERT;
964 }
965
966 m_IsMoving = true;
967 LOGF_INFO("MoveBoth: AZ %.4f° (D=%d M=%d S=%d %s), ALT %.4f° (D=%d M=%d S=%d %s) — device sequencing internally.",
968 azDegrees, azD, azM, azS, azPos ? "R" : "L",
969 altDegrees, altD, altM, altS, altPos ? "U" : "D");
970 return IPS_BUSY;
971}
972
977{
978 char res[DRIVER_LEN] = {0};
979 if (!sendCommand("STOP:1", res))
980 return false;
981
982 m_IsMoving = false;
983 m_IsHoming = false;
984 LOG_INFO("MLAstro RPA motion stopped.");
985 return true;
986}
987
991bool MLAstroRPA::SetPACSpeed(uint16_t speed)
992{
993 char cmd[64] = {0};
994 snprintf(cmd, sizeof(cmd), "SLvl:%u", speed);
995
996 char res[DRIVER_LEN] = {0};
997 if (!sendCommand(cmd, res))
998 return false;
999
1000 if (strncmp(res, "ok", 2) != 0)
1001 {
1002 LOGF_ERROR("SetPACSpeed: unexpected response: %s", res);
1003 return false;
1004 }
1005 LOGF_INFO("Speed set to level %u.", speed);
1006 return true;
1007}
1008
1012bool MLAstroRPA::ReverseAZ(bool enabled)
1013{
1014 char cmd[64] = {0};
1015 snprintf(cmd, sizeof(cmd), "AzRD:%d", enabled ? 1 : 0);
1016
1017 char res[DRIVER_LEN] = {0};
1018 if (!sendCommand(cmd, res))
1019 return false;
1020
1021 if (strncmp(res, "ok", 2) != 0)
1022 {
1023 LOGF_ERROR("ReverseAZ: unexpected response: %s", res);
1024 return false;
1025 }
1026 LOGF_INFO("AZ direction %s.", enabled ? "reversed" : "normal");
1027 return true;
1028}
1029
1033bool MLAstroRPA::ReverseALT(bool enabled)
1034{
1035 char cmd[64] = {0};
1036 snprintf(cmd, sizeof(cmd), "AlRD:%d", enabled ? 1 : 0);
1037
1038 char res[DRIVER_LEN] = {0};
1039 if (!sendCommand(cmd, res))
1040 return false;
1041
1042 if (strncmp(res, "ok", 2) != 0)
1043 {
1044 LOGF_ERROR("ReverseALT: unexpected response: %s", res);
1045 return false;
1046 }
1047 LOGF_INFO("ALT direction %s.", enabled ? "reversed" : "normal");
1048 return true;
1049}
1050
1055{
1056 char res[DRIVER_LEN] = {0};
1057 if (!sendCommand("SetH:1", res))
1058 return false;
1059
1060 if (strncmp(res, "ok", 2) != 0)
1061 {
1062 LOGF_ERROR("SetHome: unexpected response: %s", res);
1063 return false;
1064 }
1065 m_IsHomed = true;
1066 return true;
1067}
1068
1073{
1074 if (!m_IsHomed)
1075 {
1076 LOG_ERROR("No home position set. Please set home first.");
1077 return IPS_ALERT;
1078 }
1079
1080 char res[DRIVER_LEN] = {0};
1081 if (!sendCommand("RetH:1", res))
1082 return IPS_ALERT;
1083
1084 if (strncmp(res, "ok", 2) != 0)
1085 {
1086 LOGF_ERROR("GoHome: unexpected response: %s", res);
1087 if (strstr(res, "Not homed"))
1088 LOG_ERROR("Device reports: not homed.");
1089 return IPS_ALERT;
1090 }
1091
1092 m_IsHoming = true;
1093 m_IsMoving = true;
1094 return IPS_BUSY;
1095}
1096
1101{
1102 char res[DRIVER_LEN] = {0};
1103 if (!sendCommand("RstH:1", res))
1104 return false;
1105
1106 if (strncmp(res, "ok", 2) != 0)
1107 {
1108 LOGF_ERROR("ResetHome: unexpected response: %s", res);
1109 return false;
1110 }
1111 m_IsHomed = false;
1112 HomedLP[0].setState(IPS_IDLE);
1113 HomedLP.apply();
1114 return true;
1115}
1116
1122bool MLAstroRPA::SyncAZ(double degrees)
1123{
1124 INDI_UNUSED(degrees);
1125 // SetH:1 is the only "sync" mechanism available; it sets (0,0).
1126 char res[DRIVER_LEN] = {0};
1127 if (!sendCommand("SetH:1", res))
1128 return false;
1129
1130 if (strncmp(res, "ok", 2) != 0)
1131 {
1132 LOGF_ERROR("SyncAZ: unexpected response: %s", res);
1133 return false;
1134 }
1135 m_IsHomed = true;
1136 LOG_INFO("AZ synced: current position set as reference (0).");
1137 return true;
1138}
1139
1143bool MLAstroRPA::SyncALT(double degrees)
1144{
1145 INDI_UNUSED(degrees);
1146 char res[DRIVER_LEN] = {0};
1147 if (!sendCommand("SetH:1", res))
1148 return false;
1149
1150 if (strncmp(res, "ok", 2) != 0)
1151 {
1152 LOGF_ERROR("SyncALT: unexpected response: %s", res);
1153 return false;
1154 }
1155 m_IsHomed = true;
1156 LOG_INFO("ALT synced: current position set as reference (0).");
1157 return true;
1158}
1159
1164{
1165 char cmd[64] = {0};
1166 snprintf(cmd, sizeof(cmd), "Back:%d", enabled ? 1 : 0);
1167
1168 char res[DRIVER_LEN] = {0};
1169 if (!sendCommand(cmd, res))
1170 return false;
1171
1172 if (strncmp(res, "ok", 2) != 0)
1173 {
1174 LOGF_ERROR("SetBacklashEnabled: unexpected response: %s", res);
1175 return false;
1176 }
1177 LOGF_INFO("Backlash compensation %s.", enabled ? "enabled" : "disabled");
1178 return true;
1179}
1180
1184bool MLAstroRPA::SetBacklashAZ(int32_t steps)
1185{
1186 char cmd[64] = {0};
1187 snprintf(cmd, sizeof(cmd), "AzBl:%d", steps);
1188
1189 char res[DRIVER_LEN] = {0};
1190 if (!sendCommand(cmd, res))
1191 return false;
1192
1193 if (strncmp(res, "ok", 2) != 0)
1194 {
1195 LOGF_ERROR("SetBacklashAZ: unexpected response: %s", res);
1196 return false;
1197 }
1198 LOGF_INFO("AZ backlash set to %d steps.", steps);
1199 return true;
1200}
1201
1206{
1207 char cmd[64] = {0};
1208 snprintf(cmd, sizeof(cmd), "AlBl:%d", steps);
1209
1210 char res[DRIVER_LEN] = {0};
1211 if (!sendCommand(cmd, res))
1212 return false;
1213
1214 if (strncmp(res, "ok", 2) != 0)
1215 {
1216 LOGF_ERROR("SetBacklashALT: unexpected response: %s", res);
1217 return false;
1218 }
1219 LOGF_INFO("ALT backlash set to %d steps.", steps);
1220 return true;
1221}
1222
1226bool MLAstroRPA::ISNewNumber(const char *dev, const char *name, double values[], char *names[], int n)
1227{
1228 if (dev != nullptr && strcmp(dev, getDeviceName()) == 0)
1229 {
1230 // ── Soft limits
1231 if (AzSoftLimitsNP.isNameMatch(name))
1232 {
1233 AzSoftLimitsNP.update(values, names, n);
1234 char cmd[DRIVER_LEN] = {0};
1235 snprintf(cmd, DRIVER_LEN, "AzL1:%.2f,AzL2:%.2f",
1236 AzSoftLimitsNP[AZ_LIMIT_MIN].getValue(),
1237 AzSoftLimitsNP[AZ_LIMIT_MAX].getValue());
1238 char res[DRIVER_LEN] = {0};
1239 if (sendCommand(cmd, res) && strncmp(res, "ok", 2) == 0)
1240 {
1241 AzSoftLimitsNP.setState(IPS_OK);
1242 saveConfig(AzSoftLimitsNP);
1243 }
1244 else
1245 AzSoftLimitsNP.setState(IPS_ALERT);
1246 AzSoftLimitsNP.apply();
1247 return true;
1248 }
1249
1250 if (AltSoftLimitsNP.isNameMatch(name))
1251 {
1252 AltSoftLimitsNP.update(values, names, n);
1253 char cmd[DRIVER_LEN] = {0};
1254 snprintf(cmd, DRIVER_LEN, "AlL1:%.2f,AlL2:%.2f",
1255 AltSoftLimitsNP[ALT_LIMIT_MIN].getValue(),
1256 AltSoftLimitsNP[ALT_LIMIT_MAX].getValue());
1257 char res[DRIVER_LEN] = {0};
1258 if (sendCommand(cmd, res) && strncmp(res, "ok", 2) == 0)
1259 {
1260 AltSoftLimitsNP.setState(IPS_OK);
1261 saveConfig(AltSoftLimitsNP);
1262 }
1263 else
1264 AltSoftLimitsNP.setState(IPS_ALERT);
1265 AltSoftLimitsNP.apply();
1266 return true;
1267 }
1268
1269 // ── Altitude overshoot amount (OvD/OvM/OvS)
1270 if (AltOvershootAmountNP.isNameMatch(name))
1271 {
1272 AltOvershootAmountNP.update(values, names, n);
1273 int d, m, s;
1274 bool positive;
1275 degreesToDMS(AltOvershootAmountNP[0].getValue(), d, m, s, positive);
1276 char cmd[64] = {0};
1277 snprintf(cmd, sizeof(cmd), "OvD:%d,OvM:%d,OvS:%d", d, m, s);
1278 char res[DRIVER_LEN] = {0};
1279 if (sendCommand(cmd, res) && strncmp(res, "ok", 2) == 0)
1280 {
1281 AltOvershootAmountNP.setState(IPS_OK);
1282 saveConfig(AltOvershootAmountNP);
1283 }
1284 else
1285 AltOvershootAmountNP.setState(IPS_ALERT);
1286 AltOvershootAmountNP.apply();
1287 return true;
1288 }
1289
1290 // ── Motor config (AZ)
1291 if (AzMotorNP.isNameMatch(name))
1292 {
1293 AzMotorNP.update(values, names, n);
1294 char cmd[DRIVER_LEN] = {0};
1295 snprintf(cmd, DRIVER_LEN,
1296 "AzIR:%.0f,AzIH:%.0f,AzMS:%.0f,AzAc:%.0f,AzDec:%.0f,"
1297 "AzSD:%.0f,AzSB:%.0f,AzSC:%.0f,AzRM:%.0f",
1298 AzMotorNP[AZ_RUN_CURRENT].getValue(),
1299 AzMotorNP[AZ_HOLD_CURRENT].getValue(),
1300 AzMotorNP[AZ_MICROSTEPS].getValue(),
1301 AzMotorNP[AZ_ACCEL].getValue(),
1302 AzMotorNP[AZ_DECEL].getValue(),
1303 AzMotorNP[AZ_STEPS_PER_DEG].getValue(),
1304 AzMotorNP[AZ_START_BOOST].getValue(),
1305 AzMotorNP[AZ_COOL_STEP].getValue(),
1306 AzMotorNP[AZ_RUN_MODE].getValue());
1307 char res[DRIVER_LEN] = {0};
1308 if (sendCommand(cmd, res) && strncmp(res, "ok", 2) == 0)
1309 {
1310 AzMotorNP.setState(IPS_OK);
1311 saveConfig(AzMotorNP);
1312 }
1313 else
1314 AzMotorNP.setState(IPS_ALERT);
1315 AzMotorNP.apply();
1316 return true;
1317 }
1318
1319 // ── Motor config (ALT)
1320 if (AltMotorNP.isNameMatch(name))
1321 {
1322 AltMotorNP.update(values, names, n);
1323 char cmd[DRIVER_LEN] = {0};
1324 snprintf(cmd, DRIVER_LEN,
1325 "AlIR:%.0f,AlIH:%.0f,AlMS:%.0f,AlAc:%.0f,AlDe:%.0f,"
1326 "AlSD:%.0f,AlSB:%.0f,AlSC:%.0f,AlRM:%.0f",
1327 AltMotorNP[ALT_RUN_CURRENT].getValue(),
1328 AltMotorNP[ALT_HOLD_CURRENT].getValue(),
1329 AltMotorNP[ALT_MICROSTEPS].getValue(),
1330 AltMotorNP[ALT_ACCEL].getValue(),
1331 AltMotorNP[ALT_DECEL].getValue(),
1332 AltMotorNP[ALT_STEPS_PER_DEG].getValue(),
1333 AltMotorNP[ALT_START_BOOST].getValue(),
1334 AltMotorNP[ALT_COOL_STEP].getValue(),
1335 AltMotorNP[ALT_RUN_MODE].getValue());
1336 char res[DRIVER_LEN] = {0};
1337 if (sendCommand(cmd, res) && strncmp(res, "ok", 2) == 0)
1338 {
1339 AltMotorNP.setState(IPS_OK);
1340 saveConfig(AltMotorNP);
1341 }
1342 else
1343 AltMotorNP.setState(IPS_ALERT);
1344 AltMotorNP.apply();
1345 return true;
1346 }
1347
1348 // ── Driver-side correction percentage (local setting, no serial command)
1349 if (CorrectionPercentNP.isNameMatch(name))
1350 {
1351 CorrectionPercentNP.update(values, names, n);
1352 CorrectionPercentNP.setState(IPS_OK);
1353 CorrectionPercentNP.apply();
1354 saveConfig(CorrectionPercentNP);
1355 return true;
1356 }
1357
1358 // ── Driver-side correction overshoot amount (local setting, no serial command)
1359 if (CorrectionOvershootAmountNP.isNameMatch(name))
1360 {
1361 CorrectionOvershootAmountNP.update(values, names, n);
1362 CorrectionOvershootAmountNP.setState(IPS_OK);
1363 CorrectionOvershootAmountNP.apply();
1364 saveConfig(CorrectionOvershootAmountNP);
1365 return true;
1366 }
1367
1368 // Delegate to PACInterface for its number properties
1369 if (PACI::processNumber(dev, name, values, names, n))
1370 return true;
1371 }
1372
1373 return INDI::DefaultDevice::ISNewNumber(dev, name, values, names, n);
1374}
1375
1379bool MLAstroRPA::ISNewSwitch(const char *dev, const char *name, ISState *states, char *names[], int n)
1380{
1381 if (dev != nullptr && strcmp(dev, getDeviceName()) == 0)
1382 {
1383 // ── Altitude overshoot master enable
1384 if (AltOvershootSP.isNameMatch(name))
1385 {
1386 AltOvershootSP.update(states, names, n);
1387 bool enabled = AltOvershootSP[ALT_OVERSHOOT_ENABLED].getState() == ISS_ON;
1388 char cmd[64] = {0};
1389 snprintf(cmd, sizeof(cmd), "Over:%d", enabled ? 1 : 0);
1390 char res[DRIVER_LEN] = {0};
1391 if (sendCommand(cmd, res) && strncmp(res, "ok", 2) == 0)
1392 {
1393 AltOvershootSP.setState(IPS_OK);
1394 saveConfig(AltOvershootSP);
1395 }
1396 else
1397 AltOvershootSP.setState(IPS_ALERT);
1398 AltOvershootSP.apply();
1399 return true;
1400 }
1401
1402 // ── Altitude overshoot per-direction enable
1403 if (AltOvershootDirSP.isNameMatch(name))
1404 {
1405 AltOvershootDirSP.update(states, names, n);
1406 bool up = AltOvershootDirSP[ALT_OVERSHOOT_UP].getState() == ISS_ON;
1407 bool down = AltOvershootDirSP[ALT_OVERSHOOT_DOWN].getState() == ISS_ON;
1408 char cmd[64] = {0};
1409 snprintf(cmd, sizeof(cmd), "OvUp:%d,OvDn:%d", up ? 1 : 0, down ? 1 : 0);
1410 char res[DRIVER_LEN] = {0};
1411 if (sendCommand(cmd, res) && strncmp(res, "ok", 2) == 0)
1412 {
1413 AltOvershootDirSP.setState(IPS_OK);
1414 saveConfig(AltOvershootDirSP);
1415 }
1416 else
1417 AltOvershootDirSP.setState(IPS_ALERT);
1418 AltOvershootDirSP.apply();
1419 return true;
1420 }
1421
1422 // ── Driver-side correction overshoot master enable (local setting)
1423 if (CorrectionOvershootSP.isNameMatch(name))
1424 {
1425 CorrectionOvershootSP.update(states, names, n);
1426 CorrectionOvershootSP.setState(IPS_OK);
1427 CorrectionOvershootSP.apply();
1428 saveConfig(CorrectionOvershootSP);
1429 return true;
1430 }
1431
1432 // ── Driver-side correction overshoot per-direction enable (local setting)
1433 if (CorrectionOvershootDirSP.isNameMatch(name))
1434 {
1435 CorrectionOvershootDirSP.update(states, names, n);
1436 CorrectionOvershootDirSP.setState(IPS_OK);
1437 CorrectionOvershootDirSP.apply();
1438 saveConfig(CorrectionOvershootDirSP);
1439 return true;
1440 }
1441
1442 // ── Save & Reboot
1443 if (SaveRebootSP.isNameMatch(name))
1444 {
1445 SaveRebootSP.reset();
1446 char res[DRIVER_LEN] = {0};
1447 if (sendCommand("Save&Reboot:1", res) && strncmp(res, "ok", 2) == 0)
1448 {
1449 SaveRebootSP.setState(IPS_OK);
1450 LOG_INFO("Settings saved. Controller is rebooting…");
1451 }
1452 else
1453 {
1454 SaveRebootSP.setState(IPS_ALERT);
1455 LOG_ERROR("Save & Reboot command failed.");
1456 }
1457 SaveRebootSP.apply();
1458 return true;
1459 }
1460
1461 // Delegate to PACInterface for its switch properties
1462 if (PACI::processSwitch(dev, name, states, names, n))
1463 return true;
1464 }
1465
1466 return INDI::DefaultDevice::ISNewSwitch(dev, name, states, names, n);
1467}
1468
1472bool MLAstroRPA::ISNewText(const char *dev, const char *name, char *texts[], char *names[], int n)
1473{
1474 if (dev != nullptr && strcmp(dev, getDeviceName()) == 0)
1475 {
1476 // ── Station (client) WiFi SSID
1477 if (StationSSIDTP.isNameMatch(name))
1478 {
1479 StationSSIDTP.update(texts, names, n);
1480 char cmd[DRIVER_LEN] = {0};
1481 snprintf(cmd, DRIVER_LEN, "STAs:%s", StationSSIDTP[0].getText());
1482 char res[DRIVER_LEN] = {0};
1483 if (sendCommand(cmd, res) && strncmp(res, "ok", 2) == 0)
1484 {
1485 StationSSIDTP.setState(IPS_OK);
1486 LOG_INFO("Station SSID updated. Use Save & Reboot to persist.");
1487 }
1488 else
1489 StationSSIDTP.setState(IPS_ALERT);
1490 StationSSIDTP.apply();
1491 return true;
1492 }
1493
1494 // ── Station (client) WiFi password (write-only, never saved locally)
1495 if (StationPasswordTP.isNameMatch(name))
1496 {
1497 StationPasswordTP.update(texts, names, n);
1498 char cmd[DRIVER_LEN] = {0};
1499 snprintf(cmd, DRIVER_LEN, "STAp:%s", StationPasswordTP[0].getText());
1500 char res[DRIVER_LEN] = {0};
1501 if (sendCommand(cmd, res) && strncmp(res, "ok", 2) == 0)
1502 {
1503 StationPasswordTP.setState(IPS_OK);
1504 LOG_INFO("Station password updated. Use Save & Reboot to persist.");
1505 }
1506 else
1507 StationPasswordTP.setState(IPS_ALERT);
1508 StationPasswordTP.apply();
1509 return true;
1510 }
1511
1512 // ── Access Point SSID and IP
1513 if (APConfigTP.isNameMatch(name))
1514 {
1515 APConfigTP.update(texts, names, n);
1516 char cmd[DRIVER_LEN] = {0};
1517 snprintf(cmd, DRIVER_LEN, "APss:%s,APip:%s",
1518 APConfigTP[AP_SSID].getText(),
1519 APConfigTP[AP_IP].getText());
1520 char res[DRIVER_LEN] = {0};
1521 if (sendCommand(cmd, res) && strncmp(res, "ok", 2) == 0)
1522 {
1523 APConfigTP.setState(IPS_OK);
1524 LOG_INFO("Access Point settings updated. Use Save & Reboot to persist.");
1525 }
1526 else
1527 APConfigTP.setState(IPS_ALERT);
1528 APConfigTP.apply();
1529 return true;
1530 }
1531
1532 // ── Access Point password (write-only, never saved locally)
1533 if (APPasswordTP.isNameMatch(name))
1534 {
1535 APPasswordTP.update(texts, names, n);
1536 char cmd[DRIVER_LEN] = {0};
1537 snprintf(cmd, DRIVER_LEN, "APpa:%s", APPasswordTP[0].getText());
1538 char res[DRIVER_LEN] = {0};
1539 if (sendCommand(cmd, res) && strncmp(res, "ok", 2) == 0)
1540 {
1541 APPasswordTP.setState(IPS_OK);
1542 LOG_INFO("Access Point password updated. Use Save & Reboot to persist.");
1543 }
1544 else
1545 APPasswordTP.setState(IPS_ALERT);
1546 APPasswordTP.apply();
1547 return true;
1548 }
1549 }
1550
1551 return INDI::DefaultDevice::ISNewText(dev, name, texts, names, n);
1552}
1553
1557bool MLAstroRPA::sendCommand(const char *cmd, char *res)
1558{
1559 int nbytes_written = 0, nbytes_read = 0, rc = -1;
1560
1561 tcflush(PortFD, TCIOFLUSH);
1562
1563 // Append newline and send
1564 char formatted[DRIVER_LEN] = {0};
1565 snprintf(formatted, DRIVER_LEN, "%s\n", cmd);
1566
1567 LOGF_DEBUG("CMD <%s>", cmd);
1568
1569 rc = tty_write_string(PortFD, formatted, &nbytes_written);
1570 if (rc != TTY_OK)
1571 {
1572 char errstr[MAXRBUF] = {0};
1573 tty_error_msg(rc, errstr, MAXRBUF);
1574 LOGF_ERROR("Serial write error: %s.", errstr);
1575 return false;
1576 }
1577
1578 if (res == nullptr)
1579 return true;
1580
1581 rc = tty_nread_section(PortFD, res, DRIVER_LEN, DRIVER_STOP_CHAR, DRIVER_TIMEOUT, &nbytes_read);
1582 if (rc != TTY_OK)
1583 {
1584 char errstr[MAXRBUF] = {0};
1585 tty_error_msg(rc, errstr, MAXRBUF);
1586 LOGF_ERROR("Serial read error: %s.", errstr);
1587 return false;
1588 }
1589
1590 // Strip trailing CR/LF
1591 if (nbytes_read > 0 && (res[nbytes_read - 1] == '\n' || res[nbytes_read - 1] == '\r'))
1592 res[--nbytes_read] = '\0';
1593 if (nbytes_read > 0 && (res[nbytes_read - 1] == '\r'))
1594 res[--nbytes_read] = '\0';
1595
1596 LOGF_DEBUG("RES <%s>", res);
1597
1598 tcflush(PortFD, TCIOFLUSH);
1599 return true;
1600}
1601
1602void MLAstroRPA::hexDump(char *buf, const char *data, int size)
1603{
1604 for (int i = 0; i < size; i++)
1605 sprintf(buf + 3 * i, "%02X ", static_cast<uint8_t>(data[i]));
1606 if (size > 0)
1607 buf[3 * size - 1] = '\0';
1608}
void registerHandshake(std::function< bool()> callback)
registerHandshake Register a handshake function to be called once the initial connection to the devic...
The Serial class manages connection with serial devices including Bluetooth. Serial communication is ...
void setDefaultBaudRate(BaudRate newRate)
setDefaultBaudRate Set default baud rate. The default baud rate is 9600 unless otherwise changed by t...
bool isConnected() const
const char * getDeviceName() const
INDI::PropertyText getText(const char *name) const
virtual bool updateProperties()
updateProperties is called whenever there is a change in the CONNECTION status of the driver....
virtual bool saveConfig(bool silent=false, const char *property=nullptr)
Save the current properties in a configuration file.
virtual bool ISNewSwitch(const char *dev, const char *name, ISState *states, char *names[], int n)
Process the client newSwitch command.
void registerConnection(Connection::Interface *newConnection)
registerConnection Add new connection plugin to the existing connection pool. The connection type sha...
void setDriverInterface(uint32_t value)
setInterface Set driver interface. By default the driver interface is set to GENERAL_DEVICE....
void setVersion(uint16_t vMajor, uint16_t vMinor)
Set driver version information to be defined in DRIVER_INFO property as vMajor.vMinor.
virtual bool deleteProperty(const char *propertyName)
Delete a property and unregister it. It will also be deleted from all clients.
void defineProperty(INumberVectorProperty *property)
virtual bool saveConfigItems(FILE *fp)
saveConfigItems Save specific properties in the provide config file handler. Child class usually over...
uint32_t getCurrentPollingPeriod() const
getCurrentPollingPeriod Return the current polling period.
virtual bool initProperties()
Initialize properties initial state and value. The child class must implement this function.
void addAuxControls()
Add Debug, Simulation, and Configuration options to the driver.
virtual bool ISNewNumber(const char *dev, const char *name, double values[], char *names[], int n)
Process the client newNumber command.
int SetTimer(uint32_t ms)
Set a timer to call the function TimerHit after ms milliseconds.
virtual bool ISNewText(const char *dev, const char *name, char *texts[], char *names[], int n)
Process the client newSwitch command.
INDI::PropertySwitch BacklashSP
bool saveConfigItems(FILE *fp)
saveConfigItems Save PAC interface properties to config file. Call this from your driver's saveConfig...
bool processNumber(const char *dev, const char *name, double values[], char *names[], int n)
Process PAC number properties.
INDI::PropertyNumber SpeedNP
void initProperties(const char *group)
initProperties Initialize PAC properties. Call this from your driver's initProperties().
void SetCapability(uint32_t cap)
SetCapability sets the PAC device capabilities. Call this in the driver constructor.
INDI::PropertyNumber PositionNP
INDI::PropertyNumber ManualAdjustmentNP
bool processSwitch(const char *dev, const char *name, ISState *states, char *names[], int n)
Process PAC switch properties.
bool updateProperties()
updateProperties Define or delete properties based on connection state. Call this from your driver's ...
@ POSITION_ALT
Current altitude position in degrees.
@ POSITION_AZ
Current azimuth position in degrees.
INDI::PropertySwitch ALTReverseSP
INDI::PropertySwitch HomeSP
INDI::PropertySwitch AZReverseSP
INDI::PropertyNumber BacklashNP
void setState(IPState state)
void apply(const char *format,...) const ATTRIBUTE_FORMAT_PRINTF(2
void save(FILE *f) const
save Save property to configuration file.
bool isNameMatch(const char *otherName) const
void fill(const char *device, const char *name, const char *label, const char *group, IPState state)
bool update(const double values[], const char *const names[], int n)
void fill(const char *device, const char *name, const char *label, const char *group, IPerm permission, double timeout, IPState state)
bool update(const ISState states[], const char *const names[], int n)
void fill(const char *device, const char *name, const char *label, const char *group, IPerm permission, ISRule rule, double timeout, IPState state)
bool update(const char *const texts[], const char *const names[], int n)
void fill(const char *device, const char *name, const char *label, const char *group, IPerm permission, double timeout, IPState state)
The MLAstroRPA class drives the MLAstro Robotic Polar Alignment controller via its ESP32-based serial...
Definition mlastro_rpa.h:71
IPState GoHome() override
GoHome.
bool updateProperties() override
updateProperties
bool ReverseALT(bool enabled) override
ReverseALT.
bool SetBacklashALT(int32_t steps) override
SetBacklashALT.
bool AbortMotion() override
PACInterface – abort, speed and reverse.
bool initProperties() override
initProperties
void TimerHit() override
TimerHit.
IPState MoveAZ(double degrees) override
PACInterface – axis movement.
bool SyncALT(double degrees) override
SyncALT – same mechanism as SyncAZ.
IPState MoveBoth(double azDegrees, double altDegrees) override
bool saveConfigItems(FILE *fp) override
saveConfigItems
IPState MoveALT(double degrees) override
MoveALT.
bool SyncAZ(double degrees) override
PACInterface – sync.
bool SetHome() override
PACInterface – home management.
double applyCorrectionAZ(double azDegrees) const
Scale a requested azimuth step by the configured correction percentage.
MLAstroRPA()
Constructor.
double applyCorrectionALT(double altDegrees) const
applyCorrectionALT – correction percentage, or full error + overshoot
bool ReverseAZ(bool enabled) override
ReverseAZ.
bool ISNewText(const char *dev, const char *name, char *texts[], char *names[], int n) override
ISNewText.
bool Handshake()
Handshake.
bool ISNewSwitch(const char *dev, const char *name, ISState *states, char *names[], int n) override
ISNewSwitch.
const char * getDefaultName() override
Basic device info.
bool ResetHome() override
ResetHome.
bool SetBacklashAZ(int32_t steps) override
SetBacklashAZ.
bool ISNewNumber(const char *dev, const char *name, double values[], char *names[], int n) override
ISNewNumber.
bool SetBacklashEnabled(bool enabled) override
PACInterface – backlash.
bool SetPACSpeed(uint16_t speed) override
SetPACSpeed (SLvl: 1–5)
Provides interface to implement automated polar alignment correction (PAC).
const char * MAIN_CONTROL_TAB
MAIN_CONTROL_TAB Where all the primary controls for the device are located.
const char * OPTIONS_TAB
OPTIONS_TAB Where all the driver's options are located. Those may include auxiliary controls,...
std::string requested
Definition discovery.cpp:50
ISState
Switch state.
Definition indiapi.h:148
@ ISS_OFF
Definition indiapi.h:149
@ ISS_ON
Definition indiapi.h:150
@ IP_RW
Definition indiapi.h:184
@ IP_RO
Definition indiapi.h:182
@ IP_WO
Definition indiapi.h:183
IPState
Property state.
Definition indiapi.h:158
@ IPS_BUSY
Definition indiapi.h:161
@ IPS_ALERT
Definition indiapi.h:162
@ IPS_IDLE
Definition indiapi.h:159
@ IPS_OK
Definition indiapi.h:160
@ ISR_1OFMANY
Definition indiapi.h:171
@ ISR_NOFMANY
Definition indiapi.h:173
@ ISR_ATMOST1
Definition indiapi.h:172
int tty_write_string(int fd, const char *buf, int *nbytes_written)
Writes a null terminated string to fd.
Definition indicom.c:473
void tty_error_msg(int err_code, char *err_msg, int err_msg_len)
Retrieve the tty error message.
Definition indicom.c:1243
int tty_nread_section(int fd, char *buf, int nsize, char stop_char, int timeout, int *nbytes_read)
read buffer from terminal with a delimiter
Definition indicom.c:759
Implementations for common driver routines.
@ TTY_OK
Definition indicom.h:54
#define INDI_UNUSED(x)
Definition indidevapi.h:132
#define MAXRBUF
Definition indidriver.c:52
#define LOGF_INFO(fmt,...)
Definition indilogger.h:82
#define LOG_WARN(txt)
Definition indilogger.h:73
#define LOGF_DEBUG(fmt,...)
Definition indilogger.h:83
#define LOG_ERROR(txt)
Shorter logging macros. In order to use these macros, the function (or method) "getDeviceName()" must...
Definition indilogger.h:72
#define LOGF_ERROR(fmt,...)
Definition indilogger.h:80
#define LOG_INFO(txt)
Definition indilogger.h:74
uint8_t cmd[4]
Definition pwc-ioctl.h:2