Instrument Neutral Distributed Interface INDI 2.2.5
wheelly.cpp
Go to the documentation of this file.
1// SPDX-FileCopyrightText: 2026 Matteo Beretta
2// SPDX-License-Identifier: LGPL-2.1-or-later
3
4#include "wheelly.h"
5#include "wheelly_config.h"
6
8#include "indicom.h"
9#include "indilogger.h"
10
11#include <algorithm>
12#include <cerrno>
13#include <chrono>
14#include <cmath>
15#include <vector>
16#include <cstring>
17#include <ctime>
18#include <memory>
19#include <climits>
20#include <cstdlib>
21#include <dirent.h>
22#include <sys/select.h>
23#include <sys/stat.h>
24#include <unistd.h>
25
26using namespace wheelly;
27
28// The texts are English, as in every INDI driver, and written where they are
29// used; the tab names match the ones INDI creates by itself (Connection,
30// Options).
31static const char *TAB_MAIN = "Main Control";
32// The name an empty filter-name field becomes, followed by "_<slot>": the
33// reasons are where it is used, in ISNewText.
34static const char *EMPTY_SLOT_NAME = "Empty";
35// "Calibration and Diagnostics" and not "Calibration":
36// the tab holds the teaching and the checks - angles, jogs, tolerances,
37// hardware check, magnet sweep, movement log. The settings of the machine -
38// motor, holding current, LED - moved to Options. "and", not "&": a Qt tab
39// label takes "&" as a keyboard shortcut marker and would drop it.
40static const char *TAB_CALIBRATION = "Calibration and Diagnostics";
41
42// The jog rows: the steps, the element names and the
43// labels, in the same order. A step of 0 is the SLOT
44// PITCH, 360/slots, signed by its row: its value and its label follow the
45// number of slots (see label_jogs), so the element name says "pitch", not a
46// number. No 0.05 step (rejected): below the AS5600's count (0.088
47// degrees) a jog that did not move cannot be told from one that did.
48static const double JOG_STEPS[] = {0, -10, -1, -0.1, 0.1, 1, 10, 0};
49static const char *JOG_NAMES[] = {"JOG_M_PITCH", "JOG_M10", "JOG_M1", "JOG_M0_1",
50 "JOG_P0_1", "JOG_P1", "JOG_P10", "JOG_P_PITCH"
51 };
52static const char *JOG_LABELS[] = {"-%1$s°", "-10°", "-1°", "-0.1°",
53 "+0.1°", "+1°", "+10°", "+%1$s°"
54 };
55static const char *TAB_OPTIONS = "Options";
56
57static const int ANSWER_WAIT_MS = 3000;
58
59// ------------------------------------------------------------------ texts
60
61// A sentence with values in it: %1$s, %2$s ... are replaced by params, in any
62// order and as many times as the sentence needs, and %% is a percent sign.
63// Numbered placeholders and not printf's, because several sentences name the
64// same value twice or in a different order than the call gives them.
65static std::string fill_in(const char *format, const std::vector<std::string> &params)
66{
67 const std::string text = format;
68 std::string out;
69 out.reserve(text.size() + 64);
70 for (size_t i = 0; i < text.size(); i++)
71 {
72 if (text[i] != '%')
73 {
74 out += text[i];
75 continue;
76 }
77 if (i + 1 < text.size() && text[i + 1] == '%')
78 {
79 out += '%';
80 i++;
81 continue;
82 }
83 size_t j = i + 1;
84 size_t number = 0;
85 while (j < text.size() && text[j] >= '0' && text[j] <= '9')
86 {
87 number = number * 10 + (size_t)(text[j] - '0');
88 j++;
89 }
90 if (number >= 1 && j + 1 < text.size() && text[j] == '$' && text[j + 1] == 's')
91 {
92 if (number <= params.size()) out += params[number - 1];
93 i = j + 1;
94 }
95 else
96 {
97 out += text[i];
98 }
99 }
100 return out;
101}
102
103// Why the wheel refused a filter name, from the word the firmware sends with
104// ERR_BAD_FILTER_NAME (reason=, name_check_word in the firmware's dialog.cpp).
105static std::string name_refusal(const std::string &reason)
106{
107 if (reason == "empty") return "the name is empty";
108 if (reason == "too-long") return "too long: at most 32 characters";
109 if (reason == "bad-edge") return "it must start and end with a letter or a digit";
110 if (reason == "bad-char") return "there is a character that cannot be used";
111 if (reason == "reserved")
112 return "this is a reserved device name on Windows, and the filter name is also used as a folder name";
113 return reason;
114}
115
116// The sentence for an "err <code>" answer of the wheel.
117static std::string error_message(int code, const std::string &expected,
118 const std::string &got, const std::string &reason)
119{
120 switch (code)
121 {
123 return "The wheel did not understand the command.";
125 return "Wrong arguments for the command.";
126 case ERR_OUT_OF_RANGE:
127 return fill_in("Value out of range: expected %1$s, got %2$s.", {expected, got});
129 return "The position sensor is not answering. Check its wiring, and the VDD5V-VDD3V3 jumper on the module.";
130 case ERR_NO_MAGNET:
131 return "The sensor works but does not see the magnet.";
133 return "The motor driver is not answering: is the 12 V supply connected? The wheel does not move without it.";
134 case ERR_NOT_NOW:
135 return "Not allowed right now: the wheel is busy.";
136 case ERR_NVS_WRITE:
137 return "The wheel could not save to its memory.";
139 return fill_in("Invalid filter name (%1$s).", {name_refusal(reason)});
140 default:
141 return fill_in("The wheel reported error %1$s.", {std::to_string(code)});
142 }
143}
144
145// Why a filter name typed in the panel is refused, checked here before it is
146// sent. The generic reason - "only letters, digits, _ - and ." - forces
147// rereading one's own name character by character to find the crooked one.
148// Here the culprit is named: "there is a space" is understood in an instant.
149//
150// The accented letter has a sentence all of its own because it really cannot
151// be printed: in UTF-8 it is two bytes, and sending back only one of them
152// would not merely give a wrong character. Tried on purpose by removing this
153// line: the unpaired byte is not valid UTF-8, hence not valid XML, and the
154// INDI client dies with "not well-formed (invalid token)" - not on this
155// message, on the whole stream. A name with the wrong character would switch
156// the panel off.
157static std::string filter_name_reason(int outcome, const char *text)
158{
159 if (outcome == NAME_BAD_CHAR && text != nullptr)
160 {
161 for (const unsigned char *p = (const unsigned char *)text; *p != '\0'; p++)
162 {
163 if (name_is_allowed((char) * p)) continue;
164 if (*p == ' ') return "there is a space";
165 if (*p >= 0x80) return "there is an accented or non-English letter";
166 if (*p < 0x20 || *p == 0x7f) return "there is an invisible character in it";
167 const char one[2] = {(char)*p, '\0'};
168 return fill_in("\"%1$s\" cannot be used", {one});
169 }
170 }
171 switch (outcome)
172 {
173 case NAME_EMPTY:
174 return name_refusal("empty");
175 case NAME_TOO_LONG:
176 return name_refusal("too-long");
177 case NAME_BAD_EDGE:
178 return name_refusal("bad-edge");
179 case NAME_BAD_CHAR:
180 return name_refusal("bad-char");
181 case NAME_RESERVED:
182 return name_refusal("reserved");
183 default:
184 return std::string();
185 }
186}
187// The pause between two attempts to find a lost wheel again: from the first,
188// doubling up to the last. A second is about the time a XIAO takes to boot
189// and enumerate; ten keeps a wheel left unplugged from costing anything.
190static const double RETRY_FIRST_S = 1.0;
191static const double RETRY_MAX_S = 10.0;
192
193// The driver's ceiling on a move. Better that we say it, with a message that
194// explains: if Ekos says it, all that is left in the log is "timeout". It
195// follows the WHEEL's cap, which is derived from speed and
196// direction (one way round the longest path is nearly two turns, not half of
197// one): the wheel's number plus this margin, for the serial line and the
198// polling period. 28 s stays only for a firmware that does not tell its cap.
199static const double MOVE_CEILING_S = 28.0;
200static const double MOVE_CEILING_MARGIN_S = 3.0;
201
202// Every query during the sweep is a sample of the magnet.
203static const int SAMPLE_INTERVAL_MS = 100;
204
205static std::unique_ptr<Wheelly> device(new Wheelly());
206
207static double now_s()
208{
209 using namespace std::chrono;
210 return duration_cast<duration<double>>(steady_clock::now().time_since_epoch()).count();
211}
212
213// ------------------------------------------------------------------ LineReader
214
215void LineReader::attach(int descriptor)
216{
217 m_fd = descriptor;
218 m_rest.clear();
219 m_down = false;
220 m_errno = 0;
221}
222
224{
225 m_rest.clear();
226 if (m_fd < 0) return;
227 // Whatever was left on the channel from before is thrown away: at opening
228 // there can be anything, including the leftovers of a previous session.
229 std::string rubbish;
230 while (line(rubbish, 50)) { }
231}
232
233bool LineReader::line(std::string &out, int wait_ms)
234{
235 if (m_fd < 0) return false;
236
237 const double deadline = now_s() + wait_ms / 1000.0;
238 for (;;)
239 {
240 const size_t end = m_rest.find('\n');
241 if (end != std::string::npos)
242 {
243 out = m_rest.substr(0, end);
244 m_rest.erase(0, end + 1);
245 while (!out.empty() && (out.back() == '\r' || out.back() == ' '))
246 out.pop_back();
247 return true;
248 }
249
250 const double left = deadline - now_s();
251 if (left <= 0) return false;
252
253 fd_set set;
254 FD_ZERO(&set);
255 FD_SET(m_fd, &set);
256 struct timeval how_long;
257 how_long.tv_sec = (time_t)left;
258 how_long.tv_usec = (suseconds_t)((left - (double)how_long.tv_sec) * 1e6);
259
260 const int ready = select(m_fd + 1, &set, nullptr, nullptr, &how_long);
261 if (ready < 0)
262 {
263 if (errno == EINTR) continue;
264 m_down = true;
265 m_errno = errno;
266 return false;
267 }
268 if (ready == 0) return false;
269
270 char chunk[512];
271 const ssize_t got = read(m_fd, chunk, sizeof(chunk));
272 if (got <= 0)
273 {
274 if (got < 0 && (errno == EAGAIN || errno == EINTR)) continue;
275 m_down = true;
276 m_errno = got < 0 ? errno : 0;
277 return false;
278 }
279 m_rest.append(chunk, (size_t)got);
280 // An absurdly long line means that on the other side there is not
281 // what we think: better to truncate than to grow without end.
282 if (m_rest.size() > 8 * WHEELLY_LINE_MAX) m_rest.clear();
283 }
284}
285
286// --------------------------------------------------------------------- Wheelly
287
289{
290 setVersion(WHEELLY_VERSION_MAJOR, WHEELLY_VERSION_MINOR);
292}
293
295{
296 // The name comes from wheelly_config.h, that is from a CMake variable: it is a
297 // trademark, and the policy in TRADEMARK.md asks that a modified
298 // derivative is not called that. For that rule to be enforceable and not
299 // only written, the name must live in one place only.
300 return WHEELLY_DEVICE_NAME;
301}
302
304{
306
307 if (serialConnection != nullptr)
308 {
310 // The ESP32-S3 presents itself with Espressif's code: this way the
311 // port is found by INDI and not by the user, in the dark, trying them all.
312 // "espressif": on Linux the ports under /dev/serial/by-id/ carry the
313 // vendor name, not its id 303a (as seen on AstroArch)
314 serialConnection->setPortMatchPattern("303a|espressif|wheelly|usbmodem");
315 }
316
317 // The two properties of the base class sit in the main panel, together with the rest of the everyday
318 // commands.
319 FilterSlotNP.setLabel("Filter slot");
320 FilterSlotNP.setGroupName(TAB_MAIN);
321
322 // --- main panel: where the wheel is, and how the sensor is doing ------
323 PositionNP[0].fill("ANGLE", "Angle", "%.2f", 0, 360, 0, 0);
324 PositionNP[1].fill("ERROR", "Residual error", "%.2f", -180, 180, 0, 0);
325 PositionNP[2].fill("RETRIES", "Retries", "%.0f", 0, 99, 0, 0);
326 PositionNP.fill(getDeviceName(), "WHEELLY_POSITION", "Where the wheel is",
327 TAB_MAIN, IP_RO, 60, IPS_IDLE);
328
329 SensorNP[0].fill("AGC", "Gain", "%.0f", 0, 255, 0, 0);
330 SensorNP[1].fill("MAGNITUDE", "Magnitude", "%.0f", 0, 4095, 0, 0);
331 SensorNP[2].fill("MD", "Magnet detected", "%.0f", 0, 1, 0, 0);
332 SensorNP[3].fill("ML", "Field too weak", "%.0f", 0, 1, 0, 0);
333 SensorNP[4].fill("MH", "Field too strong", "%.0f", 0, 1, 0, 0);
334 SensorNP.fill(getDeviceName(), "WHEELLY_SENSOR", "Magnetic sensor",
335 TAB_MAIN, IP_RO, 60, IPS_IDLE);
336
337 // --- calibration panel ------------------------------------------------
338 // Every possible element is prepared: how many are really needed the
339 // wheel will say at connection, and the others go away.
340 // One property per slot, one element each (see AngleNP in wheelly.h).
341 AngleNP.reserve(MAX_SLOTS);
342 for (int i = 0; i < MAX_SLOTS; i++)
343 {
344 // 32 and not 16: with 16 gcc warns that the name MIGHT overflow.
345 // It cannot - i goes from 0 to MAX_SLOTS - but the compiler cannot
346 // narrow the integer type, and a warning one already knows to ignore
347 // is a warning that one day hides a real one.
348 char name[32];
349 snprintf(name, sizeof(name), "WHEELLY_ANGLE_%d", i + 1);
350 AngleNP.emplace_back(1);
351 // The calibration angles, one row - one property - per slot, each with
352 // its Set; the row's label is the slot number, "▶ n" on the slot the
353 // wheel stands on. There is no rotation trim under them any more
354 // (firmware.md 2.2, "One number per slot"). The element's label says what the number is.
355 AngleNP[i][0].fill("ANGLE", "Calibration angle (°)", "%.2f", 0, ANGLE_MAX_DEG, 0.01, 0);
356 // read-write: KStars shows an edit field and a Set on every row
357 AngleNP[i].fill(getDeviceName(), name, std::to_string(i + 1).c_str(),
358 TAB_CALIBRATION, IP_RW, 60, IPS_IDLE);
359 }
360 SlotsNP[0].fill("COUNT", "Slots", "%.0f", wheelly::MIN_SLOTS, wheelly::MAX_SLOTS, 1,
362 SlotsNP.fill(getDeviceName(), "WHEELLY_SLOTS", "Number of slots",
363 TAB_CALIBRATION, IP_RW, 60, IPS_IDLE);
364 ActionsSP[0].fill("SAVE", "Save to the wheel", ISS_OFF);
365 ActionsSP.fill(getDeviceName(), "WHEELLY_SAVE", "Calibration actions",
366 TAB_CALIBRATION, IP_RW, ISR_ATMOST1, 60, IPS_IDLE);
367 WheelConfigSP[0].fill("SAVE", "Save to the wheel", ISS_OFF);
368 // The same "Save to the wheel", in Options right under INDI's
369 // "Configuration": the settings of Options - motor,
370 // holding, direction, LED - are saved from there, without going to the
371 // calibration tab. A property of its own: CONFIG_PROCESS is INDI's.
372 WheelConfigSP.fill(getDeviceName(), "WHEELLY_CONFIG", "Wheel configuration",
373 TAB_OPTIONS, IP_RW, ISR_ATMOST1, 60, IPS_IDLE);
374
375 // The jog buttons: the calibration is done here - jog until the filter
376 // is centred, then Set on its row, which has followed the jogs. The
377 // element names carry the steps, so a script can read what each does.
378 // Four to a row: KStars turns five or more into a drop-down menu.
379 for (int i = 0; i < JOG_PER_ROW; i++)
380 {
381 JogDownSP[i].fill(JOG_NAMES[i], JOG_LABELS[i], ISS_OFF);
382 JogUpSP[i].fill(JOG_NAMES[JOG_PER_ROW + i], JOG_LABELS[JOG_PER_ROW + i], ISS_OFF);
383 }
384 label_jogs();
385 // The jog rows.
386 // Two rows, not one: KStars draws more than four exclusive switches as a
387 // drop-down menu. There is no third row ("Save position", rejected): the
388 // Set on the current slot's row does the same.
389 JogDownSP.fill(getDeviceName(), "WHEELLY_JOG_DOWN", "Step back",
390 TAB_CALIBRATION, IP_RW, ISR_ATMOST1, 60, IPS_IDLE);
391 JogUpSP.fill(getDeviceName(), "WHEELLY_JOG_UP", "Step forward",
392 TAB_CALIBRATION, IP_RW, ISR_ATMOST1, 60, IPS_IDLE);
393
394 // limits and factory values from the shared header, the same the wheel
395 // uses (factory values there: 0.30 / 0.80)
396 ToleranceNP[0].fill("GOOD", "Good (deg)", "%.2f", 0.01, TOLERANCE_MAX_DEG, 0.01, FACTORY_GOOD_DEG);
397 ToleranceNP[1].fill("WARN", "Alert (deg)", "%.2f", 0.01, TOLERANCE_MAX_DEG, 0.01, FACTORY_WARN_DEG);
398 ToleranceNP[2].fill("RETRIES", "Max retries", "%.0f", 0, RETRIES_MAX, 1, 3);
399 ToleranceNP.fill(getDeviceName(), "WHEELLY_TOLERANCE", "Tolerances",
400 TAB_CALIBRATION, IP_RW, 60, IPS_IDLE);
401
402 // the limits are the wheel's own, from the shared header: numbers
403 // written here would drift from the ones the wheel checks
404 MotorNP[0].fill("RUN_MA", "Run current (mA)", "%.0f", 1, MOTOR_MA_MAX, 5, FACTORY_RUN_MA);
405 MotorNP[1].fill("SPEED", "Speed", "%.0f", 1, MOTOR_SPEED_MAX, 10, FACTORY_SPEED);
406 MotorNP[2].fill("ACCEL", "Acceleration", "%.0f", 1, MOTOR_ACCEL_MAX, 50, FACTORY_ACCEL);
407 MotorNP.fill(getDeviceName(), "WHEELLY_MOTOR", "Motor",
408 TAB_OPTIONS, IP_RW, 60, IPS_IDLE);
409
410 // The field shows the wheel's REAL value, 0 included, read back at
411 // connection. Rejected: starting from the recommended 150 mA and not
412 // writing a 0 read from the wheel, leaving the "off" to the grey light
413 // alone - a user who saved 0 then sees 150 and believes the wheel has
414 // gone back to 150. A field that shows a number the wheel does not have
415 // is a lie, however good the intention. The recommendation lives where it
416 // is needed - in the drift message (msg.drift.suggest), said when the
417 // wheel moves on its own at rest - and in the panel guide.
418 HoldNP[0].fill("HOLD_MA", "Current (mA, 0 = released)", "%.0f", 0, 800, 5, 0);
419 // How long the motor stays energised, at the run current, after every
420 // leg - even with the current at rest at 0 - before the wheel reads its
421 // verdict and lets go: the stopped motor brakes the disc
422 // through the tyre. Starts at the factory value, which is also what a
423 // firmware without the setting does; the wheel's own is read at connection.
424 HoldNP[1].fill("SETTLE_MS", "Hold after arrival (ms)", "%.0f", 0, SETTLE_MS_MAX, 50,
426 HoldNP.fill(getDeviceName(), "WHEELLY_HOLD", "Holding current at rest",
427 TAB_OPTIONS, IP_RW, 60, IPS_IDLE);
428
429 // Starts on the factory direction (FACTORY_DIRECTION in the shared
430 // header: the shortest way); Idle until
431 // the wheel's own value is read at connection.
432 DirectionSP[0].fill("DIR_SHORTEST", "Shortest way",
433 strcmp(FACTORY_DIRECTION, ARG_SHORTEST) == 0 ? ISS_ON : ISS_OFF);
434 DirectionSP[1].fill("DIR_UP", "Increasing angles only",
435 strcmp(FACTORY_DIRECTION, ARG_UP) == 0 ? ISS_ON : ISS_OFF);
436 DirectionSP[2].fill("DIR_DOWN", "Decreasing angles only",
437 strcmp(FACTORY_DIRECTION, ARG_DOWN) == 0 ? ISS_ON : ISS_OFF);
438 // Which way the wheel may turn. "Angles" and not "clockwise":
439 // which way is clockwise depends on the side the wheel is looked from,
440 // the angle the sensor reads does not.
441 DirectionSP.fill(getDeviceName(), "WHEELLY_DIRECTION", "Direction of travel",
442 TAB_OPTIONS, IP_RW, ISR_1OFMANY, 60, IPS_IDLE);
443
444 // Four choices in the property that was already there, not a new one:
445 // the panel stays lean. "Pulse" is the default: off
446 // at rest, breathing while the wheel goes to another slot; "Off" is off
447 // for good, pulse included, for whoever wants no light near the optics.
448 // The element names are identifiers: LED_ON and LED_OFF keep the names
449 // they had, so a saved Ekos configuration still means the same thing.
450 LedSP[0].fill("LED_ON", "Steady on", ISS_OFF);
451 LedSP[1].fill("LED_PULSE", "Pulses while moving", ISS_ON);
452 LedSP[2].fill("LED_OFF", "Off", ISS_OFF);
453 LedSP[3].fill("LED_TEST", "Test (blinks WHEELLY)", ISS_OFF);
454 LedSP.fill(getDeviceName(), "WHEELLY_LED", "LED",
455 TAB_OPTIONS, IP_RW, ISR_ATMOST1, 60, IPS_IDLE);
456
457 DiagSP[0].fill("RUN", "Ask the wheel", ISS_OFF);
458 DiagSP.fill(getDeviceName(), "WHEELLY_DIAG", "Hardware check",
459 TAB_CALIBRATION, IP_RW, ISR_ATMOST1, 60, IPS_IDLE);
460
461 LogSP[0].fill("LOG_ON", "On", ISS_OFF);
462 LogSP[1].fill("LOG_OFF", "Off", ISS_ON);
463 LogSP.fill(getDeviceName(), "WHEELLY_LOG", "Movement log",
464 TAB_CALIBRATION, IP_RW, ISR_1OFMANY, 60, IPS_IDLE);
465
466 SweepSP[0].fill("RUN", "Turn all the way round", ISS_OFF);
467 SweepSP.fill(getDeviceName(), "WHEELLY_SWEEP", "Magnet sweep",
468 TAB_CALIBRATION, IP_RW, ISR_ATMOST1, 60, IPS_IDLE);
469
470 FileTP[FILE_LOG].fill("PATH", "Movement log", log_path().c_str());
471 FileTP[FILE_SWEEP].fill("SWEEP", "Last sweep", "not written yet");
472 FileTP.fill(getDeviceName(), "WHEELLY_FILES", "Files on disk",
473 TAB_CALIBRATION, IP_RO, 60, IPS_IDLE);
474
475 SweepDirTP[0].fill("DIR", "Folder", default_folder().c_str());
476 SweepDirTP.fill(getDeviceName(), "WHEELLY_SWEEP_DIR", "Sweeps folder",
477 TAB_CALIBRATION, IP_RW, 60, IPS_IDLE);
478 SweepDirTP.load();
479
480 // --- options -----------------------------------------------------------
481 FirmwareTP[0].fill("FW", "Version", "");
482 FirmwareTP[1].fill("PROTO", "Protocol", "");
483 FirmwareTP[2].fill("SERIAL", "Serial number", "");
484 FirmwareTP.fill(getDeviceName(), "WHEELLY_FIRMWARE", "Firmware",
485 TAB_OPTIONS, IP_RO, 60, IPS_IDLE);
486
487 ToleranceNP.load();
488 LogSP.load();
489 // The serial number of this profile's wheel, if one is already saved.
490 FirmwareTP.load();
491 if (FirmwareTP[2].getText() != nullptr)
492 m_expected_serial = FirmwareTP[2].getText();
493
494 // The dialogue with the XIAO can be seen by turning on *Driver Debug* in
495 // the Options tab: every line exchanged, in both directions, without
496 // recompiling anything. It is the first thing to turn on when the wheel
497 // does something unexpected.
498 //
499 // Rejected: a level of its own, with
500 // addDebugLevel("Protocol Verbose", "PROTO"), a road that looks
501 // right and does not work: in libindi 2.2 that call writes into the slot
502 // DBG_EXTRA_2, but the DEBUG_LEVEL property publishes only five of them and
503 // stops at DBG_EXTRA_1, which stays labelled "Alignment Subsystem". The
504 // level existed, the switch to turn it on did not - that is, the feature
505 // meant to explain trouble was unreachable exactly when it was needed. Verified with a probe that prints the table of levels.
506 // addAuxControls() by hand, to put "Wheel configuration" right under
507 // INDI's "Configuration": a client lays out a
508 // group in the order the properties are defined, and the device defines
509 // them in the order they were registered. It is always there, like
510 // "Configuration": pressed with the wheel disconnected, it says so.
514 defineProperty(WheelConfigSP);
517 return true;
518}
519
521{
522 if (isConnected())
523 {
524 // The surplus elements are removed BEFORE the properties are defined,
525 // otherwise the client finds itself with twelve angles on a five-slot wheel.
526 size_slots();
527 label_jogs();
528 SlotsNP[0].setValue(m_slots);
529 FilterSlotNP[0].setMin(1);
530 FilterSlotNP[0].setMax(m_slots);
531 FilterSlotNP[0].setStep(1);
533 }
534
535 // THE NAMES ARE THE WHEEL'S, ALWAYS. libindi loads
536 // FILTER_NAME from the configuration when the driver starts, and asks
537 // GetFilterNames() only when it has none: left to itself, from the second
538 // start the panel shows the names saved on the PC - stale, if the wheel
539 // was renamed elsewhere or reflashed - and m_names stays empty, so the
540 // first Set writes every name again. Asked here, before the base class defines the property.
541 if (isConnected())
543
545
546 if (isConnected())
547 {
548 defineProperty(FirmwareTP);
549 // THE SAVING GOES HERE AND NOT IN THE HANDSHAKE. Inside Handshake() the
550 // property is not DEFINED yet - this line defines it, and it runs only
551 // on a successful connection - and saveConfig() on a property the
552 // device has not registered yet writes nothing and does not complain.
553 // Saved in the handshake, the message "from now on I look for this
554 // serial number" comes out in the log, and the configuration keeps the
555 // previous serial number.
556 if (m_serial_to_save)
557 {
558 m_serial_to_save = false;
559 LOGF_INFO("%s", fill_in("From now on this profile looks for the wheel with serial %1$s.", {m_expected_serial}).c_str());
560 saveConfig(true, FirmwareTP.getName());
561 }
562 defineProperty(PositionNP);
563 defineProperty(SensorNP);
564 defineProperty(SlotsNP);
565 define_angles();
566 defineProperty(JogDownSP);
567 defineProperty(JogUpSP);
568 defineProperty(ActionsSP);
569 defineProperty(ToleranceNP);
570 defineProperty(MotorNP);
571 defineProperty(HoldNP);
572 defineProperty(DirectionSP);
573 defineProperty(LedSP);
574 defineProperty(DiagSP);
575 defineProperty(SweepSP);
576 defineProperty(SweepDirTP);
577 defineProperty(LogSP);
578 defineProperty(FileTP);
579
580 read_calibration();
581 if (LogSP[0].getState() == ISS_ON) open_log();
583 }
584 else
585 {
586 deleteProperty(FirmwareTP);
587 deleteProperty(PositionNP);
588 deleteProperty(SensorNP);
589 deleteProperty(SlotsNP);
590 delete_angles();
591 deleteProperty(JogDownSP);
592 deleteProperty(JogUpSP);
593 deleteProperty(ActionsSP);
594 deleteProperty(ToleranceNP);
595 deleteProperty(MotorNP);
596 deleteProperty(HoldNP);
597 deleteProperty(DirectionSP);
598 deleteProperty(LedSP);
599 deleteProperty(DiagSP);
600 deleteProperty(SweepSP);
601 deleteProperty(SweepDirTP);
602 deleteProperty(LogSP);
603 deleteProperty(FileTP);
604 close_log();
605 }
606 return true;
607}
608
609// ------------------------------------------------------------------ dialogue
610
611Wheelly::Fields Wheelly::split_fields(const std::string &line)
612{
613 Fields fields;
614 size_t i = 0;
615 // the first word is skipped: it is the outcome or the name of the event
616 while (i < line.size() && line[i] != ' ') i++;
617 while (i < line.size())
618 {
619 while (i < line.size() && line[i] == ' ') i++;
620 const size_t start = i;
621 while (i < line.size() && line[i] != ' ') i++;
622 const std::string piece = line.substr(start, i - start);
623 const size_t equals = piece.find('=');
624 if (equals != std::string::npos && equals > 0)
625 fields[piece.substr(0, equals)] = piece.substr(equals + 1);
626 }
627 return fields;
628}
629
630double Wheelly::number(const Fields &fields, const char *key, double if_missing)
631{
632 const auto found = fields.find(key);
633 if (found == fields.end()) return if_missing;
634 try
635 {
636 return std::stod(found->second);
637 }
638 catch (...)
639 {
640 return if_missing;
641 }
642}
643
644void Wheelly::handle_unsolicited_line(const std::string &line)
645{
646 if (line.empty()) return;
647
648 if (line[0] == PREFIX_COMMENT)
649 {
650 LOGF_DEBUG("<- %s", line.c_str());
651 if (m_show_comments) LOGF_INFO("%s", line.c_str() + 1);
652 return;
653 }
654 if (line[0] != PREFIX_EVENT) return;
655
656 LOGF_DEBUG("<- %s", line.c_str());
657
658 // The events are there to make the driver react quickly, not to inform:
659 // everything they say is found again in 'status', which is what the driver
660 // decides on. The only one handled apart is the lost magnet, because it is
661 // serious trouble and has to be said when it happens, not at the next question.
662 size_t i = 1;
663 while (i < line.size() && line[i] == ' ') i++;
664 const size_t end = line.find(' ', i);
665 const std::string name = line.substr(i, end == std::string::npos ? std::string::npos : end - i);
666
667 // The motor driver had to be set up again (EV_DRIVER in the protocol):
668 // after a power-up in the guide's order - USB, then 12 V - it is the
669 // normal case and only information; a reset with the wheel running means
670 // the 12 V dropped, and is a warning.
671 if (name == EV_DRIVER)
672 {
673 const Fields c = split_fields(line);
674 const auto reason = c.find(F_REASON);
675 if (reason != c.end()
676 && reason->second == V_RESET)
677 LOG_WARN("The motor driver had lost its settings - the 12 V dropped and came back - and has been set up again before moving.");
678 // THE MOTOR DRIVER SET UP AGAIN by the firmware (the `! driver` event):
679 // its settings live on the 12 V, not on the USB.
680 else LOG_INFO("The motor driver got its 12 V after the wheel had started, and has been set up now.");
681 }
682
683 if (name == EV_SENSOR && !m_magnet_lost)
684 {
685 m_magnet_lost = true;
686 LOG_ERROR("The sensor no longer detects the magnet. This is serious: check the sensor wiring before imaging further.");
687 }
688
689 // The wheel moved on its own, at rest. The firmware says so once per
690 // episode, so there is no need to limit the frequency here: what happened
691 // is said and THE REMEDY IS SUGGESTED, which is the reason the event
692 // exists: holding at rest is exactly the thing that is missing. This is
693 // the ONLY advice about holding: the detent is always removed, and a "no
694 // detent and no holding current" warning would sound at every connection.
695 //
696 // The suggestion changes if holding is ALREADY on: telling "turn it on" to
697 // someone who has already turned it on is advice that makes one lose trust
698 // in all the others.
699 if (name == EV_DRIFT)
700 {
701 const Fields c = split_fields(line);
702 char deviation[16];
703 snprintf(deviation, sizeof(deviation), "%.2f", std::fabs(number(c, F_ERR, 0)));
704 const bool holding_on = HoldNP.getState() == IPS_OK
705 && HoldNP[0].getValue() > 0;
706 if (holding_on)
707 {
708 char how_much[16];
709 snprintf(how_much, sizeof(how_much), "%.0f", HoldNP[0].getValue());
710 LOGF_WARN("%s",
711 fill_in("The wheel moved %1$s degrees on its own while at rest, past the alarm tolerance, and the holding current is already on at %2$s mA. Raising it may help, but look at the mechanics too: a clutch that lets go at rest is a matter of spring preload.", {deviation, how_much}).c_str());
712 }
713 else
714 {
715 // The single advice about holding: the detent is always
716 // removed, so there is no "if yours has one" left to ask - a wheel that
717 // drifts with the motor released needs the holding current, full stop.
718 LOGF_WARN("%s",
719 fill_in("The wheel moved %1$s degrees on its own while at rest, past the alarm tolerance, and the motor is released at rest, so nothing holds it: turn on the holding current at rest - %2$s mA is the value this project suggests.",
720 {deviation, std::to_string(RECOMMENDED_HOLD_MA)}).c_str());
721 }
722 }
723}
724
725void Wheelly::collect_unsolicited()
726{
727 std::string line;
728 while (m_reader.line(line, 0)) handle_unsolicited_line(line);
729}
730
731bool Wheelly::command(const std::string &text, Fields *fields,
732 std::string *error_for_user)
733{
734 // The link is down and TimerHit is looking for the wheel: said once per
735 // command the user gives, never per poll (TimerHit does not poll then).
736 if (m_link_lost && !m_reconnecting)
737 {
738 LOG_WARN("The wheel is not reachable right now: waiting for its USB link to come back.");
739 return false;
740 }
741 if (PortFD < 0) return false;
742
743 LOGF_DEBUG("-> %s", text.c_str());
744
745 const std::string to_send = text + "\n";
746 int written = 0;
747 const int result = tty_write_string(PortFD, to_send.c_str(), &written);
748 if (result != TTY_OK)
749 {
750 // errno first: anything called after the failed write may change it
751 const int error_number = errno;
752 if (!m_reconnecting && link_is_gone(error_number))
753 {
754 link_lost(strerror(error_number));
755 return false;
756 }
757 char explanation[MAXRBUF];
758 tty_error_msg(result, explanation, MAXRBUF);
759 if (!m_reconnecting) LOGF_ERROR("%s (%s)", "The wheel is not answering.", explanation);
760 return false;
761 }
762
763 // Lines are read until one arrives that does not start with '#' or '!':
764 // those are informative and do not close anything.
765 std::string line;
766 while (m_reader.line(line, ANSWER_WAIT_MS))
767 {
768 if (line.empty()) continue;
769 if (line[0] == PREFIX_COMMENT || line[0] == PREFIX_EVENT)
770 {
771 handle_unsolicited_line(line);
772 continue;
773 }
774 LOGF_DEBUG("<- %s", line.c_str());
775
776 if (line.compare(0, strlen(PREFIX_OK), PREFIX_OK) == 0)
777 {
778 if (fields != nullptr) *fields = split_fields(line);
779 return true;
780 }
781 if (line.compare(0, strlen(PREFIX_ERROR), PREFIX_ERROR) == 0)
782 {
783 const Fields c = split_fields(line);
784 int code = 0;
785 sscanf(line.c_str(), "%*s %d", &code);
786 const auto take = [&c](const char *k)
787 {
788 const auto t = c.find(k);
789 return t == c.end() ? std::string() : t->second;
790 };
791 const std::string sentence = error_message(
792 code, take(F_EXPECTED), take(F_GOT), take(F_REASON));
793 if (error_for_user != nullptr) *error_for_user = sentence;
794 else LOGF_ERROR("%s", sentence.c_str());
795 if (fields != nullptr) *fields = c;
796 return false;
797 }
798 // a line that is neither an outcome nor informative: noise on the channel
799 LOGF_DEBUG("Unexpected line from the wheel: %s", line.c_str());
800 }
801
802 if (m_reconnecting) return false; // try_reconnect() says what counts
803 if (m_reader.channel_down() && link_is_gone(m_reader.down_errno()))
804 {
805 link_lost(m_reader.down_errno() ? strerror(m_reader.down_errno())
806 : "the port was closed");
807 return false;
808 }
809 LOG_ERROR("The wheel is not answering.");
810 return false;
811}
812
813// ------------------------------------------------------------------ connection
814
816{
817 // WHEELLY IS RECOGNISED BY ITS PROTOCOL, NOT BY THE NAME OF THE PORT. The
818 // name is not an identity: on macOS /dev/cu.usbmodem20114301 carries the
819 // POSITION OF THE SOCKET, not the device, and moving the plug to another
820 // port of the hub is enough to change it. Whoever opens KStars on the Mac
821 // would find a name that yesterday belonged to something else.
822 //
823 // So: the candidate ports are opened, `version` is written, and the wheel
824 // is recognised by what it answers. And since two wheels both answer
825 // "wheelly", the SERIAL NUMBER is looked at, which the firmware derives
826 // from the MAC and is different for every unit.
827 //
828 // Two passes, and the second is the one that avoids locking the user out:
829 //
830 // 1. ONLY this profile's wheel is accepted. With two wheels attached
831 // each one ends up on its own, whatever names the ports have taken;
832 // 2. if that wheel is on no port - it stayed at home, or the board was
833 // replaced - it is tried again accepting any Wheelly, saying so in
834 // the log. Without this pass, changing the XIAO would mean a driver
835 // that no longer connects and no explanation of why.
836 // a Connect by hand takes over from the automatic search
837 m_link_lost = false;
838 m_only_mine = !m_expected_serial.empty();
839 if (INDI::FilterWheel::Connect()) return true;
840 if (!m_only_mine) return false;
841
842 m_only_mine = false;
843 LOGF_WARN("%s",
844 fill_in("The wheel of this profile (serial %1$s) is not on any port. Connecting to whichever Wheelly is here instead.", {m_expected_serial}).c_str());
846}
847
849{
850 m_reader.attach(PortFD);
851 m_reader.flush();
852 m_magnet_lost = false;
853
854 // On a reconnection (try_reconnect) the refusals are not said: the
855 // attempt is repeated every few seconds while the wheel boots, and the
856 // loss has already been said once.
857 Fields c;
858 if (!command(CMD_VERSION, &c))
859 {
860 if (!m_reconnecting) LOG_ERROR("The device on this port did not answer as a Wheelly wheel. Check the port.");
861 return false;
862 }
863 if (c[F_NAME] != "wheelly")
864 {
865 if (!m_reconnecting) LOG_ERROR("The device on this port did not answer as a Wheelly wheel. Check the port.");
866 return false;
867 }
868 if (c[F_PROTO] != std::to_string(PROTOCOL_VERSION))
869 {
870 if (!m_reconnecting)
871 LOGF_ERROR("%s", fill_in("This firmware speaks protocol %1$s, this driver speaks %2$s. Update one of the two.",
872 {c[F_PROTO], std::to_string(PROTOCOL_VERSION)}).c_str());
873 return false;
874 }
875
876 // The right wheel, not any wheel: see Connect(). In the first pass a
877 // Wheelly with the wrong serial number is discarded as if it were not a
878 // Wheelly, and INDI moves on to the next port.
879 const std::string serial = c.count(F_SERIAL) ? c[F_SERIAL] : std::string();
880 if (m_only_mine && serial != m_expected_serial)
881 {
882 if (!m_reconnecting)
883 LOGF_INFO("%s",
884 fill_in("This is a different Wheelly (serial %1$s) from the one this profile uses (%2$s): looking for yours on the other ports.", {serial, m_expected_serial}).c_str());
885 return false;
886 }
887 // The very first connection, or a board replaced and found again in the
888 // second pass: from now on this profile's wheel is this one. If it were not
889 // adopted, every connection would pay two scanning rounds forever. It is
890 // SAVED further down, after the property has been filled: saving it here
891 // would write a still empty element, and it happened.
892 if (!serial.empty() && serial != m_expected_serial)
893 {
894 m_expected_serial = serial;
895 m_serial_to_save = true;
896 }
897
898 // How many slots the wheel has, the wheel says. A firmware older than the
899 // field does not send it: in that case the factory value is kept, which is
900 // better than the theoretical maximum.
901 if (c.count(F_SLOTS))
902 {
903 const int count = (int)number(c, F_SLOTS, FACTORY_SLOTS);
904 if (count >= MIN_SLOTS && count <= MAX_SLOTS)
905 {
906 m_slots = count;
907 }
908 else
909 {
910 // with the whole range: "max 12" alone read wrong for a wheel reporting 1
911 LOGF_WARN("%s", fill_in("The wheel reports %1$s slots, which this driver cannot handle (from %2$s to %3$s).",
912 {
913 std::to_string(count), std::to_string(MIN_SLOTS),
914 std::to_string(MAX_SLOTS)
915 }).c_str());
916 }
917 }
918
919 FirmwareTP[0].setText(c[F_FW].c_str());
920 FirmwareTP[1].setText(c[F_PROTO].c_str());
921 FirmwareTP[2].setText(c[F_SERIAL].c_str());
922
923 if (!m_reconnecting)
924 {
925 LOGF_INFO("%s", fill_in("Connected to Wheelly, firmware %1$s, protocol %2$s.", {c[F_FW], c[F_PROTO]}).c_str());
926 m_port_in_use = serialConnection != nullptr ? serialConnection->port() : "";
927 remember_alias();
928 }
929 return true;
930}
931
932// ------------------------------------------------- the USB link, lost and found
933
934bool Wheelly::link_is_gone(int error_number)
935{
936 // The errors of a port that is no longer there: EIO is what a USB CDC
937 // port unplugged under an open descriptor gives on Linux (seen on the
938 // reference wheel: "Write Error: Input/output error"), ENXIO and ENODEV
939 // a device node without its device, EBADF a descriptor closed, EPIPE
940 // and 0 - end of file on a read - the other end hung up. A timeout is
941 // not among them: a busy or rebooting wheel is still there.
942 switch (error_number)
943 {
944 case 0:
945 case EIO:
946 case ENXIO:
947 case ENODEV:
948 case EBADF:
949 case EPIPE:
950 return true;
951 default:
952 return false;
953 }
954}
955
956void Wheelly::link_lost(const std::string &why)
957{
958 if (m_link_lost) return;
959 m_link_lost = true;
960 // THE USB LINK. Said once when it goes and once when it comes back:
961 // before, a wheel unplugged left the driver writing to a dead port and
962 // logging the failure at every poll, 25 times in a few seconds.
963 // %1$s is the system's reason, e.g. "Input/output error".
964 LOGF_ERROR("%s",
965 fill_in("The USB link to the wheel was lost (%1$s). The driver stays connected and reconnects by itself as soon as the wheel is back; until then commands cannot reach it.", {why}).c_str());
966
967 // The dead descriptor is closed AT ONCE. Kept open, it also keeps the
968 // device node busy, and the kernel gives the wheel coming back another
969 // name (ttyACM1 became ttyACM2).
970 if (m_own_fd >= 0)
971 {
972 tty_disconnect(m_own_fd);
973 m_own_fd = -1;
974 }
975 else if (serialConnection != nullptr)
976 {
978 }
979 PortFD = -1;
980 m_reader.attach(-1);
981
982 // What was under way cannot finish: said as failed, so that Ekos stops a
983 // sequence instead of waiting for a filter that is not coming.
984 if (m_moving)
985 {
986 m_moving = false;
989 }
990 if (m_jogging)
991 {
992 m_jogging = false;
993 if (m_jog_row != nullptr)
994 {
995 m_jog_row->setState(IPS_ALERT);
996 m_jog_row->apply();
997 }
998 }
999 if (m_sweeping) finish_sweep(false);
1000 // The angle shown is the last one read, no longer a live one: Alert
1001 // until the first status after the return.
1002 //
1003 // CONNECTION IS LEFT AS IT IS, On and Ok. Tried first: Alert on it, to
1004 // show the link down. libindi's isConnected() is true only for On AND
1005 // Ok, so with Alert the driver itself took the device for disconnected -
1006 // TimerHit stopped, and nothing looked for the wheel any more. For Ekos
1007 // the device simply stays: its filter manager is not torn down and built
1008 // again for a plug that comes back in two seconds.
1009 PositionNP.setState(IPS_ALERT);
1010 PositionNP.apply();
1011 SensorNP.setState(IPS_ALERT);
1012 SensorNP.apply();
1013
1014 m_retry_pause_s = RETRY_FIRST_S;
1015 m_next_attempt = now_s() + m_retry_pause_s;
1016}
1017
1018void Wheelly::remember_alias()
1019{
1020 // The /dev/serial/by-id/ link that leads to the port in use, if there is
1021 // one (Linux; on macOS the cu.usbmodem name already comes back the same).
1022 // Only links are read here - no port is opened - so it looks at nothing
1023 // but the one device the driver is already talking to.
1024 m_alias.clear();
1025 if (m_port_in_use.empty()) return;
1026 char resolved[PATH_MAX];
1027 if (realpath(m_port_in_use.c_str(), resolved) == nullptr) return;
1028 const char *folder = getenv("WHEELLY_SERIAL_BY_ID"); // the bench's own
1029 const std::string by_id = folder != nullptr ? folder : "/dev/serial/by-id";
1030 DIR *d = opendir(by_id.c_str());
1031 if (d == nullptr) return;
1032 while (struct dirent *e = readdir(d))
1033 {
1034 if (e->d_name[0] == '.') continue;
1035 const std::string link = by_id + "/" + e->d_name;
1036 char target[PATH_MAX];
1037 if (realpath(link.c_str(), target) != nullptr && strcmp(target, resolved) == 0)
1038 {
1039 m_alias = link;
1040 break;
1041 }
1042 }
1043 closedir(d);
1044 if (!m_alias.empty()) LOGF_DEBUG("Port %s is also %s", m_port_in_use.c_str(), m_alias.c_str());
1045}
1046
1047bool Wheelly::try_reconnect()
1048{
1049 // The port the user chose, then its by-id link: the wheel coming back
1050 // under another ttyACMn is found by the link, which follows the USB
1051 // serial number. No other port is opened - that is Auto Search's
1052 // business, and the user's choice - and the wheel must answer with this
1053 // profile's serial number (m_only_mine): a reconnection never adopts a
1054 // different wheel.
1055 std::vector<std::string> paths;
1056 if (serialConnection != nullptr && serialConnection->port() != nullptr)
1057 paths.push_back(serialConnection->port());
1058 if (!m_alias.empty() && std::find(paths.begin(), paths.end(), m_alias) == paths.end())
1059 paths.push_back(m_alias);
1060
1061 const int slots_before = m_slots;
1062 for (const std::string &path : paths)
1063 {
1064 struct stat st;
1065 if (stat(path.c_str(), &st) != 0) continue; // not back yet
1066 int fd = -1;
1067 if (tty_connect(path.c_str(), 115200, 8, 0, 1, &fd) != TTY_OK) continue;
1068 PortFD = fd;
1069 m_reconnecting = true;
1070 m_only_mine = !m_expected_serial.empty();
1071 const bool good = Handshake();
1072 m_reconnecting = false;
1073 if (good)
1074 {
1075 m_own_fd = fd;
1076 m_link_lost = false;
1077 m_port_in_use = path;
1078 remember_alias();
1079 link_found(path);
1080 if (m_slots != slots_before)
1081 {
1082 // A wheel restarted with another slot count (one changed and
1083 // never saved): the panel is rebuilt as at a connection.
1084 setConnected(false, IPS_IDLE);
1086 setConnected(true, IPS_OK);
1088 }
1089 return true;
1090 }
1092 PortFD = -1;
1093 m_reader.attach(-1);
1094 }
1095 return false;
1096}
1097
1098void Wheelly::link_found(const std::string &path)
1099{
1100 LOGF_INFO("%s", fill_in("The wheel is back on %1$s: reconnected.", {path}).c_str());
1101 // The XIAO restarted with the USB: what it held only in working memory
1102 // is gone, so the panel is read again from the wheel.
1103 forget_unsaved();
1104 read_calibration();
1106}
1107
1109{
1110 // A descriptor opened by the reconnection is ours to close: libindi's
1111 // serial plugin does not know it.
1112 if (m_own_fd >= 0)
1113 {
1114 tty_disconnect(m_own_fd);
1115 m_own_fd = -1;
1116 PortFD = -1;
1117 }
1118 m_link_lost = false;
1119 m_reader.attach(-1);
1121}
1122
1123// The rows of the angles follow m_slots: all MAX_SLOTS exist, the first
1124// m_slots are defined (define_angles). Rejected: the elements of one
1125// property, cut with resize() - a wheel with MORE slots than the one before
1126// grows them back nameless. Separate properties, each filled once, cannot.
1127void Wheelly::size_slots()
1128{
1129 m_marked_slot = (CurrentFilter >= 1 && CurrentFilter <= m_slots) ? CurrentFilter : 0;
1130 // a new set of elements carries no live row: they are defined right after
1131 m_unsaved_slot = 0;
1132 m_angles_stale = false;
1133 label_slots();
1134}
1135
1136void Wheelly::label_slots()
1137{
1138 for (int i = 0; i < m_slots; i++)
1139 {
1140 const std::string n = std::to_string(i + 1);
1141 const char *key = i + 1 == m_unsaved_slot ? "▶ %1$s *"
1142 : i + 1 == m_marked_slot ? "▶ %1$s" : nullptr;
1143 AngleNP[i].setLabel((key != nullptr ? fill_in(key, {n}) : n).c_str());
1144 }
1145}
1146
1147void Wheelly::define_angles()
1148{
1149 for (int i = 0; i < m_slots; i++) defineProperty(AngleNP[i]);
1150 m_angles_defined = m_slots;
1151}
1152
1153void Wheelly::delete_angles()
1154{
1155 for (int i = 0; i < m_angles_defined; i++) deleteProperty(AngleNP[i]);
1156 m_angles_defined = 0;
1157}
1158
1159void Wheelly::mark_current_slot()
1160{
1161 const int now = (CurrentFilter >= 1 && CurrentFilter <= m_slots) ? CurrentFilter : 0;
1162 if (now != m_marked_slot)
1163 {
1164 m_marked_slot = now;
1165 // the live angle belonged to the slot the wheel has left: a jog that
1166 // landed on the next slot, a turn by hand
1167 if (m_unsaved_slot != 0 && m_unsaved_slot != now) forget_unsaved();
1168 m_angles_stale = true;
1169 }
1170 refresh_angles();
1171}
1172
1173void Wheelly::forget_unsaved()
1174{
1175 if (m_unsaved_slot == 0) return;
1176 if (m_unsaved_slot <= MAX_SLOTS)
1177 AngleNP[m_unsaved_slot - 1][0].setValue(m_taught[m_unsaved_slot - 1]);
1178 m_unsaved_slot = 0;
1179 m_angles_stale = true;
1180}
1181
1182void Wheelly::refresh_angles()
1183{
1184 if (!m_angles_stale || m_moving || m_jogging || m_sweeping) return;
1185 m_angles_stale = false;
1186 label_slots();
1187 redefine_calibration();
1188}
1189
1190// Takes down and defines again the angles' rows and every property after
1191// them in the calibration tab, in the order updateProperties() defines them:
1192// KStars puts a property defined again at the END of its group, so
1193// redefining the angles alone would move them under the log. The
1194// Options properties defined in between are not touched: they live in
1195// another tab. The rows taken down are those the client HAS, the rows
1196// defined those the wheel has now: the number of slots may have changed.
1197void Wheelly::redefine_calibration()
1198{
1199 INDI::Property *tail[] =
1200 {
1201 std::addressof(JogDownSP), std::addressof(JogUpSP),
1202 std::addressof(ActionsSP), std::addressof(ToleranceNP),
1203 std::addressof(DiagSP), std::addressof(SweepSP),
1204 std::addressof(SweepDirTP), std::addressof(LogSP), std::addressof(FileTP)
1205 };
1206 delete_angles();
1207 for (INDI::Property *one : tail) deleteProperty(one->getName());
1208 define_angles();
1209 for (INDI::Property *one : tail) defineProperty(*one);
1210}
1211
1212// The pitch buttons: 360/slots, without trailing zeros - "72" on five slots,
1213// "51.43" on seven.
1214void Wheelly::label_jogs()
1215{
1216 char pitch[16];
1217 snprintf(pitch, sizeof(pitch), "%.2f", 360.0 / m_slots);
1218 std::string text = pitch;
1219 while (!text.empty() && text.back() == '0') text.pop_back();
1220 if (!text.empty() && text.back() == '.') text.pop_back();
1221 JogDownSP[0].setLabel(fill_in(JOG_LABELS[0], {text}).c_str());
1222 JogUpSP[JOG_PER_ROW - 1].setLabel(fill_in(JOG_LABELS[2 * JOG_PER_ROW - 1], {text}).c_str());
1223}
1224
1225// A new number of slots, from the panel: the wheel is told, then every
1226// property whose size follows it is taken down and defined again - a client
1227// does not notice elements added to a property it already has. The jog rows
1228// go with the angles: their pitch buttons follow the number of slots.
1229bool Wheelly::change_slots(int count)
1230{
1231 if (count == m_slots) return true;
1232 Fields c;
1233 std::string error;
1234 if (!command(std::string(CMD_SLOTS) + " " + std::to_string(count), &c, &error))
1235 {
1236 LOGF_ERROR("%s", fill_in("The wheel refused the new number of slots: %1$s", {error}).c_str());
1237 return false;
1238 }
1239 m_slots = (int)number(c, F_SLOTS, count);
1241 size_slots();
1242 label_jogs();
1243 FilterSlotNP[0].setMax(m_slots);
1247 redefine_calibration();
1248 read_calibration();
1249 LOGF_WARN("%s",
1250 fill_in("The wheel now has %1$s slots. Its calibration starts again from evenly spaced angles and generic names: teach each slot (steps, then Set on its row) and name the filters, then press 'Save to the wheel'. Until then nothing is written: switching the wheel off brings back the previous setup.", {std::to_string(m_slots)}).c_str());
1251 return true;
1252}
1253
1254bool Wheelly::read_calibration()
1255{
1256 Fields c;
1257 if (command(CMD_ANGLES, &c))
1258 {
1259 for (int i = 0; i < m_slots; i++)
1260 {
1261 m_taught[i] = number(c, ("a" + std::to_string(i + 1)).c_str());
1262 AngleNP[i][0].setValue(i + 1 == m_unsaved_slot ? m_live_angle : m_taught[i]);
1263 AngleNP[i].setState(IPS_OK);
1264 AngleNP[i].apply();
1265 }
1266 }
1267 if (command(CMD_TOLERANCE, &c))
1268 {
1269 ToleranceNP[0].setValue(number(c, F_GOOD, FACTORY_GOOD_DEG));
1270 ToleranceNP[1].setValue(number(c, F_WARN, FACTORY_WARN_DEG));
1271 ToleranceNP[2].setValue(number(c, F_RETRIES, 3));
1272 ToleranceNP.setState(IPS_OK);
1273 ToleranceNP.apply();
1274 }
1275 if (command(CMD_MOTOR, &c))
1276 {
1277 MotorNP[0].setValue(number(c, F_MA, FACTORY_RUN_MA));
1278 MotorNP[1].setValue(number(c, F_SPEED, FACTORY_SPEED));
1279 MotorNP[2].setValue(number(c, F_ACCEL, FACTORY_ACCEL));
1280 MotorNP.setState(IPS_OK);
1281 MotorNP.apply();
1282 }
1283 if (command(CMD_HOLD, &c))
1284 {
1285 // Zero from the firmware means "at rest the motor is released", the
1286 // default, and zero is what the field shows (not the suggested 150,
1287 // see initProperties). The light says
1288 // it too: grey when released, green when it holds.
1289 const double ma = number(c, F_MA, 0);
1290 HoldNP[0].setValue(ma);
1291 HoldNP.setState(ma > 0 ? IPS_OK : IPS_IDLE);
1292 HoldNP.apply();
1293 // An older firmware also says `detent=yes|no` here:
1294 // ignored, the option is gone (NO DETENT OPTION, wheelly_protocol.h).
1295 }
1296 read_settle();
1297 read_direction(true);
1298 read_led_mode();
1299 return true;
1300}
1301
1302void Wheelly::take_ceiling(const Fields &reply, bool warn)
1303{
1304 const double ms = number(reply, F_CEILING, 0);
1305 if (ms <= 0) return; // a firmware that does not say it
1306 m_wheel_ceiling_s = ms / 1000.0;
1307 // Ekos gives up by itself after 30 s. Past that, a long move ends in
1308 // Ekos's own bare "timeout" before the wheel has had its say: the user is
1309 // told now, while choosing the setting, not in the middle of the night.
1310 if (warn && ms > (double)EKOS_FILTER_TIMEOUT_MS)
1311 {
1312 char worst[16], ekos[16];
1313 snprintf(worst, sizeof(worst), "%.0f", std::ceil(ms / 1000.0));
1314 snprintf(ekos, sizeof(ekos), "%lu", EKOS_FILTER_TIMEOUT_MS / 1000);
1315 LOGF_WARN("%s",
1316 fill_in("With these motor settings the longest filter change can take up to %1$s s, more than the %2$s s after which Ekos gives up on it. Raise the motor speed, shorten the hold after arrival, or choose the shortest way if your wheel allows it.", {worst, ekos}).c_str());
1317 }
1318}
1319
1320void Wheelly::read_direction(bool warn)
1321{
1322 Fields c;
1323 // A firmware older than the setting answers "unknown command": the
1324 // switch stays Idle - unknown, not "shortest" - and the cap stays 28 s.
1325 if (!command(CMD_DIRECTION, &c)) return;
1326 const auto t = c.find(F_DIRECTION);
1327 if (t != c.end())
1328 {
1329 DirectionSP.reset();
1330 DirectionSP[t->second == ARG_UP ? 1 : t->second == ARG_DOWN ? 2 : 0].setState(ISS_ON);
1331 DirectionSP.setState(IPS_OK);
1332 DirectionSP.apply();
1333 }
1334 take_ceiling(c, warn);
1335}
1336
1337void Wheelly::read_settle()
1338{
1339 // A firmware older than the setting answers "unknown
1340 // command": said at debug level only, not as an error at every
1341 // connection, and the field keeps the factory 300 ms - which is exactly
1342 // the fixed wait that firmware makes. The cap is taken without a warning:
1343 // read_direction(), right after, warns once for all.
1344 Fields c;
1345 std::string ignored;
1346 if (!command(CMD_SETTLE, &c, &ignored))
1347 {
1348 LOGF_DEBUG("settle: %s", ignored.c_str());
1349 return;
1350 }
1351 HoldNP[1].setValue(number(c, F_MS, FACTORY_SETTLE_MS));
1352 HoldNP.apply();
1353 take_ceiling(c, false);
1354}
1355
1356double Wheelly::move_ceiling_s() const
1357{
1358 return m_wheel_ceiling_s > 0 ? m_wheel_ceiling_s + MOVE_CEILING_MARGIN_S : MOVE_CEILING_S;
1359}
1360
1361// The LED switch shows the mode the WHEEL has, read back, not the one the
1362// panel last asked for: the mode is saved on the wheel, and after a power
1363// cycle or with another client in between the two could differ.
1364void Wheelly::read_led_mode()
1365{
1366 Fields c;
1367 if (!command(CMD_LED, &c)) return;
1368 const std::string mode = c.count(F_MODE) ? c[F_MODE] : std::string(ARG_PULSE);
1369 LedSP.reset();
1370 LedSP[mode == ARG_ON ? 0 : mode == ARG_OFF ? 2 : 1].setState(ISS_ON);
1371 LedSP.setState(IPS_OK);
1372 LedSP.apply();
1373}
1374
1375// ------------------------------------------------------------------- the filters
1376
1378{
1379 // The names are known by the wheel: it describes itself, and connected to
1380 // a new computer it already shows up with the right names.
1381 Fields c;
1382 if (PortFD < 0 || !command(CMD_NAMES, &c))
1384
1386 FilterNameTP.reserve(m_slots);
1387 for (int i = 0; i < m_slots; i++)
1388 {
1389 const std::string key = "n" + std::to_string(i + 1);
1390 m_names[i] = c.count(key) ? c[key] : ("Filter" + std::to_string(i + 1));
1391 INDI::WidgetText one;
1392 one.fill(("FILTER_SLOT_NAME_" + std::to_string(i + 1)).c_str(),
1393 std::to_string(i + 1).c_str(), m_names[i].c_str());
1394 FilterNameTP.push(std::move(one));
1395 }
1396 // The label says the name of the field and nothing more. The naming rule
1397 // lives in the refusal MESSAGE, which is the only place in the INDI panel
1398 // where a long text really fits: the log box, at the bottom, which wraps
1399 // by itself. See firmware.md, section 5.1, for the three routes tried
1400 // before and why none of them works.
1401 FilterNameTP.fill(getDeviceName(), "FILTER_NAME", "Filter names",
1402 TAB_MAIN, IP_RW, 60, IPS_IDLE);
1403 return true;
1404}
1405
1407{
1408 // The real validation has already happened in ISNewText: here it is only written.
1409 for (int i = 0; i < m_slots && i < (int)FilterNameTP.count(); i++)
1410 {
1411 const std::string fresh = FilterNameTP[i].getText();
1412 if (fresh == m_names[i]) continue; // what does not change is not rewritten
1413 std::string error;
1414 if (!command(std::string(CMD_NAME) + " " + std::to_string(i + 1) + " " + fresh,
1415 nullptr, &error))
1416 {
1417 LOGF_ERROR("%s", fill_in("Filter name refused: %1$s", {error}).c_str());
1418 return false;
1419 }
1420 m_names[i] = fresh;
1421 }
1422 return true;
1423}
1424
1426{
1427 Fields c;
1428 if (!command(std::string(CMD_GO) + " " + std::to_string(slot), &c))
1429 return false; // the base class puts FILTER_SLOT in Alert
1430
1431 TargetFilter = slot;
1432 // a move that is not a jog: the row showing the live angle goes back to
1433 // the taught one (redefined when the wheel stops, refresh_angles)
1434 forget_unsaved();
1435 // a filter change takes over from a jog under way: its verdict will be
1436 // the change's, and the jog row goes back to idle
1437 if (m_jogging)
1438 {
1439 m_jogging = false;
1440 if (m_jog_row != nullptr)
1441 {
1442 m_jog_row->setState(IPS_IDLE);
1443 m_jog_row->apply();
1444 }
1445 }
1446 // Busy here, whoever asked: libindi sets it before calling us from the
1447 // panel, but its joystick handler calls SelectFilter directly, and a move
1448 // from the joystick left FILTER_SLOT idle while the wheel turned
1451 m_moving = true;
1452 m_move_start = now_s();
1453 LOGF_DEBUG("%s", fill_in("Moving to slot %1$s.", {std::to_string(slot)}).c_str());
1454 return true;
1455}
1456
1458{
1459 return CurrentFilter;
1460}
1461
1462bool Wheelly::read_status(Fields &fields)
1463{
1464 return command(CMD_STATUS, &fields);
1465}
1466
1467void Wheelly::update_panels(const Fields &status)
1468{
1469 PositionNP[0].setValue(number(status, F_ANGLE));
1470 PositionNP[1].setValue(number(status, F_ERR));
1471 PositionNP[2].setValue(number(status, F_RETRIES));
1472 PositionNP.setState(IPS_OK);
1473 PositionNP.apply();
1474
1475 const bool magnet = number(status, F_MD, 1) > 0.5;
1476 // the magnet is back: a later loss is a new episode, and is said again.
1477 // Cleared only at connection, a second loss in the same night would go
1478 // unsaid
1479 if (magnet) m_magnet_lost = false;
1480 SensorNP[0].setValue(number(status, F_AGC));
1481 SensorNP[1].setValue(number(status, F_MAG));
1482 SensorNP[2].setValue(magnet ? 1 : 0);
1483 SensorNP[3].setValue(number(status, F_ML));
1484 SensorNP[4].setValue(number(status, F_MH));
1485 SensorNP.setState(magnet ? IPS_OK : IPS_ALERT);
1486 SensorNP.apply();
1487}
1488
1490{
1491 if (!isConnected()) return;
1492
1493 // The link is down: no status polls - each would only fail - but an
1494 // attempt to find the wheel again, with a pause that doubles up to
1495 // RETRY_MAX_S. It does not give up: a wheel plugged back after an hour
1496 // is found after an hour.
1497 if (m_link_lost)
1498 {
1499 if (now_s() >= m_next_attempt)
1500 {
1501 if (try_reconnect())
1502 {
1504 return;
1505 }
1506 m_retry_pause_s = std::min(2.0 * m_retry_pause_s, RETRY_MAX_S);
1507 m_next_attempt = now_s() + m_retry_pause_s;
1508 }
1510 return;
1511 }
1512
1513 collect_unsolicited();
1514
1515 Fields status;
1516 if (!read_status(status))
1517 {
1518 // command() has already turned a port that is gone into link_lost()
1520 return;
1521 }
1522
1523 update_panels(status);
1524
1525 // One sample at every query: it is the same 'status' answer that updates
1526 // the panels, so the sweep does not cost a single extra command on the
1527 // serial line. The ceiling is a safety net against a wheel that never
1528 // arrived: memory must not grow without end.
1529 if (m_sweeping && m_samples.size() < 4000)
1530 {
1531 m_samples.push_back({number(status, F_ANGLE), number(status, F_MAG)});
1532 }
1533
1534 const std::string motion = status.count(F_MOTION) ? status.at(F_MOTION) : "";
1535 const std::string outcome = status.count(F_OUTCOME) ? status.at(F_OUTCOME) : OUTCOME_NONE;
1536 const int slot = (int)number(status, F_POS);
1537 const double error = number(status, F_ERR);
1538 const int retries = (int)number(status, F_RETRIES);
1539
1540 if (m_jogging)
1541 {
1542 const bool still = (motion == MOTION_IDLE || motion == MOTION_FAILED);
1543 if (!still && now_s() - m_move_start > move_ceiling_s())
1544 {
1545 command(CMD_STOP);
1546 finish_jog(status, EV_FAILED);
1547 }
1548 else if (still && outcome != OUTCOME_NONE)
1549 {
1550 finish_jog(status, outcome);
1551 }
1552 }
1553
1554 if (m_moving)
1555 {
1556 const bool still = (motion == MOTION_IDLE || motion == MOTION_FAILED);
1557
1558 if (!still && now_s() - m_move_start > move_ceiling_s())
1559 {
1560 // The ceiling is ours on purpose: if Ekos's one expires, all that
1561 // is left in the log is "timeout" and nobody knows why.
1562 m_moving = false;
1563 command(CMD_STOP);
1564 LOGF_ERROR("%s", fill_in("The wheel did not finish within %1$s seconds. Imaging is stopped.",
1565 {std::to_string((int)std::ceil(move_ceiling_s()))}).c_str());
1566 // THE HINT ON SLIP OR STALL. Said on its own line
1567 // right after every "not reached": the slot NOT reached, the time cap,
1568 // the failed jog. The first suspect when the motor stalls or the clutch
1569 // slips is a detent left in place, because the motor cannot climb out of
1570 // its notches - it is removed when the magnet is fitted, and a wheel
1571 // assembled from an older guide may still have it. A line of its own and
1572 // not appended: an INDI message is cut silently at 255 characters (see
1573 // the filter-name refusal below), and the "not reached" line is already long.
1574 // The chapter is named by its title too: its number is prose here, not
1575 // the guide's {cap_the_wheel_body}, and a chapter inserted before it
1576 // would leave the title still right.
1577 LOG_WARN("If the motor stalls or the clutch slips, check first that the wheel's detent (its spring click stop) has been removed: the motor cannot climb out of its notches. Assembly guide, chapter 10, The wheel body.");
1580 record_move(status, "timeout");
1581 if (m_sweeping) finish_sweep(false);
1582 }
1583 else if (still && outcome != OUTCOME_NONE)
1584 {
1585 m_moving = false;
1586 const std::string which = std::to_string(TargetFilter);
1587 char deviation[16];
1588 snprintf(deviation, sizeof(deviation), "%.2f", std::fabs(error));
1589
1590 if (outcome == EV_FAILED)
1591 {
1592 // Alert on FILTER_SLOT: Ekos stops the sequence at once, and
1593 // that is exactly what is wanted - no frame with the wheel out
1594 // of place.
1595 LOGF_ERROR("%s",
1596 fill_in("Slot %1$s NOT reached: off by %2$s deg after %3$s retries. Imaging is stopped so that no frame is taken with the wheel out of place.",
1597 {which, deviation, std::to_string(retries)}).c_str());
1598 LOG_WARN("If the motor stalls or the clutch slips, check first that the wheel's detent (its spring click stop) has been removed: the motor cannot climb out of its notches. Assembly guide, chapter 10, The wheel body.");
1601 }
1602 else if (outcome == EV_WARNING)
1603 {
1604 // Outside the good tolerance but inside the warning one: warn
1605 // and let it go on. Never Alert here, otherwise a recoverable
1606 // warning would stop the night.
1607 LOGF_WARN("%s", fill_in("Slot %1$s reached but off by %2$s deg, beyond the good tolerance. Imaging continues.", {which, deviation}).c_str());
1608 CurrentFilter = slot > 0 ? slot : TargetFilter;
1610 }
1611 else
1612 {
1613 if (outcome != EV_ARRIVED)
1614 {
1615 // A verdict we do not know means that the firmware is
1616 // newer than the driver. It should not happen - the
1617 // protocol version is checked at connection - but if it
1618 // happens it is not silently taken for a success: it is
1619 // said, and the driver lets it go on, because the wheel is
1620 // still at rest anyway and blocking the night would be worse.
1621 LOGF_WARN("Unknown outcome from the wheel: %s", outcome.c_str());
1622 }
1623 LOGF_INFO("%s", fill_in("Slot %1$s reached, residual error %2$s deg.", {which, deviation}).c_str());
1624 CurrentFilter = slot > 0 ? slot : TargetFilter;
1626 }
1627 record_move(status, outcome.c_str());
1628
1629 // The sweep goes on from here: one hop is over, on to the next.
1630 // If the hop failed the turn stops, because a sweep with a hole in
1631 // it would say something false about the magnet.
1632 if (m_sweeping)
1633 {
1634 if (outcome == EV_FAILED) finish_sweep(false);
1635 else sweep_step();
1636 }
1637 }
1638 }
1639 else if (slot > 0 && slot != CurrentFilter)
1640 {
1641 // Someone moved the wheel without going through here - by hand, or
1642 // from a serial monitor. It is taken note of: the encoder is absolute,
1643 // so the true position is always known.
1644 CurrentFilter = slot;
1645 FilterSlotNP[0].setValue(CurrentFilter);
1648 }
1649
1650 // During the sweep the wheel is queried more often, because there every
1651 // answer is a sample: at the normal pace a whole turn gave twenty, and
1652 // twenty points scattered over three hundred and sixty degrees do not draw
1653 // a curve, they draw a constellation. It costs little - a 'status' line is
1654 // two milliseconds of wire at 115200 - and lasts as long as the turn.
1655 // the "▶" on the current slot's angle, only when the slot changed. Not
1656 // during a sweep: every hop changes slot, and the tab would be redefined
1657 // at every hop for nothing - the sweep ends on the slot it started from,
1658 // so there is nothing to catch up afterwards.
1659 if (!m_sweeping) mark_current_slot();
1660
1661 SetTimer(m_sweeping ? SAMPLE_INTERVAL_MS : getCurrentPollingPeriod());
1662}
1663
1664// ------------------------------------------------------------ commands from Ekos
1665
1666std::string Wheelly::suggest_name(const char *text, int slot)
1667{
1668 // The suggestion is made by sanitize_filter_name(), which is the same
1669 // function the driver uses as a safety net: by construction - and it is
1670 // verified by the tests - what comes out of it passes check_filter_name().
1671 // So the rule is not checked again here; what is checked is the only thing
1672 // the rule does not know, that is whether the suggested name already
1673 // belongs to another slot. Suggesting a name that then gets refused would
1674 // be worse than suggesting nothing.
1675 char clean[wheelly::FILTER_NAME_MAX + 1];
1676 sanitize_filter_name(text, clean, sizeof(clean));
1677 if (std::strcmp(clean, text) == 0) return std::string();
1678
1679 for (int i = 0; i < (int)FilterNameTP.count(); i++)
1680 {
1681 if (i + 1 == slot) continue;
1682 const char *other = FilterNameTP[i].getText();
1683 if (other != nullptr && name_equal_nocase(clean, other)) return std::string();
1684 }
1685 return std::string(clean);
1686}
1687
1688bool Wheelly::ISNewText(const char *dev, const char *name, char *texts[],
1689 char *names[], int n)
1690{
1691 if (dev != nullptr && strcmp(dev, getDeviceName()) == 0 &&
1692 SweepDirTP.isNameMatch(name))
1693 {
1694 SweepDirTP.update(texts, names, n);
1695 // The folder is accepted even if it cannot be used right now - it can
1696 // be a disk not mounted yet - but it is SAID at once, instead of
1697 // leaving it to be discovered at the first lost sweep.
1698 const std::string where = expand_home(SweepDirTP[0].getText());
1699 ::mkdir(where.c_str(), 0755);
1700 const bool usable = (::access(where.c_str(), W_OK) == 0);
1701 if (!usable)
1702 LOGF_WARN("%s", fill_in("The folder %1$s cannot be used right now: %2$s. The sweeps will not be saved until it can.",
1703 {where, std::strerror(errno)}).c_str());
1704 SweepDirTP.setState(usable ? IPS_OK : IPS_ALERT);
1705 SweepDirTP.apply();
1706 saveConfig(SweepDirTP);
1707 return true;
1708 }
1709
1710 // The names as they will be applied - an empty field replaced, see below.
1711 // Out here and not in the block that fills them: the base class reads
1712 // them after that block has closed.
1713 std::vector<std::string> own;
1714 std::vector<char *> fixed;
1715 if (dev != nullptr && strcmp(dev, getDeviceName()) == 0 &&
1717 {
1718 // Validation happens BEFORE the property is allowed to update: the rule
1719 // lives in the shared header, so the very same one the firmware will
1720 // apply is applied here. The name is refused and not corrected behind
1721 // the user's back, because it ends up in the FITS header and in the
1722 // file name, and a name silently corrected breaks the pairing between
1723 // lights and flats.
1724 auto which_slot = [this](const char *element)
1725 {
1726 for (size_t i = 0; i < FilterNameTP.count(); i++)
1727 if (FilterNameTP[i].isNameMatch(element)) return (int)i + 1;
1728 return 0;
1729 };
1730
1731 // The refusal is said in one place only, the log, and for this the
1732 // sentence must be complete: which slot, which name, what is wrong,
1733 // and a good name to write in its place. The rule instead is read
1734 // before making the mistake, in the property's label - which in
1735 // KStars is also its tooltip.
1736 auto refuse = [&](int slot, const char *text,
1737 const std::string & why,
1738 const std::string & suggestion)
1739 {
1740 const std::string sentence = suggestion.empty()
1741 // The refusal ends up in the log, and that is the only place where the
1742 // explanation fits: so it says everything. The rule (the allowed characters) is
1743 // joined to it only when the two fit together: an INDI message is a
1744 // char[MAXINDIMESSAGE] with MAXINDIMESSAGE = 255 (indiapi.h), and whatever
1745 // is left over is cut SILENTLY - with the rule always appended, the refusal
1746 // of "-Lum-" reached Ekos truncated at "...una cif". When they do not fit
1747 // they go on two lines, the refusal LAST (see refuse() in wheelly.cpp).
1748 ? fill_in("Slot %1$s: \"%2$s\" refused - %3$s.",
1749 {std::to_string(slot), text, why})
1750 : fill_in("Slot %1$s: \"%2$s\" refused - %3$s. A name that would be accepted here: \"%4$s\".",
1751 {std::to_string(slot), text, why, suggestion});
1752
1753 // Two signals together, because one alone is not enough: the red
1754 // light on the field, and the text going back to the good one - so
1755 // one SEES that it was not accepted, instead of believing it saved.
1756 //
1757 // The red light goes on the NAMES and not on FILTER_SLOT: that one
1758 // in Alert, in Ekos, means "filter change failed" and stops the
1759 // capture sequence. A badly written name is a mistake of whoever
1760 // writes it, not of the wheel, and must not throw away a night.
1762
1763 // The allowed characters go with every refusal, and the refusal
1764 // itself must be the LAST thing said. KStars' device log puts the
1765 // newest line on top (and its status bar shows only the newest):
1766 // with the rule sent after the refusal, the line on top was the
1767 // generic rule and the one saying which slot and why sat under
1768 // it, unseen - a user left a slot empty, read only "Allowed: ...",
1769 // and could not tell what was wrong. So: one line when the two fit
1770 // together, and when they do not (MAXINDIMESSAGE = 255 bytes, the
1771 // cut is silent - a long name, a long reason and a long suggestion
1772 // do not fit), the rule first and the refusal after it.
1773 // The rule, which ALWAYS goes with the refusal - even when the
1774 // reason has nothing to do with the characters: whoever reads the refusal
1775 // is the person who at that moment wants to know what they can write.
1776 const std::string rule =
1777 "Allowed: letters without accents, digits, _ - and . ; 1 to 32 characters; the first and the last must be a letter or a digit.";
1778 const std::string both = sentence + " " + rule;
1779 if (both.size() < MAXINDIMESSAGE)
1780 {
1781 FilterNameTP.apply("%s", both.c_str());
1782 return;
1783 }
1784 LOGF_INFO("%s", rule.c_str());
1785 FilterNameTP.apply("%s", sentence.c_str());
1786 };
1787
1788 // AN EMPTY FIELD IS A SLOT WITHOUT A FILTER, and that is normal: a
1789 // five-slot wheel with four filters. Refusing it refused the whole set,
1790 // and the firmware cannot store an empty name (check_filter_name says
1791 // NAME_EMPTY, the same rule on both sides), so the driver gives the
1792 // slot a name: "Empty_<slot>". Why this form:
1793 // - the slot number makes it unique by construction, so several empty
1794 // slots never clash with each other under the duplicate rule, and no
1795 // exemption from that rule is needed - an exemption would let two
1796 // slots share a name in the FITS header and in the folder names,
1797 // which is exactly what the rule exists to prevent;
1798 // - it passes the name rule as it is, so the firmware (protocol 2,
1799 // unchanged) stores it like any other name;
1800 // - it is the same everywhere: it is stored in the wheel and written
1801 // in the FITS FILTER keyword, and a wheel moved to another
1802 // computer must keep the same names - like the factory ones,
1803 // Lum, Red, ...
1804 // The field then shows the name given, so what Ekos lists is what the
1805 // wheel holds, and one log line says it was done - once the whole set
1806 // is accepted, not before a refusal that would undo it.
1807 // A field of blanks only counts as empty: it is what is left after
1808 // clearing a field by typing spaces over it.
1809 own.assign(texts, texts + n);
1810 fixed.resize(n);
1811 std::vector<int> given;
1812 for (int i = 0; i < n; i++)
1813 {
1814 if (own[i].find_first_not_of(" \t") == std::string::npos)
1815 {
1816 const int slot = which_slot(names[i]);
1817 if (slot > 0)
1818 {
1819 own[i] = std::string(EMPTY_SLOT_NAME) + "_" + std::to_string(slot);
1820 given.push_back(i);
1821 }
1822 }
1823 fixed[i] = &own[i][0];
1824 }
1825 texts = fixed.data();
1826
1827 for (int i = 0; i < n; i++)
1828 {
1829 const NameCheck check = check_filter_name(texts[i]);
1830 if (check == NAME_OK) continue;
1831
1832 const int slot = which_slot(names[i]);
1833 refuse(slot, texts[i], filter_name_reason(check, texts[i]),
1834 suggest_name(texts[i], slot));
1835 return true;
1836 }
1837
1838 // duplicates, without telling upper and lower case apart
1839 for (int i = 0; i < n; i++)
1840 {
1841 for (int j = 0; j < (int)FilterNameTP.count(); j++)
1842 {
1843 const bool same_field = FilterNameTP[j].isNameMatch(names[i]);
1844 const char *other = same_field ? nullptr : FilterNameTP[j].getText();
1845 bool clash = (other != nullptr && name_equal_nocase(texts[i], other));
1846 for (int k = 0; k < n && !clash; k++)
1847 if (k != i && name_equal_nocase(texts[i], texts[k])) clash = true;
1848 if (!clash) continue;
1849
1850 // Nothing is suggested here: the name is already valid on its
1851 // own, and cleaning it would leave it as it is. A free name is
1852 // chosen by whoever knows what the slot is for, not by the driver.
1853 refuse(which_slot(names[i]), texts[i],
1854 "another slot already has this name, ignoring upper and lower case", std::string());
1855 return true;
1856 }
1857 }
1858
1859 for (int i : given)
1860 // A slot left without a name is a slot without a filter: the driver names
1861 // it and says so, so that the name appearing in the field is not a mystery.
1862 LOGF_INFO("%s", fill_in("Slot %1$s left empty, so it has no filter: it is now called \"%2$s\".",
1863 {std::to_string(which_slot(names[i])), own[i]}).c_str());
1864 }
1865 return INDI::FilterWheel::ISNewText(dev, name, texts, names, n);
1866}
1867
1868bool Wheelly::ISNewNumber(const char *dev, const char *name, double values[],
1869 char *names[], int n)
1870{
1871 if (dev != nullptr && strcmp(dev, getDeviceName()) == 0)
1872 {
1873
1874 for (int i = 0; i < m_slots; i++)
1875 {
1876 if (!AngleNP[i].isNameMatch(name)) continue;
1877 if (n >= 1) set_angle(i + 1, values[0]);
1878 return true;
1879 }
1880
1881 if (ToleranceNP.isNameMatch(name))
1882 {
1883 ToleranceNP.update(values, names, n);
1884 char line[96];
1885 snprintf(line, sizeof(line), "%s %.2f %.2f %.0f", CMD_TOLERANCE,
1886 ToleranceNP[0].getValue(), ToleranceNP[1].getValue(),
1887 ToleranceNP[2].getValue());
1888 Fields c;
1889 const bool ok = command(line, &c);
1890 if (ok)
1891 {
1892 ToleranceNP[0].setValue(number(c, F_GOOD, ToleranceNP[0].getValue()));
1893 ToleranceNP[1].setValue(number(c, F_WARN, ToleranceNP[1].getValue()));
1894 ToleranceNP[2].setValue(number(c, F_RETRIES, ToleranceNP[2].getValue()));
1895 }
1896 ToleranceNP.setState(ok ? IPS_OK : IPS_ALERT);
1897 ToleranceNP.apply();
1898 // more retries, a longer worst case: the wheel's cap follows
1899 if (ok) read_direction(true);
1900 return true;
1901 }
1902
1903 if (SlotsNP.isNameMatch(name))
1904 {
1905 SlotsNP.update(values, names, n);
1906 const bool ok = change_slots((int)SlotsNP[0].getValue());
1907 SlotsNP[0].setValue(m_slots);
1908 SlotsNP.setState(ok ? IPS_OK : IPS_ALERT);
1909 SlotsNP.apply();
1910 return true;
1911 }
1912
1913 if (MotorNP.isNameMatch(name))
1914 {
1915 MotorNP.update(values, names, n);
1916 char line[64];
1917 // the three together: sending the current alone, speed and
1918 // acceleration would go nowhere - the fields would show what was
1919 // typed until the next connection read the wheel's again
1920 snprintf(line, sizeof(line), "%s %.0f %.0f %.0f", CMD_MOTOR, MotorNP[0].getValue(),
1921 MotorNP[1].getValue(), MotorNP[2].getValue());
1922 Fields c;
1923 const bool ok = command(line, &c);
1924 if (ok)
1925 {
1926 MotorNP[0].setValue(number(c, F_MA, MotorNP[0].getValue()));
1927 MotorNP[1].setValue(number(c, F_SPEED, MotorNP[1].getValue()));
1928 MotorNP[2].setValue(number(c, F_ACCEL, MotorNP[2].getValue()));
1929 // the cap follows the speed: warned here, where it is chosen
1930 take_ceiling(c, true);
1931 }
1932 MotorNP.setState(ok ? IPS_OK : IPS_ALERT);
1933 MotorNP.apply();
1934 return true;
1935 }
1936
1937 if (HoldNP.isNameMatch(name))
1938 {
1939 const double settle_before = HoldNP[1].getValue();
1940 HoldNP.update(values, names, n);
1941 char line[64];
1942 snprintf(line, sizeof(line), "%s %.0f", CMD_HOLD, HoldNP[0].getValue());
1943 Fields c;
1944 bool ok = command(line, &c);
1945 if (ok)
1946 {
1947 HoldNP[0].setValue(number(c, F_MA, HoldNP[0].getValue()));
1948 // Whoever turns holding on must know what they are buying: heat
1949 // next to the cooled sensor and a chopper singing during the exposure.
1950 if (HoldNP[0].getValue() > 0)
1951 LOG_WARN("Holding current is on: the motor stays energised at rest. It warms up next to the sensor and the driver sings during exposures. Leave it off unless the wheel drifts at rest.");
1952 }
1953 // The settling hold is sent only when it changed: a firmware
1954 // without `settle` then keeps working as before when only the
1955 // current is set, and goes to Alert only if the hold is asked of
1956 // it. Its reply carries the move time cap, which grows with it.
1957 if (ok && HoldNP[1].getValue() != settle_before)
1958 {
1959 snprintf(line, sizeof(line), "%s %.0f", CMD_SETTLE, HoldNP[1].getValue());
1960 Fields s;
1961 ok = command(line, &s);
1962 if (ok)
1963 {
1964 HoldNP[1].setValue(number(s, F_MS, HoldNP[1].getValue()));
1965 take_ceiling(s, true);
1966 }
1967 else
1968 {
1969 HoldNP[1].setValue(settle_before);
1970 }
1971 }
1972 // released is grey, as at connection: green here would make the
1973 // same state read two ways
1974 HoldNP.setState(!ok ? IPS_ALERT : (HoldNP[0].getValue() > 0 ? IPS_OK : IPS_IDLE));
1975 HoldNP.apply();
1976 return true;
1977 }
1978 }
1979 return INDI::FilterWheel::ISNewNumber(dev, name, values, names, n);
1980}
1981
1982bool Wheelly::ISNewSwitch(const char *dev, const char *name, ISState *states,
1983 char *names[], int n)
1984{
1985 if (dev != nullptr && strcmp(dev, getDeviceName()) == 0)
1986 {
1987
1988 // "Save to the wheel", in the calibration tab and in Options: the
1989 // same command, and each button shows its own outcome
1990 if (ActionsSP.isNameMatch(name) || WheelConfigSP.isNameMatch(name))
1991 {
1992 INDI::PropertySwitch &which = ActionsSP.isNameMatch(name) ? ActionsSP : WheelConfigSP;
1993 which.update(states, names, n);
1994 const bool pressed = which.findOnSwitchIndex() == 0;
1995 which.reset();
1996 const bool ok = pressed ? save_to_wheel() : true;
1997 which.setState(!pressed ? IPS_IDLE : ok ? IPS_OK : IPS_ALERT);
1998 which.apply();
1999 return true;
2000 }
2001
2002 if (JogDownSP.isNameMatch(name))
2003 return jog_pressed(JogDownSP, 0, states, names, n);
2004 if (JogUpSP.isNameMatch(name))
2005 return jog_pressed(JogUpSP, JOG_PER_ROW, states, names, n);
2006
2007 if (DirectionSP.isNameMatch(name))
2008 {
2009 DirectionSP.update(states, names, n);
2010 const int choice = DirectionSP.findOnSwitchIndex();
2011 const char *argument = choice == 1 ? ARG_UP : choice == 2 ? ARG_DOWN : ARG_SHORTEST;
2012 Fields c;
2013 const bool ok = command(std::string(CMD_DIRECTION) + " " + argument, &c);
2014 if (ok)
2015 {
2016 // the switch shows what the wheel answered, not what was asked
2017 const auto t = c.find(F_DIRECTION);
2018 const std::string read = t != c.end() ? t->second : std::string(argument);
2019 DirectionSP.reset();
2020 DirectionSP[read == ARG_UP ? 1 : read == ARG_DOWN ? 2 : 0].setState(ISS_ON);
2021 LOG_INFO("Direction of travel changed. Press \"Save to the wheel\" to keep the choice after power-off.");
2022 take_ceiling(c, true);
2023 }
2024 DirectionSP.setState(ok ? IPS_OK : IPS_ALERT);
2025 DirectionSP.apply();
2026 return true;
2027 }
2028
2029 if (LedSP.isNameMatch(name))
2030 {
2031 LedSP.update(states, names, n);
2032 const int choice = LedSP.findOnSwitchIndex();
2033 const char *argument = choice == 0 ? ARG_ON
2034 : choice == 1 ? ARG_PULSE
2035 : choice == 2 ? ARG_OFF : ARG_TEST;
2036 Fields c;
2037 const bool ok = command(std::string(CMD_LED) + " " + argument, &c);
2038 if (ok && choice == 3)
2039 {
2040 LOGF_INFO("%s",
2041 fill_in("The LED is blinking WHEELLY in Morse for %1$s seconds. Do this with the cover open or in daylight: it is inside the optical path.",
2042 {c.count(F_DURATION) ? c[F_DURATION] : "7"}).c_str());
2043 }
2044 else if (ok)
2045 {
2046 // the explanation goes in the log, not in the panel
2047 LOG_INFO(choice == 1
2048 ? "The LED stays off, and breathes gently while the wheel goes to another filter. Press \"Save to the wheel\" to keep the choice after power-off."
2049 : "LED mode changed. Press \"Save to the wheel\" to keep the choice after power-off.");
2050 }
2051 // the test ends by itself: the switch goes back to the mode the
2052 // wheel has, read from it, and so does a refused command
2053 if (ok)
2054 {
2055 read_led_mode();
2056 }
2057 else
2058 {
2059 LedSP.setState(IPS_ALERT);
2060 LedSP.apply();
2061 }
2062 return true;
2063 }
2064
2065 if (DiagSP.isNameMatch(name))
2066 {
2067 // "Is it really talking to the hardware?" is a single question, and
2068 // it is worth being able to ask it from Ekos: a wire come loose on
2069 // the motor driver shows up as "the motor does not turn" and sends
2070 // one looking for the problem in ten wrong places.
2071 DiagSP.update(states, names, n);
2072 m_show_comments = true;
2073 LOG_INFO("Asking the wheel whether it can really talk to its sensor and its motor driver:");
2074 const bool ok = command(CMD_DIAG);
2075 m_show_comments = false;
2076 DiagSP.reset();
2077 DiagSP.setState(ok ? IPS_OK : IPS_ALERT);
2078 DiagSP.apply();
2079 return true;
2080 }
2081
2082 if (SweepSP.isNameMatch(name))
2083 {
2084 SweepSP.update(states, names, n);
2085 SweepSP.reset();
2086 if (m_moving || m_sweeping)
2087 {
2088 LOG_ERROR("Cannot sweep while the wheel is moving: wait for it to stop.");
2089 SweepSP.setState(IPS_ALERT);
2090 SweepSP.apply();
2091 }
2092 else
2093 {
2094 start_sweep();
2095 }
2096 return true;
2097 }
2098
2099 if (LogSP.isNameMatch(name))
2100 {
2101 LogSP.update(states, names, n);
2102 if (LogSP[0].getState() == ISS_ON) open_log();
2103 else
2104 {
2105 close_log();
2106 LOG_INFO("Movement log off.");
2107 }
2108 LogSP.setState(IPS_OK);
2109 LogSP.apply();
2110 saveConfig(LogSP);
2111 return true;
2112 }
2113
2114 }
2115 return INDI::FilterWheel::ISNewSwitch(dev, name, states, names, n);
2116}
2117
2119{
2121 // The wheel's serial number: it is the only thing in this property worth
2122 // reading back, but the whole of it is saved because it costs nothing and
2123 // the panel is not cluttered with a property of its own.
2124 FirmwareTP.save(fp);
2125 ToleranceNP.save(fp);
2126 SweepDirTP.save(fp);
2127 LogSP.save(fp);
2128 return true;
2129}
2130
2131// ------------------------------------------------------- the calibration angles
2132
2133// A Set on one row of the calibration angles (a Set per row: see AngleNP in
2134// wheelly.h). The slot is taught the angle
2135// in the field, and the wheel goes there - as the position control does,
2136// a live centring - with ONE exception: the
2137// row that follows the jogs ("▶ 2 *", m_unsaved_slot) sent back as the driver
2138// put it is a CONFIRMATION, the wheel is already there and nothing moves;
2139// that is what a "Save position" button would do. Edited, that row is an
2140// ordinary change and the wheel goes. A row whose value did not change sends
2141// no `angle` and only takes the wheel to its slot: pressing Set on a row
2142// always means "that slot, at that angle". The range is the wheel's own:
2143// its refusal is what the log says, and the row keeps its old
2144// value, in Alert.
2145void Wheelly::set_angle(int slot, double value)
2146{
2147 INDI::PropertyNumber &row = AngleNP[slot - 1];
2148 if (m_moving || m_sweeping || m_jogging)
2149 {
2150 LOG_ERROR("Cannot change the angles while the wheel is moving: wait for it to stop.");
2151 row.setState(IPS_ALERT);
2152 row.apply();
2153 return;
2154 }
2155 const bool confirm = slot == m_unsaved_slot && std::fabs(value - m_live_angle) < 0.005;
2156 const double before = m_taught[slot - 1];
2157 if (confirm || std::fabs(value - before) >= 0.005)
2158 {
2159 char line[64];
2160 snprintf(line, sizeof(line), "%s %d %.2f", CMD_ANGLE, slot, confirm ? m_live_angle : value);
2161 Fields c;
2162 std::string error;
2163 if (!command(line, &c, &error))
2164 {
2165 LOGF_ERROR("%s", fill_in("Slot %1$s: the angle was not changed. %2$s", {std::to_string(slot), error}).c_str());
2166 row[0].setValue(slot == m_unsaved_slot ? m_live_angle : before);
2167 row.setState(IPS_ALERT);
2168 row.apply();
2169 return;
2170 }
2171 const double after = number(c, ("a" + std::to_string(slot)).c_str(), value);
2172 m_taught[slot - 1] = after;
2173 row[0].setValue(after);
2174 if (slot == m_unsaved_slot)
2175 {
2176 m_unsaved_slot = 0;
2177 m_angles_stale = true; // the "*" goes: a label, so a definition
2178 }
2179 note_angle_change(slot, before, after, confirm ? "angle-taught" : "angle-set");
2180 LOGF_INFO("%s", fill_in(confirm ?
2181 "Slot %1$s now means the angle the wheel is at right now. Press 'Save to the wheel' to keep it after the wheel is switched off."
2182 : "The new angles live in the wheel's working memory: press 'Save to the wheel' to keep them after the wheel is switched off.",
2183 {std::to_string(slot)}).c_str());
2184 }
2185 row.setState(IPS_OK);
2186 row.apply();
2187 if (confirm)
2188 {
2189 refresh_angles();
2190 return;
2191 }
2192 // to that slot now, through the same path as FILTER_SLOT, so Ekos sees
2193 // the change of filter and its verdict; a "*" still owed goes when the
2194 // wheel stops (refresh_angles waits for it)
2195 FilterSlotNP[0].setValue(slot);
2196 if (!SelectFilter(slot))
2197 {
2200 refresh_angles();
2201 }
2202}
2203
2204// The line in the log, and a row in the movement register with its own
2205// outcome ("angle-set", "angle-taught"): the slot, the new angle in the
2206// target column, the old one in the angle column, and the change as the
2207// error. The history of the corrections the rotation trim was meant to show.
2208void Wheelly::note_angle_change(int slot, double before, double after, const char *what)
2209{
2210 char was[16], is[16], delta[16];
2211 snprintf(was, sizeof(was), "%.2f", before);
2212 snprintf(is, sizeof(is), "%.2f", after);
2213 snprintf(delta, sizeof(delta), "%.2f", std::remainder(after - before, 360.0));
2214 // Every change of a calibration angle, from the Set or from 'Save
2215 // position': the history the rotation trim was meant to
2216 // keep, with its date, in the log and in the movement register.
2217 LOGF_INFO("%s", fill_in("Slot %1$s: %2$s° → %3$s°.", {std::to_string(slot), was, is}).c_str());
2218 if (m_log == nullptr) return;
2219 char when[32];
2220 const std::time_t now = std::time(nullptr);
2221 std::strftime(when, sizeof(when), "%Y-%m-%dT%H:%M:%S", std::gmtime(&now));
2222 std::fprintf(m_log, "%s,%d,%s,%s,%s,,%s,,\n", when, slot, is, was, delta, what);
2223 std::fflush(m_log);
2224}
2225
2226bool Wheelly::save_to_wheel()
2227{
2228 if (!isConnected())
2229 {
2230 LOG_ERROR("Connect the wheel first.");
2231 return false;
2232 }
2233 const bool ok = command(CMD_SAVE);
2234 if (ok) LOG_INFO("Calibration saved in the wheel.");
2235 return ok;
2236}
2237
2238// ------------------------------------------------------------------ the jog
2239
2240bool Wheelly::jog_pressed(INDI::PropertySwitch &row, int first,
2241 ISState *states, char *names[], int n)
2242{
2243 row.update(states, names, n);
2244 const int choice = row.findOnSwitchIndex();
2245 row.reset();
2246 if (choice < 0)
2247 {
2248 row.apply();
2249 return true;
2250 }
2251 if (m_moving || m_sweeping || m_jogging)
2252 {
2253 LOG_ERROR("Cannot move the wheel by a step while it is moving: wait for it to stop.");
2254 row.setState(IPS_ALERT);
2255 row.apply();
2256 return true;
2257 }
2258 std::string error;
2259 // a step of 0 is the slot pitch, signed by its row (JOG_STEPS)
2260 double degrees = JOG_STEPS[first + choice];
2261 if (degrees == 0.0) degrees = (first == 0 ? -360.0 : 360.0) / m_slots;
2262 char line[32];
2263 snprintf(line, sizeof(line), "%s %.4f", CMD_JOG, degrees);
2264 if (!command(line, nullptr, &error))
2265 {
2266 // an old firmware answers "unknown command", and the log says so
2267 LOGF_ERROR("%s", error.empty() ? "The wheel is not answering." : error.c_str());
2268 row.setState(IPS_ALERT);
2269 row.apply();
2270 return true;
2271 }
2272 m_jogging = true;
2273 m_jog_row = std::addressof(row); // PropertySwitch overloads &
2274 m_move_start = now_s();
2275 row.setState(IPS_BUSY);
2276 row.apply();
2277 return true;
2278}
2279
2280void Wheelly::finish_jog(const Fields &status, const std::string &outcome)
2281{
2282 // Not a filter change: FILTER_SLOT is left alone, and the verdict is
2283 // said in the log in plain words. A jog close to the sensor's count
2284 // (0.088 degrees) may be reported by the wheel as a "warning" a hair
2285 // past its target: that is still a done jog, said as information; only
2286 // a failure is an error.
2287 m_jogging = false;
2288 char angle[16], off[16];
2289 snprintf(angle, sizeof(angle), "%.2f", number(status, F_ANGLE));
2290 snprintf(off, sizeof(off), "%.2f", std::fabs(number(status, F_ERR)));
2291 const bool failed = outcome == EV_FAILED;
2292 if (failed)
2293 {
2294 LOGF_ERROR("%s",
2295 fill_in("The wheel did not get where the step asked: it is at %1$s°, %2$s° away. Try again, or turn it by hand and write the angle it reaches in its row.", {angle, off}).c_str());
2296 LOG_WARN("If the motor stalls or the clutch slips, check first that the wheel's detent (its spring click stop) has been removed: the motor cannot climb out of its notches. Assembly guide, chapter 10, The wheel body.");
2297 }
2298 // the jog: %1$s the angle now, %2$s how far from the target
2299 else LOGF_INFO("%s", fill_in("The wheel is at %1$s°, %2$s° from where the step asked.", {angle, off}).c_str());
2300 if (m_jog_row != nullptr)
2301 {
2302 m_jog_row->setState(failed ? IPS_ALERT : IPS_OK);
2303 m_jog_row->apply();
2304 }
2305 // The current row shows where the wheel is now, "▶ 2 *", to confirm with
2306 // the Set on that row (see m_unsaved_slot). A failed jog too: the wheel
2307 // stands somewhere, and that is what the row says. If the jog carried the
2308 // wheel onto another slot, mark_current_slot, right after in the same
2309 // TimerHit, gives it back and moves the marker.
2310 const int slot = (CurrentFilter >= 1 && CurrentFilter <= m_slots) ? CurrentFilter : 0;
2311 if (slot != 0)
2312 {
2313 if (m_unsaved_slot != 0 && m_unsaved_slot != slot) forget_unsaved();
2314 m_unsaved_slot = slot;
2315 // rounded as the field shows it (%.2f): KStars sends back the text
2316 // it shows, and an unrounded live angle 0.005 away would read as an
2317 // edit - and move the wheel - instead of a confirmation
2318 m_live_angle = std::round(number(status, F_ANGLE) * 100.0) / 100.0;
2319 AngleNP[slot - 1][0].setValue(m_live_angle);
2320 m_angles_stale = true;
2321 }
2322}
2323
2324// ------------------------------------------------------------- the magnet sweep
2325
2326void Wheelly::start_sweep()
2327{
2328 m_samples.clear();
2329 m_samples.reserve(512);
2330 // One hop per slot: starting from where the wheel stands and always moving
2331 // on by one, after m_slots hops it is back at the starting point having
2332 // covered the whole turn. This way the wheel ends where it was, and
2333 // whoever was capturing does not find a filter different from the one
2334 // they had.
2335 m_sweep_remaining = m_slots;
2336 // The live row goes back to its taught angle NOW, before the sweep: during
2337 // the turn the rows are not redefined (see the end of TimerHit).
2338 forget_unsaved();
2339 refresh_angles();
2340 m_sweeping = true;
2341 SweepSP.setState(IPS_BUSY);
2342 SweepSP.apply();
2343 LOG_INFO("Turning all the way round and measuring the magnet at every step. The wheel moves: do this with the cover open, not during a sequence.");
2344 sweep_step();
2345}
2346
2347void Wheelly::sweep_step()
2348{
2349 if (m_sweep_remaining <= 0)
2350 {
2351 finish_sweep(true);
2352 return;
2353 }
2354 m_sweep_remaining--;
2355 // Always to the next slot, never to a far one: this way the wheel turns
2356 // all in one direction and the samples cover the turn without going back.
2357 const int next = (CurrentFilter % m_slots) + 1;
2358 if (!SelectFilter(next))
2359 {
2360 finish_sweep(false);
2361 return;
2362 }
2363 // FILTER_SLOT goes Busy inside SelectFilter(), whoever calls it: it is
2364 // not set again here.
2365}
2366
2367void Wheelly::finish_sweep(bool succeeded)
2368{
2369 m_sweeping = false;
2370
2371 // Fewer than four samples is not a sweep, it is a pretext: better to say
2372 // that it did not work than to leave a file that looks like a measurement.
2373 if (!succeeded || m_samples.size() < 4)
2374 {
2375 LOG_ERROR("The sweep stopped before finishing the turn: it would lie about the magnet, so nothing was written.");
2376 SweepSP.setState(IPS_ALERT);
2377 SweepSP.apply();
2378 return;
2379 }
2380
2381 double minimum = m_samples[0].magnitude, maximum = minimum;
2382 for (const Sample &c : m_samples)
2383 {
2384 minimum = std::min(minimum, c.magnitude);
2385 maximum = std::max(maximum, c.magnitude);
2386 }
2387
2388 // The samples go to a CSV file, and the driver draws nothing: an INDI
2389 // driver measures and reports, and a picture is the client's business
2390 // (INDI's maintainers asked for the drawing code to go). The file is data
2391 // any spreadsheet opens, and the project's sweep viewer (sweep.html, a
2392 // single page that works offline) draws it as the driver used to: the
2393 // magnitude against the angle, with the taught angles marked - which is
2394 // why they are written in the header, with the number of slots.
2395 // Rejected: sending the file as a BLOB. KStars opens an image BLOB in a
2396 // window whose closing also closes the INDI panel, and a CSV BLOB it only
2397 // saves, in a folder the driver cannot know: the path in "Files on disk"
2398 // is what the user needs.
2399 const std::string where = sweep_path();
2400 ensure_folder(where);
2401 bool written = false;
2402 // The reason is taken RIGHT after the call that fails. Taken at the end,
2403 // errno can be that of something else entirely - here it was the EEXIST of
2404 // the mkdir above, and the message said "File exists" to someone who could
2405 // not write a file.
2406 int why = 0;
2407 if (std::FILE *f = std::fopen(where.c_str(), "w"))
2408 {
2409 bool ok = std::fprintf(f, "# Wheelly magnet sweep\n# slots,%d\n# taught_deg", m_slots) > 0;
2410 for (int i = 0; i < m_slots; i++) ok = ok && std::fprintf(f, ",%.2f", m_taught[i]) > 0;
2411 ok = ok && std::fprintf(f, "\nangle_deg,magnitude\n") > 0;
2412 for (const Sample &c : m_samples)
2413 ok = ok && std::fprintf(f, "%.2f,%.0f\n", c.angle, c.magnitude) > 0;
2414 written = ok;
2415 if (!written) why = errno;
2416 if (std::fclose(f) != 0 && written)
2417 {
2418 written = false;
2419 why = errno;
2420 }
2421 }
2422 else
2423 {
2424 why = errno;
2425 }
2426 if (written)
2427 {
2428 FileTP[FILE_SWEEP].setText(where.c_str());
2429 }
2430 else
2431 {
2432 FileTP[FILE_SWEEP].setText("not written yet");
2433 LOGF_WARN("%s", fill_in("Cannot write the sweep to %1$s: %2$s",
2434 {where, std::strerror(why)}).c_str());
2435 }
2436 FileTP.setState(written ? IPS_OK : IPS_ALERT);
2437 FileTP.apply();
2438
2439 char deviation[16];
2440 snprintf(deviation, sizeof(deviation), "%.1f",
2441 maximum > 0 ? (maximum - minimum) / maximum * 100.0 : 0.0);
2442 LOGF_INFO("%s",
2443 fill_in("Sweep done: %1$s samples, magnitude from %2$s to %3$s, excursion %4$s counts (%5$s%%). The smaller it is, the better the magnet is centred.",
2444 {
2445 std::to_string(m_samples.size()),
2446 std::to_string((long)minimum), std::to_string((long)maximum),
2447 std::to_string((long)(maximum - minimum)), deviation
2448 }).c_str());
2449 if (written)
2450 LOGF_INFO("%s",
2451 fill_in("The sweep is here: %1$s. To see it as a curve, open it with the sweep viewer: https://teoteo.github.io/Wheelly/tools/sweep.html",
2452 {where}).c_str());
2453 SweepSP.setState(IPS_OK);
2454 SweepSP.apply();
2455}
2456
2457// ------------------------------------------------------------- the movement log
2458
2459std::string Wheelly::sweep_path() const
2460{
2461 // Every sweep its own file, with date and time in the name. The point of
2462 // measuring the magnet is comparing a before and an after - a touch to the
2463 // air gap, a screw tightened again - and a fixed name that gets overwritten
2464 // throws away exactly the term of comparison.
2465 //
2466 // The time is LOCAL and not UTC, unlike the one in the movement log: that
2467 // one is read by a spreadsheet, this one by whoever remembers touching the
2468 // wheel "last night around nine".
2469 //
2470 // And in Documents, not in the driver's hidden folder: a sweep is something
2471 // to look at, and goes where things are looked at.
2472 char when[32];
2473 const std::time_t now = std::time(nullptr);
2474 std::tm parts {};
2475 localtime_r(&now, &parts);
2476 std::strftime(when, sizeof(when), "%Y-%m-%d_%H-%M-%S", &parts);
2477
2478 std::string folder = expand_home(SweepDirTP[0].getText());
2479 while (folder.size() > 1 && folder.back() == '/') folder.pop_back();
2480 if (folder.empty()) folder = default_folder();
2481 return folder + "/" + when + "_wheelly_sweep.csv";
2482}
2483
2484std::string Wheelly::default_folder()
2485{
2486 const char *home = getenv("HOME");
2487 return std::string(home ? home : "/tmp") + "/Documents";
2488}
2489
2490std::string Wheelly::expand_home(const std::string &path)
2491{
2492 if (path.compare(0, 2, "~/") != 0) return path;
2493 const char *home = getenv("HOME");
2494 return std::string(home ? home : "/tmp") + path.substr(1);
2495}
2496
2497void Wheelly::ensure_folder(const std::string &file_path)
2498{
2499 const size_t cut = file_path.rfind('/');
2500 if (cut == std::string::npos || cut == 0) return;
2501 ::mkdir(file_path.substr(0, cut).c_str(), 0755); // if it is already there, never mind
2502}
2503
2504std::string Wheelly::log_path() const
2505{
2506 const char *home = getenv("HOME");
2507 // English, as every file the project writes (the project is published
2508 // in English); an older name was wheelly_movimenti.csv - see open_log()
2509 return std::string(home ? home : "/tmp") + "/.indi/wheelly_movements.csv";
2510}
2511
2512void Wheelly::open_log()
2513{
2514 if (m_log != nullptr) return;
2515 const std::string path = log_path();
2516
2517 // The log's old, Italian name: a log started before the rename carries on
2518 // under the new one instead of being left behind, so the history of the
2519 // wheel stays in one file. Only when the new one does not exist yet - never
2520 // over it.
2521 const std::string old = path.substr(0, path.rfind('/')) + "/wheelly_movimenti.csv";
2522 if (access(path.c_str(), F_OK) != 0 && access(old.c_str(), F_OK) == 0)
2523 std::rename(old.c_str(), path.c_str());
2524
2525 const bool fresh = (access(path.c_str(), F_OK) != 0);
2526 m_log = std::fopen(path.c_str(), "a");
2527 if (m_log == nullptr)
2528 {
2529 LOGF_ERROR("%s", fill_in("Cannot write the log file %1$s: %2$s",
2530 {path, std::strerror(errno)}).c_str());
2531 LogSP.reset();
2532 LogSP[1].setState(ISS_ON);
2533 return;
2534 }
2535 // The header is in English like the rest of what is meant to be read by a
2536 // machine: this file is opened in a spreadsheet, not in Ekos. A change
2537 // of a calibration angle is a row too, told apart by
2538 // its outcome, angle-set or angle-taught: the new angle under "target",
2539 // the old one under "angle", the change under "error"
2540 // (note_angle_change).
2541 if (fresh)
2542 std::fprintf(m_log,
2543 "timestamp,slot,target,angle,error,retries,outcome,agc,magnitude\n");
2544 std::fflush(m_log);
2545 LOGF_INFO("%s", fill_in("Movement log on: %1$s", {path}).c_str());
2546}
2547
2548void Wheelly::close_log()
2549{
2550 if (m_log == nullptr) return;
2551 std::fclose(m_log);
2552 m_log = nullptr;
2553}
2554
2555void Wheelly::record_move(const Fields &status, const char *outcome)
2556{
2557 if (m_log == nullptr) return;
2558
2559 char when[32];
2560 const std::time_t now = std::time(nullptr);
2561 std::strftime(when, sizeof(when), "%Y-%m-%dT%H:%M:%S", std::gmtime(&now));
2562
2563 const auto take = [&status](const char *k)
2564 {
2565 const auto t = status.find(k);
2566 return t == status.end() ? std::string("") : t->second;
2567 };
2568 std::fprintf(m_log, "%s,%d,%s,%s,%s,%s,%s,%s,%s\n",
2569 when, TargetFilter, take(F_TARGET).c_str(),
2570 take(F_ANGLE).c_str(), take(F_ERR).c_str(),
2571 take(F_RETRIES).c_str(), outcome,
2572 take(F_AGC).c_str(), take(F_MAG).c_str());
2573 std::fflush(m_log);
2574}
hid_device * device
virtual const char * port()
void setPortMatchPattern(const std::string &pattern)
setPortMatchPattern Set a regex pattern used to automatically select a serial port from the discovere...
void setDefaultBaudRate(BaudRate newRate)
setDefaultBaudRate Set default baud rate. The default baud rate is 9600 unless otherwise changed by t...
virtual bool Disconnect() override
Disconnect Disconnect from device.
bool isConnected() const
INDI::PropertyText getText(const char *name) const
void addPollPeriodControl()
Add Polling period control to the driver.
virtual bool saveConfig(bool silent=false, const char *property=nullptr)
Save the current properties in a configuration file.
void addConfigurationControl()
Add Configuration control to the driver.
void addSimulationControl()
Add Simulation control to the driver.
virtual bool Disconnect()
Disconnect from device.
void setDefaultPollingPeriod(uint32_t msec)
setDefaultPollingPeriod Change the default polling period to call TimerHit() function in the driver.
virtual void setConnected(bool status, IPState state=IPS_OK, const char *msg=nullptr)
Set connection switch status in the client.
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)
uint32_t getCurrentPollingPeriod() const
getCurrentPollingPeriod Return the current polling period.
int SetTimer(uint32_t ms)
Set a timer to call the function TimerHit after ms milliseconds.
virtual bool Connect()
Connect to the device. INDI::DefaultDevice implementation connects to appropriate connection interfac...
void addDebugControl()
Add Debug control to the driver.
INDI::PropertyNumber FilterSlotNP
INDI::PropertyText FilterNameTP
void SelectFilterDone(int newpos)
The child class calls this function when the hardware successfully finished selecting a new filter wh...
virtual bool GetFilterNames()
Obtains a list of filter names from the hardware and initializes the FilterNameTP property....
virtual bool updateProperties() override
updateProperties is called whenever there is a change in the CONNECTION status of the driver....
virtual bool ISNewSwitch(const char *dev, const char *name, ISState *states, char *names[], int n) override
Process the client newSwitch command.
Connection::Serial * serialConnection
virtual bool initProperties() override
Initialize properties initial state and value. The child class must implement this function.
virtual bool saveConfigItems(FILE *fp) override
saveConfigItems Save specific properties in the provide config file handler. Child class usually over...
void setFilterConnection(const uint8_t &value)
setFilterConnection Set Filter connection mode. Child class should call this in the constructor befor...
virtual bool ISNewNumber(const char *dev, const char *name, double values[], char *names[], int n) override
Process the client newNumber command.
int PortFD
For Serial & TCP connections.
virtual bool ISNewText(const char *dev, const char *name, char *texts[], char *names[], int n) override
Process the client newSwitch command.
void push(WidgetView< T > &&item)
void setState(IPState state)
void apply(const char *format,...) const ATTRIBUTE_FORMAT_PRINTF(2
bool load()
load Attempt to load property values from configuration file.
void save(FILE *f) const
save Save property to configuration file.
void resize(size_t size)
void setLabel(const char *label)
const char * getName() const
bool isNameMatch(const char *otherName) const
void setGroupName(const char *name)
void reserve(size_t size)
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)
Provides generic container for INDI properties.
INDI::PropertyViewText * getText() const
int down_errno() const
Definition wheelly.h:69
void flush()
Definition wheelly.cpp:223
bool channel_down() const
Definition wheelly.h:63
bool line(std::string &out, int wait_ms)
Definition wheelly.cpp:233
void attach(int descriptor)
Definition wheelly.cpp:215
bool SelectFilter(int slot) override
Select a new filter position.
Definition wheelly.cpp:1425
int QueryFilter() override
Return current filter position.
Definition wheelly.cpp:1457
bool updateProperties() override
updateProperties is called whenever there is a change in the CONNECTION status of the driver....
Definition wheelly.cpp:520
bool Connect() override
Connect to the device. INDI::DefaultDevice implementation connects to appropriate connection interfac...
Definition wheelly.cpp:815
bool initProperties() override
Initialize properties initial state and value. The child class must implement this function.
Definition wheelly.cpp:303
void TimerHit() override
Callback function to be called once SetTimer duration elapses.
Definition wheelly.cpp:1489
bool saveConfigItems(FILE *fp) override
saveConfigItems Save specific properties in the provide config file handler. Child class usually over...
Definition wheelly.cpp:2118
bool ISNewText(const char *dev, const char *name, char *texts[], char *names[], int n) override
Process the client newSwitch command.
Definition wheelly.cpp:1688
bool ISNewSwitch(const char *dev, const char *name, ISState *states, char *names[], int n) override
Process the client newSwitch command.
Definition wheelly.cpp:1982
bool GetFilterNames() override
Obtains a list of filter names from the hardware and initializes the FilterNameTP property....
Definition wheelly.cpp:1377
bool Handshake() override
perform handshake with device to check communication
Definition wheelly.cpp:848
const char * getDefaultName() override
Definition wheelly.cpp:294
bool SetFilterNames() override
Set filter names as defined by the client for each filter position. The desired filter names are stor...
Definition wheelly.cpp:1406
bool ISNewNumber(const char *dev, const char *name, double values[], char *names[], int n) override
Process the client newNumber command.
Definition wheelly.cpp:1868
bool Disconnect() override
Disconnect from device.
Definition wheelly.cpp:1108
int errno
ISState
Switch state.
Definition indiapi.h:148
@ ISS_OFF
Definition indiapi.h:149
@ ISS_ON
Definition indiapi.h:150
#define MAXINDIMESSAGE
Definition indiapi.h:196
@ IP_RW
Definition indiapi.h:184
@ IP_RO
Definition indiapi.h:182
@ 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_ATMOST1
Definition indiapi.h:172
int tty_connect(const char *device, int bit_rate, int word_size, int parity, int stop_bits, int *fd)
Establishes a tty connection to a terminal device.
Definition indicom.c:1022
int tty_disconnect(int fd)
Closes a tty connection and flushes the bus.
Definition indicom.c:1224
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
Implementations for common driver routines.
@ TTY_OK
Definition indicom.h:54
#define MAXRBUF
Definition indidriver.c:52
#define LOGF_INFO(fmt,...)
Definition indilogger.h:82
#define LOGF_WARN(fmt,...)
Definition indilogger.h:81
#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
#define getDeviceName
int fd
@ error
throw a parse_error exception in case of a tag
const size_t FILTER_NAME_MAX
const char F_TARGET[]
const char EV_DRIFT[]
const char MOTION_IDLE[]
const char PREFIX_EVENT
const char F_SLOTS[]
const char CMD_NAME[]
bool name_is_allowed(char c)
const char F_AGC[]
const char F_POS[]
const char ARG_UP[]
const char CMD_STATUS[]
const uint16_t RECOMMENDED_HOLD_MA
const char F_SERIAL[]
const int PROTOCOL_VERSION
const char F_DURATION[]
const char PREFIX_OK[]
const char CMD_TOLERANCE[]
bool name_equal_nocase(const char *a, const char *b)
const int FACTORY_SLOTS
const int RETRIES_MAX
const char ARG_PULSE[]
const char F_MD[]
const char F_REASON[]
const char CMD_ANGLES[]
const char CMD_GO[]
const char F_MS[]
const char F_ML[]
const char MOTION_FAILED[]
const char CMD_DIRECTION[]
const size_t WHEELLY_LINE_MAX
const char ARG_DOWN[]
const char OUTCOME_NONE[]
const char F_GOT[]
const char ARG_SHORTEST[]
const char F_ANGLE[]
const char EV_WARNING[]
const char CMD_STOP[]
const char F_ERR[]
const unsigned long MOTOR_ACCEL_MAX
const char CMD_NAMES[]
const char EV_ARRIVED[]
const char F_MH[]
void sanitize_filter_name(const char *in, char *out, size_t out_size)
const char ARG_ON[]
const char F_NAME[]
const char CMD_SETTLE[]
const char F_MA[]
const unsigned long FACTORY_ACCEL
const char F_DIRECTION[]
const char EV_SENSOR[]
const char F_MOTION[]
const int MAX_SLOTS
const char CMD_HOLD[]
const int ANGLE_MAX_DEG
const char CMD_DIAG[]
const unsigned FACTORY_SETTLE_MS
const char CMD_JOG[]
const char F_GOOD[]
const char CMD_ANGLE[]
const char F_EXPECTED[]
const char ARG_TEST[]
const char EV_FAILED[]
const char F_OUTCOME[]
const int TOLERANCE_MAX_DEG
const int MIN_SLOTS
const char F_CEILING[]
const unsigned long FACTORY_SPEED
const char F_RETRIES[]
const char PREFIX_ERROR[]
const char F_MAG[]
const char CMD_LED[]
const char EV_DRIVER[]
const unsigned MOTOR_MA_MAX
const char F_PROTO[]
const char F_MODE[]
const char F_WARN[]
NameCheck check_filter_name(const char *s)
const char CMD_SLOTS[]
const unsigned FACTORY_RUN_MA
const char F_ACCEL[]
const float FACTORY_GOOD_DEG
const char F_SPEED[]
const char F_FW[]
const char CMD_SAVE[]
const char CMD_VERSION[]
const char FACTORY_DIRECTION[]
const char PREFIX_COMMENT
const char CMD_MOTOR[]
const unsigned long MOTOR_SPEED_MAX
const unsigned SETTLE_MS_MAX
const float FACTORY_WARN_DEG
const char ARG_OFF[]
const unsigned long EKOS_FILTER_TIMEOUT_MS
const char V_RESET[]
void fill(const char *name, const char *label, const char *initialText)