Instrument Neutral Distributed Interface INDI 2.2.5
wheelly_protocol.h
Go to the documentation of this file.
1// SPDX-FileCopyrightText: 2026 Matteo Beretta
2// SPDX-License-Identifier: MIT
3
4// Wheelly - shared definition of the serial protocol.
5//
6// BOTH sides include this file: the firmware on the XIAO ESP32-S3 and the INDI
7// driver on the Raspberry. It is the best idea stolen from frankenwheely, the
8// abandoned project this one started from: if commands and fields are defined
9// only once and compiled by both sides, the class of bugs "the driver and the
10// firmware do not agree on what this message means" cannot exist by
11// construction.
12//
13// Rules for whoever changes it:
14//
15// - No Arduino, no INDI, no C++ beyond C++11. It must compile as it is on a
16// microcontroller and on a PC.
17// - The strings in here are IDENTIFIERS, not prose: they are in English and
18// are never translated. The texts for the user live in the translation
19// catalogues, on the host side. See firmware.md, section 13.
20// - Changing a word here means breaking compatibility: PROTOCOL_VERSION goes
21// up and the driver refuses the firmwares it does not know.
22// - This file exists TWICE, byte for byte: firmware/wheelly/ (the source,
23// edit that one) and driver/indi-wheelly/ (a copy, so the driver folder
24// builds on its own inside INDI's tree, where firmware/ does not exist).
25// After editing, copy it over:
26// cp firmware/wheelly/wheelly_protocol.h driver/indi-wheelly/
27// firmware/test/test_driver_standalone.py fails while the two differ.
28//
29// The reasoned documentation of the protocol is in firmware.md, section 4.
30
31#ifndef WHEELLY_PROTOCOL_H
32#define WHEELLY_PROTOCOL_H
33
34#include <stddef.h>
35#include <stdint.h>
36
37namespace wheelly
38{
39
40// ----------------------------------------------------------------- version
41
42// It goes up at every incompatible change: a word that changes, a field that
43// disappears, the meaning of a value that shifts. Adding a new field is NOT
44// incompatible - that is exactly why the replies are made of named fields
45// instead of positional ones.
46//
47// 2: the rotation trim went (`offset`, `offsets`,
48// `clear-offsets`, the `o<n>=` fields) and `angle <n> <degrees>` came, so
49// `target` is now the slot's angle alone instead of angle + trim. A pairing
50// across the change would misunderstand itself in silence: a new driver on an
51// old firmware would show the angles while the wheel went to angle + trim,
52// and an old driver on a new firmware would ask for trims that no longer
53// exist. The driver refuses such a pairing at connection, with the two
54// numbers in the message: reflash the wheel and install the driver together.
55const int PROTOCOL_VERSION = 2;
56
57// ------------------------------------------------------------------ limits
58
59// Mind the names chosen here: the preprocessor knows nothing of namespaces, and
60// this file is included after half the system library. LINE_MAX, NAME_MAX,
61// PATH_MAX and company are already macros of <limits.h>, which on Arduino comes
62// along by itself with Arduino.h: calling a constant LINE_MAX made the build
63// for the XIAO fail with a message that did not even name the conflict. On the
64// Mac it went through. Hence the rule: names ending in _MAX get a prefix of our
65// own.
66// How many slots a wheel can have, at most: it is the size of the arrays, not
67// the number of slots of THIS wheel. How many it really has is known by the
68// wheel, it lives in its memory and is read with the 'slots' command.
69//
70// The distinction matters: the firmware is only one and serves different
71// wheels, and the driver must take nothing for granted. The alternative - a
72// Wheelly5 driver, a Wheelly7 one and so on - would force whoever installs it
73// to know how many slots their wheel has in order to pick the right entry,
74// i.e. precisely the information the wheel can already tell by itself.
75const int MAX_SLOTS = 12;
76// At least two: a wheel with one slot never changes filter. When the firmware
77// accepted 1 while the INDI panel started from 2, `slots 1` typed at the
78// serial monitor left a wheel the panel could not show; so 1 is refused. One
79// number here, for firmware, simulator and driver.
80const int MIN_SLOTS = 2;
81const int FACTORY_SLOTS = 5; // how many a brand-new wheel has
82
83const size_t FILTER_NAME_MAX = 32; // see firmware.md, section 6
84// The longest line is the reply to 'names': twelve names of 32 characters plus
85// the labels. If the buffer cannot hold it, the firmware truncates a reply and
86// the driver reads a mangled name.
87const size_t WHEELLY_LINE_MAX = 512;
88
89// ------------------------------------------------- how a line begins
90
91const char PREFIX_OK[] = "ok"; // positive outcome, with its fields
92const char PREFIX_ERROR[] = "error"; // negative outcome: code, fields, text
93const char PREFIX_COMMENT = '#'; // informative, never necessary
94const char PREFIX_EVENT = '!'; // spontaneous event, not requested
95
96// ---------------------------------------------------------------- commands
97
98const char CMD_VERSION[] = "version"; // identification and handshake
99const char CMD_STATUS[] = "status"; // the truth, always available
100const char CMD_GO[] = "go"; // go <1..slots>, asynchronous
101const char CMD_STOP[] = "stop"; // interrupts and releases
102// jog <degrees>: move the disc TO the angle read now plus <degrees>, with the
103// same closed loop as `go` - approach legs, the learnt play of the tyre - and
104// the same events (it serves the jog buttons of the calibration tab). Not
105// raw steps: the TPU tyre swallows ~0.6 degrees before the disc
106// moves, so 0.05 degrees of steps would move nothing. See Wheel::jog().
107const char CMD_JOG[] = "jog";
108// The largest jog, either way: HALF A TURN. The panel's biggest jog button is
109// one slot pitch, 360/slots degrees - 72 on a five-slot wheel - and a wheel may
110// have as few as MIN_SLOTS = 2 slots, whose pitch is 180. (It was 30 while the
111// biggest button was 10 degrees.) A jog of
112// exactly half a turn has no shorter way: it goes the way its sign says (see
113// Wheel::start_leg), not whichever way the rounding of the target falls.
114const int JOG_MAX_DEG = 180;
115const char CMD_ANGLES[] = "angles"; // the calibration angles
116// angle <n> <degrees>: sets slot n's angle, 0..ANGLE_MAX_DEG, in working
117// memory until `save` (the calibration angles are editable in the panel).
118// It does not move the wheel: the driver sends `go`
119// after it when it wants to, and a serial monitor does the same by hand.
120// Refused while the wheel moves, like `teach`: the move under way aims at
121// the old angle.
122const char CMD_ANGLE[] = "angle";
123const int ANGLE_MAX_DEG = 360;
124// THERE IS NO ROTATION TRIM (removed in protocol 2). Before, every slot had
125// two numbers, the taught angle and a trim of +-45 degrees on top of
126// it (`offset <n> <degrees>`, `offsets`, `clear-offsets`, NVS keys o1..o12),
127// and the wheel went to angle + trim. Why it went: firmware.md, "One number
128// per slot". A wheel that saved trims before gets them folded into its
129// angles once, at the first start of this firmware (Wheel::load_or_default).
130const char CMD_NAMES[] = "names"; // all the names
131const char CMD_NAME[] = "name"; // name <n> <text>
132const char CMD_TEACH[] = "teach"; // teach <n>: "this one is n"
133const char CMD_SAVE[] = "save"; // writes to NVS, an explicit gesture
134const char CMD_TOLERANCE[] = "tolerance"; // tolerance [good warn retries]
135const char CMD_MOTOR[] = "motor"; // motor [mA [speed [accel]]]
136
137// The limits of the motor settings, in one place for the wheel that checks
138// them and the INDI panel that offers them (when the panel kept its own
139// numbers, speed and acceleration never reached the wheel - "motor" took only
140// mA).
141// Current in mA; speed in steps/s; acceleration in steps/s^2.
142const unsigned MOTOR_MA_MAX = 800;
143const unsigned long MOTOR_SPEED_MAX = 5000;
144const unsigned long MOTOR_ACCEL_MAX = 50000;
145// The factory speed and acceleration, in full steps: 300 steps/s and 1200
146// steps/s^2 (measured on the assembled reference wheel; 200/800 before).
147// Slower is not gentler: at 50 steps/s the motor stalled and vibrated against
148// the notches of the detent (since removed) even the easy way, while 100, 150,
149// 200, 300, 400, 700 and 1000 all arrived, 1-2.5 s per slot in 2-4 legs, with
150// no trend in the final error (400 mA, one way down). The motor's pull-out
151// curve (datasheet: 24 V, 0.4 A, half step) starts only at 500 half steps/s =
152// 75 rpm = 250 full steps/s: below that is a weak zone the datasheet does not
153// even draw, and the wheel stalls there. 300/1200 sits just above it.
154// One place for the wheel, the simulator and the panel's starting value.
155const unsigned long FACTORY_SPEED = 300;
156const unsigned long FACTORY_ACCEL = 1200;
157// The factory run current, in mA. 350 was DECIDED ON THE PART, not
158// calculated: the clutch was tried by hand from 150 to 350 in steps, and 350
159// is where it stopped slipping (the motor is rated 400 per phase). With the
160// manual wheel's detent in place 400 was needed to climb out of a notch; with
161// the detent removed, the speed test (firmware/speed_test.py: 100..1000 full
162// steps/s, every move arrived in 2 legs) ran at 350 again. Defined here so the
163// wheel, the simulator and the panel's starting value are one number: when
164// each wrote its own 350, they could drift apart.
165const unsigned FACTORY_RUN_MA = 350;
166// THE BOOST: after a leg that STALLED - it made
167// (almost) no progress, judged on the angle read, since the TMC2208 has no
168// StallGuard - the next leg runs at twice the speed and the acceleration, to
169// break out of the notch, and the legs after it go back to the set speed.
170// Never above this, nor above MOTOR_SPEED_MAX / MOTOR_ACCEL_MAX.
171const unsigned long BOOST_SPEED_MAX = 1000;
172
173// The limits of `tolerance good warn retries`: 0 < good <= warn <= this many
174// degrees, and 0..this many retries. Here and not in wheel.cpp because the
175// refusal quotes them back in `expected=` and the simulator must quote the
176// same string: when each wrote its own, they differed.
177const int TOLERANCE_MAX_DEG = 45;
178const int RETRIES_MAX = 20;
179// The FACTORY tolerances, in degrees of disc: good (a plain success) and
180// warn (beyond it the wheel retries; also the "on a slot" band and the drift
181// threshold). One place for the wheel, the simulator and the panel.
182// How the values were reached:
183// - 0.10 / 0.50, proposed before the wheel was assembled: REJECTED. The
184// last ~0.6-1.0 degrees of a leg are swallowed by the TPU tyre, so 0.10
185// forced legs and retries chasing an error the drive cannot resolve.
186// (1 degree of disc moves a filter about 0.9 mm at a filter circle radius
187// of ~50 mm - ESTIMATE, not measured.)
188// - 0.50 / 1.50: REJECTED as too loose. The speed test
189// (firmware/speed_test.py), on the reference wheel with its detent
190// removed, arrived with errors below 0.15 degrees on four slots out of
191// five at every speed.
192// - 0.30 / 0.80: 0.30 keeps twice that margin; 0.80 still lets a leg the
193// tyre swallowed (up to ~0.6 degrees) end as a warning rather than a
194// retry.
195// A wheel that already SAVED its tolerances keeps them: they live in NVS, and
196// nothing migrates.
197const float FACTORY_GOOD_DEG = 0.3f;
198const float FACTORY_WARN_DEG = 0.8f;
199const char CMD_HOLD[] = "hold"; // hold [mA], 0 = release
200const char CMD_LED[] = "led"; // led [on|pulse|off|test]
201const char CMD_DIAG[] = "diag"; // is it talking to the hardware?
202const char CMD_SLOTS[] = "slots"; // slots [n]: how many slots it has
203// There is NO `detent` command any more. `detent [yes|no]` used to declare
204// whether the wheel had a physical detent holding it at rest; why it went:
205// "NO DETENT OPTION", below.
206// An older driver that still sends it gets ERR_UNKNOWN_COMMAND: its panel
207// switch goes to Alert and nothing else happens - acceptable, the switch
208// changed no behaviour of the wheel, only warnings.
209
210// direction [shortest|up|down]: which way the wheel may turn. Born for a
211// wheel with an ASYMMETRIC detent (285 mN*m on the steep flank, firmware.md
212// 2.4): going the shortest way, half the moves climbed the steep flank and the
213// motor stalled, humming. "up" turns only towards increasing angles, "down"
214// only towards decreasing ones - every move AND every retry, so a retry after
215// an overshoot goes round the whole turn instead of backing up against the
216// detent. "shortest" is the factory value (see FACTORY_DIRECTION): the
217// one-way options stay for a mechanism that turns more easily one way than
218// the other. The reply carries the move time cap the wheel
219// derives from it (F_CEILING), and `save` keeps it.
220const char CMD_DIRECTION[] = "direction";
221const char ARG_SHORTEST[] = "shortest";
222const char ARG_UP[] = "up";
223const char ARG_DOWN[] = "down";
224// The factory direction: THE SHORTEST WAY. "down" (one way only) was the
225// factory value while the reference wheel still had an ASYMMETRIC detent that
226// stalled the motor going up (at about 51, 123, 195, 267, 339 degrees). With
227// the detent removed, the speed test arrived every time in 2 legs the
228// shortest way: a move is at most half a turn instead of nearly two, and the
229// move time cap drops from 15.8 to 12.1 s at the factory speed. The detent is now always
230// removed (see NO DETENT OPTION); "up" and "down" stay for a mechanism that
231// is stiffer one way than the other, and cost nothing to keep. One constant for the firmware, the simulator and the driver's panel.
232const char FACTORY_DIRECTION[] = "shortest";
233
234// NO DETENT OPTION. The wheel used to carry a flag, `detent yes|no` (FACTORY_DETENT = yes), saying whether the manual
235// wheel still had its spring click stop. It went because the answer is always
236// "no": the motor drives the disc by FRICTION, through the clutch, and it
237// cannot climb out of the detent's notches - on the reference wheel it
238// stalled and hummed against the steep flank (285 mN*m) and needed 400 mA, the motor's
239// rating, to get over the easy one. So the detent is ALWAYS removed when the
240// magnet is fitted (assembly guide, chapter 10), and a flag with a single
241// right value is not a setting. The flag decided only warnings ("no detent
242// and no holding current"), and with the detent always gone that one would
243// fire at every connection of every wheel, whose holding current is 0 by
244// default: a warning that always sounds is no longer read. What stays is the
245// drift watch (EV_DRIFT), which reports a wheel that really moves at rest,
246// and its advice is the holding current. A wheel that saved the flag before
247// keeps an NVS key "detent" nobody reads any more: harmless, left alone.
248
249// THE DRIVE RATIO IS A SETTING OF THE WHEEL, not a constant of the
250// firmware. The motor drives the disc by friction: a TPU tyre on the
251// clutch presses on the disc's knurled rim, and how many degrees of motor make
252// one degree of disc depends on the diameter the tyre really rolls on - its
253// squash under the spring, the knurl's depth - which differs from one wheel to
254// the next and is not the drawing's. Project rule: what depends on one
255// wheel's mechanics is an option with a default.
256//
257// The DEFAULT is the model's, DERIVED here from the model's two diameters and
258// not typed as 2.9: the disc (Disc_rotating in the CAD's parameters.py,
259// MEASURED with a caliper on the reference wheel) and the clutch's contact
260// diameter (D_clutch, SET by the design). test_units.py reads the CAD's
261// values and fails if these two drift from them. The ratio stays "declared
262// from the model" until measured on the wheel with firmware/ratio_test.py.
263const float MODEL_DISC_DIAMETER_MM = 145.0f;
264const float MODEL_CLUTCH_DIAMETER_MM = 50.0f;
266// ratio [<value>]: reads or sets the ratio the wheel STARTS from (the
267// "base"), in working memory until `save`; setting it restarts the learning
268// from it. ratio learn on|off: whether the wheel refines the ratio in use from
269// its own long legs (Wheel::judge - on by default, kept by `save`). The range only catches a typo (29 for 2.9): a wheel built with
270// another clutch or disc may be far from 2.9, and 1..10 is wider than any.
271const char CMD_RATIO[] = "ratio";
272const char ARG_LEARN[] = "learn";
273const float DRIVE_RATIO_MIN = 1.0f;
274const float DRIVE_RATIO_MAX = 10.0f;
275// legs on|off: a `! leg` event at the end of every leg, for a host that measures the ratio (firmware/ratio_test.py). OFF at every start
276// and never saved: nobody but a measuring tool wants a line per leg, and a
277// driver that does not know the event would only log it at debug level.
278const char CMD_LEGS[] = "legs";
279const char F_REPORT[] = "report"; // legs: on or off
280
281// Ekos gives up on a filter change after 30 s by itself (firmware.md 5.3).
282// The wheel's move time cap is derived from speed and direction, and the
283// driver warns when the worst case would go past this.
284const unsigned long EKOS_FILTER_TIMEOUT_MS = 30000;
285
286// The value the project RECOMMENDS to whoever switches on the holding current
287// at rest. It is not a firmware default - the default is zero, i.e. the motor
288// is released, because a wheel whose disc turns stiffly enough stays where it
289// is left - but the number to start from for whoever's wheel drifts at rest
290// (the drift watch says so).
291//
292// It is not the 350 of the run current, and the reason is that the two numbers
293// are not chosen by the same criterion: A MOVE LASTS TWO SECONDS, HOLDING LASTS
294// ALL NIGHT. At 350 mA it would be 7.4 W forever, a few centimetres from a
295// magnetic sensor and a cooled camera; at 150 it is 1.35 W.
296const uint16_t RECOMMENDED_HOLD_MA = 150;
297
298// settle [ms]: how long the motor stays energised, AT THE RUN CURRENT, after
299// every leg stops, before the angle is read for the verdict and - on the last
300// leg - the motor is released. Also with the holding
301// current at rest at 0: the drive is by friction, and an energised motor that
302// has stopped holds the TPU tyre, which brakes the disc; released at once, the
303// disc's inertia could carry it on. The verdict is read at the END of the
304// wait, so a disc that went on moving during it is judged where it stopped,
305// and the normal approach legs and retries apply.
306// It used to be a fixed 300 ms (SETTLE_MS in wheel.h), already energised; it
307// is a setting because a wheel with a heavier disc, or a softer tyre, may want
308// longer, and the run current is GUARANTEED for all of it. A fixed wait held
309// at the run current only by accident: the TMC2208 drops to the hold current
310// by itself 0.44 s after the last step (TPOWERDOWN = 20 by default), so a
311// longer wait, with the hold at 0, would let the coils freewheel half way
312// through.
313// 0 = read at once and release at once; the readings are then taken with the
314// disc still ringing, and the approach legs pay for it. The reply carries the
315// move time cap (F_CEILING), which counts this wait once per leg; `save`
316// keeps it. One constant for the firmware, the simulator and the panel.
317const char CMD_SETTLE[] = "settle";
318const unsigned FACTORY_SETTLE_MS = 300;
319const unsigned SETTLE_MS_MAX = 2000;
320
321// arguments of 'led'
322const char ARG_ON[] = "on";
323const char ARG_OFF[] = "off";
324const char ARG_TEST[] = "test"; // Wheelly in Morse code
325// Off at rest, pulsing while the wheel goes to another slot: the default.
326// "off" is then OFF for good - no pulse either - for
327// whoever wants no light at all near the optics.
328const char ARG_PULSE[] = "pulse";
329
330// ------------------------------------------------------------------ fields
331
332// status
333const char F_POS[] = "pos"; // 1..slots, or 0 = between two slots
334const char F_ANGLE[] = "angle"; // degrees read from the AS5600
335const char F_TARGET[] = "target"; // angle[pos] (+ offset[pos] until protocol 1)
336const char F_ERR[] = "err"; // residual error in degrees, signed
337const char F_MOTION[] = "motion"; // see MOTION_* below
338const char F_RETRIES[] = "retries";
339// Every leg of the last (or current) positioning, the first included, in
340// 'status'. One way the wheel aims short and creeps in, and the
341// approach legs are not retries: this is where they are seen.
342const char F_LEGS[] = "legs";
343// How many legs of the last (or current) positioning ran at the boosted
344// speed after a stall, in 'status'.
345const char F_BOOSTS[] = "boosts";
346// The verdict of the last positioning: "none" if none has been concluded yet,
347// otherwise one of the event names below.
348//
349// It exists because 'status' must be enough on its own. Without it, the driver
350// would have had to compare the residual error with the tolerances to tell
351// whether the move had succeeded or was only passable - i.e. redo the decision
352// that by design belongs to the firmware, in a second copy that diverges over
353// time. The same vocabulary as the events, on purpose: one thing, said in two
354// ways.
355const char F_OUTCOME[] = "outcome";
356const char OUTCOME_NONE[] = "none";
357const char F_FROM[] = "from"; // starting angle, in reply to 'go'
358const char F_DIR[] = "dir"; // chosen direction: "+" or "-"
359
360// sensor
361const char F_AGC[] = "agc";
362const char F_MAG[] = "mag"; // magnitude: IT is the criterion, not the AGC
363const char F_MD[] = "md"; // magnet detected
364const char F_ML[] = "ml"; // field too weak
365const char F_MH[] = "mh"; // field too strong
366
367// identification
368const char F_NAME[] = "name"; // "wheelly": it is the real handshake
369const char F_FW[] = "fw";
370const char F_PROTO[] = "proto";
371const char F_SERIAL[] = "serial";
372// How many slots the wheel has. The driver does NOT take it for granted: it
373// asks. Hard-wiring it on the other side would mean that a seven-slot wheel
374// shows twelve boxes in Ekos, or five, and neither is true.
375const char F_SLOTS[] = "slots";
376
377// preferences
378const char F_GOOD[] = "good"; // good tolerance, in degrees
379const char F_WARN[] = "warn"; // warning tolerance, in degrees
380const char F_MA[] = "ma"; // current, in milliampere
381const char F_MS[] = "ms"; // settle: the wait after a leg, in ms
382const char F_SPEED[] = "speed";
383const char F_ACCEL[] = "accel";
384const char F_DIRECTION[] = "direction"; // shortest, up or down
385// The cap on a whole positioning, in ms, as the wheel derives it from speed,
386// acceleration, retries and direction: in the replies of `direction` and
387// `motor`, so the driver's own safety net follows the wheel's number.
388const char F_CEILING[] = "ceiling";
389const char F_STATE[] = "state"; // state of the LED
390const char F_ENABLED[] = "enabled";
391const char F_MODE[] = "mode"; // LED mode: on, pulse, off
392const char F_SAVED[] = "saved";
393const char F_DURATION[] = "duration";
394// ratio: the one IN USE (the base refined by the learning, or the base alone
395// with learning off), the base it started from, the factory value, whether
396// it learns, and how many legs it has learnt from since the base was set
397const char F_RATIO[] = "ratio";
398const char F_BASE[] = "base";
399const char F_FACTORY[] = "factory";
400const char F_LEARN[] = "learn";
401const char F_LEARNT[] = "learnt";
402
403// the `! leg` event: what one leg was sent and what the disc did
404const char F_N[] = "n"; // the leg's number in the positioning, from 1
405const char F_KIND[] = "kind"; // first, approach or retry
406const char KIND_FIRST[] = "first";
407const char KIND_APPROACH[] = "approach";
408const char KIND_RETRY[] = "retry";
409const char F_JOG[] = "jog"; // yes if the positioning is a jog
410const char F_STEPS[] = "steps"; // microsteps sent, signed
411const char F_MOTOR[] = "motor"; // the same as motor degrees, signed
412const char F_AIM[] = "aim"; // disc degrees it wanted, lost motion not included
413const char F_TO[] = "to"; // the angle read after settling (from= is before)
414const char F_MOVED[] = "moved"; // disc degrees the way it was sent; negative = back
415const char F_LONG[] = "long"; // yes: long enough to tell the ratio (Wheel::judge)
416
417// parameters of the errors
418const char F_EXPECTED[] = "expected";
419const char F_GOT[] = "got";
420
421// boolean values on the wire: never 0/1 where a human has to read
422const char V_YES[] = "yes";
423const char V_NO[] = "no";
424
425// values of the 'motion' field
426const char MOTION_IDLE[] = "idle";
427const char MOTION_MOVING[] = "moving";
428const char MOTION_SETTLING[] = "settling"; // stopped but not yet settled
429const char MOTION_FAILED[] = "failed";
430
431// ------------------------------------------------------------------ events
432
433const char EV_ARRIVED[] = "arrived"; // within the good tolerance
434const char EV_WARNING[] = "warning"; // between good and warning: report and go on
435const char EV_FAILED[] = "failed"; // outside, even after the retries
436const char EV_SENSOR[] = "sensor"; // the magnet can no longer be seen
437// The wheel moved WHILE AT REST beyond the warning tolerance: nobody asked it
438// anything and it moved all the same. It is the sign that holding current at
439// rest is needed (RECOMMENDED_HOLD_MA): the detent of the manual wheel is
440// always removed, so the drift watch is the only thing that
441// says whether a released motor is enough.
442//
443// ADDING an event does not raise PROTOCOL_VERSION, and it is worth saying why:
444// the version is there to refuse the pairings that would misunderstand each
445// other, and here there are none. An old driver with a new firmware receives an
446// event it does not know and ignores it; a new driver with an old firmware
447// never receives it. CHANGING a word already in use would be another matter,
448// and that is what the rule at the top of the file forbids.
449const char EV_DRIFT[] = "drift"; // it moved by itself, at rest
450// The end of one leg, only after `legs on`: what was sent, what
451// the disc did, and whether the wheel learnt its ratio from it. Sent BEFORE
452// the verdict's event when the leg is the last one. It is for measuring
453// tools, not for the driver, and it is opt-in so that a driver that does not
454// know it never sees it; one that did would only log it at debug level.
455const char EV_LEG[] = "leg";
456// The motor driver had to be set up again before a leg or in `diag`
457// (mechanics.h, "is the driver still ours"): reason=power when it had been
458// silent and now answers - the 12 V arrived after the XIAO had started, which
459// is the order the assembly guide gives - and reason=reset when it answered
460// with its setup gone - the 12 V dropped and came back. Said once per
461// setup, not per check. With the driver silent a move is refused with
462// ERR_DRIVER_SILENT instead: a leg sent to an unpowered or reset driver
463// moved the disc a fraction of the way, or not at all.
464const char EV_DRIVER[] = "driver";
465const char V_POWER[] = "power";
466const char V_RESET[] = "reset";
467
468// ------------------------------------------------------------------ errors
469
470// The code is two things at once: the outcome for the machine and the
471// TRANSLATION KEY for the host. The named fields that come with it are the
472// parameters the driver builds the sentence from, in the user's language. The
473// English text at the end of the line is only for whoever watches the raw
474// serial.
476{
479 ERR_BAD_ARGUMENTS = 2, // missing, too many, or not numeric
480 ERR_OUT_OF_RANGE = 3, // with expected= and got=
481 ERR_SENSOR_SILENT = 4, // the AS5600 does not answer on I2C
482 ERR_NO_MAGNET = 5, // it answers, but does not see the magnet
483 ERR_DRIVER_SILENT = 6, // the TMC2208 (or 2209) does not answer on the UART
484 ERR_NOT_NOW = 7, // for instance 'teach' while the wheel is moving
486 ERR_BAD_FILTER_NAME = 9 // with reason= among the NameCheck values
488
489const char F_REASON[] = "reason"; // with ERR_BAD_FILTER_NAME, and with EV_DRIVER
490
491// ------------------------------------------------- validation of filter names
492//
493// Why this rule exists, in short: the filter name ends up as it is in the
494// FILTER keyword of the FITS header, which the standard allows only in
495// printable ASCII, and in the file name of the frame - where Ekos, unlike what
496// it does with every other field, does NOT sanitise it. A character out of
497// place gives no error: it silently changes the name and breaks the pairing
498// between lights and flats. The full reasoning is in firmware.md, section 6.
499//
500// It lives here, and not on one side only, because it must hold identically in
501// the firmware (command 'name'), in the INDI driver (FILTER_NAME) and one day on
502// the ASCOM side.
503
505{
509 NAME_BAD_EDGE = 3, // does not begin or end with a letter or digit
510 NAME_BAD_CHAR = 4, // outside [A-Za-z0-9._-]
511 NAME_RESERVED = 5 // Windows device name: NUL, COM1, ...
513
514inline bool name_is_alnum(char c)
515{
516 return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9');
517}
518
519inline bool name_is_allowed(char c)
520{
521 return name_is_alnum(c) || c == '_' || c == '-' || c == '.';
522}
523
524inline char name_lower(char c)
525{
526 return (c >= 'A' && c <= 'Z') ? (char)(c - 'A' + 'a') : c;
527}
528
529// Case-insensitive comparison. Needed twice: for the reserved Windows names and
530// for uniqueness across the slots.
531inline bool name_equal_nocase(const char *a, const char *b)
532{
533 size_t i = 0;
534 for (; a[i] != '\0' && b[i] != '\0'; i++)
535 {
536 if (name_lower(a[i]) != name_lower(b[i])) return false;
537 }
538 return a[i] == '\0' && b[i] == '\0';
539}
540
541// CON, PRN, AUX, NUL, COM1..COM9, LPT1..LPT9 are device names on Windows, and
542// they stay so even with an extension stuck on: "NUL.fits" opens the device,
543// not a file. It would look like paranoia for a filter name, were it not that
544// in Ekos the filter placeholder is ALSO a folder name.
545inline bool name_is_reserved(const char *s)
546{
547 // both the whole name and the part before the first dot are compared
548 char stem[16];
549 size_t n = 0;
550 while (s[n] != '\0' && s[n] != '.' && n < sizeof(stem) - 1)
551 {
552 stem[n] = s[n];
553 n++;
554 }
555 stem[n] = '\0';
556 if (s[n] != '\0' && s[n] != '.') return false; // too long to be one
557
558 static const char *reserved[] =
559 {
560 "CON", "PRN", "AUX", "NUL",
561 "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
562 "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"
563 };
564 for (size_t i = 0; i < sizeof(reserved) / sizeof(reserved[0]); i++)
565 {
566 if (name_equal_nocase(stem, reserved[i])) return true;
567 }
568 return false;
569}
570
571// Returns NAME_OK, or the reason for the refusal. It REFUSES, it does not
572// silently correct: every layer downstream (cfitsio, astropy, Ekos, the
573// filesystem) corrects in its own way, and if we corrected too the user would
574// find the same filter with three different names in three places without
575// knowing which one is the good one.
576inline NameCheck check_filter_name(const char *s)
577{
578 if (s == NULL || s[0] == '\0') return NAME_EMPTY;
579
580 // The length is checked BEFORE the characters, and the order is not
581 // indifferent: the reason for the refusal is shown to the user, and for a
582 // name that is both too long and full of odd characters "too long" is the
583 // useful information. Mixing the two checks in a single loop gave a reason
584 // that depended on where the first bad character happened to be - and made
585 // this rule diverge from its Python copy in the simulator.
586 size_t n = 0;
587 while (s[n] != '\0')
588 {
589 n++;
590 if (n > FILTER_NAME_MAX) return NAME_TOO_LONG;
591 }
592
593 for (size_t i = 0; i < n; i++)
594 {
595 if (!name_is_allowed(s[i])) return NAME_BAD_CHAR;
596 }
597
598 if (!name_is_alnum(s[0]) || !name_is_alnum(s[n - 1])) return NAME_BAD_EDGE;
599 if (name_is_reserved(s)) return NAME_RESERVED;
600 return NAME_OK;
601}
602
603// A safety net, not a policy. Validation on input refuses, but the driver is
604// not the only source: a third-party INDI client can write FILTER_NAME going
605// around our interface, and the NVS may hold names saved before the rule
606// existed. So the point where the file name is built and the one where the FITS
607// keyword is written both go through here.
608//
609// A guarantee the rest of the code relies on, and the tests check it: the
610// output ALWAYS passes check_filter_name(), whatever the input.
611inline void sanitize_filter_name(const char *in, char *out, size_t out_size)
612{
613 if (out == NULL || out_size == 0) return;
614
615 size_t w = 0;
616 const size_t limit = (out_size - 1 < FILTER_NAME_MAX) ? out_size - 1 : FILTER_NAME_MAX;
617
618 // Alphanumerics are copied; every run of non-alphanumeric characters
619 // becomes ONE separator. So leading and trailing separators disappear by
620 // themselves - which is what is needed, because the first and the last
621 // character must be alphanumeric - and "R (Astrodon)" becomes "R_Astrodon"
622 // instead of "R__Astrodon_". A separator that is already allowed is kept as
623 // it is, so "O-III" and "1.25" are not mangled for no reason.
624 bool separator_pending = false;
625 char separator = '_';
626
627 if (in != NULL)
628 {
629 for (size_t i = 0; in[i] != '\0' && w < limit; i++)
630 {
631 if (name_is_alnum(in[i]))
632 {
633 if (separator_pending && w > 0 && w < limit) out[w++] = separator;
634 separator_pending = false;
635 if (w < limit) out[w++] = in[i];
636 }
637 else if (!separator_pending)
638 {
639 separator_pending = true;
640 separator = name_is_allowed(in[i]) ? in[i] : '_';
641 }
642 }
643 }
644 // if the truncation left a trailing separator, drop it
645 while (w > 0 && !name_is_alnum(out[w - 1])) w--;
646 out[w] = '\0';
647
648 // if nothing usable is left, a neutral but valid name
649 if (w == 0)
650 {
651 const char fallback[] = "Filter";
652 size_t i = 0;
653 while (fallback[i] != '\0' && i + 1 < out_size)
654 {
655 out[i] = fallback[i];
656 i++;
657 }
658 out[i] = '\0';
659 w = i;
660 }
661
662 // A reserved Windows name is defused with a digit, which has to go at the
663 // end of the STEM and not at the end of the name: "nul.fits" stays reserved
664 // even written "nul.fits0", because Windows looks at the part before the dot.
665 if (name_is_reserved(out) && w + 1 <= limit)
666 {
667 size_t stem = 0;
668 while (out[stem] != '\0' && out[stem] != '.') stem++;
669 for (size_t i = w; i > stem; i--) out[i] = out[i - 1];
670 out[stem] = '0';
671 out[w + 1] = '\0';
672 }
673}
674
675// Uniqueness has to be checked at the level of the wheel, not of the single
676// name: "Ha" and "ha" are two different names for FITS and one single folder on
677// Windows and on macOS. Two slots that collapse into the same folder are a
678// silent disaster. Returns -1 if all is well, otherwise the index of the first
679// name that duplicates an earlier one.
680inline int first_duplicate_name(const char *const *names, int count)
681{
682 for (int i = 1; i < count; i++)
683 {
684 for (int j = 0; j < i; j++)
685 {
686 if (names[i] != NULL && names[j] != NULL &&
687 name_equal_nocase(names[i], names[j]))
688 {
689 return i;
690 }
691 }
692 }
693 return -1;
694}
695
696} // namespace wheelly
697
698#endif // WHEELLY_PROTOCOL_H
const size_t FILTER_NAME_MAX
const char KIND_APPROACH[]
const char F_TARGET[]
const char ARG_LEARN[]
const char EV_DRIFT[]
const char V_YES[]
const char MOTION_IDLE[]
const char KIND_RETRY[]
bool name_is_reserved(const char *s)
const char F_LEGS[]
const char F_N[]
const char PREFIX_EVENT
const char F_SLOTS[]
const char EV_LEG[]
const char CMD_NAME[]
bool name_is_allowed(char c)
const char F_AGC[]
const char F_AIM[]
const char MOTION_SETTLING[]
const char F_POS[]
const char F_FACTORY[]
const char ARG_UP[]
const char CMD_STATUS[]
const uint16_t RECOMMENDED_HOLD_MA
const char F_SERIAL[]
const char F_BASE[]
const char MOTION_MOVING[]
bool name_is_alnum(char c)
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 float FACTORY_DRIVE_RATIO
const char KIND_FIRST[]
const char CMD_ANGLES[]
const char CMD_GO[]
const char F_MS[]
const char F_STATE[]
const float DRIVE_RATIO_MIN
const char F_BOOSTS[]
const char F_ML[]
const char F_LEARNT[]
const char MOTION_FAILED[]
const float DRIVE_RATIO_MAX
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 V_NO[]
const char F_ERR[]
const unsigned long MOTOR_ACCEL_MAX
const char CMD_NAMES[]
const char CMD_TEACH[]
char name_lower(char c)
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 CMD_RATIO[]
const char F_NAME[]
const char CMD_SETTLE[]
const char F_MA[]
const unsigned long FACTORY_ACCEL
const unsigned long BOOST_SPEED_MAX
const char F_DIRECTION[]
const char EV_SENSOR[]
const char F_MOTION[]
const int MAX_SLOTS
const char CMD_HOLD[]
const char F_FROM[]
const int ANGLE_MAX_DEG
const char CMD_DIAG[]
const unsigned FACTORY_SETTLE_MS
const char CMD_JOG[]
const int JOG_MAX_DEG
const char F_GOOD[]
const char CMD_ANGLE[]
const float MODEL_CLUTCH_DIAMETER_MM
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 char F_STEPS[]
const char F_LONG[]
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_DIR[]
const char F_WARN[]
NameCheck check_filter_name(const char *s)
const char CMD_SLOTS[]
const unsigned FACTORY_RUN_MA
const char F_KIND[]
const char CMD_LEGS[]
const char F_JOG[]
const char F_ACCEL[]
const char F_RATIO[]
const float FACTORY_GOOD_DEG
const char F_SPEED[]
const char F_FW[]
const char F_TO[]
const char V_POWER[]
const char CMD_SAVE[]
const char CMD_VERSION[]
int first_duplicate_name(const char *const *names, int count)
const char F_LEARN[]
const char FACTORY_DIRECTION[]
const char PREFIX_COMMENT
const char CMD_MOTOR[]
const unsigned long MOTOR_SPEED_MAX
const char F_SAVED[]
const unsigned SETTLE_MS_MAX
const float FACTORY_WARN_DEG
const char ARG_OFF[]
const char F_ENABLED[]
const unsigned long EKOS_FILTER_TIMEOUT_MS
const char F_MOTOR[]
const float MODEL_DISC_DIAMETER_MM
const char F_MOVED[]
const char F_REPORT[]
const char V_RESET[]