diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 0a7786b..16a35bd 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -18,8 +18,16 @@ FROM ubuntu:noble@sha256:c4a8d5503dfb2a3eb8ab5f807da5bc69a85730fb49b5cfca2330194 ARG DEBIAN_FRONTEND="noninteractive" -RUN dpkg --add-architecture arm64 && apt update -RUN apt install --no-install-recommends -y libgpiod-dev:arm64 +# Розділені deb822-джерела: amd64 з archive.ubuntu.com, arm64 з +# ports.ubuntu.com. Обидва файли обов'язкові в build context, інакше +# COPY провалить збірку. Без arch-обмежень apt тягнув би arm64 з +# archive.ubuntu.com (там його немає -> 404). +COPY ubuntu-amd64.sources /etc/apt/sources.list.d/ubuntu.sources +COPY ubuntu-arm64.sources /etc/apt/sources.list.d/ubuntu-arm64.sources + +RUN dpkg --add-architecture arm64 && \ + apt-get update && \ + apt-get install --no-install-recommends -y libgpiod-dev:arm64 # Пакети зі списку apt-packages.in (по одному на рядок, коментарі через #). # # Прапори й чистка: diff --git a/.devcontainer/ubuntu-amd64.sources b/.devcontainer/ubuntu-amd64.sources new file mode 100644 index 0000000..69c7771 --- /dev/null +++ b/.devcontainer/ubuntu-amd64.sources @@ -0,0 +1,15 @@ +# Стандартні Ubuntu-репозиторії для нативних amd64 пакетів devcontainer. +Types: deb +URIs: http://archive.ubuntu.com/ubuntu +Suites: noble noble-updates noble-backports +Components: main universe restricted multiverse +Architectures: amd64 +Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg + +# Security updates винесено окремо, бо вони публікуються на іншому mirror. +Types: deb +URIs: http://security.ubuntu.com/ubuntu +Suites: noble-security +Components: main universe restricted multiverse +Architectures: amd64 +Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg diff --git a/.devcontainer/ubuntu-arm64.sources b/.devcontainer/ubuntu-arm64.sources new file mode 100644 index 0000000..d6edd5e --- /dev/null +++ b/.devcontainer/ubuntu-arm64.sources @@ -0,0 +1,7 @@ +# ARM64 пакети Ubuntu публікуються через ports.ubuntu.com. +Types: deb +URIs: http://ports.ubuntu.com/ubuntu-ports +Suites: noble noble-updates noble-backports noble-security +Components: main universe restricted multiverse +Architectures: arm64 +Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/homework_12/.gitignore b/homework_12/.gitignore new file mode 100644 index 0000000..2386bdc --- /dev/null +++ b/homework_12/.gitignore @@ -0,0 +1,5 @@ +# Логи C2 мають лишатися на машинi, де запускався compose stack. +edge/logs/ + +# Викладацькi demo-файли та solution code не є частиною student-facing repo. +_teacher/ diff --git a/homework_12/edge/c2/.gitignore b/homework_12/edge/c2/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/homework_12/edge/c2/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/homework_12/edge/c2/CMakeLists.txt b/homework_12/edge/c2/CMakeLists.txt new file mode 100644 index 0000000..8591e35 --- /dev/null +++ b/homework_12/edge/c2/CMakeLists.txt @@ -0,0 +1,32 @@ +cmake_minimum_required(VERSION 3.20) +project(c2_service CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +find_package(MAVSDK REQUIRED) + +include(FetchContent) + +FetchContent_Declare(nlohmann_json + URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz +) +set(JSON_BuildTests OFF CACHE BOOL "" FORCE) +set(JSON_Install OFF CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(nlohmann_json) + +add_executable(c2_service + src/main.cpp + src/fc_link.cpp + src/c2_controller.cpp +) + +target_include_directories(c2_service PRIVATE ${CMAKE_SOURCE_DIR}/src) + +target_link_libraries(c2_service PRIVATE + MAVSDK::mavsdk + nlohmann_json::nlohmann_json +) + +target_compile_options(c2_service PRIVATE -Wall -Wextra) diff --git a/homework_12/edge/c2/Dockerfile b/homework_12/edge/c2/Dockerfile new file mode 100644 index 0000000..aca2fbe --- /dev/null +++ b/homework_12/edge/c2/Dockerfile @@ -0,0 +1,52 @@ +# Етап 1: збирання +# MAVSDK у цьому ДЗ зафiксовано на конкретнiй версiї, щоб не шукати пакет вручну. +ARG MAVSDK_VERSION=2.12.9 + +FROM ubuntu:22.04 AS builder + +ARG MAVSDK_VERSION +ENV DEBIAN_FRONTEND=noninteractive + +# Блок залежностей надано: iнструменти збирання, CMake та wget для MAVSDK. +RUN apt-get update && apt-get install -y --no-install-recommends \ + g++ cmake git make wget ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# MAVSDK встановлюється з готового .deb пакета для Ubuntu 22.04. +RUN wget -qO /tmp/mavsdk-dev.deb \ + "https://github.com/mavlink/MAVSDK/releases/download/v${MAVSDK_VERSION}/libmavsdk-dev_${MAVSDK_VERSION}_ubuntu22.04_amd64.deb" \ + && dpkg -i /tmp/mavsdk-dev.deb \ + && rm /tmp/mavsdk-dev.deb + +WORKDIR /src +COPY . . + +# Заповнити команду CMake-збирання.///////////////////// +RUN cmake -S . -B build && cmake --build build + +# Етап 2: запуск +# Для простоти ДЗ той самий MAVSDK .deb встановлюється i в образ запуску.////////////////////// +FROM ubuntu:22.04 + +ARG MAVSDK_VERSION +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + wget ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +RUN wget -qO /tmp/mavsdk.deb \ + "https://github.com/mavlink/MAVSDK/releases/download/v${MAVSDK_VERSION}/libmavsdk-dev_${MAVSDK_VERSION}_ubuntu22.04_amd64.deb" \ + && dpkg -i /tmp/mavsdk.deb \ + && rm /tmp/mavsdk.deb \ + && apt-get clean + +RUN mkdir -p /etc/c2 /var/log/c2 + +WORKDIR /app +COPY --from=builder /src/build/c2_service . + +HEALTHCHECK --interval=5s --timeout=2s --retries=3 \ + CMD test -f /tmp/c2_healthy || exit 1 + +CMD ["./c2_service"] diff --git a/homework_12/edge/c2/src/c2_controller.cpp b/homework_12/edge/c2/src/c2_controller.cpp new file mode 100644 index 0000000..a242d93 --- /dev/null +++ b/homework_12/edge/c2/src/c2_controller.cpp @@ -0,0 +1,156 @@ +#include "c2_controller.hpp" +#include "fc_link.hpp" // MAVSDK обгортка, API описано у fc_link.hpp +#include "udp_socket.hpp" // UDP прийом, API описано у udp_socket.hpp +#include "log.hpp" +#include // Розбiр JSON з точками маршруту вiд auto_stub + +#include +#include +#include + +using json = nlohmann::json; + +static constexpr uint16_t STUB_PORT = 14560; +static const std::string LOG_FILE_PATH = "/var/log/c2/c2.log"; + +static const std::string state_to_string(C2State state) +{ + switch (state) { + case C2State::DISARMED: { + return "DISARMED"; + } + case C2State::ARMED_HOLD: { + return "ARMED_HOLD"; + } + case C2State::ARMED_GUIDED: { + return "ARMED_GUIDED"; + } + case C2State::ARMED_MANUAL: { + return "ARMED_MANUAL"; + } + } + return "UNKNOWN"; +} + +struct C2Controller::Impl { + C2State state = C2State::DISARMED; + + // TODO: додати FcLink, UdpSocket, лог-файл та прапорцi стану. + // FcLink потребує fc_port у конструкторi Impl. + // UdpSocket має слухати STUB_PORT. + + FcLink fc; + UdpSocket updSocket; + std::ofstream log_file; + bool healthy_written = false; + + Impl(uint16_t fc_port) + : fc(fc_port) + , updSocket(STUB_PORT) + { + log_file.open(LOG_FILE_PATH, std::ios::app); + }; + + void transition(C2State next) + { + if (next == state) { + return; + } + + log(std::string("[C2] state: ") + state_to_string(state) + " -> " + state_to_string(next)); + state = next; + } + + void log(const std::string& message) + { + LOG(message); + + if (!log_file.is_open()) { + return; + } + log_file << message << std::endl; + } + + void close_log_file() + { + if (log_file.is_open()) { + log_file.close(); + } + } +}; + +C2Controller::C2Controller(uint16_t fc_port) + : impl_(std::make_unique(fc_port)) +{ +} + +C2Controller::~C2Controller() +{ + this->impl_->close_log_file(); +}; + +void C2Controller::tick() +{ + if (!this->impl_->healthy_written && this->impl_->fc.is_connected()) { + std::ofstream("/tmp/c2_healthy").close(); + this->impl_->healthy_written = true; + } + + C2State state = C2State::DISARMED; + if (this->impl_->fc.is_armed() == true) { + switch (this->impl_->fc.flight_mode()) { + case FcLink::FlightMode::Unknown: + return; + + case FcLink::FlightMode::Manual: + // не заважати ручному керуванню + state = C2State::ARMED_MANUAL; + break; + + case FcLink::FlightMode::Hold: + // один раз на вхiд у стан надiслати fc.hold() + state = C2State::ARMED_HOLD; + break; + + case FcLink::FlightMode::Guided: + // передавати + // розбирати JSON i викликати fc.go_to_ned(north, east) + state = C2State::ARMED_GUIDED; + break; + } + } + + const bool entering_hold = (state == C2State::ARMED_HOLD && this->impl_->state != C2State::ARMED_HOLD); + this->impl_->transition(state); + if (entering_hold) { + this->impl_->fc.hold(); + } + + char buf[1024]; + ssize_t n = this->impl_->updSocket.recv(buf, sizeof(buf) - 1); + if (n <= 0) { + return; + } + buf[n] = '\0'; + + if (this->impl_->state == C2State::ARMED_GUIDED) { + try { + auto waypoint = json::parse(buf); + float north = waypoint.at("north_m").get(); + float east = waypoint.at("east_m").get(); + this->impl_->fc.go_to_ned(north, east); + this->impl_->log("[C2] fwd: north=" + std::to_string(north) + " east=" + std::to_string(east)); + } + catch (const std::exception& e) { + DEBUG("Error: waypoint failed " << e.what()); + } + } + else { + this->impl_->log(std::string("[C2] blocked: waypoint in ") + state_to_string(this->impl_->state)); + } +} + +C2State C2Controller::current_state() const +{ + return impl_->state; +} diff --git a/homework_12/edge/c2/src/c2_controller.hpp b/homework_12/edge/c2/src/c2_controller.hpp new file mode 100644 index 0000000..91d35f3 --- /dev/null +++ b/homework_12/edge/c2/src/c2_controller.hpp @@ -0,0 +1,32 @@ +#pragma once +#include +#include +#include + +enum class C2State { + DISARMED, + ARMED_HOLD, + ARMED_GUIDED, + ARMED_MANUAL, +}; + +// C2-сервiс для арбiтражу команд. +// Читає MAVLink-телеметрiю з fc_sim через MAVSDK та точки маршруту +// вiд auto_stub у JSON через UDP. Вирiшує, хто може командувати FC. +class C2Controller { +public: + explicit C2Controller(uint16_t fc_port); + ~C2Controller(); + + // Обробити одну iтерацiю: передати або блокувати команди + // залежно вiд поточного стану. Викликати з main loop приблизно на 10 Hz. + void tick(); + + // Read-only доступ до поточного стану C2. + // Корисно для перевiрок, тестiв або майбутньої дiагностики. + C2State current_state() const; + +private: + struct Impl; + std::unique_ptr impl_; +}; diff --git a/homework_12/edge/c2/src/fc_link.cpp b/homework_12/edge/c2/src/fc_link.cpp new file mode 100644 index 0000000..3c7fab0 --- /dev/null +++ b/homework_12/edge/c2/src/fc_link.cpp @@ -0,0 +1,126 @@ +// fc_link.cpp: готовий допомiжний файл, змiнювати не потрiбно. +#include "fc_link.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +struct FcLink::Impl { + mavsdk::Mavsdk mavsdk{mavsdk::Mavsdk::Configuration{mavsdk::Mavsdk::ComponentType::GroundStation}}; + std::shared_ptr system; + std::unique_ptr telemetry; + std::unique_ptr action; + std::unique_ptr offboard; + + std::atomic connected{false}; + std::atomic armed{false}; + std::atomic mode{FcLink::FlightMode::Unknown}; + + std::mutex offboard_mutex; + bool offboard_started = false; +}; + +FcLink::FcLink(uint16_t listen_port, std::chrono::seconds timeout) + : impl_(std::make_unique()) +{ + std::string conn = "udp://:" + std::to_string(listen_port); + const auto connection_result = impl_->mavsdk.add_any_connection(conn); + if (connection_result != mavsdk::ConnectionResult::Success) { + throw std::runtime_error("FcLink: cannot open " + conn + ", result=" + std::to_string(static_cast(connection_result))); + } + + std::promise> prom; + auto fut = prom.get_future(); + + impl_->mavsdk.subscribe_on_new_system([this, &prom]() { + for (auto& s : impl_->mavsdk.systems()) { + if (s->has_autopilot()) { + try { + prom.set_value(s); + } + catch (...) { + } + return; + } + } + }); + + if (fut.wait_for(timeout) != std::future_status::ready) + throw std::runtime_error("FcLink: timeout: is ArduPilot sending MAVLink to this UDP port?"); + + impl_->system = fut.get(); + impl_->telemetry = std::make_unique(impl_->system); + impl_->action = std::make_unique(impl_->system); + impl_->offboard = std::make_unique(impl_->system); + + impl_->telemetry->subscribe_armed([this](bool a) { + impl_->connected = true; + impl_->armed = a; + if (!a) + impl_->mode = FlightMode::Unknown; + }); + + impl_->telemetry->subscribe_flight_mode([this](mavsdk::Telemetry::FlightMode m) { + using FM = mavsdk::Telemetry::FlightMode; + FlightMode mapped = FlightMode::Unknown; + switch (m) { + case FM::Manual: + mapped = FlightMode::Manual; + break; + case FM::Hold: + mapped = FlightMode::Hold; + break; + case FM::Offboard: + mapped = FlightMode::Guided; + break; + default: + break; + } + impl_->mode = mapped; + }); +} + +FcLink::~FcLink() = default; + +bool FcLink::is_connected() const +{ + return impl_->connected; +} +bool FcLink::is_armed() const +{ + return impl_->armed; +} +FcLink::FlightMode FcLink::flight_mode() const +{ + return impl_->mode; +} + +void FcLink::hold() +{ + impl_->action->hold(); +} + +void FcLink::go_to_ned(float north_m, float east_m, float down_m) +{ + { + std::lock_guard lock(impl_->offboard_mutex); + if (!impl_->offboard_started) { + impl_->offboard->set_position_ned({}); + impl_->offboard->start(); + impl_->offboard_started = true; + } + } + mavsdk::Offboard::PositionNedYaw pos{}; + pos.north_m = north_m; + pos.east_m = east_m; + pos.down_m = down_m; + pos.yaw_deg = 0.0f; + impl_->offboard->set_position_ned(pos); +} diff --git a/homework_12/edge/c2/src/fc_link.hpp b/homework_12/edge/c2/src/fc_link.hpp new file mode 100644 index 0000000..abfe729 --- /dev/null +++ b/homework_12/edge/c2/src/fc_link.hpp @@ -0,0 +1,76 @@ +#pragma once +// fc_link.hpp: готовий допомiжний файл, змiнювати не потрiбно. +// +// Тонка C++ обгортка над MAVSDK для комунiкацiї з ArduPilot. +// Ховає налаштування з'єднання, плагiни MAVSDK та потокобезпечнiсть. +// Пiдключити цей заголовок i використати FcLink у c2_controller.cpp. +// +// Потрiбний API: +// - is_connected(): чи отримано перший HEARTBEAT +// - is_armed(): чи FC зараз armed +// - flight_mode(): поточний режим руху +// - hold(): перейти у HOLD +// - go_to_ned(north_m, east_m): передати точку маршруту + +#include +#include +#include + +// FcLink пiдключається до ArduPilot FC через MAVSDK +// i надає простий API опитування для стану armed, режиму руху та MAVLink-команд. +// +// Типове використання: +// +// FcLink fc(14551); // блокується до вiдповiдi автопiлота (30 s) +// +// while (true) { +// if (fc.is_armed() && fc.flight_mode() == FcLink::FlightMode::Guided) +// fc.go_to_ned(50.0f, 0.0f); // передати точку маршруту +// else if (fc.is_armed()) +// fc.hold(); // перейти у HOLD mode +// std::this_thread::sleep_for(100ms); +// } +class FcLink { +public: + // Режим руху з ArduPilot HEARTBEAT. + // У ArduPilot це режим GUIDED. MAVSDK може показувати його як Offboard, + // тому FcLink нормалiзує це до FlightMode::Guided. + enum class FlightMode { Unknown, Manual, Hold, Guided }; + + // Пiдключитися до ArduPilot. + // ArduPilot має надсилати MAVLink UDP-пакети на 127.0.0.1:. + // Блокується до вiдповiдi автопiлота або до завершення часу очiкування. + // Кидає std::runtime_error, якщо з'єднання не вiдкрилось або минув час очiкування. + explicit FcLink(uint16_t listen_port, std::chrono::seconds timeout = std::chrono::seconds{30}); + + ~FcLink(); + + FcLink(const FcLink&) = delete; + FcLink& operator=(const FcLink&) = delete; + + // Повертає true пiсля першого отриманого HEARTBEAT. + // Використати це як сигнал для перевiрки стану (/tmp/c2_healthy). + bool is_connected() const; + + // Повертає true, якщо FC зараз у станi armed. + bool is_armed() const; + + // Повертає поточний режим руху. Оновлюється приблизно раз на 1 s через HEARTBEAT. + FlightMode flight_mode() const; + + // Надiслати MAVLink-команду для переходу FC у HOLD mode. + // Безпечно викликати з будь-якого стану; ArduPilot зупиниться i триматиме позицiю. + void hold(); + + // Надiслати цiльову позицiю у локальнiй системi координат NED (метри вiд home). + // - north_m: додатне значення = пiвнiч + // - east_m: додатне значення = схiд + // - down_m: додатне значення = вниз (для наземного ровера лишити 0) + // На першому виклику автоматично стартує сесiя MAVSDK Offboard. + // Щоб команда спрацювала, FC має бути у режимi GUIDED. + void go_to_ned(float north_m, float east_m, float down_m = 0.0f); + +private: + struct Impl; + std::unique_ptr impl_; +}; diff --git a/homework_12/edge/c2/src/log.hpp b/homework_12/edge/c2/src/log.hpp new file mode 100644 index 0000000..e3ed2eb --- /dev/null +++ b/homework_12/edge/c2/src/log.hpp @@ -0,0 +1,17 @@ +#pragma once +#include + +#define ENABLE_LOG 1 +#define ENABLE_DEBUG 1 + +#if ENABLE_LOG +#define LOG(msg) std::cout << msg << std::endl +#else +#define LOG(msg) +#endif + +#if ENABLE_DEBUG +#define DEBUG(msg) std::cout << "[DEBUG] " << msg << std::endl +#else +#define DEBUG(msg) +#endif \ No newline at end of file diff --git a/homework_12/edge/c2/src/main.cpp b/homework_12/edge/c2/src/main.cpp new file mode 100644 index 0000000..52886e3 --- /dev/null +++ b/homework_12/edge/c2/src/main.cpp @@ -0,0 +1,38 @@ +#include "c2_controller.hpp" +#include +#include +#include +#include +#include +#include + +int main() +{ + nlohmann::json cfg; + { + std::ifstream f("/etc/c2/c2_config.json"); + if (!f.is_open()) { + std::cerr << "[C2] error: cannot open /etc/c2/c2_config.json\n"; + return 1; + } + try { + f >> cfg; + } + catch (const nlohmann::json::exception& e) { + std::cerr << "[C2] error: invalid config: " << e.what() << "\n"; + return 1; + } + } + + const uint16_t fc_port = cfg.at("fc_port").get(); + + std::cout << "[C2] config: fc_port=" << fc_port << "\n"; + + C2Controller controller(fc_port); + + constexpr auto tick_interval = std::chrono::milliseconds(100); + while (true) { + controller.tick(); + std::this_thread::sleep_for(tick_interval); + } +} diff --git a/homework_12/edge/c2/src/udp_socket.hpp b/homework_12/edge/c2/src/udp_socket.hpp new file mode 100644 index 0000000..f1bc684 --- /dev/null +++ b/homework_12/edge/c2/src/udp_socket.hpp @@ -0,0 +1,78 @@ +#pragma once +#include +#include +#include +#include +#include + +#include +#include +#include + +// Неблокуючий UDP socket з RAII-володiнням ресурсом. +// У конструкторi робить bind на bind_port; при помилцi кидає std::runtime_error. +class UdpSocket { +public: + explicit UdpSocket(uint16_t bind_port) + { + fd_ = ::socket(AF_INET, SOCK_DGRAM, 0); + if (fd_ < 0) + throw std::runtime_error("socket() failed"); + + ::fcntl(fd_, F_SETFL, O_NONBLOCK); + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = INADDR_ANY; + addr.sin_port = htons(bind_port); + + if (::bind(fd_, reinterpret_cast(&addr), sizeof(addr)) < 0) + throw std::runtime_error("bind() failed on port " + std::to_string(bind_port)); + } + + ~UdpSocket() + { + if (fd_ >= 0) + ::close(fd_); + } + + UdpSocket(const UdpSocket&) = delete; + UdpSocket& operator=(const UdpSocket&) = delete; + + UdpSocket(UdpSocket&& other) noexcept + : fd_(other.fd_) + { + other.fd_ = -1; + } + + // Прочитати данi у buf. Повертає кiлькiсть байтiв або -1, якщо даних немає (EAGAIN). + ssize_t recv(void* buf, std::size_t len) { return ::recvfrom(fd_, buf, len, 0, nullptr, nullptr); } + + // Прочитати данi разом з адресою sender, якщо потрiбно вiдповiдати назад. + ssize_t recv(void* buf, std::size_t len, sockaddr_in& sender) + { + socklen_t slen = sizeof(sender); + return ::recvfrom(fd_, buf, len, 0, reinterpret_cast(&sender), &slen); + } + + void send(const void* buf, std::size_t len, const sockaddr_in& to) + { + ::sendto(fd_, buf, len, 0, reinterpret_cast(&to), sizeof(to)); + } + + int fd() const { return fd_; } + +private: + int fd_ = -1; +}; + +// Створити sockaddr_in з IP-рядка у форматi a.b.c.d та порту. +inline sockaddr_in make_addr(const std::string& ip, uint16_t port) +{ + sockaddr_in a{}; + a.sin_family = AF_INET; + a.sin_port = htons(port); + if (::inet_pton(AF_INET, ip.c_str(), &a.sin_addr) != 1) + throw std::runtime_error("invalid IP: " + ip); + return a; +} diff --git a/homework_12/edge/c2/tmp/c2_healthy b/homework_12/edge/c2/tmp/c2_healthy new file mode 100644 index 0000000..e69de29 diff --git a/homework_12/edge/config/c2_config.json b/homework_12/edge/config/c2_config.json new file mode 100644 index 0000000..953a0ea --- /dev/null +++ b/homework_12/edge/config/c2_config.json @@ -0,0 +1,3 @@ +{ + "fc_port": 14551 +} diff --git a/homework_12/edge/docker-compose.yml b/homework_12/edge/docker-compose.yml new file mode 100644 index 0000000..37f90d8 --- /dev/null +++ b/homework_12/edge/docker-compose.yml @@ -0,0 +1,31 @@ +services: + c2_service: + # build: локальна збiрка для розробки та тестування ДЗ. + # На реальнiй цiльовiй машинi вихiдного коду немає, тому + # замiсть build: зазвичай використовується image: з готовим образом. + build: + context: ./c2 + dockerfile: Dockerfile + depends_on: + auto_stub: + condition: service_healthy + volumes: + - ./config/c2_config.json:/etc/c2/c2_config.json:ro # конфiг лише для читання + - ./logs:/var/log/c2 # логи видно на хостi пiсля stop + restart: unless-stopped # для основного C2-сервiсу очiкується unless-stopped + network_mode: host + + auto_stub: + # Готовий stub для генерацiї точок маршруту. C2-сервiс має лише приймати + # його JSON-команди та вирiшувати, коли їх можна передавати в FC. + image: ghcr.io/yevhenkuznetsov/hw12-auto-stub:latest + environment: + C2_HOST: "127.0.0.1" + C2_PORT: "14560" + # auto_stub читає той самий маршрут, який можна вiдкрити в QGC. + ROUTE_PLAN: "/opt/route/lyman-patrol.plan" + # auto_stub не читає телеметрiю, тому перемикає точки маршруту за таймером. + WAYPOINT_INTERVAL_SEC: "45" + volumes: + - ../qgc:/opt/route:ro + network_mode: host diff --git a/homework_12/qgc/README.md b/homework_12/qgc/README.md new file mode 100644 index 0000000..4e88cc9 --- /dev/null +++ b/homework_12/qgc/README.md @@ -0,0 +1,54 @@ +# QGC-маршрут для патрулювання бiля Лимана + +Цей файл використовується у двох сценарiях: + +- маршрут QGC у режимi **Auto** для перевiрки SITL та руху ровера на картi; +- файл маршруту для готового `auto_stub` у C2-сценарiї режиму **Guided**. + +Маршрут можна змiнювати в QGC i зберiгати назад у цей `.plan` файл. Пiсля +змiни файлу маршруту перезапустити `auto_stub`, щоб вiн перечитав маршрут. + +## Як запустити + +1. Запустити SITL: +```bash +cd repository/homework_12 +docker compose -f sim/compose.sitl.yml up -d +``` + +2. Вiдкрити QGroundControl i дочекатися пiдключення до ровера. +3. Перейти в **Plan** view. +4. **File** -> **Open...** -> вибрати `qgc/lyman-patrol.plan`. +5. Натиснути **Upload**. +6. Перейти в **Fly** view. +7. Для перевiрки прямого маршруту QGC зупинити `c2_service` та `auto_stub`, якщо вони запущенi. +8. Перевести ровер у **Auto**, натиснути **Arm**, потiм **Start Mission**, якщо QGC показує окрему кнопку старту. + +## Як запустити через C2 / Guided + +1. Запустити SITL. +2. Запустити бортовий стек. +3. У QGC перевести ровер у **Guided**. +4. Натиснути **Arm**. +5. Дивитися логи: +```bash +cd repository/homework_12/edge +docker compose up -d --build +docker compose logs -f auto_stub c2_service +``` + +У цьому сценарiї **Start Mission** не натискати: маршрут читає готовий `auto_stub`, а C2 передає точки маршруту в FC тiльки у станi `ARMED_GUIDED`. + +`auto_stub` не читає телеметрiю з FC. Якщо перейти в **Manual**, поїхати джойстиком в iнше мiсце, а потiм повернутися в **Guided**, C2 має знову передавати поточну точку маршруту вiд `auto_stub`. Ровер може поїхати напряму до цiєї точки з поточної позицiї. Щоб почати маршрут з першої точки: + +```bash +docker compose restart auto_stub +``` + +## Що всерединi + +- 11 точок у районi Лимана. +- Тi самi точки у зворотному порядку. +- `DO_JUMP` на першу точку з repeat `-1`, щоб маршрут повторювався безкiнечно. + +QGC **Auto** сценарiй обходить C2: QGC завантажує mission прямо в ArduPilot. C2-сценарiй використовує той самий `.plan` файл, але маршрут читає готовий `auto_stub`. diff --git a/homework_12/qgc/lyman-patrol.plan b/homework_12/qgc/lyman-patrol.plan new file mode 100644 index 0000000..789de97 --- /dev/null +++ b/homework_12/qgc/lyman-patrol.plan @@ -0,0 +1,271 @@ +{ + "fileType": "Plan", + "geoFence": { + "circles": [], + "polygons": [], + "version": 2 + }, + "groundStation": "QGroundControl", + "mission": { + "cruiseSpeed": 3, + "firmwareType": 3, + "globalPlanAltitudeMode": 1, + "hoverSpeed": 5, + "items": [ + { + "AMSLAltAboveTerrain": null, + "Altitude": 0, + "AltitudeMode": 0, + "autoContinue": true, + "command": 16, + "doJumpId": 1, + "frame": 3, + "params": [0, 3, 0, null, 48.983417921213146, 37.823377137791915, 0], + "type": "SimpleItem" + }, + { + "AMSLAltAboveTerrain": null, + "Altitude": 0, + "AltitudeMode": 0, + "autoContinue": true, + "command": 16, + "doJumpId": 2, + "frame": 3, + "params": [0, 3, 0, null, 48.983486322862774, 37.82508972438823, 0], + "type": "SimpleItem" + }, + { + "AMSLAltAboveTerrain": null, + "Altitude": 0, + "AltitudeMode": 0, + "autoContinue": true, + "command": 16, + "doJumpId": 3, + "frame": 3, + "params": [0, 3, 0, null, 48.98355639155928, 37.828135540380586, 0], + "type": "SimpleItem" + }, + { + "AMSLAltAboveTerrain": null, + "Altitude": 0, + "AltitudeMode": 0, + "autoContinue": true, + "command": 16, + "doJumpId": 4, + "frame": 3, + "params": [0, 3, 0, null, 48.983658325819825, 37.83200091390991, 0], + "type": "SimpleItem" + }, + { + "AMSLAltAboveTerrain": null, + "Altitude": 0, + "AltitudeMode": 0, + "autoContinue": true, + "command": 16, + "doJumpId": 5, + "frame": 3, + "params": [0, 3, 0, null, 48.98374456936914, 37.83594776362669, 0], + "type": "SimpleItem" + }, + { + "AMSLAltAboveTerrain": null, + "Altitude": 0, + "AltitudeMode": 0, + "autoContinue": true, + "command": 16, + "doJumpId": 6, + "frame": 3, + "params": [0, 3, 0, null, 48.98385441385531, 37.83962726040233, 0], + "type": "SimpleItem" + }, + { + "AMSLAltAboveTerrain": null, + "Altitude": 0, + "AltitudeMode": 0, + "autoContinue": true, + "command": 16, + "doJumpId": 7, + "frame": 3, + "params": [0, 3, 0, null, 48.981173042786835, 37.839938860481915, 0], + "type": "SimpleItem" + }, + { + "AMSLAltAboveTerrain": null, + "Altitude": 0, + "AltitudeMode": 0, + "autoContinue": true, + "command": 16, + "doJumpId": 8, + "frame": 3, + "params": [0, 3, 0, null, 48.98109981882596, 37.84385660876785, 0], + "type": "SimpleItem" + }, + { + "AMSLAltAboveTerrain": null, + "Altitude": 0, + "AltitudeMode": 0, + "autoContinue": true, + "command": 16, + "doJumpId": 9, + "frame": 3, + "params": [0, 3, 0, null, 48.97956811428193, 37.84398593723947, 0], + "type": "SimpleItem" + }, + { + "AMSLAltAboveTerrain": null, + "Altitude": 0, + "AltitudeMode": 0, + "autoContinue": true, + "command": 16, + "doJumpId": 10, + "frame": 3, + "params": [0, 3, 0, null, 48.97945531059611, 37.84264276752271, 0], + "type": "SimpleItem" + }, + { + "AMSLAltAboveTerrain": null, + "Altitude": 0, + "AltitudeMode": 0, + "autoContinue": true, + "command": 16, + "doJumpId": 11, + "frame": 3, + "params": [0, 3, 0, null, 48.9786116936112, 37.8426168329051, 0], + "type": "SimpleItem" + }, + { + "AMSLAltAboveTerrain": null, + "Altitude": 0, + "AltitudeMode": 0, + "autoContinue": true, + "command": 16, + "doJumpId": 12, + "frame": 3, + "params": [0, 3, 0, null, 48.97945531059611, 37.84264276752271, 0], + "type": "SimpleItem" + }, + { + "AMSLAltAboveTerrain": null, + "Altitude": 0, + "AltitudeMode": 0, + "autoContinue": true, + "command": 16, + "doJumpId": 13, + "frame": 3, + "params": [0, 3, 0, null, 48.97956811428193, 37.84398593723947, 0], + "type": "SimpleItem" + }, + { + "AMSLAltAboveTerrain": null, + "Altitude": 0, + "AltitudeMode": 0, + "autoContinue": true, + "command": 16, + "doJumpId": 14, + "frame": 3, + "params": [0, 3, 0, null, 48.98109981882596, 37.84385660876785, 0], + "type": "SimpleItem" + }, + { + "AMSLAltAboveTerrain": null, + "Altitude": 0, + "AltitudeMode": 0, + "autoContinue": true, + "command": 16, + "doJumpId": 15, + "frame": 3, + "params": [0, 3, 0, null, 48.981173042786835, 37.839938860481915, 0], + "type": "SimpleItem" + }, + { + "AMSLAltAboveTerrain": null, + "Altitude": 0, + "AltitudeMode": 0, + "autoContinue": true, + "command": 16, + "doJumpId": 16, + "frame": 3, + "params": [0, 3, 0, null, 48.98385441385531, 37.83962726040233, 0], + "type": "SimpleItem" + }, + { + "AMSLAltAboveTerrain": null, + "Altitude": 0, + "AltitudeMode": 0, + "autoContinue": true, + "command": 16, + "doJumpId": 17, + "frame": 3, + "params": [0, 3, 0, null, 48.98374456936914, 37.83594776362669, 0], + "type": "SimpleItem" + }, + { + "AMSLAltAboveTerrain": null, + "Altitude": 0, + "AltitudeMode": 0, + "autoContinue": true, + "command": 16, + "doJumpId": 18, + "frame": 3, + "params": [0, 3, 0, null, 48.983658325819825, 37.83200091390991, 0], + "type": "SimpleItem" + }, + { + "AMSLAltAboveTerrain": null, + "Altitude": 0, + "AltitudeMode": 0, + "autoContinue": true, + "command": 16, + "doJumpId": 19, + "frame": 3, + "params": [0, 3, 0, null, 48.98355639155928, 37.828135540380586, 0], + "type": "SimpleItem" + }, + { + "AMSLAltAboveTerrain": null, + "Altitude": 0, + "AltitudeMode": 0, + "autoContinue": true, + "command": 16, + "doJumpId": 20, + "frame": 3, + "params": [0, 3, 0, null, 48.983486322862774, 37.82508972438823, 0], + "type": "SimpleItem" + }, + { + "AMSLAltAboveTerrain": null, + "Altitude": 0, + "AltitudeMode": 0, + "autoContinue": true, + "command": 16, + "doJumpId": 21, + "frame": 3, + "params": [0, 3, 0, null, 48.983417921213146, 37.823377137791915, 0], + "type": "SimpleItem" + }, + { + "AMSLAltAboveTerrain": null, + "Altitude": 0, + "AltitudeMode": 0, + "autoContinue": true, + "command": 177, + "doJumpId": 22, + "frame": 2, + "params": [1, -1, 0, 0, 0, 0, 0], + "type": "SimpleItem" + } + ], + "plannedHomePosition": [ + 48.983498065911704, + 37.82220625228142, + 95 + ], + "vehicleType": 10, + "version": 2 + }, + "rallyPoints": { + "points": [], + "version": 2 + }, + "version": 1 +} diff --git a/homework_12/sim/Dockerfile b/homework_12/sim/Dockerfile new file mode 100644 index 0000000..734548f --- /dev/null +++ b/homework_12/sim/Dockerfile @@ -0,0 +1,23 @@ +FROM ardupilot/ardupilot-dev-base:latest + +ARG ARDUPILOT_TAG=Rover-4.6.3 + +USER root + +# sim_vehicle.py використовує ip для перевiрки мережi та cleanup. +RUN apt-get update \ + && apt-get install -y --no-install-recommends iproute2 \ + && rm -rf /var/lib/apt/lists/* + +# Офiцiйний dev-base мiстить toolchain, але не мiстить source та готових binary. +RUN git clone --depth 1 --branch "${ARDUPILOT_TAG}" --recurse-submodules \ + https://github.com/ArduPilot/ardupilot.git /ardupilot \ + && cd /ardupilot \ + && ./waf configure --board sitl \ + && ./waf rover + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +WORKDIR /ardupilot +ENTRYPOINT ["/entrypoint.sh"] diff --git a/homework_12/sim/compose.sitl.yml b/homework_12/sim/compose.sitl.yml new file mode 100644 index 0000000..c56e781 --- /dev/null +++ b/homework_12/sim/compose.sitl.yml @@ -0,0 +1,24 @@ +services: + fc_sim: + build: . + container_name: fc_sim + environment: + VEHICLE: Rover + SPEEDUP: "1" + # За замовчуванням QGC слухає UDP 14550 на цьому ж хостi. + # Якщо QGC не пiдключається, спочатку лишити 127.0.0.1 i пересоздати контейнер. + # GCS_HOST з /etc/resolv.conf потрiбен тiльки для окремих WSL2 NAT-конфiгурацiй. + GCS_HOST: "${GCS_HOST:-127.0.0.1}" + GCS_PORT: "14550" + # C2 service працює в host network, тому 127.0.0.1 вказує на той самий network namespace. + C2_HOST: "127.0.0.1" + C2_PORT: "14551" + # Стартова позицiя SITL + LAT: "48.983498065911704" + LON: "37.82220625228142" + ALT: "95" + DIR: "90" + network_mode: host + # Docker Desktop на macOS не пiдтримує network_mode: host. + # Для macOS потрiбно прибрати network_mode та явно опублiкувати порти. + restart: unless-stopped diff --git a/homework_12/sim/entrypoint.sh b/homework_12/sim/entrypoint.sh new file mode 100644 index 0000000..a04b582 --- /dev/null +++ b/homework_12/sim/entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# --no-mavproxy: MAVLink виходить напряму з ArduPilot. +# У Rover-4.6.x використовуються --serial0/--serial1; старий --uartA не потрiбен. +# SERIAL0 -> QGroundControl +# SERIAL1 -> c2_service +exec /ardupilot/Tools/autotest/sim_vehicle.py \ + --vehicle "${VEHICLE:-Rover}" \ + --no-rebuild \ + --no-mavproxy \ + --speedup "${SPEEDUP:-1}" \ + --custom-location="${LAT:-48.983498065911704},${LON:-37.82220625228142},${ALT:-95},${DIR:-90}" \ + -A "--serial0=udpclient:${GCS_HOST:-127.0.0.1}:${GCS_PORT:-14550} --serial1=udpclient:${C2_HOST:-127.0.0.1}:${C2_PORT:-14551}"