Skip to content

indikit.protocol

The single source of truth for the INDI 1.7 wire format: typed enums and Pydantic models that serialize to both INDI XML (for indiserver) and JSON (for browsers).

Enums

indikit.protocol.enums

INDI protocol enumerations.

Each enum subclasses :class:enum.StrEnum, so a member is the exact token used on the INDI wire (e.g. IPState.OK == "Ok" is True) and Pydantic serialises it to that token directly.

:func:coerce_switch lives here too, because every caller-facing API that takes a switch value has to accept the same three spellings of it.

IPState

Bases: _StrEnum

State of a vector property (the coloured status light in a GUI).

IPerm

Bases: _StrEnum

Client access permission for a vector property.

ISRule

Bases: _StrEnum

Constraint on how many switches in a switch vector may be On.

ISState

Bases: _StrEnum

On/Off state of a single switch.

BLOBPolicy

Bases: _StrEnum

How indiserver should deliver BLOBs to a client.

A client must send an enableBLOB with one of these policies before indiserver will forward any BLOB; the default on the wire is Never.

coerce_switch

coerce_switch(value: ISState | bool | str) -> ISState

Coerce a caller-supplied switch value into an :class:ISState.

Every API that takes a switch value from application code - the driver's BoundProperty.set, the client's set_switch, the test harness's write - accepts the same three spellings, because POWER=True is what a caller writes and "On" is what the wire calls it. This is the one implementation of that rule.

Parameters:

Name Type Description Default
value ISState or bool or str

An ISState, a bool (True -> On), or a wire token ("On" / "Off").

required

Returns:

Name Type Description
state ISState

The corresponding switch state.

Raises:

Type Description
ValueError

Raised if a string names no switch state.

Source code in src/indikit/protocol/enums.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
def coerce_switch(value: ISState | bool | str) -> ISState:
    """Coerce a caller-supplied switch value into an :class:`ISState`.

    Every API that takes a switch value from application code - the driver's
    ``BoundProperty.set``, the client's ``set_switch``, the test harness's
    ``write`` - accepts the same three spellings, because ``POWER=True`` is what
    a caller writes and ``"On"`` is what the wire calls it. This is the one
    implementation of that rule.

    Parameters
    ----------
    value : ISState or bool or str
        An `ISState`, a `bool` (`True` -> On), or a wire token (``"On"`` /
        ``"Off"``).

    Returns
    -------
    state : ISState
        The corresponding switch state.

    Raises
    ------
    ValueError
        Raised if a string names no switch state.
    """
    # ISState first: it is a str subclass, so the str branch would swallow it.
    if isinstance(value, ISState):
        return value
    if isinstance(value, bool):
        return ISState.ON if value else ISState.OFF
    return ISState(value)

Models

indikit.protocol.models

Typed Pydantic models for INDI properties and wire messages.

Design notes
  • A vector (NumberVector etc.) is the canonical, in-memory representation of a property. It carries the full metadata plus its elements. A driver holds vectors; a client caches them and applies incoming updates onto them.

  • Element metadata (format/min/max/step on numbers, rule on switch vectors, ...) is only present in a def message. In set and new messages the wire carries just name + value per element. We model that by making the metadata fields optional with defaults, so the same element class round-trips through both contexts. Clients are expected to merge set values onto the previously-defined vector, which is standard INDI behavior.

  • The def / set / new distinction is a wire intent, not a different data shape, so it is expressed by the thin event wrappers at the bottom of this module rather than by duplicating every vector five times.

  • Every timestamp is UTC. INDI requires it (white paper p.5) and libindi writes a bare, offset-less %Y-%m-%dT%H:%M:%S, so :data:IndiTimestamp normalises whatever a caller or a peer supplies into an aware UTC datetime in one place - the model - rather than at each of the codecs.

Number

Bases: _Element

A single numeric element (defNumber / oneNumber).

value refuses NaN and the infinities. It is the one non-nullable float on the wire, and JSON has no way to write a non-finite number: the codec would emit null for it and :func:~indikit.protocol.json.from_json would then reject its own output, because value is required. Refusing it here is what keeps the two codecs symmetric - the XML parser drops the element instead (a value it cannot represent, exactly like a junk one) and a driver publishing one fails at the call site. The optional metadata (min/max/step) needs no such rule: it can say "absent", and the XML parser degrades a non-finite one to None.

Text

Bases: _Element

A single text element (defText / oneText).

Switch

Bases: _Element

A single switch element (defSwitch / oneSwitch).

Light

Bases: _Element

A single light element (defLight / oneLight). Read-only status.

BLOB

Bases: _Element

A single BLOB element (defBLOB / oneBLOB).

data holds the decoded binary payload; the base64/size framing lives in the codec, not the model.

In JSON the payload is standard base64 (RFC 4648 section 4, the +// alphabet); validation accepts the URL-safe alphabet as well.

NumberVector

Bases: _Vector

A vector of numeric elements (defNumberVector / setNumberVector).

TextVector

Bases: _Vector

A vector of text elements (defTextVector / setTextVector).

SwitchVector

Bases: _Vector

A vector of switch elements with a selection rule.

selected

selected() -> str | None

Return the name of the first element that is On, or None.

The idiomatic way to read a OneOfMany/AtMostOne client write: such a write names the newly selected member (often only that member), so the question is "which element is On in this request" - never "what is element X", which raises when X was not sent.

Returns:

Name Type Description
name str or None

The first On element's name, or None when none is On (an AtMostOne deselect).

Source code in src/indikit/protocol/models.py
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
def selected(self) -> str | None:
    """Return the name of the first element that is On, or `None`.

    The idiomatic way to read a ``OneOfMany``/``AtMostOne`` client write:
    such a write names the newly selected member (often *only* that
    member), so the question is "which element is On in this request" -
    never "what is element X", which raises when X was not sent.

    Returns
    -------
    name : str or None
        The first On element's name, or `None` when none is On (an
        ``AtMostOne`` deselect).
    """
    for el in self.elements:
        if el.value is ISState.ON:
            return el.name
    return None

LightVector

Bases: _Vector

A vector of read-only light elements (no perm; lights are always RO).

BLOBVector

Bases: _Vector

A vector of BLOB elements (binary payloads).

GetProperties

Bases: _Model

Client -> device/server request to enumerate properties.

DelProperty

Bases: _Model

Notification that a property (or a whole device) has gone away.

Message

Bases: _Model

A free-form log/notification message.

EnableBLOB

Bases: _Model

Client -> server request controlling BLOB delivery.

indiserver withholds BLOBs from a client until it sends this; the policy applies to one device (and optionally one property when name is set).

DefVector

Bases: _Model

A property definition (device -> client).

SetVector

Bases: _Model

A value update to an already-defined property (device -> client).

Attributes:

Name Type Description
state_present bool

Whether the wire message actually carried a state. It is #IMPLIED on every set*Vector (white paper p.7) and means "no change if absent", where on a def*Vector it is #REQUIRED. The absence has to survive the parse or it cannot be honoured later, and it rides here rather than on the vector because a vector's state is never absent in memory - a cached property is always in some state, and making the field nullable would push a None into every consumer of a cached vector to no purpose.

NewVector

Bases: _Model

A client's request to change a property's value (client -> device).

slugify

slugify(label: str) -> str

Return the conventional INDI element name for a display label.

INDI names are machine identifiers and labels are display text, so the two differ by exactly this transformation in most drivers: "Domeslit State" becomes "domeslit_state". The default key for :meth:_Element.from_labels.

Parameters:

Name Type Description Default
label str

The human-readable label.

required

Returns:

Name Type Description
name str

The label lowercased with runs of whitespace collapsed to underscores.

Source code in src/indikit/protocol/models.py
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
def slugify(label: str) -> str:
    """Return the conventional INDI element name for a display label.

    INDI names are machine identifiers and labels are display text, so the two
    differ by exactly this transformation in most drivers: ``"Domeslit State"``
    becomes ``"domeslit_state"``. The default key for :meth:`_Element.from_labels`.

    Parameters
    ----------
    label : str
        The human-readable label.

    Returns
    -------
    name : str
        The label lowercased with runs of whitespace collapsed to underscores.
    """
    return "_".join(label.lower().split())

as_utc

as_utc(value: datetime) -> dt.datetime

Return a datetime as aware UTC, reading a naive one as UTC.

A bare INDI timestamp carries no offset and means UTC, so that is what a naive datetime is taken to be. Guessing a local offset for an unknown peer would invent information: the same string would mean a different instant depending on where the reader happens to be running.

Parameters:

Name Type Description Default
value datetime

The datetime to normalise. Naive values are labelled UTC; aware values in another zone are converted.

required

Returns:

Name Type Description
value datetime

The same instant, expressed in UTC with tzinfo set.

Source code in src/indikit/protocol/models.py
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
def as_utc(value: dt.datetime) -> dt.datetime:
    """Return a datetime as aware UTC, reading a naive one *as* UTC.

    A bare INDI timestamp carries no offset and means UTC, so that is what a
    naive datetime is taken to be. Guessing a local offset for an unknown peer
    would invent information: the same string would mean a different instant
    depending on where the reader happens to be running.

    Parameters
    ----------
    value : datetime
        The datetime to normalise. Naive values are labelled UTC; aware values
        in another zone are converted.

    Returns
    -------
    value : datetime
        The same instant, expressed in UTC with ``tzinfo`` set.
    """
    if value.tzinfo is None:
        return value.replace(tzinfo=dt.UTC)
    return value.astimezone(dt.UTC)

indi_now

indi_now() -> dt.datetime

Return the current time the way INDI stamps one: aware UTC, whole seconds.

Truncated to the second because that is the resolution the XML format has: keeping microseconds would let one emission disagree with itself, the JSON carrying a precision the XML for the same message had already dropped.

Returns:

Name Type Description
now datetime

The current UTC time with a zero microsecond field.

Source code in src/indikit/protocol/models.py
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
def indi_now() -> dt.datetime:
    """Return the current time the way INDI stamps one: aware UTC, whole seconds.

    Truncated to the second because that is the resolution the XML format has:
    keeping microseconds would let one emission disagree with itself, the JSON
    carrying a precision the XML for the same message had already dropped.

    Returns
    -------
    now : datetime
        The current UTC time with a zero microsecond field.
    """
    return dt.datetime.now(dt.UTC).replace(microsecond=0)

Numbers

indikit.protocol.numbers

INDI number text: the printf format and the sexagesimal forms.

The two halves of libindi's fs_sexa / f_scansexa pair, as pure functions over a value and a format string. They are a protocol concern rather than an XML one - the same rendering decides what a browser sees through the JSON codec, and a driver's "on_change" policy compares numbers in exactly this representation, because "changed" means changed as far as a client can tell.

parse_number

parse_number(text: str) -> float

Parse a number that may be decimal or sexagesimal.

Accepts plain decimals as well as dd:mm:ss (or space-separated) sexagesimal forms used for RA/Dec, as libindi's f_scansexa does.

Deliberately a superset of libindi on the set path: libindi reads a oneNumber with std::stod there and only uses f_scansexa on the def path, so it reads "10:30:00" in a setNumberVector as 10.0. We read sexagesimal in both, which cannot silently truncate a coordinate. Do not "fix" this to match: matching would mean reading 10:30 as 10, which is a data-corruption bug wearing a compatibility badge.

Strict on purpose, where the XML codec around it is lenient: value is not nullable on the model, so there is no way to say "absent". Raising here lets the stream parser drop the whole element rather than publish a reading a mount would act on.

That covers the non-finite values too. float reads "nan" and "inf" happily, but neither survives the round trip - JSON cannot write them, and an integer format cannot render them - so they are refused here as well as on the model.

Parameters:

Name Type Description Default
text str

The raw element text.

required

Returns:

Name Type Description
value float

The parsed value; 0.0 for empty input.

Raises:

Type Description
ProtocolError

Raised (as a ValueError, which the stream parser drops on) if the text is neither a decimal nor a sexagesimal number, or if it names a non-finite value.

Source code in src/indikit/protocol/numbers.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
def parse_number(text: str) -> float:
    """Parse a number that may be decimal or sexagesimal.

    Accepts plain decimals as well as ``dd:mm:ss`` (or space-separated)
    sexagesimal forms used for RA/Dec, as libindi's ``f_scansexa`` does.

    Deliberately a superset of libindi on the ``set`` path: libindi reads a
    ``oneNumber`` with ``std::stod`` there and only uses ``f_scansexa`` on the
    ``def`` path, so *it* reads ``"10:30:00"`` in a ``setNumberVector`` as
    ``10.0``. We read sexagesimal in both, which cannot silently truncate a
    coordinate. Do not "fix" this to match: matching would mean reading 10:30
    as 10, which is a data-corruption bug wearing a compatibility badge.

    Strict on purpose, where the XML codec around it is lenient: ``value`` is
    not nullable on the model, so there is no way to say "absent". Raising here
    lets the stream parser drop the whole element rather than publish a reading
    a mount would act on.

    That covers the non-finite values too. ``float`` reads ``"nan"`` and
    ``"inf"`` happily, but neither survives the round trip - JSON cannot write
    them, and an integer ``format`` cannot render them - so they are refused
    here as well as on the model.

    Parameters
    ----------
    text : str
        The raw element text.

    Returns
    -------
    value : float
        The parsed value; ``0.0`` for empty input.

    Raises
    ------
    ProtocolError
        Raised (as a ValueError, which the stream parser drops on) if the text
        is neither a decimal nor a sexagesimal number, or if it names a
        non-finite value.
    """
    s = text.strip()
    if not s:
        return 0.0
    value = _decimal_or_sexagesimal(s)
    if not math.isfinite(value):
        raise ProtocolError(f"non-finite number {s!r}")
    return value

format_number

format_number(value: float, fmt: str) -> str

Format a number per an INDI printf-style format.

Handles ordinary printf conversions as well as the %m sexagesimal form (e.g. %9.6m), field-width padded like libindi's fs_sexa.

Half-way values round away from zero, which is what fs_sexa does (indicom.c:165 casts a * fracbase + 0.5 to an integer). Python's built-in round is half-to-even, so it disagrees on exactly the values that land on a tick boundary: at %10.6m it renders 0.03125 as 0:01:52 where libindi says 0:01:53. One arcsecond, only on exact halves, in the format mounts use for RA and Dec.

One divergence from fs_sexa is kept on purpose: libindi passes a negative width to %*s when w - f < 3 (indicom.c:171), which left-justifies instead. No real driver declares such a format, and matching the quirk would only preserve it.

Parameters:

Name Type Description Default
value float

The number to format.

required
fmt str

The INDI format string from the element definition.

required

Returns:

Name Type Description
text str

The formatted value.

Source code in src/indikit/protocol/numbers.py
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
def format_number(value: float, fmt: str) -> str:
    """Format a number per an INDI printf-style format.

    Handles ordinary printf conversions as well as the ``%m`` sexagesimal form
    (e.g. ``%9.6m``), field-width padded like libindi's ``fs_sexa``.

    Half-way values round *away from zero*, which is what ``fs_sexa`` does
    (``indicom.c:165`` casts ``a * fracbase + 0.5`` to an integer). Python's
    built-in `round` is half-to-*even*, so it disagrees on exactly the values
    that land on a tick boundary: at ``%10.6m`` it renders ``0.03125`` as
    ``0:01:52`` where libindi says ``0:01:53``. One arcsecond, only on exact
    halves, in the format mounts use for RA and Dec.

    One divergence from ``fs_sexa`` is kept on purpose: libindi passes a
    negative width to ``%*s`` when ``w - f < 3`` (``indicom.c:171``), which
    left-justifies instead. No real driver declares such a format, and matching
    the quirk would only preserve it.

    Parameters
    ----------
    value : float
        The number to format.
    fmt : str
        The INDI ``format`` string from the element definition.

    Returns
    -------
    text : str
        The formatted value.
    """
    m = re.fullmatch(r"%(\d+)\.(\d+)m", fmt.strip())
    if not m:
        # OverflowError belongs with the other two: an integer conversion
        # against a non-finite value raises it (``"%d" % float("inf")``), and
        # this is the last stop before the writer loop. The models refuse a
        # non-finite ``value`` now, but ``format`` is the peer's string and this
        # function is public, so the fallback still has to hold.
        try:
            return (fmt % value).strip()
        except (TypeError, ValueError, OverflowError):
            return repr(value)

    width, frac = int(m.group(1)), int(m.group(2))
    fracbase = {9: 360000, 8: 36000, 6: 3600, 5: 600}.get(frac, 60)
    neg = value < 0
    n = math.floor(abs(value) * fracbase + 0.5)
    d, f = divmod(n, fracbase)
    dd = f"{'-' if neg else ''}{d}"
    field = max(width - frac, 1)
    dd = dd.rjust(field)
    if fracbase == 60:  # dd:mm
        return f"{dd}:{f:02d}"
    if fracbase == 600:  # dd:mm.m
        return f"{dd}:{f // 10:02d}.{f % 10:1d}"
    if fracbase == 3600:  # dd:mm:ss
        return f"{dd}:{f // 60:02d}:{f % 60:02d}"
    if fracbase == 36000:  # dd:mm:ss.s
        return f"{dd}:{f // 600:02d}:{(f % 600) // 10:02d}.{f % 10:1d}"
    # dd:mm:ss.ss
    return f"{dd}:{f // 6000:02d}:{(f % 6000) // 100:02d}.{f % 100:02d}"

Compression

indikit.protocol.compression

The INDI .z transport encoding: inflate a BLOB payload on receipt.

The 1.7 whitepaper defines a BLOB's format as a chain of suffixes and says a trailing .z means the payload is zlib-compressed - .fits.z is a FITS file that has been deflated for the wire - and that clients are encouraged to support it. libindi does exactly that in BaseDevicePrivate::setBLOB: every client built on it inflates the payload, strips the .z and hands the application .fits. A consumer written against KStars has never seen a .z and will not look for one, so delivering deflated bytes beside a size describing data we did not deliver is a correctness bug, not a missing feature.

This lives beside :mod:indikit.protocol.numbers rather than inside :mod:indikit.protocol.xml for the same reason that one does: it is not an XML concern. A BLOB reaches a browser as JSON off the same models, and a browser must no more see a .z than a Python application does, so the rule belongs where both codecs can call it. Doing it in :mod:indikit.client.store instead - where libindi happens to put it - would cover the client and leave the driver's inbound newBLOBVector and the whole JSON path uncovered.

Compressing is receive-only in the sense that matters: nothing here ever deflates anything. That stays the driver author's decision, as it is in libindi (an opt-in CCD_COMPRESSION switch, defaulting to off). A driver may set format=".fits.z" with deflated bytes and an explicit uncompressed size, and both codecs serialise that faithfully; what neither will do is guess the size it cannot know, which is :func:require_declared_size.

zlib_encoded

zlib_encoded(fmt: str | None) -> bool

Whether a BLOB format marks its payload as zlib-compressed.

Parameters:

Name Type Description Default
fmt str or None

The BLOB's format, a chain of suffixes such as .fits.z.

required

Returns:

Name Type Description
encoded bool

True only for a trailing .z. .fz is FITS tile compression and is not a transport encoding, so it reads as False.

Source code in src/indikit/protocol/compression.py
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
def zlib_encoded(fmt: str | None) -> bool:
    """Whether a BLOB ``format`` marks its payload as zlib-compressed.

    Parameters
    ----------
    fmt : str or None
        The BLOB's ``format``, a chain of suffixes such as ``.fits.z``.

    Returns
    -------
    encoded : bool
        `True` only for a trailing ``.z``. ``.fz`` is FITS tile compression and
        is not a transport encoding, so it reads as `False`.
    """
    return fmt is not None and fmt.endswith(_ZLIB_SUFFIX)

require_declared_size

require_declared_size(el: BLOB) -> None

Refuse to emit a compressed payload whose uncompressed length is unstated.

INDI's size is the decoded and uncompressed length, so len(data) is the right default only for a payload that is neither encoded nor compressed. A .z format says the bytes are deflated, and the number the attribute is defined as could only be learned by inflating them - work the driver did not ask for on a path where it already knows the answer. So it has to say, and a model that does not is refused rather than described with the wrong number.

Both codecs call this, and that is the point: they serialise the same models, so a frame one of them will emit and the other will not is drift between two descriptions of one contract. Keeping the rule here rather than in either codec is what stops it being written twice and diverging once. Refusing is loud and contained - the driver runtime's writer reports a message it cannot serialise and drops it, rather than taking the driver down.

.fz is untouched, as everywhere else in this module: it is a container format whose size the driver states for reasons of its own.

Parameters:

Name Type Description Default
el BLOB

The element about to be serialised.

required

Raises:

Type Description
ProtocolError

Raised if the element carries a payload and a .z format but no size. Also a ValueError.

Source code in src/indikit/protocol/compression.py
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
def require_declared_size(el: BLOB) -> None:
    """Refuse to emit a compressed payload whose uncompressed length is unstated.

    INDI's ``size`` is the decoded **and uncompressed** length, so ``len(data)``
    is the right default only for a payload that is neither encoded nor
    compressed. A ``.z`` format says the bytes are deflated, and the number the
    attribute is defined as could only be learned by inflating them - work the
    driver did not ask for on a path where it already knows the answer. So it
    has to say, and a model that does not is refused rather than described with
    the wrong number.

    Both codecs call this, and that is the point: they serialise the *same*
    models, so a frame one of them will emit and the other will not is drift
    between two descriptions of one contract. Keeping the rule here rather than
    in either codec is what stops it being written twice and diverging once.
    Refusing is loud and contained - the driver runtime's writer reports a
    message it cannot serialise and drops it, rather than taking the driver down.

    ``.fz`` is untouched, as everywhere else in this module: it is a container
    format whose ``size`` the driver states for reasons of its own.

    Parameters
    ----------
    el : BLOB
        The element about to be serialised.

    Raises
    ------
    ProtocolError
        Raised if the element carries a payload and a ``.z`` format but no
        ``size``. Also a ValueError.
    """
    if el.data is not None and el.size is None and zlib_encoded(el.format):
        raise ProtocolError(
            f"BLOB {el.name!r} declares {el.format!r} but no size; a compressed "
            "payload must carry the uncompressed length explicitly"
        )

inflate_blob

inflate_blob(el: BLOB) -> None

Inflate a received .z payload in place, mirroring libindi exactly.

Three things change together, and an application must never see any of them half-applied: the payload becomes the inflated bytes, format loses its .z, and size becomes the inflated length. Anything else that is already what a caller wants - an uncompressed payload, a .fz frame, a def carrying no bytes at all - is left exactly as it arrived.

A def-shaped BLOB is untouched even when its format says .z: there is no payload to inflate, and renaming a format on the strength of bytes that never arrived would describe a frame nobody sent. The set that carries the payload brings the corrected format with it, and a client's merge takes the format from the set.

Parameters:

Name Type Description Default
el BLOB

The freshly parsed element, mutated in place.

required

Raises:

Type Description
ProtocolError

Raised if the payload will not inflate. Also a ValueError, so the XML stream parser's documented drop-and-count path applies and a corrupt frame costs the message carrying it rather than the connection. The compressed bytes are never delivered instead: a caller that asked for a .fits and got deflate would hand it to a FITS reader.

Source code in src/indikit/protocol/compression.py
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
def inflate_blob(el: BLOB) -> None:
    """Inflate a received ``.z`` payload in place, mirroring libindi exactly.

    Three things change together, and an application must never see any of them
    half-applied: the payload becomes the inflated bytes, ``format`` loses its
    ``.z``, and ``size`` becomes the inflated length. Anything else that is
    already what a caller wants - an uncompressed payload, a ``.fz`` frame, a
    ``def`` carrying no bytes at all - is left exactly as it arrived.

    A ``def``-shaped BLOB is untouched even when its ``format`` says ``.z``:
    there is no payload to inflate, and renaming a format on the strength of
    bytes that never arrived would describe a frame nobody sent. The ``set``
    that carries the payload brings the corrected format with it, and a client's
    merge takes the format from the ``set``.

    Parameters
    ----------
    el : BLOB
        The freshly parsed element, mutated in place.

    Raises
    ------
    ProtocolError
        Raised if the payload will not inflate. Also a ValueError, so the XML
        stream parser's documented drop-and-count path applies and a corrupt
        frame costs the message carrying it rather than the connection. The
        compressed bytes are never delivered instead: a caller that asked for a
        ``.fits`` and got deflate would hand it to a FITS reader.
    """
    fmt, data = el.format, el.data
    if data is None or fmt is None or not zlib_encoded(fmt):
        return

    try:
        # RFC 1950, not raw deflate. The whitepaper's footnote cites RFC 1951,
        # but libindi calls zlib's compress2()/uncompress(), which write and
        # expect the 2-byte zlib header and the Adler-32 trailer. Passing
        # wbits=-15 here would refuse every real payload.
        inflated = zlib.decompress(data)
    except zlib.error as exc:
        raise ProtocolError(
            f"BLOB {el.name!r} declares {fmt!r} but its payload will not inflate: {exc}"
        ) from exc

    # `size` is the sender's claim about the uncompressed length, and it is only
    # ever a cross-check: zlib.decompress grows its own buffer, so the number is
    # never an allocation bound, and a sender that miscounts must not cost us the
    # frame we successfully inflated.
    if el.size is not None and el.size != len(inflated):
        logger.warning(
            "BLOB %r declared size %d but inflated to %d bytes",
            el.name,
            el.size,
            len(inflated),
        )

    el.data = inflated
    el.size = len(inflated)
    el.format = _strip_suffix(fmt)

XML codec

indikit.protocol.xml

INDI 1.7 XML codec: models <-> canonical INDI XML.

Two directions:

  • :func:to_xml serializes a model (DefVector/SetVector/NewVector or a bare message) to the exact def*/set*/new* XML that indiserver and C++ INDI clients expect.
  • :class:XMLStreamParser consumes the raw byte stream from a socket or stdio pipe and yields fully-formed :data:~indikit.protocol.models.IndiMessage objects as complete top-level elements arrive, reassembling messages across arbitrary chunk boundaries (BLOB payloads included).

Parsing is lenient by policy. The peer at the other end is somebody else's C driver, and one element it malformed must not take down a session that is otherwise working: an unparseable optional attribute degrades to absent, an element whose value cannot be parsed at all is dropped and counted, and an unmatched close tag that would end the document reopens it. What leniency never does is invent a value - see :func:~indikit.protocol.numbers.parse_number for a leaf and :func:_required for the device/name a message is nothing without.

Number values honour the INDI printf-style format, including the %m sexagesimal form used for RA/Dec, so values round-trip faithfully with libindi. That rendering is not an XML concern and lives in :mod:indikit.protocol.numbers; this module only calls it.

XMLStreamParser

XMLStreamParser()

Incremental parser for the unbounded INDI element stream.

The INDI wire is a sequence of sibling top-level elements with no enclosing document root, so we feed a synthetic root and emit each depth-1 element as it completes, clearing consumed nodes to keep memory flat.

Nothing a peer can send makes this raise. A stream parser that throws takes the whole session with it - on a driver, the raise escapes the runtime's per-message isolation because it happens while iterating this generator, and on a client it kills the reconnect loop. So malformed input is absorbed and counted instead, and the counters are how a caller finds out.

Attributes:

Name Type Description
dropped int

Top-level elements discarded because a value would not parse and the model had no way to say "absent". An interop signal: it means somebody's codec is emitting something this one will not read.

resets int

Times the synthetic document had to be reopened - by :meth:_reset when a peer's unmatched close tag ended it, and by :meth:resync when a reader inferred the same failure from the silence that followed one. Both are the same framing violation, so they share a counter. An operational signal, and explicitly not a loss count: one reset typically loses the one message the close tag was embedded in, and 50 consecutive resets can lose nothing at all.

bytes_since_last_message int

Bytes fed since a top-level element last completed. Compared against :data:STALL_THRESHOLD_BYTES by :attr:stalled. Only a completed element and :meth:resync clear it; reopening the document does not, because recovering framing is not the same as producing a message.

Both counters describe *the peer on this stream*, not the lifetime of one
lxml object :meth:`resync` rebuilds the parser underneath them and leaves
them running, so a reader that rebuilds behind the caller's back does not
quietly zero the history it is about to want.

Start the pull parser and open a synthetic enclosing root element.

Source code in src/indikit/protocol/xml.py
757
758
759
760
761
762
def __init__(self) -> None:
    """Start the pull parser and open a synthetic enclosing root element."""
    self.dropped = 0
    self.resets = 0
    self.bytes_since_last_message = 0
    self._open()

stalled property

stalled: bool

Whether bytes keep arriving but no message has come out for far too long.

The one failure this class cannot see from the inside: lxml can be left in a state that emits no event at all - a root close arriving while a start tag is half-parsed does it, with nothing in error_log - and from then on every message is swallowed in silence. A reader loop holds both halves of the evidence, so it asks this and then either calls :meth:resync (a driver, which has only the one stdin) or drops the connection (a client, which can just reconnect).

It also catches the stream that never produces anything in the first place - nothing but unmatched close tags, say - because reopening the document is not progress and leaves this counter running.

resync

resync() -> None

Rebuild the parser after a reader has judged the stream :attr:stalled.

The remedy for a parser that has gone mute: the lxml object is thrown away and a fresh document opened, so the stream picks up again at the next well-formed element. :attr:dropped and :attr:resets survive, because they describe the peer on the other end and not the object being replaced - and a peer's malformed-input history is never more interesting than at the moment its stream stops saying anything.

The stall budget does start again here, unlike after :meth:_reset: the reader has just applied the remedy, and leaving the counter above the threshold would report a stall on every later chunk. The reason for the rebuild belongs to the caller, which has the device name or the peer address to log it against, so this method stays quiet.

Source code in src/indikit/protocol/xml.py
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
def resync(self) -> None:
    """Rebuild the parser after a reader has judged the stream :attr:`stalled`.

    The remedy for a parser that has gone mute: the lxml object is thrown
    away and a fresh document opened, so the stream picks up again at the
    next well-formed element. :attr:`dropped` and :attr:`resets` survive,
    because they describe the peer on the other end and not the object being
    replaced - and a peer's malformed-input history is never more
    interesting than at the moment its stream stops saying anything.

    The stall budget does start again here, unlike after :meth:`_reset`: the
    reader has just applied the remedy, and leaving the counter above the
    threshold would report a stall on every later chunk. The reason for the
    rebuild belongs to the caller, which has the device name or the peer
    address to log it against, so this method stays quiet.
    """
    self.resets += 1
    self.bytes_since_last_message = 0
    self._open()

feed

feed(data: bytes | str) -> Iterator[IndiMessage]

Feed the next chunk of bytes and yield any completed messages.

Parameters:

Name Type Description Default
data bytes or str

The next bytes or string from the stream.

required

Yields:

Name Type Description
message IndiMessage

Each top-level message that completed within this chunk.

Source code in src/indikit/protocol/xml.py
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
def feed(self, data: bytes | str) -> Iterator[IndiMessage]:
    """Feed the next chunk of bytes and yield any completed messages.

    Parameters
    ----------
    data : bytes or str
        The next bytes or string from the stream.

    Yields
    ------
    message : IndiMessage
        Each top-level message that completed within this chunk.
    """
    if isinstance(data, str):
        data = data.encode("utf-8")
    self.bytes_since_last_message += len(data)
    self._parser.feed(data)
    for _event, item in self._parser.read_events():
        # We only subscribed to "end" events, so the payload is always an
        # element; the lxml stubs can't narrow that, hence the cast.
        element = cast("etree._Element", item)
        parent = element.getparent()
        if parent is None:
            # Depth 0: our synthetic root just ended, which only happens
            # when the peer sent a close tag with nothing open - its own
            # "</indi>", a stray "</bogus>", even one split across two
            # chunks. lxml ends the document there and would silently
            # swallow every later element, so reopen and carry on.
            self._reset()
            # Stop draining: the depth-0 end always sorts last in the queue,
            # so nothing valid is stranded behind this break, and the events
            # after it belong to a document that no longer exists. Do not
            # "fix" this to continue.
            break
        if parent.getparent() is not None:
            continue  # deeper than depth 1: still part of a message
        msg = self._convert(element)
        # A completed top-level element means the framing still works,
        # whatever the element turned out to contain.
        self.bytes_since_last_message = 0
        if msg is not None:
            yield msg
        # Free memory: drop this element and earlier siblings.
        element.clear()
        prev = element.getprevious()
        while prev is not None:
            parent.remove(prev)
            prev = element.getprevious()

to_xml

to_xml(msg: IndiMessage, *, pretty: bool = False) -> bytes

Serialise an INDI message model to canonical INDI XML bytes.

Parameters:

Name Type Description Default
msg IndiMessage

The message model to serialise.

required
pretty bool

Whether to pretty-print the output.

False

Returns:

Name Type Description
xml bytes

The encoded XML.

Raises:

Type Description
ProtocolError

Raised if a BLOB declares a compressed format without the uncompressed size the wire attribute is defined as. Also a ValueError.

Source code in src/indikit/protocol/xml.py
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
def to_xml(msg: IndiMessage, *, pretty: bool = False) -> bytes:
    """Serialise an INDI message model to canonical INDI XML bytes.

    Parameters
    ----------
    msg : IndiMessage
        The message model to serialise.
    pretty : bool, optional
        Whether to pretty-print the output.

    Returns
    -------
    xml : bytes
        The encoded XML.

    Raises
    ------
    ProtocolError
        Raised if a BLOB declares a compressed ``format`` without the
        uncompressed ``size`` the wire attribute is defined as. Also a
        ValueError.
    """
    return etree.tostring(_message_xml(msg), pretty_print=pretty)

message_from_xml

message_from_xml(node: _Element) -> IndiMessage | None

Convert a single top-level INDI element node to a message model.

Parameters:

Name Type Description Default
node _Element

A top-level INDI element node.

required

Returns:

Name Type Description
message IndiMessage or None

The parsed message, or None for comments/PIs or unrecognised tags.

Raises:

Type Description
ValueError

Raised if a value the model cannot represent as absent is malformed - a number's text, a BLOB's base64, one of the state/permission/rule tokens, or a missing #REQUIRED device/name (see :func:_required). The caller (:meth:XMLStreamParser._convert) turns that into a dropped message.

Source code in src/indikit/protocol/xml.py
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
def message_from_xml(node: etree._Element) -> IndiMessage | None:
    """Convert a single top-level INDI element node to a message model.

    Parameters
    ----------
    node : lxml.etree._Element
        A top-level INDI element node.

    Returns
    -------
    message : IndiMessage or None
        The parsed message, or `None` for comments/PIs or unrecognised tags.

    Raises
    ------
    ValueError
        Raised if a value the model cannot represent as absent is malformed -
        a number's text, a BLOB's base64, one of the state/permission/rule
        tokens, or a missing ``#REQUIRED`` ``device``/``name`` (see
        :func:`_required`). The caller (:meth:`XMLStreamParser._convert`) turns
        that into a dropped message.
    """
    tag = node.tag
    if not isinstance(tag, str):  # comments / PIs
        return None

    if tag == "getProperties":
        return GetProperties(
            version=node.get("version") or "1.7",
            device=node.get("device"),
            name=node.get("name"),
        )
    if tag == "delProperty":
        return DelProperty(
            device=_required(node, "device"),
            name=node.get("name"),
            timestamp=_optts(node.get("timestamp")),
            message=node.get("message"),
        )
    if tag == "message":
        return Message(
            device=node.get("device"),
            timestamp=_optts(node.get("timestamp")),
            message=node.get("message") or "",
        )
    if tag == "enableBLOB":
        text = (node.text or "").strip()
        return EnableBLOB(
            device=_required(node, "device"),
            name=node.get("name"),
            policy=BLOBPolicy(text) if text else BLOBPolicy.ALSO,
        )

    m = re.fullmatch(r"(def|set|new)(Number|Text|Switch|Light|BLOB)Vector", tag)
    if m:
        mode, stem = m.group(1), m.group(2)
        vector = _vector_from_xml(node, stem)
        if mode == "def":
            return DefVector(vector=vector)
        if mode == "set":
            return SetVector(vector=vector, state_present=node.get("state") is not None)
        return NewVector(vector=vector)

    return None

parse_indi

parse_indi(data: bytes | str) -> list[IndiMessage]

Parse a complete chunk of INDI XML into message models.

Convenience wrapper over :class:XMLStreamParser for a self-contained chunk that holds one or more complete top-level elements, and it inherits that class's leniency: a malformed element is dropped, not raised, so a shorter list than expected - not an exception - is how bad input shows up here.

Parameters:

Name Type Description Default
data bytes or str

The XML bytes or string to parse.

required

Returns:

Name Type Description
messages list of IndiMessage

Every message found in the chunk, in order.

Source code in src/indikit/protocol/xml.py
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
def parse_indi(data: bytes | str) -> list[IndiMessage]:
    """Parse a complete chunk of INDI XML into message models.

    Convenience wrapper over :class:`XMLStreamParser` for a self-contained chunk
    that holds one or more complete top-level elements, and it inherits that
    class's leniency: a malformed element is dropped, not raised, so a shorter
    list than expected - not an exception - is how bad input shows up here.

    Parameters
    ----------
    data : bytes or str
        The XML bytes or string to parse.

    Returns
    -------
    messages : list of IndiMessage
        Every message found in the chunk, in order.
    """
    parser = XMLStreamParser()
    return list(parser.feed(data))

JSON codec

indikit.protocol.json

INDI JSON codec: models <-> typed JSON for browser clients.

The INDI wire toward indiserver is XML (see :mod:indikit.protocol.xml); toward browsers it is JSON. Both directions serialise the same Pydantic models, so the JSON contract is just the models dumped to JSON - one source of truth. The frontend's TypeScript types are not generated from it: INDI 1.7 is frozen, so web/packages/client/src/types.ts is a hand-authored mirror of these models and has to be updated in step with them.

:func:to_json and :func:from_json mirror to_xml / parse_indi over a single :class:pydantic.TypeAdapter for :data:~indikit.protocol.models.IndiMessage. That union is discriminated on tag, the way the element and vector unions are discriminated on kind, which is what makes it closed: a payload whose tag is missing or unknown is refused outright rather than matched against every member in turn. Left undiscriminated it was not closed at all - :class:GetProperties defaults every field and the base model ignores extras, so {} and any other unrecognised object validated as a getProperties. BLOB payloads travel as base64 (configured on the base model).

The only thing either function does beyond (de)serialising is the .z rule (see :mod:indikit.protocol.compression), and it is applied here for the same reason the XML codec applies it: these two read and write the same models, so a payload one of them inflates and the other does not, or a frame one will emit and the other refuses, is drift between two descriptions of one contract. On the way in a zlib-compressed BLOB payload is inflated and its format loses the suffix, so the browser at the end of this contract - which has no zlib of its own - never meets one. On the way out a .z format with no explicit size is refused, exactly as to_xml refuses it.

Everything :func:to_json writes, :func:from_json reads back. JSON has no literal for NaN or the infinities, so a non-finite Number.value would be serialised as null and then rejected on the way back in - the payload would be unreadable by its own codec. The models refuse a non-finite value instead (see :class:~indikit.protocol.models.Number), which puts the failure at the point the value enters rather than on the far side of a network hop, and the XML parser drops such an element for the same reason.

to_json

to_json(msg: IndiMessage) -> str

Serialise an INDI message model to a JSON string.

Parameters:

Name Type Description Default
msg IndiMessage

The message model to serialise.

required

Returns:

Name Type Description
text str

The message as JSON (a bytes BLOB payload is base64-encoded), always readable by :func:from_json.

Raises:

Type Description
ProtocolError

Raised (also a ValueError) if a BLOB declares a .z format without the uncompressed size that attribute is defined as - the same refusal, from the same rule, that :func:~indikit.protocol.xml.to_xml makes. Two codecs over one set of models must not disagree about which frames are emittable.

Source code in src/indikit/protocol/json.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
def to_json(msg: IndiMessage) -> str:
    """Serialise an INDI message model to a JSON string.

    Parameters
    ----------
    msg : IndiMessage
        The message model to serialise.

    Returns
    -------
    text : str
        The message as JSON (a ``bytes`` BLOB payload is base64-encoded), always
        readable by :func:`from_json`.

    Raises
    ------
    ProtocolError
        Raised (also a `ValueError`) if a BLOB declares a ``.z`` format without
        the uncompressed ``size`` that attribute is defined as - the same
        refusal, from the same rule, that :func:`~indikit.protocol.xml.to_xml`
        makes. Two codecs over one set of models must not disagree about which
        frames are emittable.
    """
    for el in _blobs(msg):
        require_declared_size(el)
    return _ADAPTER.dump_json(msg).decode("utf-8")

from_json

from_json(data: str | bytes) -> IndiMessage

Parse a JSON string into the matching INDI message model.

Parameters:

Name Type Description Default
data str or bytes

A single JSON object with a tag field identifying the message.

required

Returns:

Name Type Description
message IndiMessage

The parsed, fully typed message model.

Raises:

Type Description
ValidationError

Raised (as a ValueError) if the payload is not a message this codec can represent. Nothing :func:to_json produces reaches this.

ProtocolError

Raised (also a ValueError) if a BLOB declares a .z format and its payload will not inflate.

Source code in src/indikit/protocol/json.py
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
def from_json(data: str | bytes) -> IndiMessage:
    """Parse a JSON string into the matching INDI message model.

    Parameters
    ----------
    data : str or bytes
        A single JSON object with a ``tag`` field identifying the message.

    Returns
    -------
    message : IndiMessage
        The parsed, fully typed message model.

    Raises
    ------
    pydantic.ValidationError
        Raised (as a `ValueError`) if the payload is not a message this codec
        can represent. Nothing :func:`to_json` produces reaches this.
    ProtocolError
        Raised (also a `ValueError`) if a BLOB declares a ``.z`` format and its
        payload will not inflate.
    """
    msg = _ADAPTER.validate_json(data)
    # The `.z` rule is the protocol's, not XML's, so both codecs apply it on the
    # way in and neither codec's consumer ever meets a deflated payload. See
    # indikit.protocol.compression.
    for el in _blobs(msg):
        inflate_blob(el)
    return msg