Skip to content

Commit c5a322c

Browse files
Update API to match TypeScript/JavaScript API
Refactor Topic iterator to yield values directly instead of futures, fixing the unbound queue variable bug. Add cancelPayload support to startTopic and simplify subscribeToProperty, subscribeToEvent, and subscribeToLogMessages to use it. Add __aiter__/__anext__/next() to Topic for cleaner `async for` and `await topic.next()` usage. Add type annotations and improve docstrings throughout api.py, topic.py, and socketwrapper.py.
1 parent 9f1d8bf commit c5a322c

10 files changed

Lines changed: 758 additions & 633 deletions

File tree

README.md

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,58 @@
11
# openspace-api-python
2-
Python library to interface with [OpenSpace](https://github.com/OpenSpace/OpenSpace) using sockets.
2+
Python library to interface with [OpenSpace](https://github.com/OpenSpace/OpenSpace) using TCP sockets.
33

4-
## Work in progress
5-
Both the API and library are still very much a work in progress and are subject to change.
4+
**Work in progress** - Both the API and library are still under active development and are subject to change.
65

76
## Install
8-
Stable release of the OpenSpace API Python package are registered at [PyPi](https://pypi.org/project/openspace-api/). The latest version can be installed using `pip`:
7+
Stable releases are published on [PyPI](https://pypi.org/project/openspace-api/). Install the latest version with:
98

10-
`pip install openspace-api`
9+
```sh
10+
pip install openspace-api
11+
```
1112

12-
## Python in the terminal
13-
https://github.com/OpenSpace/openspace-api-python/blob/master/example/example.py provides an example of how to connect from a Python script using sockets. To run it, run `python example.py` from the working directory in the terminal.
13+
## Quick start
14+
15+
```python
16+
import asyncio
17+
import openspace as OpenSpace
18+
19+
api = OpenSpace.Api('localhost', 4681)
20+
disconnect = asyncio.Event()
21+
22+
async def onConnect():
23+
openspace = await api.library()
24+
time = await openspace.time.UTC()
25+
print(f"Current simulation time: {time}")
26+
disconnect.set()
27+
28+
def onDisconnect():
29+
disconnect.set()
30+
31+
api.onConnect(onConnect)
32+
api.onDisconnect(onDisconnect)
33+
34+
async def main():
35+
await api.connect()
36+
await disconnect.wait()
37+
38+
asyncio.run(main())
39+
```
40+
41+
## Examples
42+
43+
Install the example dependencies first:
44+
45+
```sh
46+
pip install -r example/requirements.txt
47+
```
48+
49+
- [example.py](https://github.com/OpenSpace/openspace-api-python/blob/master/example/example.py) - Async script demonstrating get/set property, property subscriptions, event subscriptions, calling Lua library functions, and adding scene graph nodes. Run with:
50+
```sh
51+
python example/example.py
52+
```
53+
- [example/sync-example.py](https://github.com/OpenSpace/openspace-api-python/blob/master/example/sync-example.py) - Shows how to wrap async API calls to make them synchronous, useful in interactive shells (Python REPL, IPython).
54+
- [example/notebook-examples.py](https://github.com/OpenSpace/openspace-api-python/blob/master/example/notebook-examples.py) - Self-contained async functions designed for Jupyter notebooks (`await functionName()`) or scripts (`asyncio.run(functionName())`). Covers pausing simulation, navigating to geo coordinates, setting time, and adding globe layers.
55+
56+
## Requirements
57+
- Python 3.10+
58+
- A running instance of [OpenSpace](https://github.com/OpenSpace/OpenSpace)

example/example.py

Lines changed: 98 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -1,169 +1,150 @@
11
import asyncio
22
import openspace
3+
from openspace import toNamedTuple
34

45
ADDRESS = 'localhost'
56
PORT = 4681
7+
68
# Create an OpenSpaceApi instance with the OpenSpace address and port
7-
os = openspace.Api(ADDRESS, PORT)
9+
api = openspace.Api(ADDRESS, PORT)
810

911
# This event is used to cleanly exit the event loop.
1012
disconnect = asyncio.Event()
1113

1214
#--------------------------------TEST FUNCTIONS--------------------------------
1315
# Define a callback function to handle the received payload
1416
def event_callback(result):
15-
print("event_callback:", result)
16-
17-
async def scaleEarth(value):
18-
print("Scaling Earth")
17+
print("event_callback result:", result)
1918

20-
property = "Scene.Earth.Scale.Scale"
21-
data = await os.getProperty(property)
22-
data = os.toNamedTuple(data)
19+
async def scaleEarth(value: float):
20+
print("Scaling Earth")
2321

24-
print(f"Current scale value: {data.Value}")
25-
os.setProperty(property, value)
22+
property = "Scene.Earth.Scale.Scale"
23+
data = await api.getProperty(property)
24+
data = toNamedTuple(data)
25+
print(f"Current scale value: {data.value.value}")
26+
api.setProperty(property, value)
2627

2728
async def subscribeToEarthScaleUpdates():
28-
print("Subscribing to Earth scale updates")
29-
30-
subscription = os.subscribeToProperty("Scene.Earth.Scale.Scale")
31-
# We can iterate the subscription using by looping nextValue()
32-
i = 0
33-
while i < 3:
34-
print("Waiting for Earth scale update...")
35-
result = await os.nextValue(subscription)
36-
dic = os.toNamedTuple(result)
37-
print(f"{dic.Description.Identifier} changed to {dic.Value}")
38-
i += 1
39-
subscription.cancel()
40-
41-
## Or using async for loop
42-
# async for future in subscription.iterator():
43-
# result = await future
44-
# dic = api.toNamedTuple(result)
45-
# print(f"{dic.Description.Identifier} changed to {dic.Value}")
46-
# if i > 3:
47-
# subscription.cancel()
48-
# i += 1
29+
print("Subscribing to Earth scale updates")
30+
31+
topic = api.subscribeToProperty("Scene.Earth.Scale.Scale")
32+
i = 0
33+
async for data in topic:
34+
print(f"Waiting for Earth scale update {i}/3")
35+
print(f"Earth scale update data: {data}")
36+
if i >= 3:
37+
topic.cancel()
38+
i += 1
4939

5040
async def subscribeToEventOnce(events):
51-
topic = os.subscribeToEvent(events)
41+
topic = api.subscribeToEvent(events)
5242

53-
async for future in topic.iterator():
54-
print(f"SubscribeToEventOnce: Waiting for {events} to fire...")
55-
result = await future
56-
print("Event fired: ", result)
57-
topic.cancel()
43+
print(f"SubscribeToEventOnce: Waiting for {events} to fire...")
44+
async for result in topic:
45+
print("SubscribeToEventOnce: Event fired: ", result)
46+
topic.cancel()
5847

5948
async def subscribeToEventWithCallback(events, callback):
60-
topic = os.subscribeToEvent(events)
61-
j = 0
62-
async for future in topic.iterator():
63-
print(f"SubscribeToEventWithCallback: Subscription callback waiting for {events} to fire...")
64-
data = await future
65-
callback(data)
66-
if j >= 1:
67-
topic.cancel()
68-
print("Cancelled SubscribeToEventWithCallback")
69-
j += 1
49+
topic = api.subscribeToEvent(events)
50+
i = 0
51+
print(f"SubscribeToEventWithCallback: Subscription callback waiting for {events} to fire...")
52+
async for result in topic:
53+
print(f"SubscibeToEventWithCallback: Event fired: {result}")
54+
callback(result)
55+
i+=1
56+
if i >= 2:
57+
topic.cancel()
58+
print("Cancelled SubscribeToEventWithCallback")
7059

7160
async def getTime(openspace):
72-
time = await openspace.time.UTC()
73-
# equivalent to:
74-
# time = await openspace['time']['UTC']()
75-
print(f"Current simulation time: {time}")
61+
time = await openspace.time.UTC()
62+
print(f"Current simulation time: {time}")
7663

77-
async def getGeoPosition(openspace):
78-
pos = await openspace.globebrowsing.localPositionFromGeo("Earth", 10, 10, 10)
79-
print(f"Earth geo position: {pos}")
8064

8165
async def getGeoPositionForCamera(openspace):
82-
pos = await openspace.globebrowsing.geoPositionForCamera()
83-
print(f"Geo position from camera: {pos}")
66+
pos = await openspace.globebrowsing.geoPositionForCamera()
67+
print(f"Geo position from camera: {pos}")
8468

8569
async def addSceneGraphNode(openspace):
86-
IDENTIFIER = "TestNode"
87-
NAME = "Test Node"
88-
89-
node = {
90-
"Identifier": IDENTIFIER,
91-
"Name": NAME,
92-
"Parent": "Earth",
93-
"Transform": {
94-
"Type": "GlobeTranslation",
95-
"Globe": "Earth",
96-
"Latitude": 0,
97-
"Longitude": 0,
98-
"FixedAltitude": 10
99-
},
100-
"GUI": {
101-
"Path": "/MyTest/Test",
102-
"Name": "TestNode"
103-
}
70+
identifier = "TestNode"
71+
name = "Test Node"
72+
73+
node = {
74+
"Identifier": identifier,
75+
"Name": name,
76+
"Parent": "Earth",
77+
"Transform": {
78+
"Type": "GlobeTranslation",
79+
"Globe": "Earth",
80+
"Latitude": 0,
81+
"Longitude": 0,
82+
"FixedAltitude": 10
83+
},
84+
"GUI": {
85+
"Path": "/MyTest/Test",
86+
"Name": "TestNode"
10487
}
88+
}
10589

106-
await openspace.addSceneGraphNode(node)
107-
print("Added scene graph node")
90+
await openspace.addSceneGraphNode(node)
91+
print("Added scene graph node")
10892

109-
await openspace.setPropertyValue("NavigationHandler.OrbitalNavigator.Anchor", IDENTIFIER)
110-
await openspace.setPropertyValue("NavigationHandler.OrbitalNavigator.RetargetAnchor", None)
93+
await openspace.setPropertyValue("NavigationHandler.OrbitalNavigator.Anchor", identifier)
94+
await openspace.setPropertyValue("NavigationHandler.OrbitalNavigator.RetargetAnchor", None)
11195

11296
#--------------------------------MAIN FUNCTION--------------------------------
11397
async def main(openspace):
98+
await scaleEarth(0.9)
99+
await getTime(openspace)
100+
await getGeoPositionForCamera(openspace)
101+
await addSceneGraphNode(openspace)
114102

115-
await scaleEarth(0.9)
116-
await getTime(openspace)
117-
await getGeoPosition(openspace)
118-
await getGeoPositionForCamera(openspace)
119-
await addSceneGraphNode(openspace)
103+
# Create a task to not block event loop
104+
earthScaleTask = asyncio.create_task(subscribeToEarthScaleUpdates())
105+
asyncio.create_task(subscribeToEventOnce(["RenderableEnabled"]))
120106

121-
# Create a task to not block event loop
122-
earthScale_Task = asyncio.create_task(subscribeToEarthScaleUpdates())
107+
await earthScaleTask
123108

124-
asyncio.create_task(subscribeToEventOnce(["RenderableEnabled"]))
109+
event_Task = asyncio.create_task(
110+
subscribeToEventWithCallback(
111+
["RenderableEnabled", "RenderableDisabled"],
112+
event_callback
113+
)
114+
)
125115

126-
await earthScale_Task
116+
await event_Task
127117

128-
event_Task = asyncio.create_task(subscribeToEventWithCallback(
129-
["RenderableEnabled", "RenderableDisabled"], event_callback))
130-
131-
await event_Task
132-
133-
disconnect.set()
118+
disconnect.set()
134119

135120
async def onConnect():
136-
PASSWORD = ''
137-
res = await os.authenticate(PASSWORD)
138-
if not res[1] == 'authorized':
139-
disconnect.set()
140-
return
121+
PASSWORD = ''
122+
result = await api.authenticate(PASSWORD)
123+
if not result["status"] == 'authorized':
124+
disconnect.set()
125+
return
141126

142-
print("Connected to OpenSpace")
143-
openspace = await os.singleReturnLibrary()
127+
print("Connected to OpenSpace")
128+
openspace = await api.library()
144129

145-
# Create a main task to run all function logic
146-
asyncio.create_task(main(openspace), name="Main")
130+
# Create a main task to run all function logic
131+
asyncio.create_task(main(openspace), name="Main")
147132

148133
def onDisconnect():
149-
if asyncio.get_event_loop().is_running():
150-
asyncio.get_event_loop().stop()
151-
print("Disconnected from OpenSpace")
152-
# If connection failed this helps the program exit gracefully
153-
disconnect.set()
134+
print("Disconnected from OpenSpace")
135+
# If connection failed this helps the program exit gracefully
136+
disconnect.set()
154137

155-
os.onConnect(onConnect)
156-
os.onDisconnect(onDisconnect)
138+
api.onConnect(onConnect)
139+
api.onDisconnect(onDisconnect)
157140

158141
# Main loop serves as an entry point to allow for authentication before running any other
159142
# logic. This part can be skipped if no authentication is needed, reducing the overhead of
160143
# creating multiple tasks before main() is run.
161144
async def mainLoop():
162-
os.connect()
163-
# Wait for the disconnect event to be set
164-
await disconnect.wait()
165-
os.disconnect()
166-
167-
loop = asyncio.new_event_loop()
168-
loop.run_until_complete(mainLoop())
169-
loop.run_forever()
145+
await api.connect()
146+
# Wait for the disconnect event to be set
147+
await disconnect.wait()
148+
api.disconnect()
149+
150+
asyncio.run(mainLoop())

0 commit comments

Comments
 (0)