indipyserver 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 bernie-skipole
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: indipyserver
3
+ Version: 0.0.1
4
+ Summary: Server for the INDI protocol.
5
+ Keywords: indi,server,astronomy,instrument,remote,control
6
+ Author-email: Bernard Czenkusz <bernie@skipole.co.uk>
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: POSIX :: Linux
11
+ Classifier: Topic :: Scientific/Engineering :: Astronomy
12
+ Classifier: Topic :: Scientific/Engineering :: Interface Engine/Protocol Translator
13
+ License-File: LICENSE
14
+ Project-URL: Documentation, https://indipyserver.readthedocs.io
15
+ Project-URL: Source, https://github.com/bernie-skipole/indipyserver
16
+
17
+ # indipyserver
18
+ Server for the INDI protocol, written in Python
19
+
20
+ This package provides an IPyServer class used to serve the INDI protocol on a port.
21
+
22
+ INDI - Instrument Neutral Distributed Interface.
23
+
24
+ See https://en.wikipedia.org/wiki/Instrument_Neutral_Distributed_Interface
25
+
26
+ Drivers controlling instrumentation can be written, typically using the IPyDriver class from the associated indipydriver package, this server opens a port to which an INDI client can connect.
27
+
28
+ You would create a script something like:
29
+
30
+
31
+ import asyncio
32
+ from indipyserver import IPyServer
33
+ import ... your own modules creating driver1, driver2 ...
34
+
35
+ server = IPyServer(driver1, driver2, host="localhost", port=7624, maxconnections=5)
36
+ asyncio.run(server.asyncrun())
37
+
38
+ A connected client can then control all the drivers. The above illustrates multiple drivers can be served, however it could equally be one or none at all.
39
+
40
+
41
+ ## Third party drivers
42
+
43
+ IPyServer can also run third party INDI drivers created with other languages or tools, using an add_exdriver method to include executable drivers.
44
+
45
+ For example, using drivers available from indilib:
46
+
47
+ import asyncio
48
+ from indipyserver import IPyServer
49
+
50
+ server = IPyServer(host="localhost", port=7624, maxconnections=5)
51
+
52
+ server.add_exdriver("indi_simulator_telescope")
53
+ server.add_exdriver("indi_simulator_ccd")
54
+ asyncio.run(server.asyncrun())
55
+
56
+
57
+ Please note: The author has no relationship with indilib, these indipyserver and indipydriver packages are independently developed implementations. However they were developed with reference to the INDI version 1.7 specification, and are intended to interwork with other implementations that also meets that spec.
58
+
59
+
60
+ ## Networked instruments
61
+
62
+ IPyServer also has an add_remote method which can be used to add connections to remote servers, creating a tree network of servers:
63
+
64
+ import asyncio
65
+ from indipyserver import IPyServer
66
+ import ... your own modules creating DriverA, DriverB ...
67
+
68
+ server = IPyServer(DriverA, DriverB, host="localhost", port=7624, maxconnections=5)
69
+
70
+ server.add_remote(host="nameofserverB", port=7624, blob_enable=True)
71
+ server.add_remote(host="nameofserverC", port=7624, blob_enable=True)
72
+
73
+ asyncio.run(server.asyncrun())
74
+
75
+
76
+ ![INDI Network](https://github.com/bernie-skipole/indipyserver/raw/main/docs/source/usage/images/rem2.png)
77
+
78
+ With such a layout, the client can control all the instruments.
79
+
80
+ Drivers made with indipydriver, third party executable drivers and remote connections can all be served together.
81
+
82
+ Further documentation can be found at:
83
+
84
+ https://indipyserver.readthedocs.io/en/latest/index.html
85
+
@@ -0,0 +1,68 @@
1
+ # indipyserver
2
+ Server for the INDI protocol, written in Python
3
+
4
+ This package provides an IPyServer class used to serve the INDI protocol on a port.
5
+
6
+ INDI - Instrument Neutral Distributed Interface.
7
+
8
+ See https://en.wikipedia.org/wiki/Instrument_Neutral_Distributed_Interface
9
+
10
+ Drivers controlling instrumentation can be written, typically using the IPyDriver class from the associated indipydriver package, this server opens a port to which an INDI client can connect.
11
+
12
+ You would create a script something like:
13
+
14
+
15
+ import asyncio
16
+ from indipyserver import IPyServer
17
+ import ... your own modules creating driver1, driver2 ...
18
+
19
+ server = IPyServer(driver1, driver2, host="localhost", port=7624, maxconnections=5)
20
+ asyncio.run(server.asyncrun())
21
+
22
+ A connected client can then control all the drivers. The above illustrates multiple drivers can be served, however it could equally be one or none at all.
23
+
24
+
25
+ ## Third party drivers
26
+
27
+ IPyServer can also run third party INDI drivers created with other languages or tools, using an add_exdriver method to include executable drivers.
28
+
29
+ For example, using drivers available from indilib:
30
+
31
+ import asyncio
32
+ from indipyserver import IPyServer
33
+
34
+ server = IPyServer(host="localhost", port=7624, maxconnections=5)
35
+
36
+ server.add_exdriver("indi_simulator_telescope")
37
+ server.add_exdriver("indi_simulator_ccd")
38
+ asyncio.run(server.asyncrun())
39
+
40
+
41
+ Please note: The author has no relationship with indilib, these indipyserver and indipydriver packages are independently developed implementations. However they were developed with reference to the INDI version 1.7 specification, and are intended to interwork with other implementations that also meets that spec.
42
+
43
+
44
+ ## Networked instruments
45
+
46
+ IPyServer also has an add_remote method which can be used to add connections to remote servers, creating a tree network of servers:
47
+
48
+ import asyncio
49
+ from indipyserver import IPyServer
50
+ import ... your own modules creating DriverA, DriverB ...
51
+
52
+ server = IPyServer(DriverA, DriverB, host="localhost", port=7624, maxconnections=5)
53
+
54
+ server.add_remote(host="nameofserverB", port=7624, blob_enable=True)
55
+ server.add_remote(host="nameofserverC", port=7624, blob_enable=True)
56
+
57
+ asyncio.run(server.asyncrun())
58
+
59
+
60
+ ![INDI Network](https://github.com/bernie-skipole/indipyserver/raw/main/docs/source/usage/images/rem2.png)
61
+
62
+ With such a layout, the client can control all the instruments.
63
+
64
+ Drivers made with indipydriver, third party executable drivers and remote connections can all be served together.
65
+
66
+ Further documentation can be found at:
67
+
68
+ https://indipyserver.readthedocs.io/en/latest/index.html
@@ -0,0 +1,9 @@
1
+
2
+ import sys
3
+
4
+ from .ipyserver import IPyServer
5
+
6
+ if sys.version_info < (3, 11):
7
+ raise ImportError('indipyserver requires Python >= 3.11')
8
+
9
+ version = "0.0.1"
@@ -0,0 +1,314 @@
1
+
2
+ import asyncio, copy
3
+
4
+ import xml.etree.ElementTree as ET
5
+
6
+ import logging
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ # All xml data sent from the driver should be contained in one of the following tags
11
+ TAGS = (b'message',
12
+ b'delProperty',
13
+ b'defSwitchVector',
14
+ b'setSwitchVector',
15
+ b'defLightVector',
16
+ b'setLightVector',
17
+ b'defTextVector',
18
+ b'setTextVector',
19
+ b'defNumberVector',
20
+ b'setNumberVector',
21
+ b'defBLOBVector',
22
+ b'setBLOBVector',
23
+ b'getProperties' # for snooping
24
+ )
25
+
26
+ # Note these are strings, as they are used for checking xmldata.tag values
27
+
28
+ DEFTAGS = ( 'defSwitchVector',
29
+ 'defLightVector',
30
+ 'defTextVector',
31
+ 'defNumberVector',
32
+ 'defBLOBVector'
33
+ )
34
+
35
+
36
+ NEWTAGS = ('newTextVector',
37
+ 'newNumberVector',
38
+ 'newSwitchVector',
39
+ 'newBLOBVector'
40
+ )
41
+
42
+ # _STARTTAGS is a tuple of ( b'<defTextVector', ... ) data received will be tested to start with such a starttag
43
+ _STARTTAGS = tuple(b'<' + tag for tag in TAGS)
44
+
45
+ # _ENDTAGS is a tuple of ( b'</defTextVector>', ... ) data received will be tested to end with such an endtag
46
+ _ENDTAGS = tuple(b'</' + tag + b'>' for tag in TAGS)
47
+
48
+
49
+ class ExVector:
50
+
51
+ def __init__(self, name, deftag):
52
+ "Object which only holds a name and vectortype"
53
+ self.name = name
54
+ self.vectortype = deftag[3:]
55
+
56
+
57
+ class ExDriver:
58
+
59
+ def __init__(self, program, *args, debug_enable=False):
60
+ "Executes a third party indi driver, communicates by stdin stdout"
61
+
62
+ self.program = program
63
+ self.args = args
64
+ self.debug_enable = debug_enable
65
+ self.proc = None
66
+
67
+ # An object for communicating is set when this driver is added to the server
68
+ self._commsobj = None
69
+
70
+ # This dictionary will be populated with devicename:{vectorname:vector}
71
+ # where vector is an ExVector object
72
+ self.devicenames = {}
73
+
74
+ self.snoopall = False # gets set to True if it is snooping everything
75
+ self.snoopdevices = set() # gets set to a set of device names
76
+ self.snoopvectors = set() # gets set to a set of (devicename,vectorname) tuples
77
+
78
+ self._remainder = b"" # Used to store intermediate data
79
+ self._stop = False # Gets set to True to stop communications
80
+
81
+
82
+ def shutdown(self):
83
+ self._stop = True
84
+ if self._commsobj is not None:
85
+ self._commsobj.shutdown()
86
+ if self.proc is not None:
87
+ self.proc.terminate()
88
+
89
+
90
+ def __contains__(self, item):
91
+ "So a devicename can easily be checked if it is in this driver"
92
+ return item in self.devicenames
93
+
94
+
95
+ async def _readdata(self, root):
96
+ """Called from communications object with received xmldata
97
+ and sends it towards the driver"""
98
+
99
+ if self._stop:
100
+ return
101
+
102
+ if root.tag == "enableBLOB":
103
+ return
104
+
105
+ if root.tag == "getProperties":
106
+ await self.xml_to_ext(root)
107
+ return
108
+
109
+ devicename = root.get("device")
110
+ propertyname = root.get("name")
111
+
112
+ if root.tag in NEWTAGS:
113
+ if devicename is None:
114
+ # invalid
115
+ return
116
+ if devicename in self.devicenames:
117
+ await self.xml_to_ext(root)
118
+ return
119
+ else:
120
+ # not for this driver
121
+ return
122
+
123
+ # so not in NEWTAGS
124
+ # Check if this driver is snooping on this device/vector
125
+ if self.snoopall:
126
+ await self.xml_to_ext(root)
127
+ elif devicename and (devicename in self.snoopdevices):
128
+ await self.xml_to_ext(root)
129
+ elif devicename and propertyname and ((devicename, propertyname) in self.snoopvectors):
130
+ await self.xml_to_ext(root)
131
+
132
+
133
+ async def xml_to_ext(self, root):
134
+ "Pass data to the external driver"
135
+
136
+ binarydata = ET.tostring(root)
137
+
138
+ # log the received data
139
+ if logger.isEnabledFor(logging.DEBUG) and self.debug_enable:
140
+ logger.debug(f"RX:: {binarydata.decode('utf-8')}")
141
+
142
+ binarydata += b"\n"
143
+ self.proc.stdin.write(binarydata)
144
+ await self.proc.stdin.drain()
145
+
146
+
147
+
148
+ async def _run_tx(self):
149
+ "Get data from driver and send it towards the server"
150
+ try:
151
+ # get block of xml.etree.ElementTree data
152
+ # from self._xmlinput and append it to self.writerque
153
+ while not self._stop:
154
+ txdata = await self._xmlinput()
155
+ if txdata is None:
156
+ return
157
+ # get block of xml.etree.ElementTree data
158
+ devicename = txdata.get("device")
159
+ if devicename and txdata.tag in DEFTAGS:
160
+ # its a definition
161
+ if devicename not in self.devicenames:
162
+ self.devicenames[devicename] = {}
163
+ vectorname = txdata.get("name")
164
+ if vectorname:
165
+ if vectorname not in self.devicenames[devicename]:
166
+ # add this vector to the self.devicenames[devicename] dictionary
167
+ self.devicenames[devicename][vectorname] = ExVector(vectorname, txdata.tag)
168
+ # check for a getProperties being sent, record what is being snooped
169
+ if txdata.tag == "getProperties":
170
+ vectorname = txdata.get("name")
171
+ if devicename is None:
172
+ self.snoopall = True
173
+ elif vectorname is None:
174
+ self.snoopdevices.add(devicename)
175
+ else:
176
+ self.snoopvectors.add((devicename,vectorname))
177
+
178
+ # transmit this data towards the server
179
+ await self._commsobj.run_tx(txdata)
180
+
181
+ if logger.isEnabledFor(logging.DEBUG) and self.debug_enable:
182
+ binarydata = ET.tostring(txdata)
183
+ logger.debug(f"TX:: {binarydata.decode('utf-8')}")
184
+
185
+ except Exception:
186
+ logger.exception("Exception report from ExDriver._run_tx")
187
+ raise
188
+
189
+
190
+ async def _xmlinput(self):
191
+ """get data from driver, parse it, and return it as xml.etree.ElementTree object
192
+ Returns None if stop flags arises"""
193
+ message = b''
194
+ messagetagnumber = None
195
+ while not self._stop:
196
+ data = await self._datainput()
197
+ # data is either None, or binary data ending in b">"
198
+ if data is None:
199
+ return
200
+ if self._stop:
201
+ return
202
+ if not message:
203
+ # data is expected to start with <tag, first strip any newlines
204
+ data = data.strip()
205
+ for index, st in enumerate(_STARTTAGS):
206
+ if data.startswith(st):
207
+ messagetagnumber = index
208
+ break
209
+ elif st in data:
210
+ # remove any data prior to a starttag
211
+ positionofst = data.index(st)
212
+ data = data[positionofst:]
213
+ messagetagnumber = index
214
+ break
215
+ else:
216
+ # data does not start with a recognised tag, so ignore it
217
+ # and continue waiting for a valid message start
218
+ continue
219
+ # set this data into the received message
220
+ message = data
221
+ # either further children of this tag are coming, or maybe its a single tag ending in "/>"
222
+ if message.endswith(b'/>'):
223
+ # the message is complete, handle message here
224
+ try:
225
+ root = ET.fromstring(message.decode("us-ascii"))
226
+ except Exception:
227
+ # failed to parse the message, continue at beginning
228
+ message = b''
229
+ messagetagnumber = None
230
+ continue
231
+ # xml datablock done, return it
232
+ return root
233
+ # and read either the next message, or the children of this tag
234
+ continue
235
+ # To reach this point, the message is in progress, with a messagetagnumber set
236
+ # keep adding the received data to message, until an endtag is reached
237
+ message += data
238
+ if message.endswith(_ENDTAGS[messagetagnumber]):
239
+ # the message is complete, handle message here
240
+ try:
241
+ root = ET.fromstring(message.decode("us-ascii"))
242
+ except Exception:
243
+ # failed to parse the message, continue at beginning
244
+ message = b''
245
+ messagetagnumber = None
246
+ continue
247
+ # xml datablock done, return it
248
+ return root
249
+ # so message is in progress, with a messagetagnumber set
250
+ # but no valid endtag received yet, so continue the loop
251
+
252
+
253
+ async def _datainput(self):
254
+ """Waits for binary string of data ending in > from the driver
255
+ Returns None if stop flags arises"""
256
+ remainder = self._remainder
257
+ if b">" in remainder:
258
+ # This returns with binary data ending in > as long
259
+ # as there are > characters in self._remainder
260
+ binarydata, self._remainder = remainder.split(b'>', maxsplit=1)
261
+ binarydata += b">"
262
+ return binarydata
263
+ # As soon as there are no > characters left in self._remainder
264
+ # get more data from the driver
265
+ while not self._stop:
266
+ await asyncio.sleep(0)
267
+ indata = await self.proc.stdout.read(100)
268
+ if not indata:
269
+ await asyncio.sleep(0.02)
270
+ continue
271
+ remainder += indata
272
+ if b">" in indata:
273
+ binarydata, self._remainder = remainder.split(b'>', maxsplit=1)
274
+ binarydata += b">"
275
+ return binarydata
276
+
277
+
278
+ async def _run_err(self):
279
+ """gets binary string of data from exdriver proc.stderr
280
+ and logs it to logging.error."""
281
+
282
+ while not self._stop:
283
+ bindata = await self.proc.stderr.readline()
284
+ if not bindata:
285
+ await asyncio.sleep(0.02)
286
+ continue
287
+ logger.error(bindata.decode('utf-8'))
288
+
289
+
290
+ async def asyncrun(self):
291
+ "Runs the external driver"
292
+
293
+ self._stop = False
294
+
295
+ try:
296
+
297
+ self.proc = await asyncio.create_subprocess_exec(self.program,
298
+ *self.args,
299
+ stdin=asyncio.subprocess.PIPE,
300
+ stdout=asyncio.subprocess.PIPE,
301
+ stderr=asyncio.subprocess.PIPE)
302
+
303
+ # wait for external program to start
304
+ await asyncio.sleep(0.1)
305
+ # send a getProperties into the driver
306
+ await self._readdata( ET.fromstring("""<getProperties version="1.7" />""") )
307
+
308
+ async with asyncio.TaskGroup() as tg:
309
+ tg.create_task( self._run_tx() ) # Get data from driver and send it towards the server
310
+ tg.create_task( self._run_err() ) # data from exdriver proc.stderr and logs it to logging.error
311
+ except Exception:
312
+ logger.exception("Driver shutdown")
313
+ finally:
314
+ self._stop = True