pyflix 0.5__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.
- pyflix-0.5/PKG-INFO +238 -0
- pyflix-0.5/pyflix/README.md +225 -0
- pyflix-0.5/pyflix/__init__.py +1 -0
- pyflix-0.5/pyflix/__outtakes.py +136 -0
- pyflix-0.5/pyflix/flix.py +324 -0
- pyflix-0.5/pyflix/proxy.py +37 -0
- pyflix-0.5/pyflix.egg-info/PKG-INFO +238 -0
- pyflix-0.5/pyflix.egg-info/SOURCES.txt +12 -0
- pyflix-0.5/pyflix.egg-info/dependency_links.txt +1 -0
- pyflix-0.5/pyflix.egg-info/entry_points.txt +2 -0
- pyflix-0.5/pyflix.egg-info/requires.txt +1 -0
- pyflix-0.5/pyflix.egg-info/top_level.txt +1 -0
- pyflix-0.5/pyproject.toml +29 -0
- pyflix-0.5/setup.cfg +4 -0
pyflix-0.5/PKG-INFO
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyflix
|
|
3
|
+
Version: 0.5
|
|
4
|
+
Summary: Python API for Flix drone
|
|
5
|
+
Author-email: Oleg Kalachev <okalachev@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/okalachev/flix/tree/master/tools/pyflix
|
|
8
|
+
Project-URL: Repository, https://github.com/okalachev/flix
|
|
9
|
+
Project-URL: Issues, https://github.com/okalachev/flix/issues
|
|
10
|
+
Requires-Python: >=3.8
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: pymavlink
|
|
13
|
+
|
|
14
|
+
# Flix Python library
|
|
15
|
+
|
|
16
|
+
The Flix Python library allows you to remotely connect to a Flix quadcopter. It provides access to telemetry data, supports executing CLI commands, and controlling the drone's flight.
|
|
17
|
+
|
|
18
|
+
To use the library, connect to the drone's Wi-Fi. To use it with the simulator, ensure the script runs on the same local network as the simulator.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
If you have cloned the [repo](https://github.com/okalachev/flix), install the library from the repo:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
cd /path/to/flix/repo
|
|
26
|
+
pip install -e tools
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Alternatively, install from pip:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install pyflix
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
The API is accessed through the `Flix` class:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from flix import Flix
|
|
41
|
+
flix = Flix() # create a Flix object and wait for connection
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Telemetry
|
|
45
|
+
|
|
46
|
+
Basic telemetry is available through object properties. The properties names generally match the corresponding variables in the firmware itself:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
print(flix.connected) # True if connected to the drone
|
|
50
|
+
print(flix.mode) # current flight mode (str)
|
|
51
|
+
print(flix.armed) # True if the drone is armed
|
|
52
|
+
print(flix.landed) # True if the drone is landed
|
|
53
|
+
print(flix.attitude) # attitude quaternion [w, x, y, z]
|
|
54
|
+
print(flix.attitude_euler) # attitude as Euler angles [roll, pitch, yaw]
|
|
55
|
+
print(flix.rates) # angular rates [roll_rate, pitch_rate, yaw_rate]
|
|
56
|
+
print(flix.channels) # raw RC channels (list)
|
|
57
|
+
print(flix.motors) # motors outputs (list)
|
|
58
|
+
print(flix.acc) # accelerometer output (list)
|
|
59
|
+
print(flix.gyro) # gyroscope output (list)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
> [!NOTE]
|
|
63
|
+
> The library uses the Front-Left-Up coordinate system — the same as in the firmware. All angles are in radians.
|
|
64
|
+
|
|
65
|
+
### Events
|
|
66
|
+
|
|
67
|
+
The Flix object implements the *Observable* pattern, allowing to listen for events. You can subscribe to events using `on` method:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
flix.on('connected', lambda: print('Connected to Flix'))
|
|
71
|
+
flix.on('disconnected', lambda: print('Disconnected from Flix'))
|
|
72
|
+
flix.on('print', lambda text: print(f'Flix says: {text}'))
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
You can also wait for specific events using `wait` method. This method returns the data associated with the event:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
gyro = flix.wait('gyro') # wait for gyroscope update
|
|
79
|
+
attitude = flix.wait('attitude', timeout=3) # wait for attitude update, raise TimeoutError after 3 seconds
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The `value` argument specifies a condition for filtering events. It can be either an expected value or a callable:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
flix.wait('armed', value=True) # wait until armed
|
|
86
|
+
flix.wait('armed', value=False) # wait until disarmed
|
|
87
|
+
flix.wait('motors', value=lambda motors: all(m == 0 for m in motors)) # wait until all motors stop
|
|
88
|
+
flix.wait('attitude_euler', value=lambda att: att[0] > 0) # wait until roll angle is positive
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Full list of events:
|
|
92
|
+
|
|
93
|
+
|Event|Description|Associated data|
|
|
94
|
+
|-----|-----------|----------------|
|
|
95
|
+
|`connected`|Connected to the drone||
|
|
96
|
+
|`disconnected`|Connection is lost||
|
|
97
|
+
|`armed`|Armed state update|Armed state (*bool*)|
|
|
98
|
+
|`mode`|Flight mode update|Flight mode (*str*)|
|
|
99
|
+
|`landed`|Landed state update|Landed state (*bool*)|
|
|
100
|
+
|`print`|The drone sends text to the console|Text|
|
|
101
|
+
|`attitude`|Attitude update|Attitude quaternion (*list*)|
|
|
102
|
+
|`attitude_euler`|Attitude update|Euler angles (*list*)|
|
|
103
|
+
|`rates`|Angular rates update|Angular rates (*list*)|
|
|
104
|
+
|`channels`|Raw RC channels update|Raw RC channels (*list*)|
|
|
105
|
+
|`motors`|Motors outputs update|Motors outputs (*list*)|
|
|
106
|
+
|`acc`|Accelerometer update|Accelerometer output (*list*)|
|
|
107
|
+
|`gyro`|Gyroscope update|Gyroscope output (*list*)|
|
|
108
|
+
|`mavlink`|Received MAVLink message|Message object|
|
|
109
|
+
|`mavlink.<message_name>`|Received specific MAVLink message|Message object|
|
|
110
|
+
|`mavlink.<message_id>`|Received specific MAVLink message|Message object|
|
|
111
|
+
|`value`|Named value update (see below)|Name, value|
|
|
112
|
+
|`value.<name>`|Specific named value update (see bellow)|Value|
|
|
113
|
+
|
|
114
|
+
> [!NOTE]
|
|
115
|
+
> Update events trigger on every new data from the drone, and do not mean the value is changed.
|
|
116
|
+
|
|
117
|
+
### Common methods
|
|
118
|
+
|
|
119
|
+
Get and set firmware parameters using `get_param` and `set_param` methods:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
print(flix.get_param('PITCH_P')) # get parameter value
|
|
123
|
+
flix.set_param('PITCH_P', 5) # set parameter value
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Execute CLI commands using `cli` method. This method returns command response:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
imu = flix.cli('imu') # get detailed IMU data
|
|
130
|
+
time = flix.cli('time') # get detailed time data
|
|
131
|
+
flix.cli('reboot') # reboot the drone
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
> [!TIP]
|
|
135
|
+
> Use `help` command to get the list of available commands.
|
|
136
|
+
|
|
137
|
+
### Automatic flight
|
|
138
|
+
|
|
139
|
+
The flight control feature is in development. List of methods intended for automatic flight control:
|
|
140
|
+
|
|
141
|
+
* `set_position`
|
|
142
|
+
* `set_attitude`
|
|
143
|
+
* `set_rates`
|
|
144
|
+
* `set_motors`
|
|
145
|
+
* `set_controls`
|
|
146
|
+
* `set_mode`
|
|
147
|
+
|
|
148
|
+
## Usage alongside QGroundControl
|
|
149
|
+
|
|
150
|
+
You can use the Flix library alongside the QGroundControl app, using a proxy mode. To do that:
|
|
151
|
+
|
|
152
|
+
1. Run proxy for `pyflix` and QGroundControl in background:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
flix-proxy
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
2. Go to QGroundControl settings ⇒ *Comm Links*.
|
|
159
|
+
3. Add new link with the following settings:
|
|
160
|
+
* *Name*: Proxy
|
|
161
|
+
* *Automatically Connect on Start*: ✓
|
|
162
|
+
* *Type*: UDP
|
|
163
|
+
* *Port*: 14560
|
|
164
|
+
4. Restart QGroundControl.
|
|
165
|
+
|
|
166
|
+
Now you can run `pyflix` scripts and QGroundControl simultaneously.
|
|
167
|
+
|
|
168
|
+
## Tools
|
|
169
|
+
|
|
170
|
+
The following scripts demonstrate how to use the library:
|
|
171
|
+
|
|
172
|
+
* [`cli.py`](../cli.py) — remote access to the drone's command line interface.
|
|
173
|
+
* [`log.py`](../log.py) — download flight logs from the drone.
|
|
174
|
+
* [`example.py`](../example.py) — a simple example, prints telemetry data and waits for events.
|
|
175
|
+
|
|
176
|
+
## Advanced usage
|
|
177
|
+
|
|
178
|
+
### MAVLink
|
|
179
|
+
|
|
180
|
+
You can access the most recently received messages using `messages` property:
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
print(flix.messages.get('HEARTBEAT')) # print the latest HEARTBEAT message
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
You can wait for a specific message using `wait` method:
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
heartbeat = flix.wait('mavlink.HEARTBEAT')
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
You can send raw messages using `mavlink` property:
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
from pymavlink.dialects.v20 import common as mavlink
|
|
196
|
+
|
|
197
|
+
flix.mavlink.heartbeat_send(mavlink.MAV_TYPE_GCS, mavlink.MAV_AUTOPILOT_INVALID,
|
|
198
|
+
mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED, 0, 0)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Named values
|
|
202
|
+
|
|
203
|
+
You can pass arbitrary named values from the firmware to the Python script using `NAMED_VALUE_FLOAT`, `NAMED_VALUE_INT`, `DEBUG`, `DEBUG_VECT`, and `DEBUG_FLOAT_ARRAY` MAVLink messages.
|
|
204
|
+
|
|
205
|
+
All these named values will appear in the `values` dictionary:
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
print(flix.values['some_value'])
|
|
209
|
+
print(flix.values['some_vector'])
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
You can send values from the firmware like this (`mavlink.ino`):
|
|
213
|
+
|
|
214
|
+
```cpp
|
|
215
|
+
// Send float named value
|
|
216
|
+
mavlink_msg_named_value_float_pack(SYSTEM_ID, MAV_COMP_ID_AUTOPILOT1, &msg, t, "some_value", loopRate);
|
|
217
|
+
sendMessage(&msg);
|
|
218
|
+
|
|
219
|
+
// Send vector named value
|
|
220
|
+
mavlink_msg_debug_vect_pack(SYSTEM_ID, MAV_COMP_ID_AUTOPILOT1, &msg, "some_vector", t, gyroBias.x, gyroBias.y, gyroBias.z);
|
|
221
|
+
sendMessage(&msg);
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Logging
|
|
225
|
+
|
|
226
|
+
You can control Flix library verbosity using Python's `logging` module:
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
import logging
|
|
230
|
+
|
|
231
|
+
logger = logging.getLogger('flix')
|
|
232
|
+
logger.setLevel(logging.DEBUG) # be more verbose
|
|
233
|
+
logger.setLevel(logging.WARNING) # be less verbose
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Stability
|
|
237
|
+
|
|
238
|
+
The library is in development stage. The API is not stable.
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# Flix Python library
|
|
2
|
+
|
|
3
|
+
The Flix Python library allows you to remotely connect to a Flix quadcopter. It provides access to telemetry data, supports executing CLI commands, and controlling the drone's flight.
|
|
4
|
+
|
|
5
|
+
To use the library, connect to the drone's Wi-Fi. To use it with the simulator, ensure the script runs on the same local network as the simulator.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
If you have cloned the [repo](https://github.com/okalachev/flix), install the library from the repo:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
cd /path/to/flix/repo
|
|
13
|
+
pip install -e tools
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Alternatively, install from pip:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install pyflix
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
The API is accessed through the `Flix` class:
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from flix import Flix
|
|
28
|
+
flix = Flix() # create a Flix object and wait for connection
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Telemetry
|
|
32
|
+
|
|
33
|
+
Basic telemetry is available through object properties. The properties names generally match the corresponding variables in the firmware itself:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
print(flix.connected) # True if connected to the drone
|
|
37
|
+
print(flix.mode) # current flight mode (str)
|
|
38
|
+
print(flix.armed) # True if the drone is armed
|
|
39
|
+
print(flix.landed) # True if the drone is landed
|
|
40
|
+
print(flix.attitude) # attitude quaternion [w, x, y, z]
|
|
41
|
+
print(flix.attitude_euler) # attitude as Euler angles [roll, pitch, yaw]
|
|
42
|
+
print(flix.rates) # angular rates [roll_rate, pitch_rate, yaw_rate]
|
|
43
|
+
print(flix.channels) # raw RC channels (list)
|
|
44
|
+
print(flix.motors) # motors outputs (list)
|
|
45
|
+
print(flix.acc) # accelerometer output (list)
|
|
46
|
+
print(flix.gyro) # gyroscope output (list)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
> [!NOTE]
|
|
50
|
+
> The library uses the Front-Left-Up coordinate system — the same as in the firmware. All angles are in radians.
|
|
51
|
+
|
|
52
|
+
### Events
|
|
53
|
+
|
|
54
|
+
The Flix object implements the *Observable* pattern, allowing to listen for events. You can subscribe to events using `on` method:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
flix.on('connected', lambda: print('Connected to Flix'))
|
|
58
|
+
flix.on('disconnected', lambda: print('Disconnected from Flix'))
|
|
59
|
+
flix.on('print', lambda text: print(f'Flix says: {text}'))
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
You can also wait for specific events using `wait` method. This method returns the data associated with the event:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
gyro = flix.wait('gyro') # wait for gyroscope update
|
|
66
|
+
attitude = flix.wait('attitude', timeout=3) # wait for attitude update, raise TimeoutError after 3 seconds
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The `value` argument specifies a condition for filtering events. It can be either an expected value or a callable:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
flix.wait('armed', value=True) # wait until armed
|
|
73
|
+
flix.wait('armed', value=False) # wait until disarmed
|
|
74
|
+
flix.wait('motors', value=lambda motors: all(m == 0 for m in motors)) # wait until all motors stop
|
|
75
|
+
flix.wait('attitude_euler', value=lambda att: att[0] > 0) # wait until roll angle is positive
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Full list of events:
|
|
79
|
+
|
|
80
|
+
|Event|Description|Associated data|
|
|
81
|
+
|-----|-----------|----------------|
|
|
82
|
+
|`connected`|Connected to the drone||
|
|
83
|
+
|`disconnected`|Connection is lost||
|
|
84
|
+
|`armed`|Armed state update|Armed state (*bool*)|
|
|
85
|
+
|`mode`|Flight mode update|Flight mode (*str*)|
|
|
86
|
+
|`landed`|Landed state update|Landed state (*bool*)|
|
|
87
|
+
|`print`|The drone sends text to the console|Text|
|
|
88
|
+
|`attitude`|Attitude update|Attitude quaternion (*list*)|
|
|
89
|
+
|`attitude_euler`|Attitude update|Euler angles (*list*)|
|
|
90
|
+
|`rates`|Angular rates update|Angular rates (*list*)|
|
|
91
|
+
|`channels`|Raw RC channels update|Raw RC channels (*list*)|
|
|
92
|
+
|`motors`|Motors outputs update|Motors outputs (*list*)|
|
|
93
|
+
|`acc`|Accelerometer update|Accelerometer output (*list*)|
|
|
94
|
+
|`gyro`|Gyroscope update|Gyroscope output (*list*)|
|
|
95
|
+
|`mavlink`|Received MAVLink message|Message object|
|
|
96
|
+
|`mavlink.<message_name>`|Received specific MAVLink message|Message object|
|
|
97
|
+
|`mavlink.<message_id>`|Received specific MAVLink message|Message object|
|
|
98
|
+
|`value`|Named value update (see below)|Name, value|
|
|
99
|
+
|`value.<name>`|Specific named value update (see bellow)|Value|
|
|
100
|
+
|
|
101
|
+
> [!NOTE]
|
|
102
|
+
> Update events trigger on every new data from the drone, and do not mean the value is changed.
|
|
103
|
+
|
|
104
|
+
### Common methods
|
|
105
|
+
|
|
106
|
+
Get and set firmware parameters using `get_param` and `set_param` methods:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
print(flix.get_param('PITCH_P')) # get parameter value
|
|
110
|
+
flix.set_param('PITCH_P', 5) # set parameter value
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Execute CLI commands using `cli` method. This method returns command response:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
imu = flix.cli('imu') # get detailed IMU data
|
|
117
|
+
time = flix.cli('time') # get detailed time data
|
|
118
|
+
flix.cli('reboot') # reboot the drone
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
> [!TIP]
|
|
122
|
+
> Use `help` command to get the list of available commands.
|
|
123
|
+
|
|
124
|
+
### Automatic flight
|
|
125
|
+
|
|
126
|
+
The flight control feature is in development. List of methods intended for automatic flight control:
|
|
127
|
+
|
|
128
|
+
* `set_position`
|
|
129
|
+
* `set_attitude`
|
|
130
|
+
* `set_rates`
|
|
131
|
+
* `set_motors`
|
|
132
|
+
* `set_controls`
|
|
133
|
+
* `set_mode`
|
|
134
|
+
|
|
135
|
+
## Usage alongside QGroundControl
|
|
136
|
+
|
|
137
|
+
You can use the Flix library alongside the QGroundControl app, using a proxy mode. To do that:
|
|
138
|
+
|
|
139
|
+
1. Run proxy for `pyflix` and QGroundControl in background:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
flix-proxy
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
2. Go to QGroundControl settings ⇒ *Comm Links*.
|
|
146
|
+
3. Add new link with the following settings:
|
|
147
|
+
* *Name*: Proxy
|
|
148
|
+
* *Automatically Connect on Start*: ✓
|
|
149
|
+
* *Type*: UDP
|
|
150
|
+
* *Port*: 14560
|
|
151
|
+
4. Restart QGroundControl.
|
|
152
|
+
|
|
153
|
+
Now you can run `pyflix` scripts and QGroundControl simultaneously.
|
|
154
|
+
|
|
155
|
+
## Tools
|
|
156
|
+
|
|
157
|
+
The following scripts demonstrate how to use the library:
|
|
158
|
+
|
|
159
|
+
* [`cli.py`](../cli.py) — remote access to the drone's command line interface.
|
|
160
|
+
* [`log.py`](../log.py) — download flight logs from the drone.
|
|
161
|
+
* [`example.py`](../example.py) — a simple example, prints telemetry data and waits for events.
|
|
162
|
+
|
|
163
|
+
## Advanced usage
|
|
164
|
+
|
|
165
|
+
### MAVLink
|
|
166
|
+
|
|
167
|
+
You can access the most recently received messages using `messages` property:
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
print(flix.messages.get('HEARTBEAT')) # print the latest HEARTBEAT message
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
You can wait for a specific message using `wait` method:
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
heartbeat = flix.wait('mavlink.HEARTBEAT')
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
You can send raw messages using `mavlink` property:
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
from pymavlink.dialects.v20 import common as mavlink
|
|
183
|
+
|
|
184
|
+
flix.mavlink.heartbeat_send(mavlink.MAV_TYPE_GCS, mavlink.MAV_AUTOPILOT_INVALID,
|
|
185
|
+
mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED, 0, 0)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Named values
|
|
189
|
+
|
|
190
|
+
You can pass arbitrary named values from the firmware to the Python script using `NAMED_VALUE_FLOAT`, `NAMED_VALUE_INT`, `DEBUG`, `DEBUG_VECT`, and `DEBUG_FLOAT_ARRAY` MAVLink messages.
|
|
191
|
+
|
|
192
|
+
All these named values will appear in the `values` dictionary:
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
print(flix.values['some_value'])
|
|
196
|
+
print(flix.values['some_vector'])
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
You can send values from the firmware like this (`mavlink.ino`):
|
|
200
|
+
|
|
201
|
+
```cpp
|
|
202
|
+
// Send float named value
|
|
203
|
+
mavlink_msg_named_value_float_pack(SYSTEM_ID, MAV_COMP_ID_AUTOPILOT1, &msg, t, "some_value", loopRate);
|
|
204
|
+
sendMessage(&msg);
|
|
205
|
+
|
|
206
|
+
// Send vector named value
|
|
207
|
+
mavlink_msg_debug_vect_pack(SYSTEM_ID, MAV_COMP_ID_AUTOPILOT1, &msg, "some_vector", t, gyroBias.x, gyroBias.y, gyroBias.z);
|
|
208
|
+
sendMessage(&msg);
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Logging
|
|
212
|
+
|
|
213
|
+
You can control Flix library verbosity using Python's `logging` module:
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
import logging
|
|
217
|
+
|
|
218
|
+
logger = logging.getLogger('flix')
|
|
219
|
+
logger.setLevel(logging.DEBUG) # be more verbose
|
|
220
|
+
logger.setLevel(logging.WARNING) # be less verbose
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Stability
|
|
224
|
+
|
|
225
|
+
The library is in development stage. The API is not stable.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .flix import Flix
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
def ping(self) -> bool:
|
|
2
|
+
self.mav.ping_send(int(time.time() * 1e6), 0, 0, 0)
|
|
3
|
+
|
|
4
|
+
def set_wifi(self, ssid: str, password: str, mode: Literal['ap', 'client']='ap') -> bool:
|
|
5
|
+
if mode != 'ap':
|
|
6
|
+
raise NotImplementedError('Only AP mode is supported')
|
|
7
|
+
self.mav.wifi_config_ap_send(bytes(ssid, 'utf-8'), bytes(password, 'utf-8'))
|
|
8
|
+
|
|
9
|
+
def on_print(self, callback: Callable): # TODO: Callable[[int], str] possible
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
def on_connected(self, callback: Callable[[], None]):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
def on_disconnected(self, callback: Callable[[], None]):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
def state(self) -> dict:
|
|
19
|
+
# Send status command to obtain the full internal state
|
|
20
|
+
# > state
|
|
21
|
+
# landed: 1
|
|
22
|
+
# gyroBias: 0 0 0
|
|
23
|
+
# ...
|
|
24
|
+
pass
|
|
25
|
+
# https://chatgpt.com/c/6872a832-5430-800e-8984-3f55a623d783
|
|
26
|
+
|
|
27
|
+
def take_off(self, alt: float, speed: float=0.5):
|
|
28
|
+
# send set_position_target
|
|
29
|
+
raise NotImplementedError
|
|
30
|
+
|
|
31
|
+
def _set_attitude(self, roll: float, pitch: float, yaw: float, thrust: float):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
def get_telemetry(self):
|
|
35
|
+
return {'connected': True,
|
|
36
|
+
'motors': [0, 0, 0, 0],
|
|
37
|
+
'pitch': 0, #?
|
|
38
|
+
'attitude': [0, 0, 0],
|
|
39
|
+
# TODO:
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# TODO: GCS bridge (TCP)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# TODO: By default: autodiscovery
|
|
47
|
+
# Listen to Flix itself (if not busy) and to QGC Forwarding
|
|
48
|
+
# https://docs.qgroundcontrol.com/Stable_V4.3/en/qgc-user-guide/settings_view/mavlink.html
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
"""Flix quadcopter control library.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
connected (bool): True if connected to the drone.
|
|
55
|
+
mode (str): Current flight mode.
|
|
56
|
+
armed (bool): True if the drone is armed.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
# from pymavlink.mavutil import mavlink
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def update_parameters(self):
|
|
63
|
+
self.connection.param_fetch_all()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def parameters(self) -> dict:
|
|
68
|
+
return {}
|
|
69
|
+
|
|
70
|
+
def quaternion_to_euler(q: Sequence[float]) -> List[float]:
|
|
71
|
+
"""Convert quaternion to euler angles (roll, pitch, yaw)"""
|
|
72
|
+
if len(q) != 4:
|
|
73
|
+
raise ValueError('Quaternion must have 4 values')
|
|
74
|
+
w, x, y, z = q
|
|
75
|
+
roll = math.atan2(2 * (y * w - x * z), 1 - 2 * (y**2 + z**2))
|
|
76
|
+
pitch = math.asin(2 * (x * y + w * z))
|
|
77
|
+
yaw = math.atan2(2 * (x * w - y * z), 1 - 2 * (x**2 + y**2))
|
|
78
|
+
return [roll, pitch, yaw]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# TODO: obtaining ESP32 images using ENCAPSULATED_DATA
|
|
82
|
+
# flix.get_camera_image()
|
|
83
|
+
# flix.on_camera_image()
|
|
84
|
+
# flix.get_camera_info()
|
|
85
|
+
# flix.set_camera_settings()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# TODO: close socket on __del__
|
|
89
|
+
|
|
90
|
+
# TODO: don't override telemetry state data, if timestamp decreases
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def download_log(self) -> dict:
|
|
94
|
+
return {} # TODO:
|
|
95
|
+
|
|
96
|
+
# TODO: pyflix, flixpy, flixuav, flixlink, flixconn
|
|
97
|
+
# https://chatgpt.com/c/687cb782-2e18-800e-afaa-4ef60876153a
|
|
98
|
+
|
|
99
|
+
# TODO: test print event
|
|
100
|
+
|
|
101
|
+
# TODO: setup.py
|
|
102
|
+
|
|
103
|
+
# TODO:
|
|
104
|
+
# events:
|
|
105
|
+
# connected.true
|
|
106
|
+
# armed.true
|
|
107
|
+
# mode.MANUAL
|
|
108
|
+
|
|
109
|
+
# TODO: on SERIAL_CONTROL print like
|
|
110
|
+
# Flix 0: Text
|
|
111
|
+
|
|
112
|
+
if os.environ.get('DRONEKIT'):
|
|
113
|
+
import dronekit # TODO: remove
|
|
114
|
+
|
|
115
|
+
# TODO: use tuples for properties?
|
|
116
|
+
|
|
117
|
+
# TODO: print_cli
|
|
118
|
+
|
|
119
|
+
def __del__(self):
|
|
120
|
+
# TODO:
|
|
121
|
+
print('Close connection')
|
|
122
|
+
self.connection.close()
|
|
123
|
+
|
|
124
|
+
def set_attitude(self, attitude: List[float], thrust: float):
|
|
125
|
+
# if len(attitude) is 4 then quaternion, if 3 then euler angles
|
|
126
|
+
# TODO: send 3 times (redundancy) for to guarantee delivery on unreliable connection
|
|
127
|
+
# duplicate sending for reliability
|
|
128
|
+
raise NotImplementedError('Automatic flight is not implemented yet')
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# set motors
|
|
132
|
+
# https://mavlink.io/en/messages/common.html#SET_ACTUATOR_CONTROL_TARGET ❓
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _parse_serial_control(msg: mavlink.MAVLink_serial_control_message) -> str:
|
|
136
|
+
return bytes(msg.data)[:msg.count].decode('utf-8') # check
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
# Copyright (c) 2025 Oleg Kalachev <okalachev@gmail.com>
|
|
2
|
+
# Repository: https://github.com/okalachev/flix
|
|
3
|
+
|
|
4
|
+
"""Python API for Flix drone."""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from queue import Queue, Empty
|
|
9
|
+
from typing import Literal, Optional, Callable, List, Dict, Any, Union
|
|
10
|
+
import logging
|
|
11
|
+
import errno
|
|
12
|
+
from threading import Thread, Timer
|
|
13
|
+
from pymavlink import mavutil
|
|
14
|
+
from pymavlink.quaternion import Quaternion
|
|
15
|
+
from pymavlink.dialects.v20 import common as mavlink
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger('flix')
|
|
18
|
+
if not logger.hasHandlers():
|
|
19
|
+
handler = logging.StreamHandler()
|
|
20
|
+
handler.setFormatter(logging.Formatter('%(name)s - %(levelname)s - %(message)s'))
|
|
21
|
+
logger.addHandler(handler)
|
|
22
|
+
logger.setLevel(logging.INFO)
|
|
23
|
+
|
|
24
|
+
class Flix:
|
|
25
|
+
connected: bool = False
|
|
26
|
+
mode: str = ''
|
|
27
|
+
armed: bool = False
|
|
28
|
+
landed: bool = False
|
|
29
|
+
attitude: List[float]
|
|
30
|
+
attitude_euler: List[float] # roll, pitch, yaw
|
|
31
|
+
rates: List[float]
|
|
32
|
+
channels: List[int]
|
|
33
|
+
motors: List[float]
|
|
34
|
+
acc: List[float]
|
|
35
|
+
gyro: List[float]
|
|
36
|
+
|
|
37
|
+
mav_id: int
|
|
38
|
+
messages: Dict[str, Dict[str, Any]] # MAVLink messages storage
|
|
39
|
+
values: Dict[Union[str, int], Union[float, List[float]]] = {} # named values
|
|
40
|
+
|
|
41
|
+
_connection_timeout = 3
|
|
42
|
+
_print_buffer: str = ''
|
|
43
|
+
|
|
44
|
+
def __init__(self, mav_id: int=1, wait_connection: bool=True):
|
|
45
|
+
if not (0 <= mav_id < 256):
|
|
46
|
+
raise ValueError('mav_id must be in range [0, 255]')
|
|
47
|
+
self._setup_mavlink()
|
|
48
|
+
self.mav_id = mav_id
|
|
49
|
+
self._init_state()
|
|
50
|
+
try:
|
|
51
|
+
# Direct connection
|
|
52
|
+
logger.debug('Listening on port 14550')
|
|
53
|
+
self.connection: mavutil.mavfile = mavutil.mavlink_connection('udpin:0.0.0.0:14550', source_system=255) # type: ignore
|
|
54
|
+
except OSError as e:
|
|
55
|
+
if e.errno != errno.EADDRINUSE:
|
|
56
|
+
raise
|
|
57
|
+
# Port busy - using proxy
|
|
58
|
+
logger.debug('Listening on port 14560 (proxy)')
|
|
59
|
+
self.connection: mavutil.mavfile = mavutil.mavlink_connection('udpin:0.0.0.0:14555', source_system=254) # type: ignore
|
|
60
|
+
self.connection.target_system = mav_id
|
|
61
|
+
self.mavlink: mavlink.MAVLink = self.connection.mav
|
|
62
|
+
self._event_listeners: Dict[str, List[Callable[..., Any]]] = {}
|
|
63
|
+
self.messages = {}
|
|
64
|
+
self._disconnected_timer = Timer(0, self._disconnected)
|
|
65
|
+
self._reader_thread = Thread(target=self._read_mavlink, daemon=True)
|
|
66
|
+
self._reader_thread.start()
|
|
67
|
+
self._heartbeat_thread = Thread(target=self._send_heartbeat, daemon=True)
|
|
68
|
+
self._heartbeat_thread.start()
|
|
69
|
+
if wait_connection:
|
|
70
|
+
self.wait('mavlink.HEARTBEAT')
|
|
71
|
+
time.sleep(0.2) # give some time to receive initial state
|
|
72
|
+
|
|
73
|
+
def _init_state(self):
|
|
74
|
+
self.attitude = [1, 0, 0, 0]
|
|
75
|
+
self.attitude_euler = [0, 0, 0]
|
|
76
|
+
self.rates = [0, 0, 0]
|
|
77
|
+
self.channels = [0, 0, 0, 0, 0, 0, 0, 0]
|
|
78
|
+
self.motors = [0, 0, 0, 0]
|
|
79
|
+
self.acc = [0, 0, 0]
|
|
80
|
+
self.gyro = [0, 0, 0]
|
|
81
|
+
|
|
82
|
+
def on(self, event: str, callback: Callable):
|
|
83
|
+
event = event.lower()
|
|
84
|
+
if event not in self._event_listeners:
|
|
85
|
+
self._event_listeners[event] = []
|
|
86
|
+
self._event_listeners[event].append(callback)
|
|
87
|
+
|
|
88
|
+
def off(self, callback: Callable):
|
|
89
|
+
for event in self._event_listeners:
|
|
90
|
+
if callback in self._event_listeners[event]:
|
|
91
|
+
self._event_listeners[event].remove(callback)
|
|
92
|
+
|
|
93
|
+
def _trigger(self, event: str, *args):
|
|
94
|
+
event = event.lower()
|
|
95
|
+
for callback in self._event_listeners.get(event, []):
|
|
96
|
+
try:
|
|
97
|
+
callback(*args)
|
|
98
|
+
except Exception as e:
|
|
99
|
+
logger.error(f'Error in event listener for event {event}: {e}')
|
|
100
|
+
|
|
101
|
+
def wait(self, event: str, value: Union[Any, Callable[..., bool]] = lambda *args: True, timeout=None) -> Any:
|
|
102
|
+
"""Wait for an event"""
|
|
103
|
+
event = event.lower()
|
|
104
|
+
q = Queue()
|
|
105
|
+
def callback(*args):
|
|
106
|
+
if len(args) == 0:
|
|
107
|
+
result = None
|
|
108
|
+
elif len(args) == 1:
|
|
109
|
+
result = args[0]
|
|
110
|
+
else:
|
|
111
|
+
result = args
|
|
112
|
+
if callable(value) and value(*args):
|
|
113
|
+
q.put_nowait(result)
|
|
114
|
+
elif value == result:
|
|
115
|
+
q.put_nowait(result)
|
|
116
|
+
self.on(event, callback)
|
|
117
|
+
try:
|
|
118
|
+
return q.get(timeout=timeout)
|
|
119
|
+
except Empty:
|
|
120
|
+
raise TimeoutError
|
|
121
|
+
finally:
|
|
122
|
+
self.off(callback)
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _setup_mavlink():
|
|
126
|
+
# otherwise it will use MAVLink 1.0 until connected
|
|
127
|
+
os.environ['MAVLINK20'] = '1'
|
|
128
|
+
mavutil.set_dialect('common')
|
|
129
|
+
|
|
130
|
+
def _read_mavlink(self):
|
|
131
|
+
while True:
|
|
132
|
+
try:
|
|
133
|
+
msg: Optional[mavlink.MAVLink_message] = self.connection.recv_match(blocking=True)
|
|
134
|
+
if msg is None:
|
|
135
|
+
continue
|
|
136
|
+
self._connected()
|
|
137
|
+
msg_dict = msg.to_dict()
|
|
138
|
+
msg_dict['_timestamp'] = time.time() # add timestamp
|
|
139
|
+
self.messages[msg.get_type()] = msg_dict
|
|
140
|
+
self._trigger('mavlink', msg)
|
|
141
|
+
self._trigger(f'mavlink.{msg.get_type()}', msg) # trigger mavlink.<message_type>
|
|
142
|
+
self._trigger(f'mavlink.{msg.get_msgId()}', msg) # trigger mavlink.<message_id>
|
|
143
|
+
self._handle_mavlink_message(msg)
|
|
144
|
+
|
|
145
|
+
except Exception as e:
|
|
146
|
+
logger.error(f'Error reading MAVLink message: {e}')
|
|
147
|
+
|
|
148
|
+
def _handle_mavlink_message(self, msg: mavlink.MAVLink_message):
|
|
149
|
+
if isinstance(msg, mavlink.MAVLink_heartbeat_message):
|
|
150
|
+
self.mode = ['MANUAL', 'ACRO', 'STAB', 'USER'][msg.custom_mode]
|
|
151
|
+
self.armed = msg.base_mode & mavlink.MAV_MODE_FLAG_SAFETY_ARMED != 0
|
|
152
|
+
self._trigger('mode', self.mode)
|
|
153
|
+
self._trigger('armed', self.armed)
|
|
154
|
+
|
|
155
|
+
if isinstance(msg, mavlink.MAVLink_extended_sys_state_message):
|
|
156
|
+
self.landed = msg.landed_state == mavlink.MAV_LANDED_STATE_ON_GROUND
|
|
157
|
+
self._trigger('landed', self.landed)
|
|
158
|
+
|
|
159
|
+
if isinstance(msg, mavlink.MAVLink_attitude_quaternion_message):
|
|
160
|
+
self.attitude = self._mavlink_to_flu([msg.q1, msg.q2, msg.q3, msg.q4])
|
|
161
|
+
self.rates = self._mavlink_to_flu([msg.rollspeed, msg.pitchspeed, msg.yawspeed])
|
|
162
|
+
self.attitude_euler = list(Quaternion(self.attitude).euler) # type: ignore
|
|
163
|
+
self._trigger('attitude', self.attitude)
|
|
164
|
+
self._trigger('attitude_euler', self.attitude_euler)
|
|
165
|
+
|
|
166
|
+
if isinstance(msg, mavlink.MAVLink_rc_channels_raw_message):
|
|
167
|
+
self.channels = [msg.chan1_raw, msg.chan2_raw, msg.chan3_raw, msg.chan4_raw,
|
|
168
|
+
msg.chan5_raw, msg.chan6_raw, msg.chan7_raw, msg.chan8_raw]
|
|
169
|
+
self._trigger('channels', self.channels)
|
|
170
|
+
|
|
171
|
+
if isinstance(msg, mavlink.MAVLink_actuator_output_status_message):
|
|
172
|
+
self.motors = msg.actuator[:4] # type: ignore
|
|
173
|
+
self._trigger('motors', self.motors)
|
|
174
|
+
|
|
175
|
+
if isinstance(msg, mavlink.MAVLink_scaled_imu_message):
|
|
176
|
+
self.acc = self._mavlink_to_flu([msg.xacc / 1000, msg.yacc / 1000, msg.zacc / 1000])
|
|
177
|
+
self.gyro = self._mavlink_to_flu([msg.xgyro / 1000, msg.ygyro / 1000, msg.zgyro / 1000])
|
|
178
|
+
self._trigger('acc', self.acc)
|
|
179
|
+
self._trigger('gyro', self.gyro)
|
|
180
|
+
|
|
181
|
+
if isinstance(msg, mavlink.MAVLink_serial_control_message):
|
|
182
|
+
# new chunk of data
|
|
183
|
+
text = bytes(msg.data)[:msg.count].decode('utf-8', errors='ignore')
|
|
184
|
+
logger.debug(f'Console: {repr(text)}')
|
|
185
|
+
self._trigger('print', text)
|
|
186
|
+
self._print_buffer += text
|
|
187
|
+
if msg.flags & mavlink.SERIAL_CONTROL_FLAG_MULTI == 0:
|
|
188
|
+
# last chunk
|
|
189
|
+
self._trigger('print_full', self._print_buffer)
|
|
190
|
+
self._print_buffer = ''
|
|
191
|
+
|
|
192
|
+
if isinstance(msg, mavlink.MAVLink_statustext_message):
|
|
193
|
+
logger.info(f'Flix #{msg.get_srcSystem()}: {msg.text}')
|
|
194
|
+
self._trigger('status', msg.text)
|
|
195
|
+
|
|
196
|
+
if isinstance(msg, (mavlink.MAVLink_named_value_float_message, mavlink.MAVLink_named_value_int_message)):
|
|
197
|
+
self.values[msg.name] = msg.value
|
|
198
|
+
self._trigger('value', msg.name, msg.value)
|
|
199
|
+
self._trigger(f'value.{msg.name}', msg.value)
|
|
200
|
+
|
|
201
|
+
if isinstance(msg, mavlink.MAVLink_debug_message):
|
|
202
|
+
self.values[msg.ind] = msg.value
|
|
203
|
+
self._trigger('value', msg.ind, msg.value)
|
|
204
|
+
self._trigger(f'value.{msg.ind}', msg.value)
|
|
205
|
+
|
|
206
|
+
if isinstance(msg, mavlink.MAVLink_debug_vect_message):
|
|
207
|
+
self.values[msg.name] = [msg.x, msg.y, msg.z]
|
|
208
|
+
self._trigger('value', msg.name, self.values[msg.name])
|
|
209
|
+
self._trigger(f'value.{msg.name}', self.values[msg.name])
|
|
210
|
+
|
|
211
|
+
if isinstance(msg, mavlink.MAVLink_debug_float_array_message):
|
|
212
|
+
self.values[msg.name] = list(msg.data)
|
|
213
|
+
self._trigger('value', msg.name, self.values[msg.name])
|
|
214
|
+
self._trigger(f'value.{msg.name}', self.values[msg.name])
|
|
215
|
+
|
|
216
|
+
def _send_heartbeat(self):
|
|
217
|
+
while True:
|
|
218
|
+
self.mavlink.heartbeat_send(mavlink.MAV_TYPE_GCS, mavlink.MAV_AUTOPILOT_INVALID, 0, 0, 0)
|
|
219
|
+
time.sleep(1)
|
|
220
|
+
|
|
221
|
+
@staticmethod
|
|
222
|
+
def _mavlink_to_flu(v: List[float]) -> List[float]:
|
|
223
|
+
if len(v) == 3: # vector
|
|
224
|
+
return [v[0], -v[1], -v[2]]
|
|
225
|
+
elif len(v) == 4: # quaternion
|
|
226
|
+
return [v[0], v[1], -v[2], -v[3]]
|
|
227
|
+
else:
|
|
228
|
+
raise ValueError(f'List must have 3 (vector) or 4 (quaternion) elements')
|
|
229
|
+
|
|
230
|
+
@staticmethod
|
|
231
|
+
def _flu_to_mavlink(v: List[float]) -> List[float]:
|
|
232
|
+
return Flix._mavlink_to_flu(v)
|
|
233
|
+
|
|
234
|
+
def _connected(self):
|
|
235
|
+
# Reset disconnection timer
|
|
236
|
+
self._disconnected_timer.cancel()
|
|
237
|
+
self._disconnected_timer = Timer(self._connection_timeout, self._disconnected)
|
|
238
|
+
self._disconnected_timer.start()
|
|
239
|
+
|
|
240
|
+
if not self.connected:
|
|
241
|
+
logger.info('Connection is established')
|
|
242
|
+
self.connected = True
|
|
243
|
+
self._trigger('connected')
|
|
244
|
+
|
|
245
|
+
def _disconnected(self):
|
|
246
|
+
logger.info('Connection is lost')
|
|
247
|
+
self.connected = False
|
|
248
|
+
self._trigger('disconnected')
|
|
249
|
+
|
|
250
|
+
def get_param(self, name: str) -> float:
|
|
251
|
+
if len(name.encode('ascii')) > 16:
|
|
252
|
+
raise ValueError('Parameter name must be 16 characters or less')
|
|
253
|
+
for attempt in range(3):
|
|
254
|
+
try:
|
|
255
|
+
logger.debug(f'Get param {name} (attempt #{attempt + 1})')
|
|
256
|
+
self.mavlink.param_request_read_send(self.mav_id, 0, name.encode('ascii'), -1)
|
|
257
|
+
msg: mavlink.MAVLink_param_value_message = \
|
|
258
|
+
self.wait('mavlink.PARAM_VALUE', value=lambda msg: msg.param_id == name, timeout=0.1)
|
|
259
|
+
return msg.param_value
|
|
260
|
+
except TimeoutError:
|
|
261
|
+
continue
|
|
262
|
+
raise RuntimeError(f'Failed to get parameter {name} after 3 attempts')
|
|
263
|
+
|
|
264
|
+
def set_param(self, name: str, value: float):
|
|
265
|
+
if len(name.encode('ascii')) > 16:
|
|
266
|
+
raise ValueError('Parameter name must be 16 characters or less')
|
|
267
|
+
for attempt in range(3):
|
|
268
|
+
try:
|
|
269
|
+
logger.debug(f'Set param {name} to {value} (attempt #{attempt + 1})')
|
|
270
|
+
self.mavlink.param_set_send(self.mav_id, 0, name.encode('ascii'), value, mavlink.MAV_PARAM_TYPE_REAL32)
|
|
271
|
+
self.wait('mavlink.PARAM_VALUE', value=lambda msg: msg.param_id == name, timeout=0.1)
|
|
272
|
+
return
|
|
273
|
+
except TimeoutError:
|
|
274
|
+
# on timeout try again
|
|
275
|
+
continue
|
|
276
|
+
raise RuntimeError(f'Failed to set parameter {name} to {value} after 3 attempts')
|
|
277
|
+
|
|
278
|
+
def set_position(self, position: List[float], yaw: Optional[float] = None, wait: bool = False, tolerance: float = 0.1):
|
|
279
|
+
raise NotImplementedError('Position control is not implemented yet')
|
|
280
|
+
|
|
281
|
+
def set_attitude(self, attitude: List[float], thrust: float):
|
|
282
|
+
raise NotImplementedError('Automatic flight is not implemented yet')
|
|
283
|
+
|
|
284
|
+
def set_rates(self, rates: List[float], thrust: float):
|
|
285
|
+
raise NotImplementedError('Automatic flight is not implemented yet')
|
|
286
|
+
|
|
287
|
+
def set_motors(self, motors: List[float]):
|
|
288
|
+
if len(motors) != 4:
|
|
289
|
+
raise ValueError('motors must have 4 values')
|
|
290
|
+
if not all(0 <= m <= 1 for m in motors):
|
|
291
|
+
raise ValueError('motors must be in range [0, 1]')
|
|
292
|
+
raise NotImplementedError
|
|
293
|
+
|
|
294
|
+
def set_controls(self, roll: float, pitch: float, yaw: float, throttle: float):
|
|
295
|
+
"""Send pilot's controls. Warning: not intended for automatic control"""
|
|
296
|
+
if not (-1 <= roll <= 1 and -1 <= pitch <= 1 and -1 <= yaw <= 1):
|
|
297
|
+
raise ValueError('roll, pitch, yaw must be in range [-1, 1]')
|
|
298
|
+
if not 0 <= throttle <= 1:
|
|
299
|
+
raise ValueError('throttle must be in range [0, 1]')
|
|
300
|
+
self.mavlink.manual_control_send(self.mav_id, roll * 1000, pitch * 1000, yaw * 1000, throttle * 1000, 0) # type: ignore
|
|
301
|
+
|
|
302
|
+
def set_mode(self, mode: Literal['MANUAL', 'ACRO', 'STAB', 'USER']):
|
|
303
|
+
raise NotImplementedError('Setting mode is not implemented yet')
|
|
304
|
+
|
|
305
|
+
def cli(self, cmd: str, wait_response: bool = True) -> str:
|
|
306
|
+
cmd = cmd.strip()
|
|
307
|
+
if cmd == 'reboot':
|
|
308
|
+
wait_response = False # reboot command doesn't respond
|
|
309
|
+
cmd_bytes = (cmd + '\n').encode('utf-8')
|
|
310
|
+
if len(cmd_bytes) > 70:
|
|
311
|
+
raise ValueError(f'Command is too long: {len(cmd_bytes)} > 70')
|
|
312
|
+
cmd_bytes = cmd_bytes.ljust(70, b'\0')
|
|
313
|
+
response_prefix = f'> {cmd}\n'
|
|
314
|
+
for attempt in range(3):
|
|
315
|
+
logger.debug(f'Send command {cmd} (attempt #{attempt + 1})')
|
|
316
|
+
try:
|
|
317
|
+
self.mavlink.serial_control_send(0, 0, 0, 0, len(cmd_bytes), cmd_bytes)
|
|
318
|
+
if not wait_response:
|
|
319
|
+
return ''
|
|
320
|
+
response = self.wait('print_full', timeout=0.1, value=lambda text: text.startswith(response_prefix))
|
|
321
|
+
return response[len(response_prefix):].strip()
|
|
322
|
+
except TimeoutError:
|
|
323
|
+
continue
|
|
324
|
+
raise RuntimeError(f'Failed to send command {cmd} after 3 attempts')
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""Proxy for running pyflix library alongside QGroundControl app."""
|
|
4
|
+
|
|
5
|
+
import socket
|
|
6
|
+
|
|
7
|
+
LOCAL = ('0.0.0.0', 14550) # from Flix
|
|
8
|
+
TARGETS = (
|
|
9
|
+
('127.0.0.1', 14560), # to QGroundControl
|
|
10
|
+
('127.0.0.1', 14555), # to pyflix
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
def main():
|
|
14
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
15
|
+
sock.bind(LOCAL)
|
|
16
|
+
|
|
17
|
+
source_addr = None
|
|
18
|
+
packets = 0
|
|
19
|
+
|
|
20
|
+
print('Proxy started - run QGroundControl')
|
|
21
|
+
|
|
22
|
+
while True:
|
|
23
|
+
data, addr = sock.recvfrom(1024) # read entire UDP packet
|
|
24
|
+
if addr in TARGETS: # packet from target
|
|
25
|
+
if source_addr is None:
|
|
26
|
+
continue
|
|
27
|
+
sock.sendto(data, source_addr)
|
|
28
|
+
else: # packet from source
|
|
29
|
+
source_addr = addr
|
|
30
|
+
for target in TARGETS:
|
|
31
|
+
sock.sendto(data, target)
|
|
32
|
+
|
|
33
|
+
packets += 1
|
|
34
|
+
print(f'\rPackets: {packets}', end='')
|
|
35
|
+
|
|
36
|
+
if __name__ == '__main__':
|
|
37
|
+
main()
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyflix
|
|
3
|
+
Version: 0.5
|
|
4
|
+
Summary: Python API for Flix drone
|
|
5
|
+
Author-email: Oleg Kalachev <okalachev@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/okalachev/flix/tree/master/tools/pyflix
|
|
8
|
+
Project-URL: Repository, https://github.com/okalachev/flix
|
|
9
|
+
Project-URL: Issues, https://github.com/okalachev/flix/issues
|
|
10
|
+
Requires-Python: >=3.8
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: pymavlink
|
|
13
|
+
|
|
14
|
+
# Flix Python library
|
|
15
|
+
|
|
16
|
+
The Flix Python library allows you to remotely connect to a Flix quadcopter. It provides access to telemetry data, supports executing CLI commands, and controlling the drone's flight.
|
|
17
|
+
|
|
18
|
+
To use the library, connect to the drone's Wi-Fi. To use it with the simulator, ensure the script runs on the same local network as the simulator.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
If you have cloned the [repo](https://github.com/okalachev/flix), install the library from the repo:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
cd /path/to/flix/repo
|
|
26
|
+
pip install -e tools
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Alternatively, install from pip:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install pyflix
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
The API is accessed through the `Flix` class:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from flix import Flix
|
|
41
|
+
flix = Flix() # create a Flix object and wait for connection
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Telemetry
|
|
45
|
+
|
|
46
|
+
Basic telemetry is available through object properties. The properties names generally match the corresponding variables in the firmware itself:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
print(flix.connected) # True if connected to the drone
|
|
50
|
+
print(flix.mode) # current flight mode (str)
|
|
51
|
+
print(flix.armed) # True if the drone is armed
|
|
52
|
+
print(flix.landed) # True if the drone is landed
|
|
53
|
+
print(flix.attitude) # attitude quaternion [w, x, y, z]
|
|
54
|
+
print(flix.attitude_euler) # attitude as Euler angles [roll, pitch, yaw]
|
|
55
|
+
print(flix.rates) # angular rates [roll_rate, pitch_rate, yaw_rate]
|
|
56
|
+
print(flix.channels) # raw RC channels (list)
|
|
57
|
+
print(flix.motors) # motors outputs (list)
|
|
58
|
+
print(flix.acc) # accelerometer output (list)
|
|
59
|
+
print(flix.gyro) # gyroscope output (list)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
> [!NOTE]
|
|
63
|
+
> The library uses the Front-Left-Up coordinate system — the same as in the firmware. All angles are in radians.
|
|
64
|
+
|
|
65
|
+
### Events
|
|
66
|
+
|
|
67
|
+
The Flix object implements the *Observable* pattern, allowing to listen for events. You can subscribe to events using `on` method:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
flix.on('connected', lambda: print('Connected to Flix'))
|
|
71
|
+
flix.on('disconnected', lambda: print('Disconnected from Flix'))
|
|
72
|
+
flix.on('print', lambda text: print(f'Flix says: {text}'))
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
You can also wait for specific events using `wait` method. This method returns the data associated with the event:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
gyro = flix.wait('gyro') # wait for gyroscope update
|
|
79
|
+
attitude = flix.wait('attitude', timeout=3) # wait for attitude update, raise TimeoutError after 3 seconds
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The `value` argument specifies a condition for filtering events. It can be either an expected value or a callable:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
flix.wait('armed', value=True) # wait until armed
|
|
86
|
+
flix.wait('armed', value=False) # wait until disarmed
|
|
87
|
+
flix.wait('motors', value=lambda motors: all(m == 0 for m in motors)) # wait until all motors stop
|
|
88
|
+
flix.wait('attitude_euler', value=lambda att: att[0] > 0) # wait until roll angle is positive
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Full list of events:
|
|
92
|
+
|
|
93
|
+
|Event|Description|Associated data|
|
|
94
|
+
|-----|-----------|----------------|
|
|
95
|
+
|`connected`|Connected to the drone||
|
|
96
|
+
|`disconnected`|Connection is lost||
|
|
97
|
+
|`armed`|Armed state update|Armed state (*bool*)|
|
|
98
|
+
|`mode`|Flight mode update|Flight mode (*str*)|
|
|
99
|
+
|`landed`|Landed state update|Landed state (*bool*)|
|
|
100
|
+
|`print`|The drone sends text to the console|Text|
|
|
101
|
+
|`attitude`|Attitude update|Attitude quaternion (*list*)|
|
|
102
|
+
|`attitude_euler`|Attitude update|Euler angles (*list*)|
|
|
103
|
+
|`rates`|Angular rates update|Angular rates (*list*)|
|
|
104
|
+
|`channels`|Raw RC channels update|Raw RC channels (*list*)|
|
|
105
|
+
|`motors`|Motors outputs update|Motors outputs (*list*)|
|
|
106
|
+
|`acc`|Accelerometer update|Accelerometer output (*list*)|
|
|
107
|
+
|`gyro`|Gyroscope update|Gyroscope output (*list*)|
|
|
108
|
+
|`mavlink`|Received MAVLink message|Message object|
|
|
109
|
+
|`mavlink.<message_name>`|Received specific MAVLink message|Message object|
|
|
110
|
+
|`mavlink.<message_id>`|Received specific MAVLink message|Message object|
|
|
111
|
+
|`value`|Named value update (see below)|Name, value|
|
|
112
|
+
|`value.<name>`|Specific named value update (see bellow)|Value|
|
|
113
|
+
|
|
114
|
+
> [!NOTE]
|
|
115
|
+
> Update events trigger on every new data from the drone, and do not mean the value is changed.
|
|
116
|
+
|
|
117
|
+
### Common methods
|
|
118
|
+
|
|
119
|
+
Get and set firmware parameters using `get_param` and `set_param` methods:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
print(flix.get_param('PITCH_P')) # get parameter value
|
|
123
|
+
flix.set_param('PITCH_P', 5) # set parameter value
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Execute CLI commands using `cli` method. This method returns command response:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
imu = flix.cli('imu') # get detailed IMU data
|
|
130
|
+
time = flix.cli('time') # get detailed time data
|
|
131
|
+
flix.cli('reboot') # reboot the drone
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
> [!TIP]
|
|
135
|
+
> Use `help` command to get the list of available commands.
|
|
136
|
+
|
|
137
|
+
### Automatic flight
|
|
138
|
+
|
|
139
|
+
The flight control feature is in development. List of methods intended for automatic flight control:
|
|
140
|
+
|
|
141
|
+
* `set_position`
|
|
142
|
+
* `set_attitude`
|
|
143
|
+
* `set_rates`
|
|
144
|
+
* `set_motors`
|
|
145
|
+
* `set_controls`
|
|
146
|
+
* `set_mode`
|
|
147
|
+
|
|
148
|
+
## Usage alongside QGroundControl
|
|
149
|
+
|
|
150
|
+
You can use the Flix library alongside the QGroundControl app, using a proxy mode. To do that:
|
|
151
|
+
|
|
152
|
+
1. Run proxy for `pyflix` and QGroundControl in background:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
flix-proxy
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
2. Go to QGroundControl settings ⇒ *Comm Links*.
|
|
159
|
+
3. Add new link with the following settings:
|
|
160
|
+
* *Name*: Proxy
|
|
161
|
+
* *Automatically Connect on Start*: ✓
|
|
162
|
+
* *Type*: UDP
|
|
163
|
+
* *Port*: 14560
|
|
164
|
+
4. Restart QGroundControl.
|
|
165
|
+
|
|
166
|
+
Now you can run `pyflix` scripts and QGroundControl simultaneously.
|
|
167
|
+
|
|
168
|
+
## Tools
|
|
169
|
+
|
|
170
|
+
The following scripts demonstrate how to use the library:
|
|
171
|
+
|
|
172
|
+
* [`cli.py`](../cli.py) — remote access to the drone's command line interface.
|
|
173
|
+
* [`log.py`](../log.py) — download flight logs from the drone.
|
|
174
|
+
* [`example.py`](../example.py) — a simple example, prints telemetry data and waits for events.
|
|
175
|
+
|
|
176
|
+
## Advanced usage
|
|
177
|
+
|
|
178
|
+
### MAVLink
|
|
179
|
+
|
|
180
|
+
You can access the most recently received messages using `messages` property:
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
print(flix.messages.get('HEARTBEAT')) # print the latest HEARTBEAT message
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
You can wait for a specific message using `wait` method:
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
heartbeat = flix.wait('mavlink.HEARTBEAT')
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
You can send raw messages using `mavlink` property:
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
from pymavlink.dialects.v20 import common as mavlink
|
|
196
|
+
|
|
197
|
+
flix.mavlink.heartbeat_send(mavlink.MAV_TYPE_GCS, mavlink.MAV_AUTOPILOT_INVALID,
|
|
198
|
+
mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED, 0, 0)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Named values
|
|
202
|
+
|
|
203
|
+
You can pass arbitrary named values from the firmware to the Python script using `NAMED_VALUE_FLOAT`, `NAMED_VALUE_INT`, `DEBUG`, `DEBUG_VECT`, and `DEBUG_FLOAT_ARRAY` MAVLink messages.
|
|
204
|
+
|
|
205
|
+
All these named values will appear in the `values` dictionary:
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
print(flix.values['some_value'])
|
|
209
|
+
print(flix.values['some_vector'])
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
You can send values from the firmware like this (`mavlink.ino`):
|
|
213
|
+
|
|
214
|
+
```cpp
|
|
215
|
+
// Send float named value
|
|
216
|
+
mavlink_msg_named_value_float_pack(SYSTEM_ID, MAV_COMP_ID_AUTOPILOT1, &msg, t, "some_value", loopRate);
|
|
217
|
+
sendMessage(&msg);
|
|
218
|
+
|
|
219
|
+
// Send vector named value
|
|
220
|
+
mavlink_msg_debug_vect_pack(SYSTEM_ID, MAV_COMP_ID_AUTOPILOT1, &msg, "some_vector", t, gyroBias.x, gyroBias.y, gyroBias.z);
|
|
221
|
+
sendMessage(&msg);
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Logging
|
|
225
|
+
|
|
226
|
+
You can control Flix library verbosity using Python's `logging` module:
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
import logging
|
|
230
|
+
|
|
231
|
+
logger = logging.getLogger('flix')
|
|
232
|
+
logger.setLevel(logging.DEBUG) # be more verbose
|
|
233
|
+
logger.setLevel(logging.WARNING) # be less verbose
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Stability
|
|
237
|
+
|
|
238
|
+
The library is in development stage. The API is not stable.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
pyflix/README.md
|
|
3
|
+
pyflix/__init__.py
|
|
4
|
+
pyflix/__outtakes.py
|
|
5
|
+
pyflix/flix.py
|
|
6
|
+
pyflix/proxy.py
|
|
7
|
+
pyflix.egg-info/PKG-INFO
|
|
8
|
+
pyflix.egg-info/SOURCES.txt
|
|
9
|
+
pyflix.egg-info/dependency_links.txt
|
|
10
|
+
pyflix.egg-info/entry_points.txt
|
|
11
|
+
pyflix.egg-info/requires.txt
|
|
12
|
+
pyflix.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pymavlink
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyflix
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pyflix"
|
|
3
|
+
version = "0.5"
|
|
4
|
+
description = "Python API for Flix drone"
|
|
5
|
+
authors = [{ name="Oleg Kalachev", email="okalachev@gmail.com" }]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "pyflix/README.md"
|
|
8
|
+
requires-python = ">=3.8"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"pymavlink",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
flix-proxy = "pyflix.proxy:main"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["setuptools>=61.0"]
|
|
18
|
+
build-backend = "setuptools.build_meta"
|
|
19
|
+
|
|
20
|
+
[tool.setuptools]
|
|
21
|
+
packages = ["pyflix"]
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.package-data]
|
|
24
|
+
pyflix = ["README.md"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/okalachev/flix/tree/master/tools/pyflix"
|
|
28
|
+
Repository = "https://github.com/okalachev/flix"
|
|
29
|
+
Issues = "https://github.com/okalachev/flix/issues"
|
pyflix-0.5/setup.cfg
ADDED