indikit.client¶
A reconnecting asyncio TCP client to indiserver with a typed property
cache, subscriptions, and send helpers.
IndiClient¶
indikit.client.client ¶
IndiClient: a reconnecting async client for indiserver.
The client is a TCP peer of the C indiserver (default port 7624). It keeps a
typed :class:~indikit.client.store.PropertyStore up to date from the inbound
stream, lets application code watch for changes and wait on conditions, and sends
updates - always as M1 typed models, never raw XML.
Concurrency is plain :mod:asyncio: a background connection loop reconnects with
a fixed delay, and per connection a reader task folds inbound messages into the
store (dispatching to subscribers) while a writer task drains an outbox queue. The
transport is injectable (a connect coroutine returning read/write/
close callables) so tests drive the client over in-memory streams; the default
opens a real TCP connection via :func:indikit.transport.open_tcp. The
close callable is invoked whenever a connection ends - EOF, error, or
:meth:IndiClient.aclose - so the OS socket never lingers between reconnects.
Sending is deliberately not buffered across connections: a send with no live
connection raises :class:~indikit.exceptions.NotConnectedError and the
outbox is emptied whenever a connection ends, so nothing a caller issued while
indiserver was away can be delivered to an instrument minutes later. See
:meth:IndiClient.send.
:attr:IndiClient.stats is the operational read of all of that - how long this
connection has been up, how many reconnects it took to get here, and what the
parser has made of the peer - and it is what /health reports.
ClientStats
dataclass
¶
ClientStats(connected: bool, uptime_seconds: float | None, reconnects: int, last_message_age_seconds: float | None, dropped: int, resets: int, bytes_since_last_message: int, dropped_total: int, resets_total: int)
A point-in-time read of one client's upstream link and its parser.
Taken as a snapshot rather than exposed as live attributes, so a caller that
reports several of these fields - /health does - reports them all as of
one instant.
Attributes:
| Name | Type | Description |
|---|---|---|
connected |
bool
|
Whether there is a live connection right now. |
uptime_seconds |
float or None
|
How long the current connection has been up, and |
reconnects |
int
|
How many times a connection has been successfully re-established
since the client started; the first connection is not a reconnect. Failed
attempts are not counted, so a rising number means the link is genuinely
flapping, while a bridge that has never reached |
last_message_age_seconds |
float or None
|
Seconds since an INDI message was last parsed, and |
dropped |
int
|
Top-level elements this connection's parser discarded because a value
would not parse. Before the first connection, and while the client has
never had one, this is |
resets |
int
|
Times this connection's parser had to reopen its synthetic document. A
framing signal, not a loss count. |
bytes_since_last_message |
int
|
Bytes fed to this connection's parser since a message last came out of
it. |
dropped_total |
int
|
:attr: |
resets_total |
int
|
:attr: |
IndiClient ¶
IndiClient(host: str = 'localhost', port: int = 7624, *, connect_timeout: float = 10.0, reconnect_delay: float = 2.0, connect: Connect | None = None)
A reconnecting client that mirrors indiserver state into a cache.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
host
|
str
|
The |
'localhost'
|
port
|
int
|
The |
7624
|
connect_timeout
|
float
|
Seconds to wait for each connection attempt. |
10.0
|
reconnect_delay
|
float
|
Seconds to wait between a lost connection and the next attempt. |
2.0
|
connect
|
Connect
|
Injectable connection factory returning |
None
|
Source code in src/indikit/client/client.py
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 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 | |
stats
property
¶
stats: ClientStats
A snapshot of the upstream link and this connection's parser.
Cheap: it reads counters, so an endpoint may call it per request.
Returns:
| Name | Type | Description |
|---|---|---|
stats |
ClientStats
|
The current statistics; see that class for what each field means and what it reports while disconnected. |
start
async
¶
start(*, wait: bool = True) -> None
Start the background connection loop.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
wait
|
bool
|
Whether to block until the first connection succeeds. Scripts and
monitors want that. A long-running server that must stay responsive
while |
True
|
Source code in src/indikit/client/client.py
214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 | |
aclose
async
¶
aclose() -> None
Stop the connection loop, drop the connection, and fail the waiters.
Every :meth:wait_for still parked on its future is resolved with
:class:~indikit.exceptions.NotConnectedError, because nothing will
ever read the socket again: without that, a wait with no timeout hangs
for good and a wait with one sits out its full timeout to learn what the
client already knows.
Source code in src/indikit/client/client.py
230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 | |
__aenter__
async
¶
__aenter__() -> IndiClient
Start the client and return it once initially connected.
Source code in src/indikit/client/client.py
253 254 255 256 | |
__aexit__
async
¶
__aexit__(*exc: object) -> None
Close the client on context exit.
Source code in src/indikit/client/client.py
258 259 260 | |
get ¶
get(device: str, name: str) -> Vector | None
Return a cached vector, or None if it is not present.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
device
|
str
|
The device name. |
required |
name
|
str
|
The property name. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
vector |
Vector or None
|
The cached vector, or |
Source code in src/indikit/client/client.py
533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 | |
__getitem__ ¶
__getitem__(device: str) -> Any
Return the cached properties of one device.
Source code in src/indikit/client/client.py
555 556 557 | |
subscribe ¶
subscribe(callback: Subscriber, *, device: str | None = None, name: str | None = None) -> Callable[[], None]
Register a property-event callback (see :meth:PropertyStore.subscribe).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
callback
|
Subscriber
|
Called with each matching :class: |
required |
device
|
str
|
Restrict to one device; |
None
|
name
|
str
|
Restrict to one property; |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
unsubscribe |
Callable
|
Call with no arguments to remove the subscription. |
Source code in src/indikit/client/client.py
560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 | |
on_message ¶
on_message(callback: MessageCallback) -> Callable[[], None]
Register a callback for inbound message notifications.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
callback
|
Callable
|
Called with each inbound :class: |
required |
Returns:
| Name | Type | Description |
|---|---|---|
unsubscribe |
Callable
|
Call with no arguments to remove the subscription. |
Source code in src/indikit/client/client.py
581 582 583 584 585 586 587 588 589 590 591 592 593 594 | |
on_connection ¶
on_connection(callback: ConnectionCallback) -> Callable[[], None]
Register a callback for connect/disconnect transitions.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
callback
|
Callable
|
Called with |
required |
Returns:
| Name | Type | Description |
|---|---|---|
unsubscribe |
Callable
|
Call with no arguments to remove the subscription. |
Source code in src/indikit/client/client.py
596 597 598 599 600 601 602 603 604 605 606 607 608 609 | |
wait_for
async
¶
wait_for(device: str, name: str, predicate: Predicate | None = None, *, timeout: float | None = None) -> Vector
Wait until a property exists (and satisfies predicate).
Resolves immediately if the cached property already matches.
What comes back is a snapshot, detached from the cache: the vector
as it was at the instant the predicate held. The cached vector is
mutated in place by every later set, and a whole TCP chunk's worth
of messages is folded in before the reader yields, so a property that
goes Busy, Ok, Busy inside one chunk would satisfy a
state == OK wait and then read back Busy to the coroutine that
was waiting on it. Read the live vector through :meth:get when that is
what you want.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
device
|
str
|
The device name. |
required |
name
|
str
|
The property name. |
required |
predicate
|
Predicate
|
Called with the vector; the wait resolves when it returns |
None
|
timeout
|
float
|
Seconds to wait before raising |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
vector |
Vector
|
A detached copy of the matching vector, as it was when it matched. |
Raises:
| Type | Description |
|---|---|
TimeoutError
|
Raised if the timeout elapses first. |
NotConnectedError
|
Raised if :meth: |
Source code in src/indikit/client/client.py
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 694 695 696 697 698 699 700 701 702 703 704 705 706 | |
send
async
¶
send(msg: IndiMessage) -> None
Hand one message to the live connection's writer.
The typed helpers (:meth:set_number, :meth:get_properties, ...) cover
the common cases; this forwards any already-built message - used by the
web bridge to relay a browser-authored new*/getProperties/
enableBLOB frame verbatim.
A send with no connection fails; it is never held. This is
instrument control: a command queued while indiserver is down would
be delivered whenever the hub came back, minutes or hours later, to
hardware whose state has nothing to do with the one the caller was
reasoning about. Every send routes through here, so every one of them
either reaches a live connection or raises. Callers that want to wait
for the link instead can watch :meth:on_connection.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
msg
|
IndiMessage
|
The message to send. |
required |
Raises:
| Type | Description |
|---|---|
NotConnectedError
|
Raised if there is no live connection to |
SendQueueFull
|
Raised if the outbox is full because the connection has stopped draining it. Also a RuntimeError. |
Source code in src/indikit/client/client.py
709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 | |
get_properties
async
¶
get_properties(device: str | None = None, name: str | None = None) -> None
Ask the server to (re-)send property definitions.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
device
|
str
|
Restrict to one device; |
None
|
name
|
str
|
Restrict to one property; |
None
|
Source code in src/indikit/client/client.py
751 752 753 754 755 756 757 758 759 760 761 | |
enable_blob
async
¶
enable_blob(device: str, name: str | None = None, policy: BLOBPolicy = BLOBPolicy.ALSO) -> None
Set the BLOB delivery policy for a device (or one property).
The request is remembered and replayed on every reconnect.
The policy is recorded even when the send fails. Unlike every other send here, this is not a command to an instrument: it is a standing subscription preference, idempotent, and already part of what the client replays onto each new connection. Recording it while disconnected is therefore the same statement as recording it while connected - "BLOBs from this device, please" - and the next connection honours it. The raise still happens, because nothing went out now, so a caller that wants to know the request reached the server can act on it; a caller that just wants BLOBs when the hub returns can ignore it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
device
|
str
|
The device to set the policy for. |
required |
name
|
str
|
Restrict to one property; |
None
|
policy
|
BLOBPolicy
|
Whether BLOBs are never sent, sent alongside other updates, or sent exclusively. |
ALSO
|
Raises:
| Type | Description |
|---|---|
NotConnectedError
|
Raised if there is no live connection; the policy is remembered regardless. |
Source code in src/indikit/client/client.py
763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 | |
set_number
async
¶
set_number(device: str, name: str, values: dict[str, float]) -> None
Send new number values for a property.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
device
|
str
|
The device name. |
required |
name
|
str
|
The property name. |
required |
values
|
dict
|
Mapping of element name to numeric value. |
required |
Source code in src/indikit/client/client.py
800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 | |
set_text
async
¶
set_text(device: str, name: str, values: dict[str, str]) -> None
Send new text values for a property.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
device
|
str
|
The device name. |
required |
name
|
str
|
The property name. |
required |
values
|
dict
|
Mapping of element name to string value. |
required |
Source code in src/indikit/client/client.py
816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 | |
set_switch
async
¶
set_switch(device: str, name: str, values: dict[str, Any]) -> None
Send new switch states for a property.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
device
|
str
|
The device name. |
required |
name
|
str
|
The property name. |
required |
values
|
dict
|
Mapping of element name to state ( |
required |
Source code in src/indikit/client/client.py
832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 | |
set_blob
async
¶
set_blob(device: str, name: str, values: dict[str, bytes]) -> None
Send new BLOB payloads for a property.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
device
|
str
|
The device name. |
required |
name
|
str
|
The property name. |
required |
values
|
dict
|
Mapping of element name to raw |
required |
Source code in src/indikit/client/client.py
849 850 851 852 853 854 855 856 857 858 859 860 861 862 | |
run ¶
run() -> None
Connect and process the stream until interrupted (blocking).
A convenience entrypoint for scripts and monitors: register subscriptions
first, then call this. Returns on KeyboardInterrupt.
Source code in src/indikit/client/client.py
864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 | |
PropertyStore¶
indikit.client.store ¶
PropertyStore: the client's typed cache of INDI properties.
The store is the single source of cached truth for a client. It folds inbound
messages into a device -> name -> vector cache following standard INDI
semantics (def defines, set merges values onto the definition, del
removes), and it holds the subscription registry.
It is deliberately free of any socket or asyncio behaviour: :meth:apply
updates the cache and returns a :class:PropertyEvent, and :meth:matching
returns the callbacks interested in that event. The client performs the actual
(possibly asynchronous) dispatch, so the store stays pure and trivially testable.
PropertyEvent
dataclass
¶
PropertyEvent(type: EventType, device: str, name: str | None, vector: Vector | None, message: str | None = None, timestamp: datetime | None = None)
A change the store applied to its cache.
Attributes:
| Name | Type | Description |
|---|---|---|
type |
str
|
|
device |
str
|
The device the change applies to. |
name |
str or None
|
The property name, or |
vector |
Vector or None
|
The affected (post-merge) vector, or |
message |
str or None
|
The explanation a |
timestamp |
datetime or None
|
When a |
PropertyStore ¶
PropertyStore()
A cache of INDI property vectors plus a subscription registry.
Create an empty store with no cached properties or subscribers.
Source code in src/indikit/client/store.py
115 116 117 118 119 | |
get ¶
get(device: str, name: str) -> Vector | None
Return a cached vector, or None if it is not present.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
device
|
str
|
The device name. |
required |
name
|
str
|
The property name. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
vector |
Vector or None
|
The cached vector, or |
Source code in src/indikit/client/store.py
122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | |
device ¶
device(name: str) -> Mapping[str, Vector]
Return a read-only mapping of one device's properties.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
The device name. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
properties |
Mapping
|
The device's |
Source code in src/indikit/client/store.py
139 140 141 142 143 144 145 146 147 148 149 150 151 152 | |
devices ¶
devices() -> list[str]
Return the names of all known devices.
Source code in src/indikit/client/store.py
154 155 156 | |
__getitem__ ¶
__getitem__(device: str) -> Mapping[str, Vector]
Return one device's properties (see :meth:device).
Source code in src/indikit/client/store.py
158 159 160 | |
__contains__ ¶
__contains__(device: str) -> bool
Return whether any property is cached for device.
Source code in src/indikit/client/store.py
162 163 164 | |
__iter__ ¶
__iter__() -> Iterator[str]
Iterate over the known device names.
Source code in src/indikit/client/store.py
166 167 168 | |
apply ¶
apply(msg: IndiMessage) -> PropertyEvent | None
Fold one inbound message into the cache.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
msg
|
IndiMessage
|
The parsed inbound message. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
event |
PropertyEvent or None
|
The change applied, or |
Source code in src/indikit/client/store.py
171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 | |
subscribe ¶
subscribe(callback: Subscriber, *, device: str | None = None, name: str | None = None) -> Callable[[], None]
Register a callback for matching property events.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
callback
|
Subscriber
|
Called with each matching :class: |
required |
device
|
str
|
Restrict to one device; |
None
|
name
|
str
|
Restrict to one property name; |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
unsubscribe |
Callable
|
Call with no arguments to remove the subscription. |
Source code in src/indikit/client/store.py
232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 | |
matching ¶
matching(event: PropertyEvent) -> list[Subscriber]
Return the callbacks subscribed to a given event.
A whole-device del reaches every subscriber for that device,
including the name-filtered ones. Its event carries no name because the
deletion names no property - it takes all of them - so matching the
filter against it literally would silence exactly the subscribers with
the most to lose: subscribe(cb, device="CCD", name="EXPOSURE") heard
nothing when the CCD's driver died and indiserver withdrew the
device, which is the one event that watcher must not miss.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
event
|
PropertyEvent
|
The event to match against the registry. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
callbacks |
list of Subscriber
|
The callbacks whose device/name filters match, in registration order. |
Source code in src/indikit/client/store.py
261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 | |