Instrument Neutral Distributed Interface INDI 2.2.5
wheelly.h
Go to the documentation of this file.
1// SPDX-FileCopyrightText: 2026 Matteo Beretta
2// SPDX-License-Identifier: LGPL-2.1-or-later
3
4// Wheelly - INDI driver for the filter wheel.
5//
6// The driver only relays: the decisions - whether the wheel has arrived,
7// whether to retry, whether to declare failure - live in the firmware. That
8// way the wheel behaves the same even when driven by hand from a serial
9// monitor, and there are no two copies of the same logic that can diverge.
10//
11// See firmware.md for the reasoning, and wheelly_protocol.h for the vocabulary.
12
13#ifndef WHEELLY_H
14#define WHEELLY_H
15
16#include "wheelly_protocol.h"
17
18#include <string>
19#include <vector>
20
21namespace wheelly
22{
23// A sample of the sweep: where the wheel stood, and how strongly the sensor
24// saw the magnet at that moment.
25struct Sample
26{
27 double angle; // degrees, 0..360
28 double magnitude; // AS5600 counts
29};
30} // namespace wheelly
31
32// libindi's headers are named the way INDI's own drivers name them - no
33// "libindi/" prefix - because that is the only spelling that resolves both
34// against the installed headers (pkg-config puts include/libindi on the path)
35// and inside INDI's source tree, where the headers sit in libs/indibase and
36// there is no libindi/ directory at all.
37#include "indifilterwheel.h"
38#include "indipropertynumber.h"
39#include "indipropertyswitch.h"
40#include "indipropertytext.h"
41
42#include <cstdio>
43#include <string>
44#include <map>
45#include <vector>
46
47// Line reader with its own buffer.
48//
49// tty_nread_section is not used because, when it times out halfway through a
50// line, it returns what it has read and the rest of the line is lost. On our
51// channel unsolicited lines arrive - the events - which can fall in the middle
52// of an answer, so a reader that loses pieces here would cause trouble that is
53// hard to understand. Whatever is not consumed stays in here until the next
54// read.
56{
57 public:
58 void attach(int descriptor);
59 void flush();
60 // Returns true and fills `out` if a whole line has arrived within the
61 // given time. Returns false on timeout or on a channel error.
62 bool line(std::string &out, int wait_ms);
63 bool channel_down() const
64 {
65 return m_down;
66 }
67 // The errno of the failure that put the channel down; 0 when the
68 // other end hung up (end of file) - which on a tty is the port gone.
69 int down_errno() const
70 {
71 return m_errno;
72 }
73
74 private:
75 int m_fd {-1};
76 std::string m_rest;
77 bool m_down {false};
78 int m_errno {0};
79};
80
82{
83 public:
84 Wheelly();
85 virtual ~Wheelly() override = default;
86
87 const char *getDefaultName() override;
88 // Connecting in two passes: see wheelly.cpp. It is needed because the
89 // wheel is recognised by its protocol and not by the name of the port.
90 bool Connect() override;
91 bool Disconnect() override;
92 bool initProperties() override;
93 bool updateProperties() override;
94 bool ISNewNumber(const char *dev, const char *name, double values[],
95 char *names[], int n) override;
96 bool ISNewSwitch(const char *dev, const char *name, ISState *states,
97 char *names[], int n) override;
98 bool ISNewText(const char *dev, const char *name, char *texts[],
99 char *names[], int n) override;
100
101 protected:
102 bool Handshake() override;
103 bool SelectFilter(int slot) override;
104 int QueryFilter() override;
105 bool SetFilterNames() override;
106 bool GetFilterNames() override;
107 void TimerHit() override;
108 bool saveConfigItems(FILE *fp) override;
109
110 private:
111 // --- dialogue with the firmware ----------------------------------
112 using Fields = std::map<std::string, std::string>;
113
114 // Sends a command and waits for its outcome. The informative lines and
115 // the events that arrive in the meantime do not end the wait: they are
116 // set aside, because they are not what is being waited for.
117 bool command(const std::string &text, Fields *fields = nullptr,
118 std::string *error_for_user = nullptr);
119 void collect_unsolicited(); // events that arrived unasked
120 void handle_unsolicited_line(const std::string &line);
121 static Fields split_fields(const std::string &line);
122 static double number(const Fields &fields, const char *key, double if_missing = 0.0);
123
124 // --- reading and writing the state --------------------------------
125 bool read_status(Fields &fields);
126 bool read_calibration(); // angles, offsets, tolerances, motor
127 void update_panels(const Fields &status);
128
129 // --- the magnet sweep ---------------------------------------------
130 // It is not a loop that just runs: blocking in here for twenty
131 // seconds would freeze the driver, and with it the chance of stopping
132 // it from Ekos. So it is a state machine that advances inside
133 // TimerHit, one hop at a time, as a normal filter change does.
134 void start_sweep();
135 void sweep_step();
136 void finish_sweep(bool succeeded);
137
138 // --- the movement log ---------------------------------------------
139 std::string log_path() const;
140 std::string sweep_path() const;
141 // The leading '~' is expanded by the shell, not by fopen: if the user
142 // writes it in a text field, it has to be expanded here or the file
143 // ends up in a folder really called "~".
144 static std::string expand_home(const std::string &path);
145 static std::string default_folder();
146 // Creates the folder of the driver's files if it is not there yet. The
147 // first run on a new machine really happens.
148 static void ensure_folder(const std::string &file_path);
149 void open_log();
150 void close_log();
151 void record_move(const Fields &status, const char *outcome);
152
153 // --- properties: main panel ---------------------------------------
154 INDI::PropertyNumber PositionNP {3}; // angle, error, retries
155 INDI::PropertyNumber SensorNP {5}; // agc, mag, md, ml, mh
156
157 // --- properties: calibration panel --------------------------------
158 // How many slots the wheel has, set from the panel (simpler for the
159 // user than a serial monitor). It lives
160 // in the wheel; changing it restarts the calibration from evenly
161 // spaced angles, in the wheel's working memory only until "Save".
162 INDI::PropertyNumber SlotsNP {1};
163 void size_slots(); // the per-slot elements, exactly m_slots
164 bool change_slots(int count);
165 // The calibration angles, EDITABLE, ONE PROPERTY PER SLOT,
166 // WHEELLY_ANGLE_1 .. _n, one element each: every row needs a Set of
167 // its own, and KStars gives one Set per property, with a caption that
168 // cannot be changed. A Set on a row teaches that slot the angle in
169 // its field (set_angle). Rejected: a single vector (WHEELLY_ANGLES)
170 // plus a "Save position" button (WHEELLY_TEACH) - the Set on the
171 // current row does what that button did. No rotation trim under the
172 // angles (WHEELLY_OFFSET, removed): firmware.md 2.2, "One number per
173 // slot". Built in initProperties, all MAX_SLOTS of them
174 // (reserve: no reallocation after), defined only the first m_slots.
175 std::vector<INDI::PropertyNumber> AngleNP;
176 int m_angles_defined {0}; // how many rows the client has
177 // THE CURRENT SLOT IS MARKED in its row's label, "▶ 2": INDI cannot
178 // colour a property. A label reaches a client
179 // only with a DEFINITION, so the rows are taken down and defined
180 // again - and with them every property after them in the tab, or
181 // KStars, which appends a new definition at the end of its group,
182 // would move them to the bottom. Only when something changes, never
183 // at a plain poll. The marked slot is the driver's current filter: it
184 // stays on while the wheel is jogged off it to centre the filter.
185 int m_marked_slot {0};
186 // THE CURRENT ROW FOLLOWS THE JOGS: otherwise, with the filter jogged
187 // centred, the row still shows the old angle and the number to
188 // confirm is nowhere on the panel. After every jog the current slot's
189 // element shows the angle read NOW, labelled "▶ 2 *" - not taught
190 // yet. KStars fills the editable field only from a definition, hence
191 // the redefinition, and only at the end of a jog, at a change of
192 // slot or at a teach, never at a poll. "Set" on that row confirms
193 // it; any other move (a filter change, the sweep, a jog that
194 // lands on another slot) gives the row back its taught angle.
195 // m_taught holds the wheel's angles, which the elements show except
196 // for that one row: every comparison and every "before" in the log
197 // is made against it, never against the shown value.
198 double m_taught[wheelly::MAX_SLOTS] {};
199 int m_unsaved_slot {0}; // 0 = no row shows a live angle
200 double m_live_angle {0.0};
201 bool m_angles_stale {false}; // a redefinition is owed
202 void label_slots(); // the angles' labels, marker included
203 void mark_current_slot(); // re-label and redefine if it changed
204 void forget_unsaved(); // the live row back to its taught angle
205 // Redefines the angles if something is owed, once the wheel is still:
206 // during a move it waits, so a change of slot costs one definition
207 // and not two, and during a sweep it never runs (every hop changes slot).
208 void refresh_angles();
209 void redefine_calibration(); // the rows and all after them in the tab
210 void define_angles(); // the first m_slots rows
211 void delete_angles(); // the rows the client has
212 void set_angle(int slot, double value);
213 // Every angle change, in the log and in the movement register.
214 void note_angle_change(int slot, double before, double after, const char *what);
215 INDI::PropertySwitch ActionsSP {1}; // save (no "clear trims": there are no trims)
216 // "Save to the wheel" again, in Options right under INDI's
217 // "Configuration": a property of its own, since
218 // CONFIG_PROCESS belongs to INDI - Ekos counts on its four elements -
219 // and with five buttons KStars would make it a drop-down menu.
220 INDI::PropertySwitch WheelConfigSP {1};
221 bool save_to_wheel();
222 // THE JOG BUTTONS: -pitch -10 -1 -0.1 and +0.1 +1 +10 +pitch
223 // degrees, the pitch being 360/slots (72 on a five-slot wheel; the
224 // 0.05 step was rejected, below one AS5600 count). ONE row would be
225 // simpler, but KStars draws a switch vector of more than four
226 // exclusive elements as a drop-down menu, so it is two rows, back and
227 // forward. A jog is `jog <deg>`: a move TO the angle read plus that
228 // much, closed loop in the firmware; the driver only follows it. No
229 // third row "Save position" (WHEELLY_TEACH, rejected): the Set on the
230 // current slot's row, which follows the jogs, does the same.
231 static const int JOG_PER_ROW = 4;
232 INDI::PropertySwitch JogDownSP {JOG_PER_ROW};
233 INDI::PropertySwitch JogUpSP {JOG_PER_ROW};
234 bool m_jogging {false};
235 INDI::PropertySwitch *m_jog_row {nullptr}; // the row whose jog runs
236 bool jog_pressed(INDI::PropertySwitch &row, int first,
237 ISState *states, char *names[], int n);
238 void label_jogs(); // the pitch buttons follow m_slots
239 void finish_jog(const Fields &status, const std::string &outcome);
240 INDI::PropertyNumber ToleranceNP {3};
241 INDI::PropertyNumber MotorNP {3};
242 // [0] the holding current at rest, [1] the settling hold after every
243 // leg (`settle`): one property, because both say how the
244 // motor holds the disc, and the panel gets no new row to explain it
245 INDI::PropertyNumber HoldNP {2};
246 // No WHEELLY_DETENT switch: the wheel's detent is
247 // always removed (the motor cannot climb out of its notches), so the
248 // "has a detent / no detent" setting had one right value. Why, at
249 // length: NO DETENT OPTION in wheelly_protocol.h.
250 // Which way the wheel may turn: the shortest way, or one way only for
251 // a mechanism stiffer one way (an asymmetric detent, for instance,
252 // stalls the motor on its steep flank).
253 // Read at connection with 'direction', set with
254 // 'direction shortest|up|down'.
255 INDI::PropertySwitch DirectionSP {3}; // shortest, up, down
256 // Reads the direction and the wheel's move time cap; with warn, says
257 // in the log when the worst case would go past Ekos's 30 s.
258 void read_direction(bool warn);
259 // Takes the wheel's cap from a reply that carries it, and warns if
260 // it is past Ekos's timeout.
261 void take_ceiling(const Fields &reply, bool warn);
262 // The driver's own safety net on a move, in seconds: the wheel's cap
263 // plus a margin, or 28 s with a firmware that does not tell it.
264 double move_ceiling_s() const;
265 // Reads the settling hold after every leg into HoldNP[1].
266 void read_settle();
267 void read_led_mode();
268 INDI::PropertySwitch LedSP {4}; // steady, pulse, off, test
269 INDI::PropertySwitch DiagSP {1}; // "is it talking to the hardware?"
270 INDI::PropertySwitch LogSP {2}; // on, off
271 // The sweep: a full turn measuring the magnet at every step. The
272 // samples go to a CSV file in the sweeps folder; the driver draws
273 // nothing (see finish_sweep).
274 INDI::PropertySwitch SweepSP {1};
275 // The two files the driver leaves on disk: the movement log and the
276 // last sweep. Written in the panel, because a path said once in the
277 // log is a lost path.
278 enum FileRow { FILE_LOG, FILE_SWEEP, FILE_COUNT };
279 INDI::PropertyText FileTP {FILE_COUNT};
280 // Where the sweeps end up. Writable, and saved in the configuration:
281 // "~/Documents" is a reasonable default, not a truth - whoever keeps
282 // their data on an external disk points it there.
283 INDI::PropertyText SweepDirTP {1};
284
285 // --- properties: options ------------------------------------------
286 INDI::PropertyText FirmwareTP {3};
287
288 // --- the USB link, lost and found again -----------------------------
289 // A wheel unplugged and plugged back left the driver "Connected" on a
290 // descriptor that could only fail: every write "Input/output error",
291 // one line in the log per poll, the angle frozen in the panel, and
292 // only Disconnect/Connect by hand brought it back. Now an I/O error
293 // that means the port is gone (link_is_gone) closes the descriptor,
294 // says so ONCE, and TimerHit tries to open the wheel again, with a
295 // growing pause, until it answers with this profile's serial number.
296 // CONNECTION stays On and Ok (Ekos keeps its device, see link_lost);
297 // the position and sensor turn Alert while the link is down.
298 static bool link_is_gone(int error_number);
299 void link_lost(const std::string &why);
300 bool try_reconnect();
301 void link_found(const std::string &path);
302 void remember_alias();
303 bool m_link_lost {false};
304 bool m_reconnecting {false}; // Handshake() on a reconnection
305 double m_next_attempt {0.0};
306 double m_retry_pause_s {0.0};
307 // A descriptor opened by the reconnection, not by libindi's serial
308 // plugin, which was told to let the dead one go: closed by Disconnect().
309 int m_own_fd {-1};
310 // The /dev/serial/by-id/ link of the port in use, found at connection:
311 // it carries the USB serial number, so it points at the wheel under
312 // whatever ttyACMn the kernel gives it when it comes back.
313 std::string m_alias;
314 std::string m_port_in_use;
315
316 // --- internal state -------------------------------------------------
317 LineReader m_reader;
318 bool m_moving {false};
319 double m_move_start {0.0};
320 double m_wheel_ceiling_s {0.0}; // 0 = the wheel did not say
321 bool m_magnet_lost {false};
322 // The '#' lines are developer diagnostics and normally end up only in
323 // the protocol's debug level. During 'diag', though, they are the
324 // content of the answer itself, and have to be shown.
325 bool m_show_comments {false};
326 std::FILE *m_log {nullptr};
327
328 bool m_sweeping {false};
329 int m_sweep_remaining {0};
330 std::vector<wheelly::Sample> m_samples;
331 // The serial number of the wheel of WHOEVER USES THIS PROFILE, learned
332 // at the first connection and saved in the configuration. Empty until
333 // nothing has ever been connected.
334 std::string m_expected_serial;
335 bool m_only_mine {false}; // true during the first pass
336 bool m_serial_to_save {false};
337
338 // A valid name close to the refused one, to suggest to the user.
339 // Empty if none is found that does not tread on a name already in use.
340 std::string suggest_name(const char *text, int slot);
341
342 // How many slots the connected wheel has. It is NOT a constant: the
343 // wheel says it at the handshake, and the driver adapts. It is the
344 // reason why a Wheelly5 driver and a Wheelly7 one are not needed.
345 int m_slots {wheelly::FACTORY_SLOTS};
346
347 // names as the wheel knows them, so as not to rewrite what does not change
348 std::string m_names[wheelly::MAX_SLOTS];
349};
350
351#endif // WHEELLY_H
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
virtual ~Wheelly() override=default
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
ISState
Switch state.
Definition indiapi.h:148
const int FACTORY_SLOTS
const int MAX_SLOTS
double magnitude
Definition wheelly.h:28
double angle
Definition wheelly.h:27