|
1 | 1 | # Duet Software Framework Python Bindings |
2 | 2 |
|
3 | | -`dsf-python` provides Python bindings for the Duet Software Framework control server. |
4 | | -It exposes the DSF socket protocol as Python connection classes, typed object model |
5 | | -classes, command builders, and helpers for custom HTTP endpoints. |
| 3 | +`dsf-python` is a Python client for the [Duet Software Framework](https://github.com/Duet3D/DuetSoftwareFramework) |
| 4 | +(DSF) control server. It talks to DSF over its UNIX socket and provides: |
6 | 5 |
|
7 | | -This project is also published on [PyPI](https://pypi.org/project/dsf-python/). |
| 6 | +- `dsf.connections`: connections for sending commands, subscribing to the object model and intercepting codes |
| 7 | +- `dsf.object_model`: the DSF object model as typed Python classes |
| 8 | +- `dsf.http`: custom HTTP and WebSocket endpoints served through DSF |
| 9 | +- `dsf.commands`: low-level builders for every DSF command |
8 | 10 |
|
9 | | -Useful links: |
| 11 | +It follows the C# `DuetAPIClient` closely, but uses Python naming. See |
| 12 | +[Differences from the DSF API](#differences-from-the-dsf-api) before porting C# code or reading the DSF docs. |
10 | 13 |
|
11 | | -- [Duet Software Framework](https://github.com/Duet3D/DuetSoftwareFramework) |
12 | | -- [dsf-python examples](https://github.com/Duet3D/dsf-python/tree/main/examples) |
13 | | -- [Duet Software Framework forum](https://forum.duet3d.com/category/31/dsf-development) |
14 | | - |
15 | | -## What The Library Contains |
16 | | - |
17 | | -The top-level package is organised around the main DSF workflows. |
18 | | - |
19 | | -- `dsf.connections`: socket-based client connections for commands, subscriptions, and code interception |
20 | | -- `dsf.commands`: request payload builders for low-level DSF commands |
21 | | -- `dsf.object_model`: the typed DSF object model and related enums |
22 | | -- `dsf.http`: helpers for custom HTTP and WebSocket endpoints exposed through DSF |
23 | | -- `dsf.exceptions`: shared exception types raised by connection classes |
| 14 | +- [Examples](https://github.com/Duet3D/dsf-python/tree/HEAD/examples) |
| 15 | +- [PyPI package](https://pypi.org/project/dsf-python/) |
| 16 | +- [DSF forum](https://forum.duet3d.com/category/31/dsf-development) |
24 | 17 |
|
25 | 18 | ## Installation |
26 | 19 |
|
27 | | -The package can be installed from source: |
28 | | - |
29 | | -```bash |
30 | | -python3 setup.py install |
31 | | -``` |
32 | | - |
33 | | -Or with `pip`: |
34 | | - |
35 | 20 | ```bash |
36 | 21 | python3 -m pip install dsf-python |
37 | 22 | ``` |
38 | 23 |
|
39 | | -Most code using this library must run on a system with Duet Software Framework |
40 | | -installed and with permission to access the DSF UNIX socket. |
| 24 | +Python 3.11 or newer is required. Your script must run on the machine that runs DSF, as a user |
| 25 | +that can access the DSF socket (usually the `dsf` user). The socket path is read from |
| 26 | +`/opt/dsf/conf/config.json` and falls back to `/run/dsf/dcs.sock`. |
41 | 27 |
|
42 | 28 | ## Quick Start |
43 | 29 |
|
44 | | -### Run A Simple Command |
45 | | - |
46 | | -Use `CommandConnection` to send general-purpose commands such as G-code or to |
47 | | -request the full object model on demand. |
48 | | - |
49 | 30 | ```python |
50 | 31 | from dsf.connections import CommandConnection |
51 | 32 |
|
52 | 33 | connection = CommandConnection() |
53 | 34 | connection.connect() |
54 | | - |
55 | | -response = connection.perform_simple_code("M115") |
56 | | -print(response.result) |
57 | | - |
58 | | -connection.close() |
| 35 | +try: |
| 36 | + print(connection.perform_simple_code("M115")) |
| 37 | + |
| 38 | + object_model = connection.get_object_model() |
| 39 | + print(object_model.state.status.value) |
| 40 | + print(object_model.state.up_time) # "upTime" in DSF |
| 41 | +finally: |
| 42 | + connection.close() |
59 | 43 | ``` |
60 | 44 |
|
61 | | -### Read The Object Model Once |
62 | | - |
63 | | -```python |
64 | | -from dsf.connections import CommandConnection |
65 | | - |
66 | | -connection = CommandConnection() |
67 | | -connection.connect() |
68 | | - |
69 | | -object_model = connection.get_object_model() |
70 | | -print(object_model.state.status) |
71 | | -print(object_model.move.axes[0].letter) |
| 45 | +## Connections |
72 | 46 |
|
73 | | -connection.close() |
74 | | -``` |
| 47 | +| Connection | Use it to | Example | |
| 48 | +|---|---|---| |
| 49 | +| `CommandConnection` | Run codes, evaluate expressions, read the object model, manage plugins, files and HTTP endpoints | [send_commands.py](https://github.com/Duet3D/dsf-python/blob/HEAD/examples/send_commands.py) | |
| 50 | +| `SubscribeConnection` | Keep a local copy of the object model up to date and react to changes | [subscribe_object_model.py](https://github.com/Duet3D/dsf-python/blob/HEAD/examples/subscribe_object_model.py) | |
| 51 | +| `InterceptConnection` | Handle custom M-codes, or inspect codes before or after DSF processes them | [custom_m_codes.py](https://github.com/Duet3D/dsf-python/blob/HEAD/examples/custom_m_codes.py) | |
75 | 52 |
|
76 | | -### Subscribe To Object Model Updates |
| 53 | +Every connection is configured in its constructor, opened with `connect()` and closed with `close()`. |
| 54 | +Errors reported by DSF are raised as `InternalServerException` (with `error_type` and `error_message`), |
| 55 | +or as `TaskCanceledException` if DSF cancelled the request. |
77 | 56 |
|
78 | | -Use `SubscribeConnection` when you want DSF to push object model updates over a |
79 | | -subscription socket. |
| 57 | +### SubscribeConnection |
80 | 58 |
|
81 | 59 | ```python |
82 | 60 | from dsf.connections import SubscribeConnection, SubscriptionMode |
83 | 61 |
|
84 | | -subscription = SubscribeConnection(SubscriptionMode.PATCH) |
| 62 | +subscription = SubscribeConnection(SubscriptionMode.PATCH, filter_list=["state/status", "heat/heaters[*]/current"]) |
85 | 63 | subscription.connect() |
86 | 64 |
|
87 | | -object_model = subscription.get_object_model() |
88 | | - |
89 | | -while True: |
90 | | - object_model = subscription.get_object_model() |
91 | | -``` |
92 | | - |
93 | | -In `SubscriptionMode.PATCH`, the first `get_object_model()` call reads the full |
94 | | -model. Later calls reuse an internal cached model and apply one queued patch if |
95 | | -available. |
96 | | - |
97 | | -## Connections |
98 | | - |
99 | | -### CommandConnection |
100 | | - |
101 | | -`CommandConnection` is the general-purpose connection type. It is used for: |
102 | | - |
103 | | -- sending simple G-code |
104 | | -- requesting the full object model |
105 | | -- working with files, packages, plugins, and user sessions through DSF commands |
106 | | -- issuing lower-level requests through the command helpers in `dsf.commands` |
107 | | - |
108 | | -Choose this connection when you want request-response behaviour and do not need |
109 | | -streamed updates. |
110 | | - |
111 | | -### SubscribeConnection |
112 | | - |
113 | | -`SubscribeConnection` receives streamed object model updates from DSF. It supports: |
114 | | - |
115 | | -- `SubscriptionMode.FULL`: every update is a full object model |
116 | | -- `SubscriptionMode.PATCH`: every update is a partial JSON fragment |
117 | | - |
118 | | -#### Key-Based Callbacks |
119 | | - |
120 | | -`SubscribeConnection.subscribe_to_keys()` can run callbacks synchronously when |
121 | | -selected object model paths appear in a patch update processed by `get_object_model()`. |
| 65 | +object_model = subscription.get_object_model() # First call: blocks for the full object model |
122 | 66 |
|
123 | | -Key paths use dot notation and can include list indexes: |
| 67 | +def on_heater_changed(*, key, data, indices): |
| 68 | + print(f"Heater {indices[0]} is now at {data}C") |
124 | 69 |
|
125 | | -- `state.upTime` |
126 | | -- `heat.heaters.0.current` |
127 | | -- `move.axes.2.userPosition` |
| 70 | +unsubscribe = subscription.subscribe_to_keys(["heat.heaters.^.current"], on_heater_changed) |
128 | 71 |
|
129 | | -Use `^` in a list position to match any changed index: |
130 | | - |
131 | | -- `heat.heaters.^.current` |
132 | | -- `tools.^.state` |
| 72 | +while True: |
| 73 | + object_model = subscription.get_object_model() # Later calls: apply queued patches, run callbacks |
| 74 | + ... |
| 75 | +``` |
133 | 76 |
|
134 | | -Callbacks always receive keyword arguments: |
| 77 | +- In `SubscriptionMode.PATCH`, the first `get_object_model()` call blocks until the full model arrives. |
| 78 | + Later calls do not block: they apply every queued patch to the cached model and return it. |
| 79 | +- In `SubscriptionMode.FULL`, every call blocks until the next full object model arrives. |
| 80 | +- `subscribe_to_keys()` callbacks run synchronously inside `get_object_model()`, only in patch mode, and |
| 81 | + receive the keyword arguments `key`, `data` (the raw JSON value from the patch) and `indices` (the list |
| 82 | + indexes matched by `^`, or `None`). Register them after the first `get_object_model()` call. |
| 83 | + The return value removes the callback again. |
135 | 84 |
|
136 | | -- `key`: the subscribed key path that matched |
137 | | -- `data`: the changed value found at that path |
138 | | -- `indices`: a tuple of matched wildcard indexes, or `None` when the key does not use `^` |
| 85 | +### InterceptConnection |
139 | 86 |
|
140 | 87 | ```python |
141 | | -from dsf.connections import SubscribeConnection, SubscriptionMode |
142 | | - |
143 | | - |
144 | | -def handle_change(*, key, data, indices): |
145 | | - print(key, data, indices) |
146 | | - |
147 | | - |
148 | | -subscription = SubscribeConnection(SubscriptionMode.PATCH) |
149 | | -subscription.connect() |
150 | | -subscription.get_object_model() |
151 | | - |
152 | | -unsubscribe = subscription.subscribe_to_keys( |
153 | | - ["heat.heaters.0.current", "state.upTime"], |
154 | | - handle_change, |
155 | | -) |
| 88 | +from dsf.connections import InterceptConnection, InterceptionMode |
| 89 | +from dsf.object_model import MessageType |
156 | 90 |
|
| 91 | +interceptor = InterceptConnection(InterceptionMode.PRE, filters=["M1234"]) |
| 92 | +interceptor.connect() |
157 | 93 | while True: |
158 | | - object_model = subscription.get_object_model() |
| 94 | + code = interceptor.receive_code() |
| 95 | + name = code.parameter("S", "world").string_value |
| 96 | + interceptor.resolve_code(MessageType.Success, f"Hello {name}!") |
159 | 97 | ``` |
160 | 98 |
|
161 | | -Wildcard example: |
162 | | - |
163 | | -```python |
164 | | -def handle_any_heater(*, key, data, indices): |
165 | | - print(key, indices, data) |
| 99 | +Every intercepted code must be answered with `resolve_code()`, `ignore_code()` or `cancel_code()`. |
| 100 | +`InterceptConnection` also has all `CommandConnection` methods, so it can run other codes while a |
| 101 | +code is intercepted, e.g. `perform_simple_code("M117 hi", code.channel)`. |
166 | 102 |
|
| 103 | +### Custom HTTP Endpoints |
167 | 104 |
|
168 | | -subscription.subscribe_to_keys( |
169 | | - ["heat.heaters.^.current"], |
170 | | - handle_any_heater, |
171 | | -) |
| 105 | +```python |
| 106 | +from dsf.http import HttpEndpointConnection, HttpResponseType |
| 107 | +from dsf.object_model import HttpEndpointType |
| 108 | + |
| 109 | +async def hello(connection: HttpEndpointConnection) -> None: |
| 110 | + request = await connection.read_request() |
| 111 | + await connection.send_response(200, f"Hello {request.queries.get('name', 'world')}!", HttpResponseType.PlainText) |
| 112 | + |
| 113 | +endpoint = command_connection.add_http_endpoint(HttpEndpointType.GET, "example", "hello") |
| 114 | +endpoint.set_endpoint_handler(hello) # Serves GET /machine/example/hello in a background thread |
| 115 | +... |
| 116 | +command_connection.remove_http_endpoint(HttpEndpointType.GET, "example", "hello") |
| 117 | +endpoint.close() |
172 | 118 | ``` |
173 | 119 |
|
174 | | -### InterceptConnection |
| 120 | +See [custom_http_endpoint.py](https://github.com/Duet3D/dsf-python/blob/HEAD/examples/custom_http_endpoint.py). |
175 | 121 |
|
176 | | -`InterceptConnection` is used for custom code handling and code interception. Use it |
177 | | -when a plugin needs to receive G/M/T-code events before or after the firmware handles them. |
| 122 | +## Differences From The DSF API |
178 | 123 |
|
179 | | -Typical use cases include: |
| 124 | +### Naming |
180 | 125 |
|
181 | | -- implementing custom M-codes |
182 | | -- inspecting or rewriting commands |
183 | | -- reacting to executed code notifications |
| 126 | +Python attributes and methods are `snake_case`. **Anything passed to DSF as a string stays in DSF's |
| 127 | +`camelCase` format**, because DSF interprets it and not this library. |
184 | 128 |
|
185 | | -See `examples/custom_m_codes.py` for a complete example. |
| 129 | +| | DSF / C# | dsf-python | |
| 130 | +|---|---|---| |
| 131 | +| Object model properties | `state.upTime`, `move.axes[0].userPosition` | `state.up_time`, `move.axes[0].user_position` | |
| 132 | +| Uppercase acronyms | `move.skew.tanXY` | `move.skew.tan_XY` (acronyms stay uppercase) | |
| 133 | +| Global variables | `global` | `globals` (`global` is a Python keyword) | |
| 134 | +| Names shadowing builtins (`type`, `id`, `min`, `max`, `format`, `license`) | `axis.max` | `axis.max` (unchanged) | |
| 135 | +| Methods | `PerformSimpleCodeAsync()` | `perform_simple_code()` | |
| 136 | +| Intercepted `Code` attributes | `MajorNumber`, `KeywordArgument` | `majorNumber`, `keywordArgument` (**camelCase, as in the JSON**) | |
| 137 | +| `CodeParameter` attributes | `StringValue`, `IsString` | `string_value`, `is_string` | |
| 138 | +| HTTP request fields | `SessionId`, `ContentType` | `session_id`, `content_type` | |
| 139 | +| Error responses | `errorType`, `errorMessage` | `error_type`, `error_message` | |
| 140 | +| Enum members | `MessageType.Success` | Not normalised: `MessageType.Success`, `MachineStatus.idle`, `SubscriptionMode.PATCH`, `HttpEndpointType.GET`. Values match the DSF JSON. | |
186 | 141 |
|
187 | | -## Object Model |
| 142 | +These strings keep DSF naming: |
188 | 143 |
|
189 | | -The `dsf.object_model` package mirrors the DSF object model in typed Python classes. |
190 | | -It lets you work with structured properties instead of manually traversing raw JSON. |
191 | | - |
192 | | -Examples: |
193 | | - |
194 | | -```python |
195 | | -print(object_model.boards[0].name) |
196 | | -print(object_model.state.up_time) |
197 | | -print(object_model.move.axes[0].letter) |
198 | | -print(object_model.tools[0].state) |
199 | | -``` |
| 144 | +| Where | Format | Example | |
| 145 | +|---|---|---| |
| 146 | +| `SubscribeConnection(filter_list=...)` | `/`-separated, `[*]` for any list item, `**` for everything below | `"heat/heaters[*]/current"`, `"move/**"` | |
| 147 | +| `CommandConnection.get_object_model(filters)` | `.`-separated key paths | `["state", "move.axes"]` | |
| 148 | +| `SubscribeConnection.subscribe_to_keys(keys)` | `.`-separated, list indexes as numbers, `^` for any list item | `"heat.heaters.^.current"`, `"move.axes.2.userPosition"` | |
| 149 | +| `evaluate_expression()`, `query_object_model()`, G-code | RepRapFirmware expressions | `"state.upTime"`, `"move.axes[0].homed"` | |
200 | 150 |
|
201 | | -Object model instances support JSON-driven updates: |
| 151 | +`ObjectModel.update_from_json()` and `from_json()` take DSF JSON (`camelCase`), and `to_json()` writes it back: |
202 | 152 |
|
203 | 153 | ```python |
204 | 154 | object_model.update_from_json({"state": {"upTime": 1234}}) |
205 | | -print(object_model.state.up_time) |
| 155 | +object_model.state.up_time # 1234 |
| 156 | +object_model.to_json() # '{..."state": {..."upTime": 1234...}...}' |
206 | 157 | ``` |
207 | 158 |
|
208 | | -This is the mechanism used internally when patch subscriptions are applied. |
| 159 | +### Behaviour |
| 160 | + |
| 161 | +- **Synchronous.** Connection methods block and have no `Async` suffix or `CancellationToken`. |
| 162 | + Only HTTP endpoint handlers are `async` coroutines. |
| 163 | +- **Configuration in the constructor.** Options passed to C# `ConnectAsync()` (mode, filters, channels, |
| 164 | + `verbose`, ...) are constructor arguments. `connect()` takes only an optional socket path. |
| 165 | +- **Patches are applied for you.** `SubscribeConnection.get_object_model()` keeps an internal model up |
| 166 | + to date in patch mode. In C#, you apply patches from `GetObjectModelPatchAsync()` yourself. |
| 167 | + `get_object_model_patch()` and `get_serialized_object_model()` still return raw JSON if you need it. |
| 168 | +- **Key callbacks** (`subscribe_to_keys()`) are specific to dsf-python. |
| 169 | +- **Return values.** `perform_simple_code()` returns the reply as a string. `evaluate_expression()` |
| 170 | + and the lower-level methods return a `Response` whose value is in `.result`. |
| 171 | +- **Interception.** `resolve_code()` takes a `MessageType` and an optional string, not a `Message`. |
| 172 | + There is no `rewrite_code()`. `flush()` takes a channel instead of flushing the intercepted code's channel. |
| 173 | +- **HTTP endpoints.** Handlers are registered with `set_endpoint_handler()` instead of an event. |
| 174 | + `add_http_endpoint()` returns an `HttpEndpointUnixSocket`, which must be closed after |
| 175 | + `remove_http_endpoint()`. |
| 176 | + |
| 177 | +## Examples |
| 178 | + |
| 179 | +| Example | Shows | |
| 180 | +|---|---| |
| 181 | +| [send_commands.py](https://github.com/Duet3D/dsf-python/blob/HEAD/examples/send_commands.py) | Run codes, evaluate expressions, read part of the object model, write messages, handle errors | |
| 182 | +| [subscribe_object_model.py](https://github.com/Duet3D/dsf-python/blob/HEAD/examples/subscribe_object_model.py) | Filtered patch subscription, key callbacks with and without `^` | |
| 183 | +| [custom_m_codes.py](https://github.com/Duet3D/dsf-python/blob/HEAD/examples/custom_m_codes.py) | Custom M-codes, reading parameters, running codes from an interceptor | |
| 184 | +| [custom_http_endpoint.py](https://github.com/Duet3D/dsf-python/blob/HEAD/examples/custom_http_endpoint.py) | GET and POST endpoints with plain text and JSON responses | |
| 185 | + |
| 186 | +## Development |
209 | 187 |
|
210 | | -## Commands |
211 | | - |
212 | | -The `dsf.commands` package contains low-level command builders for code execution, |
213 | | -file access, plugins, packages, object model manipulation, and other DSF protocol |
214 | | -requests. |
215 | | - |
216 | | -Most users should start with the higher-level methods on `CommandConnection` or |
217 | | -`BaseCommandConnection`. Reach for `dsf.commands` directly when you need explicit |
218 | | -control over the payload sent to DSF. |
219 | | - |
220 | | -## Custom HTTP Endpoints |
221 | | - |
222 | | -The `dsf.http` module contains helpers for custom HTTP and WebSocket endpoints. |
223 | | -This is useful when a DSF plugin needs to expose an HTTP route that is handled by |
224 | | -Python code. |
225 | | - |
226 | | -Key types include: |
227 | | - |
228 | | -- `HttpEndpointUnixSocket` |
229 | | -- `HttpEndpointConnection` |
230 | | -- `ReceivedHttpRequest` |
231 | | -- `HttpResponseType` |
232 | | - |
233 | | -See `examples/custom_http_endpoint.py` for a practical example. |
234 | | - |
235 | | -## Included Examples |
236 | | - |
237 | | -The `examples/` directory demonstrates the main workflows supported by the library. |
238 | | - |
239 | | -- `send_commands.py`: run G-codes, evaluate expressions, read the object model, write messages and handle errors with a `CommandConnection` |
240 | | -- `subscribe_object_model.py`: keep a filtered object model up-to-date and react to changes with key callbacks |
241 | | -- `custom_m_codes.py`: implement custom M-codes with an `InterceptConnection`, read code parameters and run codes from the interceptor |
242 | | -- `custom_http_endpoint.py`: serve custom GET and POST endpoints with plain text and JSON responses |
243 | | - |
244 | | -## API Reference |
245 | | - |
246 | | -The Sphinx docs expose the package API for the major public modules: |
247 | | - |
248 | | -- `dsf` |
249 | | -- `dsf.connections` |
250 | | -- `dsf.commands` |
251 | | -- `dsf.object_model` |
252 | | -- `dsf.http` |
253 | | - |
254 | | -## Development Notes |
255 | | - |
256 | | -The library talks to DSF using the configured UNIX socket path. By default this is |
257 | | -resolved from the DSF config and falls back to `/run/dsf/dcs.sock`. |
258 | | - |
259 | | -If you are extending the library itself, the test suite under `tests/` contains |
260 | | -mock socket servers and object model fixtures that are useful for validating new |
261 | | -connection behaviour. |
| 188 | +```bash |
| 189 | +python3 -m pip install -e ".[dev]" |
| 190 | +python3 -m pytest # Tests use mock DSF sockets, no DSF install needed |
| 191 | +sphinx-build -b html docs/source docs/build # API reference |
| 192 | +``` |
0 commit comments