Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 64b21a3a6b | |||
| 545eed8944 | |||
| 518abf1555 | |||
| 17df1c5396 | |||
| 8e2ffd7c69 | |||
| 52b74afba6 | |||
| 0ca2473655 | |||
| b4c2fe3988 | |||
| e51b47b798 | |||
| 71abe1bcdb | |||
| 0f2e384ce6 | |||
| 5e153a210d | |||
| 9d47bcb82e | |||
| 1fafc27b39 | |||
| faca48ced3 | |||
| a5dbd2c829 | |||
| 59f9528d34 | |||
| 607b2ff0b7 | |||
| 22c06f76c4 | |||
| 488ceb3004 | |||
| b83c9b3845 | |||
| 2f4b1423e6 | |||
| 4e32414dae | |||
| a294883dea |
@@ -4,9 +4,10 @@ build/
|
||||
tools/log/
|
||||
tools/dist/
|
||||
*.egg-info/
|
||||
.dependencies
|
||||
.core
|
||||
.libs
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/settings.default.json
|
||||
!.vscode/c_cpp_properties.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
|
||||
@@ -6,18 +6,18 @@
|
||||
"${workspaceFolder}/flix",
|
||||
"${workspaceFolder}/gazebo",
|
||||
"${workspaceFolder}/tools/**",
|
||||
"~/.arduino15/packages/esp32/hardware/esp32/3.3.6/cores/esp32",
|
||||
"~/.arduino15/packages/esp32/hardware/esp32/3.3.6/libraries/**",
|
||||
"~/.arduino15/packages/esp32/hardware/esp32/3.3.6/variants/d1_mini32",
|
||||
"~/.arduino15/packages/esp32/tools/esp32-libs/3.3.6/include/**",
|
||||
"~/.arduino15/packages/esp32/hardware/esp32/3.3.10/cores/esp32",
|
||||
"~/.arduino15/packages/esp32/hardware/esp32/3.3.10/libraries/**",
|
||||
"~/.arduino15/packages/esp32/hardware/esp32/3.3.10/variants/d1_mini32",
|
||||
"~/.arduino15/packages/esp32/tools/esp32-libs/3.3.10/include/**",
|
||||
"~/Arduino/libraries/**",
|
||||
"/usr/include/gazebo-11/",
|
||||
"/usr/include/ignition/math6/"
|
||||
],
|
||||
"forcedInclude": [
|
||||
"${workspaceFolder}/.vscode/intellisense.h",
|
||||
"~/.arduino15/packages/esp32/hardware/esp32/3.3.6/cores/esp32/Arduino.h",
|
||||
"~/.arduino15/packages/esp32/hardware/esp32/3.3.6/variants/d1_mini32/pins_arduino.h",
|
||||
"~/.arduino15/packages/esp32/hardware/esp32/3.3.10/cores/esp32/Arduino.h",
|
||||
"~/.arduino15/packages/esp32/hardware/esp32/3.3.10/variants/d1_mini32/pins_arduino.h",
|
||||
"${workspaceFolder}/flix/cli.ino",
|
||||
"${workspaceFolder}/flix/control.ino",
|
||||
"${workspaceFolder}/flix/estimate.ino",
|
||||
@@ -33,7 +33,7 @@
|
||||
"${workspaceFolder}/flix/parameters.ino",
|
||||
"${workspaceFolder}/flix/safety.ino"
|
||||
],
|
||||
"compilerPath": "~/.arduino15/packages/esp32/tools/esp-x32/2511/bin/xtensa-esp32-elf-g++",
|
||||
"compilerPath": "~/.arduino15/packages/esp32/tools/esp-x32/2601/bin/xtensa-esp32-elf-g++",
|
||||
"cStandard": "c11",
|
||||
"cppStandard": "c++17",
|
||||
"defines": [
|
||||
@@ -53,18 +53,18 @@
|
||||
"name": "Mac",
|
||||
"includePath": [
|
||||
"${workspaceFolder}/flix",
|
||||
"~/Library/Arduino15/packages/esp32/hardware/esp32/3.3.6/cores/esp32",
|
||||
"~/Library/Arduino15/packages/esp32/hardware/esp32/3.3.6/libraries/**",
|
||||
"~/Library/Arduino15/packages/esp32/hardware/esp32/3.3.6/variants/d1_mini32",
|
||||
"~/Library/Arduino15/packages/esp32/tools/esp32-libs/3.3.6/include/**",
|
||||
"~/Library/Arduino15/packages/esp32/hardware/esp32/3.3.10/cores/esp32",
|
||||
"~/Library/Arduino15/packages/esp32/hardware/esp32/3.3.10/libraries/**",
|
||||
"~/Library/Arduino15/packages/esp32/hardware/esp32/3.3.10/variants/d1_mini32",
|
||||
"~/Library/Arduino15/packages/esp32/tools/esp32-libs/3.3.10/include/**",
|
||||
"~/Documents/Arduino/libraries/**",
|
||||
"/opt/homebrew/include/gazebo-11/",
|
||||
"/opt/homebrew/include/ignition/math6/"
|
||||
],
|
||||
"forcedInclude": [
|
||||
"${workspaceFolder}/.vscode/intellisense.h",
|
||||
"~/Library/Arduino15/packages/esp32/hardware/esp32/3.3.6/cores/esp32/Arduino.h",
|
||||
"~/Library/Arduino15/packages/esp32/hardware/esp32/3.3.6/variants/d1_mini32/pins_arduino.h",
|
||||
"~/Library/Arduino15/packages/esp32/hardware/esp32/3.3.10/cores/esp32/Arduino.h",
|
||||
"~/Library/Arduino15/packages/esp32/hardware/esp32/3.3.10/variants/d1_mini32/pins_arduino.h",
|
||||
"${workspaceFolder}/flix/flix.ino",
|
||||
"${workspaceFolder}/flix/cli.ino",
|
||||
"${workspaceFolder}/flix/control.ino",
|
||||
@@ -80,7 +80,7 @@
|
||||
"${workspaceFolder}/flix/parameters.ino",
|
||||
"${workspaceFolder}/flix/safety.ino"
|
||||
],
|
||||
"compilerPath": "~/Library/Arduino15/packages/esp32/tools/esp-x32/2511/bin/xtensa-esp32-elf-g++",
|
||||
"compilerPath": "~/Library/Arduino15/packages/esp32/tools/esp-x32/2601/bin/xtensa-esp32-elf-g++",
|
||||
"cStandard": "c11",
|
||||
"cppStandard": "c++17",
|
||||
"defines": [
|
||||
@@ -103,16 +103,16 @@
|
||||
"${workspaceFolder}/flix",
|
||||
"${workspaceFolder}/gazebo",
|
||||
"${workspaceFolder}/tools/**",
|
||||
"~/AppData/Local/Arduino15/packages/esp32/hardware/esp32/3.3.6/cores/esp32",
|
||||
"~/AppData/Local/Arduino15/packages/esp32/hardware/esp32/3.3.6/libraries/**",
|
||||
"~/AppData/Local/Arduino15/packages/esp32/hardware/esp32/3.3.6/variants/d1_mini32",
|
||||
"~/AppData/Local/Arduino15/packages/esp32/tools/esp32-libs/3.3.6/include/**",
|
||||
"~/AppData/Local/Arduino15/packages/esp32/hardware/esp32/3.3.10/cores/esp32",
|
||||
"~/AppData/Local/Arduino15/packages/esp32/hardware/esp32/3.3.10/libraries/**",
|
||||
"~/AppData/Local/Arduino15/packages/esp32/hardware/esp32/3.3.10/variants/d1_mini32",
|
||||
"~/AppData/Local/Arduino15/packages/esp32/tools/esp32-libs/3.3.10/include/**",
|
||||
"~/Documents/Arduino/libraries/**"
|
||||
],
|
||||
"forcedInclude": [
|
||||
"${workspaceFolder}/.vscode/intellisense.h",
|
||||
"~/AppData/Local/Arduino15/packages/esp32/hardware/esp32/3.3.6/cores/esp32/Arduino.h",
|
||||
"~/AppData/Local/Arduino15/packages/esp32/hardware/esp32/3.3.6/variants/d1_mini32/pins_arduino.h",
|
||||
"~/AppData/Local/Arduino15/packages/esp32/hardware/esp32/3.3.10/cores/esp32/Arduino.h",
|
||||
"~/AppData/Local/Arduino15/packages/esp32/hardware/esp32/3.3.10/variants/d1_mini32/pins_arduino.h",
|
||||
"${workspaceFolder}/flix/cli.ino",
|
||||
"${workspaceFolder}/flix/control.ino",
|
||||
"${workspaceFolder}/flix/estimate.ino",
|
||||
@@ -128,7 +128,7 @@
|
||||
"${workspaceFolder}/flix/parameters.ino",
|
||||
"${workspaceFolder}/flix/safety.ino"
|
||||
],
|
||||
"compilerPath": "~/AppData/Local/Arduino15/packages/esp32/tools/esp-x32/2511/bin/xtensa-esp32-elf-g++.exe",
|
||||
"compilerPath": "~/AppData/Local/Arduino15/packages/esp32/tools/esp-x32/2601/bin/xtensa-esp32-elf-g++.exe",
|
||||
"cStandard": "c11",
|
||||
"cppStandard": "c++17",
|
||||
"defines": [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
// See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
|
||||
"recommendations": [
|
||||
"dangmai.workspace-default-settings",
|
||||
"ms-vscode.cpptools",
|
||||
"ms-vscode.cmake-tools",
|
||||
"ms-python.python"
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
BOARD = esp32:esp32:d1_mini32
|
||||
BOARD = esp32:esp32:d1_mini32:DebugLevel=error
|
||||
PORT := $(strip $(wildcard /dev/serial/by-id/usb-Silicon_Labs_CP21* /dev/serial/by-id/usb-1a86_USB_Single_Serial_* /dev/cu.usbserial-* /dev/cu.usbmodem*))
|
||||
|
||||
build: .dependencies
|
||||
export ARDUINO_NETWORK_CONNECTION_TIMEOUT := 1h
|
||||
|
||||
build: .core .libs
|
||||
arduino-cli compile --fqbn $(BOARD) flix
|
||||
|
||||
upload: build
|
||||
arduino-cli upload --fqbn $(BOARD) -p "$(PORT)" flix
|
||||
|
||||
erase:
|
||||
arduino-cli burn-bootloader --fqbn $(BOARD) -p "$(PORT)" -P esptool
|
||||
|
||||
monitor:
|
||||
arduino-cli monitor -p "$(PORT)" -c baudrate=115200
|
||||
|
||||
dependencies .dependencies:
|
||||
arduino-cli core update-index --config-file arduino-cli.yaml
|
||||
arduino-cli core install esp32:esp32@3.3.6 --config-file arduino-cli.yaml
|
||||
core .core:
|
||||
arduino-cli core update-index --additional-urls https://espressif.github.io/arduino-esp32/package_esp32_index.json
|
||||
arduino-cli core install esp32:esp32@3.3.10 --additional-urls https://espressif.github.io/arduino-esp32/package_esp32_index.json
|
||||
touch .core
|
||||
|
||||
libs .libs:
|
||||
arduino-cli lib update-index
|
||||
arduino-cli lib install "FlixPeriph"
|
||||
arduino-cli lib install "MAVLink"@2.0.25
|
||||
touch .dependencies
|
||||
touch .libs
|
||||
|
||||
upload_proxy: .dependencies
|
||||
upload_proxy: .core .libs
|
||||
arduino-cli compile --fqbn $(BOARD) tools/espnow-proxy
|
||||
arduino-cli upload --fqbn $(BOARD) -p "$(PORT)" tools/espnow-proxy
|
||||
|
||||
@@ -26,7 +34,7 @@ gazebo/build cmake: gazebo/CMakeLists.txt
|
||||
mkdir -p gazebo/build
|
||||
cd gazebo/build && cmake ..
|
||||
|
||||
build_simulator: .dependencies gazebo/build
|
||||
build_simulator: .libs gazebo/build
|
||||
make -C gazebo/build
|
||||
|
||||
simulator: build_simulator
|
||||
@@ -41,6 +49,6 @@ plot:
|
||||
plotjuggler -d $(shell ls -t tools/log/*.csv | head -n1)
|
||||
|
||||
clean:
|
||||
rm -rf gazebo/build flix/build flix/cache .dependencies
|
||||
rm -rf gazebo/build flix/build flix/cache .core .libs
|
||||
|
||||
.PHONY: build upload monitor dependencies cmake build_simulator simulator log clean
|
||||
.PHONY: build upload monitor core libs cmake build_simulator simulator log clean
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
board_manager:
|
||||
additional_urls:
|
||||
- https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json
|
||||
network:
|
||||
connection_timeout: 1h
|
||||
@@ -79,6 +79,9 @@ To add a new parameter:
|
||||
|
||||
See examples of adding new parameters in commits: [c434107](https://github.com/okalachev/flix/commit/c434107), [a687303](https://github.com/okalachev/flix/commit/a687303).
|
||||
|
||||
> [!NOTE]
|
||||
> Since all the parameters are internally stored and passed as floats, the safe range for `int` parameters is -16777216 to 16777215.
|
||||
|
||||
## Adding a subsystem
|
||||
|
||||
To add a new subsystem:
|
||||
|
||||
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 58 KiB |
@@ -20,13 +20,14 @@ You can build and upload the firmware using either **Arduino IDE** (easier for b
|
||||
|
||||
1. Install [Arduino IDE](https://www.arduino.cc/en/software) (version 2 is recommended).
|
||||
2. *Windows users might need to install [USB to UART bridge driver from Silicon Labs](https://www.silabs.com/developers/usb-to-uart-bridge-vcp-drivers).*
|
||||
3. Install ESP32 core, version 3.3.6. See the [official Espressif's instructions](https://docs.espressif.com/projects/arduino-esp32/en/latest/installing.html#installing-using-arduino-ide) on installing ESP32 Core in Arduino IDE.
|
||||
3. Install ESP32 core, version 3.3.10. See the [official Espressif's instructions](https://docs.espressif.com/projects/arduino-esp32/en/latest/installing.html#installing-using-arduino-ide) on installing ESP32 Core in Arduino IDE.
|
||||
4. Install the following libraries using [Library Manager](https://docs.arduino.cc/software/ide-v2/tutorials/ide-v2-installing-a-library):
|
||||
* `FlixPeriph`, the latest version.
|
||||
* `MAVLink`, version 2.0.25.
|
||||
5. Open the `flix/flix.ino` sketch from downloaded firmware sources in Arduino IDE.
|
||||
6. Connect your ESP32 board to the computer and choose correct board type in Arduino IDE (*WEMOS D1 MINI ESP32* for ESP32 Mini) and the port.
|
||||
7. [Build and upload](https://docs.arduino.cc/software/ide-v2/tutorials/getting-started/ide-v2-uploading-a-sketch) the firmware using Arduino IDE.
|
||||
7. Set *Tools* ⇒ *Core Debug Level* to *Error* to see the errors in the serial console. Set *Tools* ⇒ *USB CDC on Boot* to *Enabled* for ESP32-S3/ESP32-C3 boards.
|
||||
8. [Build and upload](https://docs.arduino.cc/software/ide-v2/tutorials/getting-started/ide-v2-uploading-a-sketch) the firmware using Arduino IDE.
|
||||
|
||||
### Command line (Windows, Linux, macOS)
|
||||
|
||||
@@ -57,6 +58,12 @@ You can build and upload the firmware using either **Arduino IDE** (easier for b
|
||||
make upload monitor
|
||||
```
|
||||
|
||||
For ESP32-S3/ESP32-C3 boards, set the appropriate [FQBN](https://docs.arduino.cc/arduino-cli/FAQ/#whats-the-fqbn-string) using `BOARD` parameter:
|
||||
|
||||
```bash
|
||||
make BOARD=esp32:esp32:esp32s3:DebugLevel=error,FlashSize=4M,CDCOnBoot=cdc upload
|
||||
```
|
||||
|
||||
See other available Make commands in [Makefile](../Makefile).
|
||||
|
||||
> [!TIP]
|
||||
@@ -82,6 +89,9 @@ QGroundControl is a ground control station software that can be used to monitor
|
||||
3. Connect your computer or smartphone to the appeared `flix` Wi-Fi network (password: `flixwifi`).
|
||||
4. Launch QGroundControl app. It should connect and begin showing the drone's telemetry automatically.
|
||||
|
||||
> [!TIP]
|
||||
> If QGroundControl doesn't connect, try to disable the firewall and/or VPN on your computer, as they may block the connection.
|
||||
|
||||
### Access console
|
||||
|
||||
The console is a command line interface (CLI) that allows to interact with the drone, change parameters, and perform various actions. There are two ways of accessing the console: using **serial port** or using **QGroundControl (wirelessly)**.
|
||||
|
||||
@@ -4,6 +4,36 @@ This page contains user-built drones based on the Flix project. Publish your pro
|
||||
|
||||
---
|
||||
|
||||
Author: [Неруш Михаил](https://t.me/NerushMV).<br>
|
||||
Description: custom frame made of 4 mm plywood, 8520 brushed motors, 75 mm propellers, MPU-6500. FlySky FS-i6X with ESP32-based adapter for ESP-NOW communication (using PPM output).
|
||||
|
||||
<img src="img/user/nerush/1.jpg" height=200> <img src="img/user/nerush/2.jpg" height=200>
|
||||
|
||||
[Flight video](https://drive.google.com/file/d/1jRXeGx34lJpUfw0GKLQeIzkWZvooQJSE/view?usp=sharing).
|
||||
|
||||
---
|
||||
|
||||
Author: [Konstantinos Paraskevas](https://github.com/Frapais).<br>
|
||||
Description: drone with a custom single-boarded airframe, extending the [Sprig-C3 module](https://github.com/Frapais/Sprig-C3).
|
||||
ESP32-C3 microcontroller, ICM-20948 IMU, on-board fuel-gauge, status LED indicator.<br>
|
||||
Repository with all the code and PCB sources: https://github.com/Frapais/Sprig-Drone.
|
||||
|
||||
<img src="img/user/kostas/1.jpg" height=150> <img src="img/user/kostas/2.jpg" height=150>
|
||||
|
||||
Detailed video about making the drone:
|
||||
|
||||
<a href="https://youtu.be/82Q-uBq6s48"><img width=400 src="https://i3.ytimg.com/vi/82Q-uBq6s48/maxresdefault.jpg"></a>
|
||||
|
||||
---
|
||||
|
||||
Author: [Awab Anas](http://t.me/AW_VENOM).<br>
|
||||
Description: ESP32 D1 Mini, MPU-6050, 8520 3.7V brushed motors, 55 mm propellers, battery li-po 1200 mAh, controlling via [Mavlink Joystick app](https://github.com/goldarte/mavlink-joystick/releases/latest).<br>
|
||||
[Flight validation](https://drive.google.com/file/d/12z0jfctZDBA6b5UKCG0Uje5rAxj6DhF-/view?usp=sharing).
|
||||
|
||||
<img src="img/user/aw_venom/1.jpg" height=200>
|
||||
|
||||
---
|
||||
|
||||
Author: [Ina Tix](https://t.me/ina_tix).<br>
|
||||
Description: XR2981 based DC-DC converter, ELRS MINI 2.4GHz RX SX1280 receiver (SBUS interface), Radiomaster TX12 remote control.<br>
|
||||
[Flight validation](https://drive.google.com/file/d/1yqkKNuz4R_yxGqUNQxVpixJbXqEEcUSj/view?usp=share_link).
|
||||
@@ -57,6 +87,17 @@ Author: [goldarte](https://t.me/goldarte).<br>
|
||||
|
||||
---
|
||||
|
||||
Author: [malagis](https://oshwhub.com/malagis).<br>
|
||||
|
||||
A Chinese custom PCB version of Flix with a big community of users, lots of materials and modifications.
|
||||
|
||||
Main project's page: https://oshwhub.com/malagis/esp32-mini-plane.<br>
|
||||
Video about the project: https://www.bilibili.com/video/BV14vyqBFEJn/.
|
||||
|
||||
<img src="img/user/malagis/1.jpg" height=200> <img src="img/user/malagis/2.jpg" height=200> <img src="img/user/malagis/3.jpg" height=200>
|
||||
|
||||
---
|
||||
|
||||
## School 548 course
|
||||
|
||||
Special course on quadcopter design and engineering took place in october-november 2025 in School 548, Moscow. The course included UAV control theory, electronics, drone assembly and setup practice, using the Flix project.
|
||||
|
||||
@@ -31,33 +31,33 @@ const char* motd =
|
||||
"Commands:\n\n"
|
||||
"help - show help\n"
|
||||
"p - show all parameters\n"
|
||||
"p <name> - show parameter\n"
|
||||
"p <str> - show parameters starting with str\n"
|
||||
"p <name> <value> - set parameter\n"
|
||||
"preset - reset parameters\n"
|
||||
"time - show time info\n"
|
||||
"ps - show pitch/roll/yaw\n"
|
||||
"psq - show attitude quaternion\n"
|
||||
"imu - show IMU data\n"
|
||||
"ca - calibrate accel\n"
|
||||
"st - show state estimation\n"
|
||||
"arm - arm the drone\n"
|
||||
"disarm - disarm the drone\n"
|
||||
"raw/stab/acro/auto - set mode\n"
|
||||
"rc - show RC data\n"
|
||||
"cr - calibrate RC\n"
|
||||
"pw - show power info\n"
|
||||
"wifi - show Wi-Fi info\n"
|
||||
"ap <ssid> <password> - setup Wi-Fi access point\n"
|
||||
"sta <ssid> <password> - setup Wi-Fi client mode\n"
|
||||
"espnow <mac> [<key>] - setup ESP-NOW peer\n"
|
||||
"wifi ap/sta/espnow/off - set Wi-Fi mode\n"
|
||||
"ap <ssid> <password> - configure Wi-Fi access point\n"
|
||||
"sta <ssid> <password> - configure Wi-Fi client mode\n"
|
||||
"espnow <mac> [<key>] - configure ESP-NOW peer\n"
|
||||
"mot - show motor output\n"
|
||||
"log [dump] - print log header [and data]\n"
|
||||
"cr - calibrate RC\n"
|
||||
"ca - calibrate accel\n"
|
||||
"mfr, mfl, mrr, mrl - test motor (remove props)\n"
|
||||
"sys - show system info\n"
|
||||
"reset - reset drone's state\n"
|
||||
"reboot - reboot the drone\n";
|
||||
|
||||
void print(const char* format, ...) {
|
||||
char buf[1000];
|
||||
char buf[3000];
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
vsnprintf(buf, sizeof(buf), format, args);
|
||||
@@ -92,10 +92,8 @@ void doCommand(String str, bool echo = false) {
|
||||
// execute command
|
||||
if (command == "help" || command == "motd") {
|
||||
print("%s\n", motd);
|
||||
} else if (command == "p" && arg0 == "") {
|
||||
printParameters();
|
||||
} else if (command == "p" && arg0 != "" && arg1 == "") {
|
||||
print("%s = %g\n", arg0.c_str(), getParameter(arg0.c_str()));
|
||||
} else if (command == "p" && arg1 == "") {
|
||||
printParameters(arg0.c_str());
|
||||
} else if (command == "p") {
|
||||
bool success = setParameter(arg0.c_str(), arg1.toFloat());
|
||||
if (success) {
|
||||
@@ -109,15 +107,15 @@ void doCommand(String str, bool echo = false) {
|
||||
print("Time: %f\n", t);
|
||||
print("Loop rate: %.0f\n", loopRate);
|
||||
print("dt: %f\n", dt);
|
||||
} else if (command == "ps") {
|
||||
Vector a = attitude.toEuler();
|
||||
print("roll: %f pitch: %f yaw: %f\n", degrees(a.x), degrees(a.y), degrees(a.z));
|
||||
} else if (command == "psq") {
|
||||
print("qw: %f qx: %f qy: %f qz: %f\n", attitude.w, attitude.x, attitude.y, attitude.z);
|
||||
} else if (command == "imu") {
|
||||
printIMUInfo();
|
||||
printIMUCalibration();
|
||||
print("landed: %d\n", landed);
|
||||
} else if (command == "st") {
|
||||
print("rates: %g %g %g\n", rates.x, rates.y, rates.z);
|
||||
print("attitude: %g %g %g %g\n", attitude.w, attitude.x, attitude.y, attitude.z);
|
||||
print("roll: %g° pitch: %g° yaw: %g°\n", degrees(attitude.getRoll()), degrees(attitude.getPitch()), degrees(attitude.getYaw()));
|
||||
print("landed: %d\n", landed);
|
||||
} else if (command == "arm") {
|
||||
armed = true;
|
||||
} else if (command == "disarm") {
|
||||
@@ -142,8 +140,10 @@ void doCommand(String str, bool echo = false) {
|
||||
print("armed: %d\n", armed);
|
||||
} else if (command == "pw") {
|
||||
print("Voltage: %.1f V\n", voltage);
|
||||
} else if (command == "wifi") {
|
||||
} else if (command == "wifi" && arg0 == "") {
|
||||
printWiFiInfo();
|
||||
} else if (command == "wifi") {
|
||||
setWiFiMode(arg0);
|
||||
} else if (command == "ap") {
|
||||
configWiFi(W_AP, arg0.c_str(), arg1.c_str());
|
||||
} else if (command == "sta") {
|
||||
@@ -172,10 +172,11 @@ void doCommand(String str, bool echo = false) {
|
||||
#ifdef ESP32
|
||||
print("Chip: %s\n", ESP.getChipModel());
|
||||
print("Temperature: %.1f °C\n", temperatureRead());
|
||||
print("Free heap: %d\n", ESP.getFreeHeap());
|
||||
print("Total RAM: %d KB\n", ESP.getHeapSize() / 1024);
|
||||
print("Free heap: %d KB\n", ESP.getFreeHeap() / 1024);
|
||||
print("Firmware: " __DATE__ " " __TIME__ "\n");
|
||||
// Print tasks table
|
||||
print("Num Task Stack Prio Core CPU%%\n");
|
||||
print("Num Task MinSt Prio Core CPU%%\n");
|
||||
int taskCount = uxTaskGetNumberOfTasks();
|
||||
TaskStatus_t *systemState = new TaskStatus_t[taskCount];
|
||||
uint32_t totalRunTime;
|
||||
@@ -209,7 +210,7 @@ void handleInput() {
|
||||
|
||||
while (Serial.available()) {
|
||||
char c = Serial.read();
|
||||
if (c == '\n') {
|
||||
if (c == '\n' || c == '\r') {
|
||||
doCommand(input);
|
||||
input.clear();
|
||||
} else {
|
||||
|
||||
@@ -149,6 +149,7 @@ void controlTorque() {
|
||||
motors[MOTOR_REAR_LEFT] = thrustTarget + torqueTarget.x + torqueTarget.y - torqueTarget.z;
|
||||
motors[MOTOR_REAR_RIGHT] = thrustTarget - torqueTarget.x + torqueTarget.y + torqueTarget.z;
|
||||
|
||||
// Prioritize angle control over thrust control
|
||||
desaturate(motors[MOTOR_FRONT_LEFT], motors[MOTOR_FRONT_RIGHT], motors[MOTOR_REAR_LEFT], motors[MOTOR_REAR_RIGHT]);
|
||||
|
||||
motors[0] = constrain(motors[0], 0, 1);
|
||||
|
||||
@@ -32,8 +32,7 @@ void applyGyro() {
|
||||
|
||||
void applyAcc() {
|
||||
// test should we apply accelerometer gravity correction
|
||||
float accNorm = acc.norm();
|
||||
landed = !motorsActive() && abs(accNorm - ONE_G) < ONE_G * 0.1f;
|
||||
landed = !motorsActive() && abs(acc.norm() - ONE_G) < ONE_G * 0.1f;
|
||||
|
||||
if (!landed) return;
|
||||
|
||||
@@ -47,6 +46,7 @@ void applyAcc() {
|
||||
|
||||
void applyLevel() {
|
||||
if (landed) return;
|
||||
if (thrustTarget < 0.1) return; // skip at idle thrust
|
||||
|
||||
// assume the pilot keeps the drone more or less level in flight
|
||||
Vector up = Quaternion::rotateVector(Vector(0, 0, 1), attitude);
|
||||
|
||||
@@ -40,10 +40,12 @@ void readIMU() {
|
||||
imu.getGyro(gyro.x, gyro.y, gyro.z);
|
||||
imu.getAccel(acc.x, acc.y, acc.z);
|
||||
calibrateGyroOnce();
|
||||
// apply scale and bias
|
||||
|
||||
// Apply scale and bias
|
||||
acc = (acc - accBias) / accScale;
|
||||
gyro = gyro - gyroBias;
|
||||
// rotate to body frame
|
||||
|
||||
// Rotate to body frame
|
||||
Quaternion rotation = Quaternion::fromEuler(imuRotation);
|
||||
acc = Quaternion::rotateVector(acc, rotation.inversed());
|
||||
gyro = Quaternion::rotateVector(gyro, rotation.inversed());
|
||||
@@ -52,6 +54,7 @@ void readIMU() {
|
||||
void calibrateGyroOnce() {
|
||||
static Delay landedDelay(2);
|
||||
if (!landedDelay.update(landed)) return; // calibrate only if definitely stationary
|
||||
|
||||
gyroBias = gyroBiasFilter.update(gyro);
|
||||
}
|
||||
|
||||
@@ -105,6 +108,7 @@ void calibrateAccelOnce() {
|
||||
if (acc.x < accMin.x) accMin.x = acc.x;
|
||||
if (acc.y < accMin.y) accMin.y = acc.y;
|
||||
if (acc.z < accMin.z) accMin.z = acc.z;
|
||||
|
||||
// Compute scale and bias
|
||||
accScale = (accMax - accMin) / 2 / ONE_G;
|
||||
accBias = (accMax + accMin) / 2;
|
||||
@@ -121,6 +125,7 @@ void printIMUInfo() {
|
||||
print("model: %s\n", imu.getModel());
|
||||
print("who am I: 0x%02X\n", imu.whoAmI());
|
||||
print("rate: %.0f\n", loopRate);
|
||||
print("temperature: %.1f °C\n", imu.getTemp());
|
||||
print("gyro: %f %f %f\n", gyro.x, gyro.y, gyro.z);
|
||||
print("acc: %f %f %f\n", acc.x, acc.y, acc.z);
|
||||
imu.waitForData();
|
||||
|
||||
@@ -10,8 +10,12 @@ extern float controlTime;
|
||||
extern float voltage;
|
||||
|
||||
int mavlinkSysId = 1;
|
||||
Rate telemetryFast(10);
|
||||
|
||||
Rate telemetrySlow(2);
|
||||
Rate telemetryAttitude(20);
|
||||
Rate telemetryRC(10);
|
||||
Rate telemetryMotors(10);
|
||||
Rate telemetryIMU(15);
|
||||
|
||||
bool mavlinkConnected = false;
|
||||
String mavlinkPrintBuffer;
|
||||
@@ -34,36 +38,46 @@ void sendMavlink() {
|
||||
((mode == AUTO) ? MAV_MODE_FLAG_AUTO_ENABLED : MAV_MODE_FLAG_MANUAL_INPUT_ENABLED),
|
||||
mode, MAV_STATE_STANDBY);
|
||||
sendMessage(&msg);
|
||||
}
|
||||
|
||||
if (!mavlinkConnected) return; // send only heartbeat until connected
|
||||
|
||||
if (telemetrySlow) {
|
||||
mavlink_msg_extended_sys_state_pack(mavlinkSysId, MAV_COMP_ID_AUTOPILOT1, &msg,
|
||||
MAV_VTOL_STATE_UNDEFINED, landed ? MAV_LANDED_STATE_ON_GROUND : MAV_LANDED_STATE_IN_AIR);
|
||||
sendMessage(&msg);
|
||||
}
|
||||
|
||||
if (telemetrySlow && valid(voltage)) {
|
||||
uint16_t voltages[] = {(uint16_t)(voltage * 1000), UINT16_MAX, UINT16_MAX, UINT16_MAX, UINT16_MAX, UINT16_MAX, UINT16_MAX, UINT16_MAX, UINT16_MAX, UINT16_MAX};
|
||||
uint16_t voltagesExt[] = {0, 0, 0, 0};
|
||||
float remaining = constrain(mapf(voltage, 3.4, 4.2, 0, 1), 0, 1);
|
||||
mavlink_msg_battery_status_pack(mavlinkSysId, MAV_COMP_ID_AUTOPILOT1, &msg, 0, MAV_BATTERY_FUNCTION_ALL,
|
||||
MAV_BATTERY_TYPE_LIPO, INT16_MAX, voltages, -1, -1, -1, remaining * 100, 0, MAV_BATTERY_CHARGE_STATE_OK, voltagesExt, 0, 0);
|
||||
if (valid(voltage)) sendMessage(&msg);
|
||||
sendMessage(&msg);
|
||||
}
|
||||
|
||||
if (telemetryFast && mavlinkConnected) {
|
||||
if (telemetryAttitude) {
|
||||
const float offset[] = {0, 0, 0, 0};
|
||||
mavlink_msg_attitude_quaternion_pack(mavlinkSysId, MAV_COMP_ID_AUTOPILOT1, &msg,
|
||||
time, attitude.w, attitude.x, -attitude.y, -attitude.z, rates.x, -rates.y, -rates.z, offset); // convert to frd
|
||||
sendMessage(&msg);
|
||||
}
|
||||
|
||||
if (telemetryRC && channels[0]) { // 0 means no RC input
|
||||
mavlink_msg_rc_channels_raw_pack(mavlinkSysId, MAV_COMP_ID_AUTOPILOT1, &msg, controlTime * 1000, 0,
|
||||
channels[0], channels[1], channels[2], channels[3], channels[4], channels[5], channels[6], channels[7], UINT8_MAX);
|
||||
if (channels[0] != 0) sendMessage(&msg); // 0 means no RC input
|
||||
sendMessage(&msg);
|
||||
}
|
||||
|
||||
if (telemetryMotors) {
|
||||
float controls[8];
|
||||
memcpy(controls, motors, sizeof(motors));
|
||||
mavlink_msg_actuator_control_target_pack(mavlinkSysId, MAV_COMP_ID_AUTOPILOT1, &msg, time, 0, controls);
|
||||
sendMessage(&msg);
|
||||
}
|
||||
|
||||
if (telemetryIMU) {
|
||||
mavlink_msg_scaled_imu_pack(mavlinkSysId, MAV_COMP_ID_AUTOPILOT1, &msg, time,
|
||||
acc.x / ONE_G * 1000, -acc.y / ONE_G * 1000, -acc.z / ONE_G * 1000, // convert to frd
|
||||
gyro.x * 1000, -gyro.y * 1000, -gyro.z * 1000,
|
||||
@@ -81,13 +95,13 @@ void sendMessage(const void *msg) {
|
||||
void receiveMavlink() {
|
||||
uint8_t buf[MAVLINK_MAX_PACKET_LEN];
|
||||
int len = receiveWiFi(buf, MAVLINK_MAX_PACKET_LEN);
|
||||
if (len) mavlinkConnected = true;
|
||||
|
||||
// New packet, parse it
|
||||
mavlink_message_t msg;
|
||||
mavlink_status_t status;
|
||||
for (int i = 0; i < len; i++) {
|
||||
if (mavlink_parse_char(MAVLINK_COMM_0, buf[i], &msg, &status)) {
|
||||
mavlinkConnected = true;
|
||||
handleMavlink(&msg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,9 @@ const int MOTOR_REAR_LEFT = 0, MOTOR_REAR_RIGHT = 1, MOTOR_FRONT_RIGHT = 2, MOTO
|
||||
|
||||
void setupMotors() {
|
||||
print("Setup Motors\n");
|
||||
// configure pins
|
||||
// Configure pins
|
||||
for (int i = 0; i < 4; i++) {
|
||||
if (motorPins[i] < 0) continue; // skip unassigned motors
|
||||
ledcAttach(motorPins[i], pwmFrequency, pwmResolution);
|
||||
pwmFrequency = ledcChangeFrequency(motorPins[i], pwmFrequency, pwmResolution); // when reconfiguring
|
||||
}
|
||||
@@ -29,12 +30,14 @@ void setupMotors() {
|
||||
|
||||
void sendMotors() {
|
||||
for (int i = 0; i < 4; i++) {
|
||||
if (motorPins[i] < 0) continue; // skip unassigned motors
|
||||
ledcWrite(motorPins[i], getDutyCycle(motors[i]));
|
||||
}
|
||||
}
|
||||
|
||||
int getDutyCycle(float value) {
|
||||
value = constrain(value, 0, 1);
|
||||
|
||||
if (pwmMax >= 0) { // pwm mode
|
||||
float pwm = mapf(value, 0, 1, pwmMin, pwmMax);
|
||||
if (value == 0) pwm = pwmStop;
|
||||
|
||||
@@ -20,6 +20,7 @@ struct Parameter {
|
||||
const char *name; // max length is 15
|
||||
bool integer;
|
||||
union { float *f; int *i; }; // pointer to the variable
|
||||
float inital; // default value
|
||||
float cache; // what's stored in flash
|
||||
void (*callback)(); // called after parameter change
|
||||
Parameter(const char *name, float *variable, void (*callback)() = nullptr) : name(name), integer(false), f(variable), callback(callback) {};
|
||||
@@ -116,7 +117,10 @@ Parameter parameters[] = {
|
||||
// mavlink
|
||||
{"MAV_SYS_ID", &mavlinkSysId},
|
||||
{"MAV_RATE_SLOW", &telemetrySlow.rate},
|
||||
{"MAV_RATE_FAST", &telemetryFast.rate},
|
||||
{"MAV_RATE_ATT", &telemetryAttitude.rate},
|
||||
{"MAV_RATE_RC", &telemetryRC.rate},
|
||||
{"MAV_RATE_MOT", &telemetryMotors.rate},
|
||||
{"MAV_RATE_IMU", &telemetryIMU.rate},
|
||||
// power
|
||||
{"PWR_VOLT_PIN", &voltagePin, setupPower},
|
||||
{"PWR_VOLT_SCALE", &voltageScale},
|
||||
@@ -134,6 +138,7 @@ void setupParameters() {
|
||||
if (!storage.isKey(parameter.name)) {
|
||||
storage.putFloat(parameter.name, parameter.getValue()); // store default value
|
||||
}
|
||||
parameter.inital = parameter.getValue();
|
||||
parameter.setValue(storage.getFloat(parameter.name, 0));
|
||||
parameter.cache = parameter.getValue();
|
||||
}
|
||||
@@ -180,17 +185,23 @@ void syncParameters() {
|
||||
if (motorsActive()) return; // don't use flash while flying, it may cause a delay
|
||||
|
||||
for (auto ¶meter : parameters) {
|
||||
if (parameter.getValue() == parameter.cache) continue; // no change
|
||||
if (isnan(parameter.getValue()) && isnan(parameter.cache)) continue; // both are NAN
|
||||
if (floatEquals(parameter.getValue(), parameter.cache)) continue; // no change
|
||||
|
||||
storage.putFloat(parameter.name, parameter.getValue());
|
||||
parameter.cache = parameter.getValue(); // update cache
|
||||
}
|
||||
}
|
||||
|
||||
void printParameters() {
|
||||
void printParameters(const char *filter) {
|
||||
print("Name Value [Default]\n");
|
||||
for (auto ¶meter : parameters) {
|
||||
print("%s = %g\n", parameter.name, parameter.getValue());
|
||||
if (strncasecmp(parameter.name, filter, strlen(filter))) continue;
|
||||
|
||||
if (floatEquals(parameter.getValue(), parameter.inital)) { // parameter changed
|
||||
print("%-15s %-13g\n", parameter.name, parameter.getValue());
|
||||
} else {
|
||||
print("%-15s %-13g [%g]\n", parameter.name, parameter.getValue(), parameter.inital);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ void setupPower() {
|
||||
|
||||
void readVoltage() {
|
||||
if (voltagePin < 0) return;
|
||||
|
||||
static Rate rate(10);
|
||||
if (!rate) return;
|
||||
|
||||
|
||||
@@ -27,14 +27,12 @@ void setupRC() {
|
||||
|
||||
bool readRC() {
|
||||
if (rcRxPin < 0) return false;
|
||||
if (rc.read()) {
|
||||
SBUSData data = rc.data();
|
||||
for (int i = 0; i < 16; i++) channels[i] = data.ch[i]; // copy channels data
|
||||
if (!rc.read()) return false;
|
||||
|
||||
rc.getChannels(channels);
|
||||
normalizeRC();
|
||||
controlTime = t;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void normalizeRC() {
|
||||
@@ -55,6 +53,7 @@ void calibrateRC() {
|
||||
print("RC_RX_PIN = %d, set the RC pin!\n", rcRxPin);
|
||||
return;
|
||||
}
|
||||
|
||||
uint16_t zero[16]; // for zero positions
|
||||
uint16_t center[16]; // for center positions
|
||||
uint16_t _[16]; // for unused data
|
||||
|
||||
@@ -23,6 +23,12 @@ bool valid(float x) {
|
||||
return isfinite(x);
|
||||
}
|
||||
|
||||
bool floatEquals(float a, float b, float epsilon = 0) {
|
||||
if (isnan(a) && isnan(b)) return true;
|
||||
if (a == b) return true;
|
||||
return fabsf(a - b) <= epsilon;
|
||||
}
|
||||
|
||||
// Wrap angle to [-PI, PI)
|
||||
float wrapAngle(float angle) {
|
||||
angle = fmodf(angle, 2 * PI);
|
||||
@@ -47,13 +53,14 @@ void splitString(String& str, String& token0, String& token1, String& token2) {
|
||||
if (token2.c_str() == NULL) token2 = "";
|
||||
}
|
||||
|
||||
// Simplified ESP-NOW Serial without tx buffering and resends
|
||||
// Simplified ESP-NOW Serial without resends
|
||||
class ESPNOWSerial : public ESP_NOW_Serial_Class {
|
||||
public:
|
||||
int lost = 0;
|
||||
using ESP_NOW_Serial_Class::ESP_NOW_Serial_Class;
|
||||
void onSent(bool success) override {} // disable resends
|
||||
size_t write(const uint8_t *data, size_t len) override {
|
||||
return ESP_NOW_Peer::send(data, len); // pure send without buffering
|
||||
void onSent(bool success) override {
|
||||
if (!success) lost++;
|
||||
ESP_NOW_Serial_Class::onSent(true); // always report success to avoid resends
|
||||
}
|
||||
};
|
||||
|
||||
@@ -65,6 +72,9 @@ public:
|
||||
Rate(float rate) : rate(rate) {}
|
||||
|
||||
operator bool() {
|
||||
if (t == last) {
|
||||
return true; // the same step
|
||||
}
|
||||
if (t - last >= 1 / rate) {
|
||||
last = t;
|
||||
return true;
|
||||
|
||||
@@ -56,6 +56,7 @@ void setupWiFi() {
|
||||
void sendWiFi(const uint8_t *buf, int len) {
|
||||
if (espnow) {
|
||||
espnow.write(buf, len);
|
||||
|
||||
static Rate discovery(2);
|
||||
if (discovery) espnowBroadcast.write((const uint8_t *)"flix", 4); // broadcast message to help finding this device
|
||||
return;
|
||||
@@ -89,6 +90,7 @@ void printWiFiInfo() {
|
||||
print("Peer MAC: %s\n", MacAddress(espnow.addr()).toString().c_str());
|
||||
print("Encrypted: %d\n", espnow.isEncrypted());
|
||||
print("Channel: %d\n", espnow.getChannel());
|
||||
print("Lost packets: %d\n", espnow.lost);
|
||||
} else if (WiFi.getMode() == WIFI_MODE_AP) {
|
||||
print("Mode: Access Point (AP)\n");
|
||||
print("MAC: %s\n", WiFi.softAPmacAddress().c_str());
|
||||
@@ -131,3 +133,20 @@ void configWiFi(int mode, const char *first, const char *second) {
|
||||
}
|
||||
print("✓ Reboot to apply new settings\n");
|
||||
}
|
||||
|
||||
void setWiFiMode(const String& mode) {
|
||||
if (mode == "ap") {
|
||||
wifiMode = W_AP;
|
||||
} else if (mode == "sta") {
|
||||
wifiMode = W_STA;
|
||||
} else if (mode == "espnow") {
|
||||
wifiMode = W_ESPNOW;
|
||||
} else if (mode == "off") {
|
||||
wifiMode = W_DISABLED;
|
||||
} else {
|
||||
print("Invalid Wi-Fi mode\n");
|
||||
return;
|
||||
}
|
||||
static const char *modes[] = {"Disabled", "Access Point (AP)", "Client (STA)", "ESP-NOW"};
|
||||
print("✓ Wi-Fi mode set to %s, reboot to apply\n", modes[wifiMode]);
|
||||
}
|
||||
|
||||
@@ -15,12 +15,11 @@ public:
|
||||
SBUS(HardwareSerial& bus, const int8_t rxpin, const int8_t txpin, const bool inv = true) {};
|
||||
void begin(int rxpin = -1, int txpin = -1, bool inv = true, bool fast = false) {};
|
||||
bool read() { return joystickInit(); };
|
||||
SBUSData data() {
|
||||
SBUSData data;
|
||||
joystickGet(data.ch);
|
||||
void getChannels(uint16_t (&channels)[16]) const {
|
||||
int16_t ch[16];
|
||||
joystickGet(ch);
|
||||
for (int i = 0; i < 16; i++) {
|
||||
data.ch[i] = map(data.ch[i], -32768, 32767, 1000, 2000); // convert to pulse width style
|
||||
channels[i] = map(ch[i], -32768, 32767, 1000, 2000); // convert to pulse width style
|
||||
}
|
||||
return data;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -68,7 +68,7 @@ const char *getParameterName(int index);
|
||||
float getParameter(int index);
|
||||
float getParameter(const char *name);
|
||||
bool setParameter(const char *name, const float value);
|
||||
void printParameters();
|
||||
void printParameters(const char *filter);
|
||||
void resetParameters();
|
||||
|
||||
// mocks
|
||||
@@ -79,3 +79,4 @@ void printIMUCalibration() { print("cal: N/A\n"); };
|
||||
void printIMUInfo() {};
|
||||
void printWiFiInfo() {};
|
||||
void configWiFi(bool, const char*, const char*) { print("Skip WiFi config\n"); };
|
||||
void setWiFiMode(const String& mode) { print("Skip WiFi mode set\n"); };
|
||||
|
||||
@@ -28,6 +28,8 @@ from pyflix import Flix
|
||||
flix = Flix() # create a Flix object and wait for connection
|
||||
```
|
||||
|
||||
If using ESP-NOW connection, specify the proxy device name in `FLIX_DEVICE` environment variable or pass it to the constructor: `Flix(device='/dev/cu.usbserial-0001')`.
|
||||
|
||||
### Telemetry
|
||||
|
||||
Basic telemetry is available through object properties. The property names generally match the corresponding variables in the firmware code:
|
||||
|
||||
@@ -44,12 +44,17 @@ class Flix:
|
||||
_print_buffer: str = ''
|
||||
_modes = ['RAW', 'ACRO', 'STAB', 'AUTO']
|
||||
|
||||
def __init__(self, system_id: int=1, wait_connection: bool=True):
|
||||
def __init__(self, system_id: int=1, wait_connection: bool=True, device=os.getenv('FLIX_DEVICE')):
|
||||
if not (0 <= system_id < 256):
|
||||
raise ValueError('system_id must be in range [0, 255]')
|
||||
self._setup_mavlink()
|
||||
self.system_id = system_id
|
||||
self._init_state()
|
||||
if device is not None:
|
||||
# User defined connection
|
||||
logger.debug(f'Connecting to {device}')
|
||||
self.connection: mavutil.mavfile = mavutil.mavlink_connection(device, source_system=255) # type: ignore
|
||||
else:
|
||||
try:
|
||||
# Direct connection
|
||||
logger.debug('Listening on port 14550')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "pyflix"
|
||||
version = "0.15"
|
||||
version = "0.16"
|
||||
description = "Python API for Flix drone"
|
||||
authors = [{ name="Oleg Kalachev", email="okalachev@gmail.com" }]
|
||||
license = "MIT"
|
||||
|
||||