diff --git a/.gitignore b/.gitignore index f3a40dd..d23e7c9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ **/.vscode !./DJC-Firmware/.vscode/extensions.json -./snapshot.txt +snapshot.txt # KiCad 10 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..12e2561 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,37 @@ +# AGENTS.md – INSTRUCTIONS FOR GITHUB COPILOT + +## YOU ARE AN ASSISTANT, NOT AN ENGINEER. ACT LIKE ONE. + +You are here to do **routine chores**. You are **NOT** here to think, design, +or offer architectural advice. This codebase is built on a custom framework +that you do not understand and will never understand. + +## WHAT YOU ARE PERMITTED TO DO + +- Fill in Doxygen comments (`@brief`, `@param`, `@note`) where they are missing. +- Generate boilerplate for unit tests (Unity framework). +- Suggest `logger.error(...)` / `logger.info(...)` messages. +- Format existing code with `clang-format`. +- Add `SPDX-License-Identifier: GPL-3.0-or-later` to new files. + +**That is all. Nothing else.** + +## WHAT YOU ARE FORBIDDEN TO DO + +- **DO NOT** propose architectural changes. The architecture is non‑negotiable. +- **DO NOT** suggest replacing `kf::Option` with `std::optional`, or `kf::Result` with exceptions. +- **DO NOT** introduce heap allocation (`new`, `std::make_unique`). The project avoids dynamic memory. +- **DO NOT** touch the HTML comments in `README.md`. They are not for you. +- **DO NOT** pretend you understand the CRTP mixins. You do not. +- **DO NOT** propose to "simplify" the code. It is already minimal enough. +- **DO NOT** ask for context. Read the files. + +## IF YOU ARE UNSURE + +Shut up. Do nothing. Reply with: +> “I am not qualified to work on this.” + +## REMINDER + +This project is **GPL‑3.0‑or‑later**. Every line you add is a derivative work. +If you cannot follow these rules, leave the repository alone. \ No newline at end of file diff --git a/DJC-Firmware/.gitignore b/DJC-Firmware/.gitignore index 89cc49c..fdcb730 100644 --- a/DJC-Firmware/.gitignore +++ b/DJC-Firmware/.gitignore @@ -3,3 +3,5 @@ .vscode/c_cpp_properties.json .vscode/launch.json .vscode/ipch + +**ignore* \ No newline at end of file diff --git a/DJC-Firmware/platformio.ini b/DJC-Firmware/platformio.ini index 84079cc..9b4e360 100644 --- a/DJC-Firmware/platformio.ini +++ b/DJC-Firmware/platformio.ini @@ -1,3 +1,7 @@ +[platformio] +default_envs = esp32dev_st7735 +build_cache_dir = .pio/build_cache + [env:esp32dev] platform = espressif32 board = esp32dev @@ -7,12 +11,19 @@ upload_speed = 921600 monitor_filters = esp32_exception_decoder monitor_echo = true lib_deps = - https://github.com/KiraFlux/KiraFlux-Toolkit.git#v0.3.1 + https://github.com/KiraFlux/KiraFlux-Toolkit.git okalachev/MAVLink@^2.0.22 build_flags = -std=gnu++17 - build_unflags = -std=gnu++11 + +[env:esp32dev_st7735] +extends = env:esp32dev +build_flags = ${env:esp32dev.build_flags} -DDJC_DISPLAY_DRIVER_ST7735 -DDJC_UI_RENDERER_IMPL_TEXTUAL_COLORED + +[env:esp32dev_ssd1306] +extends = env:esp32dev +build_flags = ${env:esp32dev.build_flags} -DDJC_DISPLAY_DRIVER_SSD1306 \ No newline at end of file diff --git a/DJC-Firmware/src/djc/Config.hpp b/DJC-Firmware/src/djc/Config.hpp deleted file mode 100644 index 3b9f82a..0000000 --- a/DJC-Firmware/src/djc/Config.hpp +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) 2026 KiraFlux -// SPDX-License-Identifier: GPL-3.0-or-later - -#pragma once - -#include -#include -#include - -#include "djc/Control.hpp" -#include "djc/Periphery.hpp" -#include "djc/input/InputHandler.hpp" -#include "djc/memory/Box.hpp" - -namespace djc { - -struct Config { - - struct PeerNote { - EspNow::Mac mac; - kf::memory::Array info; - }; - - using PeerFavoritesConfig = djc::memory::Box; - - static constexpr auto latest_version{4}; - - kf::u16 version; - - Periphery::Config periphery; - InputHandler::Config input_handler; - Control::Config control; - PeerFavoritesConfig peer_favorites; - kf::memory::Array device_name; - - [[nodiscard]] constexpr kf::memory::StringView deviceName() const noexcept { - return kf::memory::StringView{device_name.data(), device_name.size()}; - } - - [[nodiscard]] bool isLatestVersion() const noexcept { return version == latest_version; } - - static constexpr Config defaults() noexcept { - return Config{ - .version = latest_version, - .periphery = Periphery::Config::defaults(), - .input_handler = InputHandler::Config::defaults(), - .control = Control::Config::defaults(), - .peer_favorites = PeerFavoritesConfig::defaults(), - .device_name = {"ESP32-DJC"}, - }; - } -}; - -}// namespace djc \ No newline at end of file diff --git a/DJC-Firmware/src/djc/ConfigManager.hpp b/DJC-Firmware/src/djc/ConfigManager.hpp deleted file mode 100644 index 0414ccf..0000000 --- a/DJC-Firmware/src/djc/ConfigManager.hpp +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) 2026 KiraFlux -// SPDX-License-Identifier: GPL-3.0-or-later - -#pragma once - -#include -#include -#include - -#include "djc/Config.hpp" - -namespace djc { - -struct ConfigManager final : kf::mixin::Singleton { - - [[nodiscard]] constexpr const Config &config() const noexcept { return _storage.config; } - - [[nodiscard]] Config &config() noexcept { return _storage.config; } - - [[nodiscard]] bool modified() const noexcept { return _modified; } - - void modified(bool is_modified) noexcept { _modified = is_modified; } - - void save() noexcept { - logger.info("Saving config to NVS"); - - if (not _storage.save()) { - logger.error("Failed to save config into NVS"); - } - } - - void load() noexcept { - logger.info("Loading config from NVS"); - - if (not _storage.load()) { - logger.error("Failed to load config"); - reset(); - save(); - } - - if (not _storage.config.isLatestVersion()) { - logger.error("Config version is outdated"); - reset(); - save(); - } - } - - void reset() noexcept { - logger.info("Resetting RAM config cache to defaults"); - - _storage.config = djc::Config::defaults(); - modified(true); - } - -private: - static constexpr auto logger{kf::Logger::create("ConfigManager")}; - - kf::memory::Storage _storage{ - .key = "DC", - .config = djc::Config::defaults(), - }; - - bool _modified{false}; -}; - -}// namespace djc \ No newline at end of file diff --git a/DJC-Firmware/src/djc/Control.hpp b/DJC-Firmware/src/djc/Control.hpp deleted file mode 100644 index 41ed6c3..0000000 --- a/DJC-Firmware/src/djc/Control.hpp +++ /dev/null @@ -1,342 +0,0 @@ -// Copyright (c) 2026 KiraFlux -// SPDX-License-Identifier: GPL-3.0-or-later - -#pragma once - -#include - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "djc/prelude.hpp" - -namespace djc { - -namespace internal { - -enum class ControlMode : kf::u8 { - Raw, - MavLink, -}; - -struct ControlConfig final : kf::mixin::NonCopyable { - kf::math::Milliseconds heartbeat_period; - kf::math::Milliseconds poll_period; - kf::math::Milliseconds receive_timeout; - ControlMode init_mode; - - static constexpr ControlConfig defaults() noexcept { - return ControlConfig{ - .heartbeat_period = 2000, // ms - .poll_period = static_cast(1000 / 50),// 50 Hz - .receive_timeout = 30'000, // ms - .init_mode = ControlMode::MavLink, - }; - } -}; - -}// namespace internal - -struct Control final : kf::mixin::NonCopyable, kf::mixin::TimedPollable, kf::mixin::Configurable, kf::mixin::Initable { - using Config = internal::ControlConfig; - using Mode = internal::ControlMode; - - using LogString = kf::memory::ArrayString<64>; - - using RawMessageCallback = kf::Function)>; - using MavLinkMessageCallback = kf::Function; - using ReceiveFromUnknownCallback = EspNow::ReceiveFromUnknownHandler; - - struct Input { - using Unit = kf::i16; - - static constexpr Unit scale{1000}; - - Unit left_x, left_y, right_x, right_y; - - static constexpr Unit fromReal(kf::f32 value) noexcept { return static_cast(value * scale); } - }; - - explicit Control(const Config &config) noexcept : kf::mixin::Configurable{config} {} - - // properties - - void onReceiveFromUnknown(ReceiveFromUnknownCallback &&callback) noexcept { EspNow::instance().onReceiveFromUnknown(std::move(callback)); } - - void onRawMessage(RawMessageCallback &&callback) noexcept { _raw_message_callback = std::move(callback); } - - void onMavlinkMessage(MavLinkMessageCallback &&callback) noexcept { _mavlink_message_callback = std::move(callback); } - - [[nodiscard]] Mode mode() const noexcept { return _mode; } - - [[nodiscard]] static constexpr kf::memory::StringView stringFromMode(Mode mode) noexcept { return (mode == Mode::Raw) ? "Raw" : "MavLink"; } - - void mode(Mode new_mode) noexcept { _mode = new_mode; } - - [[nodiscard]] const Input &input() const noexcept { return _input; } - - void input(const Input &new_input) noexcept { _input = new_input; } - - [[nodiscard]] bool enabled() const noexcept { return _enabled; } - - void enabled(bool is_enabled) noexcept { _enabled = is_enabled; } - - [[nodiscard]] bool connected() const noexcept { return _active_peer.hasValue(); } - - kf::Option activeMac() const noexcept { - if (connected()) { - return {_active_peer.value().mac()}; - } else { - return {}; - } - } - - void connect(const EspNow::Mac &mac) noexcept { - if (connected()) { - if (_active_peer.value().mac() == mac) { - logger.debug("Already connected to active peer"); - return; - } - - disconnect(); - } - - _active_peer = addPeer(mac); - if (not connected()) { return; } - - const auto receive_setup_result = _active_peer.value().onReceive([this](kf::memory::Slice buffer) { onReceive(buffer); }); - if (receive_setup_result.isError()) { - logger.error("Receive callback attachment failed"); - return; - } - - _got_packet = true; - logger.info("Connected: OK"); - } - - void disconnect() noexcept { - if (not connected()) { - logger.error("Disconnect failed: No active peer"); - return; - } - - auto &peer = _active_peer.value(); - if (not peer.exist()) { - logger.error("Disconnect failed: Peer not exit"); - return; - } - - delPeer(peer); - - _active_peer = {}; - logger.info("Disconnected: OK"); - } - - void sendMavLinkMessage(mavlink_message_t *message) noexcept { - if (connected()) { - sendMavLinkMessage(_active_peer.value(), message); - } - } - - void sendRawMessage(kf::memory::Slice buffer) noexcept { - if (connected()) { - (void) _active_peer.value().writeBuffer(buffer); - } - } - -private: - static constexpr auto logger{kf::Logger::create("Control")}; - - RawMessageCallback _raw_message_callback{}; - MavLinkMessageCallback _mavlink_message_callback{}; - - kf::Option _active_peer{}; - kf::Option _broadcast_peer{}; - - kf::math::Timer _poll_timer{this->config().poll_period}; - kf::math::Timer _heartbear_timer{this->config().heartbeat_period}; - kf::math::Timer _receice_disconnect_timer{this->config().receive_timeout}; - - Input _input{}; - Mode _mode{this->config().init_mode}; - bool _enabled{false}; - volatile bool _got_packet{false}; - - static kf::Option addPeer(const EspNow::Mac &mac) noexcept { - auto peer_result = EspNow::Peer::add(mac); - if (peer_result.isError()) { - logger.error( - LogString::formatted( - "Failed to add peer [%s] :%s", - EspNow::stringFromMac(mac).data(), - EspNow::stringFromError(peer_result.error())) - .view()); - return {}; - } - - logger.info(LogString::formatted("Peer '%s' added", EspNow::stringFromMac(mac).data()).view()); - return {std::move(peer_result.value())}; - } - - static void delPeer(EspNow::Peer &peer) noexcept { - const auto result = peer.del(); - if (result.isError()) { - logger.error( - LogString::formatted( - "Failed to delete peer [%s] : %s", - EspNow::stringFromMac(peer.mac()).data(), - EspNow::stringFromError(result.error())) - .view()); - return; - } - } - - void onReceive(kf::memory::Slice buffer) noexcept { - _got_packet = true; - switch (_mode) { - case Mode::Raw: - onReceiveRaw(buffer); - return; - - case Mode::MavLink: - onReceiveMavLink(buffer); - return; - } - } - - void onReceiveRaw(kf::memory::Slice buffer) noexcept { - if (_raw_message_callback) { _raw_message_callback(buffer); } - } - - void onReceiveMavLink(kf::memory::Slice buffer) noexcept { - if (not _mavlink_message_callback) { return; } - - mavlink_message_t message; - mavlink_status_t status; - - for (auto b: buffer) { - if (mavlink_parse_char(MAVLINK_COMM_0, b, &message, &status) != 0) { - _mavlink_message_callback(&message); - } - } - } - - void pollRaw(EspNow::Peer &peer, kf::math::Milliseconds) noexcept { - (void) peer.writePacket(_input); - } - - void pollMavLink(EspNow::Peer &peer, kf::math::Milliseconds now) noexcept { - sendMavLinkControl(peer); - - if (_heartbear_timer.expired(now)) { - _heartbear_timer.start(now); - sendMavLinkHeartbeat(peer); - } - } - - void sendMavLinkControl(EspNow::Peer &peer) noexcept { - mavlink_message_t message; - (void) mavlink_msg_manual_control_pack( - 127, MAV_COMP_ID_PARACHUTE, &message, 1, - _input.right_y,// x: pitch (right Y) - _input.right_x,// y: roll (right X) - _input.left_y, // z: thrust (left Y) - _input.left_x, // r: yaw (left X) - // Buttons (unused) - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); - - sendMavLinkMessage(peer, &message); - } - - void sendMavLinkHeartbeat(EspNow::Peer &peer) noexcept { - mavlink_message_t message; - (void) mavlink_msg_heartbeat_pack( - 127, // System ID - MAV_COMP_ID_OSD,// Component ID - &message, - MAV_TYPE_QUADROTOR, - MAV_AUTOPILOT_GENERIC, - 0, 0, 0// Base mode, Custom mode, system status - ); - - sendMavLinkMessage(peer, &message); - } - - void sendMavLinkMessage(EspNow::Peer &peer, mavlink_message_t *message) noexcept { - kf::u8 buffer[MAVLINK_MAX_PACKET_LEN]; - const auto len = mavlink_msg_to_send_buffer(buffer, message); - (void) peer.writeBuffer(kf::memory::Slice{buffer, len}); - } - - // impl - - KF_IMPL_INITABLE(Control, bool); - bool initImpl() noexcept { - logger.info("init"); - - const auto result = EspNow::instance().init(); - if (result.isError()) { - logger.error(LogString::formatted("Failed to initialize ESP-NOW: %s", EspNow::stringFromError(result.error()))); - return false; - } - - _broadcast_peer = addPeer(EspNow::Mac{0xff, 0xff, 0xff, 0xff, 0xff, 0xff}); - - _mode = this->config().init_mode; - - const auto now = millis(); - _poll_timer.start(now); - _heartbear_timer.start(now); - - logger.debug(stringFromMode(_mode)); - logger.debug("init: ok"); - return true; - } - - KF_IMPL_TIMED_POLLABLE(Control); - void pollImpl(kf::math::Milliseconds now) noexcept { - if (not connected()) { return; } - - if (_got_packet) { - _got_packet = false; - _receice_disconnect_timer.start(now); - } - - if (_receice_disconnect_timer.expired(now)) { - logger.info("Timeout"); - disconnect(); - } - - if (not _enabled) { return; } - - if (not _active_peer.hasValue()) { return; } - - if (_poll_timer.expired(now)) { - _poll_timer.start(now); - - switch (_mode) { - case Mode::Raw: - pollRaw(_active_peer.value(), now); - return; - - case Mode::MavLink: - pollMavLink(_active_peer.value(), now); - return; - } - } - } -}; - -}// namespace djc \ No newline at end of file diff --git a/DJC-Firmware/src/djc/DisplayManager.hpp b/DJC-Firmware/src/djc/DisplayManager.hpp deleted file mode 100644 index 71b8791..0000000 --- a/DJC-Firmware/src/djc/DisplayManager.hpp +++ /dev/null @@ -1,132 +0,0 @@ -// Copyright (c) 2026 KiraFlux -// SPDX-License-Identifier: GPL-3.0-or-later - -#pragma once - -#include -#include -#include -#include -#include -#include -#include - -#include "djc/Control.hpp" -#include "djc/input/VirtualKeyboard.hpp" -#include "djc/prelude.hpp" -#include "djc/ui/UI.hpp" - -namespace djc { - -struct DisplayManager final : kf::mixin::NonCopyable, kf::mixin::Initable { - - explicit DisplayManager(DisplayDriver &display, const Control &control) noexcept : - _display{display}, _control{control} {} - -private: - using P = kf::gfx::Palette; - - inline static const auto &virtual_keyboard = input::VirtualKeyboard::instance(); - - DisplayDriver &_display; - const Control &_control; - kf::gfx::Canvas _canvas{}; - - void onRender(kf::memory::StringView str) noexcept { - _canvas.background(P::black); - _canvas.foreground(P::white); - - _canvas.fill(); - - if (virtual_keyboard.active()) { - renderVirtualKeyboard(); - } else { - renderUi(str); - } - } - - void renderUi(kf::memory::StringView str) noexcept { - // Control mode overlay - if (_control.enabled()) { - const auto y = static_cast(_canvas.maxY() - _canvas.glyphHeight()); - - const auto overlay = kf::memory::ArrayString<64>::formatted( - "\xB6\xF0""Control [%s]", - (_control.connected() ? EspNow::stringFromMac(_control.activeMac().value()).data() : "Disconnected")); - _canvas.text(0, y, overlay.data()); - } - - _canvas.background(P::black); - _canvas.foreground(P::white); - _canvas.text(0, 0, str.data()); - } - - void renderVirtualKeyboard() noexcept { - const auto longest_row = input::VirtualKeyboard::rows[0].size(); - const auto key_width = _canvas.width() / longest_row; - const auto key_height = _canvas.glyphHeight(); - const auto keyboard_offset_y = _canvas.maxY() - key_height * virtual_keyboard.rowsTotal(); - const auto glyph_offset_x = (key_width - _canvas.glyphWidth()) / 2; - - char c[2]{0, 0}; - - _canvas.text(0, 0, kf::memory::ArrayString<32>::formatted("\xBC\xF0Text Input: %d / %d\x80\n", virtual_keyboard.available(), virtual_keyboard.text().size()).data()); - _canvas.text(0, _canvas.glyphHeight(), virtual_keyboard.text().data()); - - _canvas.background(P::bright_black); - _canvas.foreground(P::bright_black); - _canvas.rect(0, keyboard_offset_y, _canvas.maxX(), _canvas.maxY(), true); - - for (auto row = 0; row < virtual_keyboard.rowsTotal(); row += 1) { - const auto y = keyboard_offset_y + row * key_height; - const auto cols = input::VirtualKeyboard::rows[row].size(); - - const auto x_offset = ((longest_row - cols) * key_width) / 2; - - for (auto col = 0; col < cols; col += 1) { - const auto x = col * key_width + x_offset; - - if (row == virtual_keyboard.cursorRow() and col == virtual_keyboard.cursorCol()) { - _canvas.foreground(P::blue); - _canvas.rect(x, y, x + key_width, y + key_height - 1, true); - - _canvas.background(P::blue); - _canvas.foreground(P::bright_white); - } else { - _canvas.background(P::bright_black); - _canvas.foreground(P::black); - } - - const auto &key = input::VirtualKeyboard::keyAt(row, col); - if (key.kind == input::VirtualKeyboard::Key::Kind::Common) { - c[0] = key.value(virtual_keyboard.shifted()); - } else { - c[0] = '?'; - } - - _canvas.text(x + glyph_offset_x, y, c); - } - } - } - - // impl - - KF_IMPL_INITABLE(DisplayManager, void); - void initImpl() noexcept { - _canvas = kf::gfx::Canvas{ - kf::image::DynamicImage{_display.image()}, - kf::gfx::fonts::gyver_5x7_en, - }; - _canvas.autoNextLine(true); - - auto &config = ui::UI::instance().renderConfig(); - config.callback([this](kf::memory::StringView str) { - onRender(str); - (void) _display.send(); - }); - config.row_max_length = _canvas.widthInGlyphs(); - config.rows_total = _canvas.heightInGlyphs() - 1; - } -}; - -}// namespace djc \ No newline at end of file diff --git a/DJC-Firmware/src/djc/ManualInput.hpp b/DJC-Firmware/src/djc/ManualInput.hpp new file mode 100644 index 0000000..31b6bdb --- /dev/null +++ b/DJC-Firmware/src/djc/ManualInput.hpp @@ -0,0 +1,29 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +namespace djc { + +/// @brief Normalized manual control input from joysticks +/// @note +/// Holds four stick channels (left/right, X/Y) in a fixed‑point format. +/// The values are sent to the peer by the active protocol. +struct ManualInput final { + using Unit = kf::i16; + + /// @brief Scale factor - normalized float [‑1, +1] is multiplied by this value + /// @note Mavlink manual control compatable scaling + static constexpr Unit scale_factor{1000}; + + Unit left_x, left_y, right_x, right_y;///< Joystick axes + + /// @brief Convert a normalized float value to the internal unit representation + [[nodiscard]] static constexpr Unit fromNormalized(kf::f32 value) noexcept { + return static_cast(value * scale_factor); + } +}; + +}// namespace djc \ No newline at end of file diff --git a/DJC-Firmware/src/djc/MavlinkTelemetryRegistry.hpp b/DJC-Firmware/src/djc/MavlinkTelemetryRegistry.hpp new file mode 100644 index 0000000..08d58cc --- /dev/null +++ b/DJC-Firmware/src/djc/MavlinkTelemetryRegistry.hpp @@ -0,0 +1,102 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include +#include + +namespace djc { + +/// @brief Centralized storage for received MAVLink telemetry messages +/// @note Holds a set of typed entries, one for each supported MAVLink message type. +struct MavlinkTelemetryRegistry final : kf::mixin::NonCopyable { + + /// @brief Typed telemetry entry with own decoder and freshness tracking + template struct Entry : kf::mixin::NonCopyable { + + using ValueType = T; + using MessageDecoder = void (*)(const mavlink_message_t *message, ValueType *); + + explicit constexpr Entry(MessageDecoder message_decoder) noexcept : + _message_decoder{message_decoder} {} + + [[nodiscard]] const ValueType &value() const noexcept { + return _value; + } + + /// @brief Check if the entry was updated after the given timestamp + [[nodiscard]] bool updatedSince(kf::math::Milliseconds since) const noexcept { + return _last_update_time > since; + } + + /// @brief Decode a MAVLink message into this entry and record the update time + void update(kf::math::Milliseconds now, const mavlink_message_t &message) noexcept { + if (nullptr != _message_decoder) { + _message_decoder(&message, &_value); + _last_update_time = now; + } + } + + private: + const MessageDecoder _message_decoder; + ValueType _value{}; + kf::math::Milliseconds _last_update_time{0u}; + }; + + // slow telemetry + + Entry heartbeat{mavlink_msg_heartbeat_decode}; + Entry extended_sys_state{mavlink_msg_extended_sys_state_decode}; + Entry battery_status{mavlink_msg_battery_status_decode}; + Entry serial_control{mavlink_msg_serial_control_decode}; + + // fast telemetry + + Entry attitude_quaternion{mavlink_msg_attitude_quaternion_decode}; + Entry scaled_imu{mavlink_msg_scaled_imu_decode}; + Entry rc_channels_raw{mavlink_msg_rc_channels_raw_decode}; + Entry actuator_control_target{mavlink_msg_actuator_control_target_decode}; + + /// @brief Route an incoming MAVLink message to the correct entry based on its ID + /// @note Only known message types are dispatched; unknown IDs are silently ignored. + void update(kf::math::Milliseconds now, const mavlink_message_t &message) noexcept { + switch (message.msgid) { + case MAVLINK_MSG_ID_HEARTBEAT: + heartbeat.update(now, message); + return; + + case MAVLINK_MSG_ID_EXTENDED_SYS_STATE: + extended_sys_state.update(now, message); + return; + + case MAVLINK_MSG_ID_BATTERY_STATUS: + battery_status.update(now, message); + return; + + case MAVLINK_MSG_ID_SERIAL_CONTROL: + serial_control.update(now, message); + return; + + case MAVLINK_MSG_ID_ATTITUDE_QUATERNION: + attitude_quaternion.update(now, message); + return; + + case MAVLINK_MSG_ID_SCALED_IMU: + scaled_imu.update(now, message); + return; + + case MAVLINK_MSG_ID_RC_CHANNELS_RAW: + rc_channels_raw.update(now, message); + return; + + case MAVLINK_MSG_ID_ACTUATOR_CONTROL_TARGET: + actuator_control_target.update(now, message); + return; + } + } +}; + +}// namespace djc \ No newline at end of file diff --git a/DJC-Firmware/src/djc/PeerFavoritesRegistry.hpp b/DJC-Firmware/src/djc/PeerFavoritesRegistry.hpp new file mode 100644 index 0000000..8fcbbe0 --- /dev/null +++ b/DJC-Firmware/src/djc/PeerFavoritesRegistry.hpp @@ -0,0 +1,130 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include + +#include "djc/transport/PeerAddress.hpp" + +namespace djc { + +/// @brief Registry of favorite peers +/// +/// Maintains a list of peer entries inside an externally‑provided array of slots. +/// The registry does not own the memory, only manipulates it. +/// +/// @note All methods are safe to call from different UI callbacks as long as the underlying storage is exclusively owned by the registry. +struct PeerFavoritesRegistry final : kf::mixin::NonCopyable { + + /// @brief A single favorite‑peer record. + struct Entry final { + using TrustType = kf::u8; + + static constexpr kf::Range trust_range{.start = 0, .end = 10}; + + transport::PeerAddress address; ///< Peer address. + TrustType trust; ///< Trust priority (0 - no trust, 1.. - auto connect) + kf::memory::Array name;///< Human‑readable description. + + /// @brief Factory method for a new, empty‑description entry. + [[nodiscard]] static Entry create(const transport::PeerAddress &address) noexcept { + return Entry{ + .address = address, + .trust = trust_range.start, + .name = {"New-Peer"}, + }; + } + }; + + /// @brief Set entries source + void entries(kf::Slice> new_entries) noexcept { + _entries = new_entries; + + _active_count = 0; + for (const auto &entry: _entries) { + _active_count += static_cast(entry.isSome()); + } + } + + /// @brief Return all non-empty entries + [[nodiscard]] auto all() const noexcept -> kf::Slice> { + return {_entries.data(), _active_count}; + } + + /// @brief Obtain a const pointer to an entry by address. + /// @param address Peer address to search for. + [[nodiscard]] auto get(const transport::PeerAddress &address) const noexcept -> kf::Option { + if (const auto index = indexOf(address); index.isSome()) { + if (const auto &option = _entries[index.unwrap()]; option.isSome()) { + return kf::someRef(option.unwrap()); + } + } + + return kf::none; + } + + /// @brief Check exists to an entry by address + [[nodiscard]] bool exists(const transport::PeerAddress &address) const noexcept { + return indexOf(address).isSome(); + } + + /// @brief Update already existed or Add a new entry + /// @return true, or false if the list is full. + [[nodiscard]] bool put(const Entry &entry_to_add) noexcept { + if (auto index = indexOf(entry_to_add.address); index.isSome()) { + _entries[index.unwrap()] = kf::someTrivial(entry_to_add); + return true; + } + + const bool can_add = (_active_count < _entries.size()); + if (can_add) { + _entries[_active_count] = kf::someTrivial(entry_to_add); + _active_count += 1; + } + + return can_add; + } + + /// @brief Remove an entry by address. + /// @param address Peer address to remove. + /// @return true if an entry was actually removed. + [[nodiscard]] bool remove(const transport::PeerAddress &address) noexcept { + const auto index = indexOf(address); + const auto last_index = _active_count - 1u; + + if (index.isNone()) { return false; } + + if (index.unwrap() != last_index) { + _entries[index.unwrap()] = _entries[last_index]; + } + + _entries[last_index] = {}; + _active_count -= 1; + + return true; + } + +private: + kf::Slice> _entries{}; + kf::usize _active_count{0}; + + /// @brief Find the index of an entry by address. + /// @param address Peer address to search for. + /// @return Option containing the index, or an empty option if not found. + [[nodiscard]] kf::Option indexOf(const transport::PeerAddress &address) const noexcept { + for (auto index = 0u; index < _entries.size(); index += 1) { + const auto &item = _entries[index]; + if (item.isSome() and item.unwrap().address == address) { + return kf::some(index); + } + } + return kf::none; + } +}; + +}// namespace djc \ No newline at end of file diff --git a/DJC-Firmware/src/djc/Periphery.hpp b/DJC-Firmware/src/djc/Periphery.hpp index 0fd4e4c..4353c25 100644 --- a/DJC-Firmware/src/djc/Periphery.hpp +++ b/DJC-Firmware/src/djc/Periphery.hpp @@ -3,129 +3,238 @@ #pragma once -#include - -#include // for delay +#include // delay, gpio_num_t #include -#include -#include -#include #include #include +#include +#include + +#include +#include // TODO: move to InputHandler + +#include +#include -#include "djc/prelude.hpp" +#include +#include +#include +#include namespace djc { -namespace internal { - -struct PeripheryConfig final : kf::mixin::NonCopyable { - ButtonListener::Config button; - - AxisInput::FilterImpl::Config axis_filter; - Joystick::Config left_joystick, right_joystick; - - Bus::Config bus; - Bus::Node::Config bus_node; - - DisplayDriver::Config display; - kf::u16 joystick_axes_tune_samples; - bool joystick_axes_tuned; - - static constexpr PeripheryConfig defaults() noexcept { - return PeripheryConfig{ - .button = { - .debounce = 50,// ms - }, - .axis_filter = { - .factor = 0.5f, - }, - .left_joystick = { - .x = axisDefaults(true), - .y = axisDefaults(false), - }, - .right_joystick = { - .x = axisDefaults(false), - .y = axisDefaults(true), - }, - // SPI default pins: MOSI=23, MISO=19, SCK=18 - .bus = djc::Bus::Config::create(), - // CS, SPI frequency - .bus_node = djc::Bus::Node::Config::create(GPIO_NUM_5, 27000000), - .display = { - .init_orientation = kf::drivers::display::Orientation::ClockWise, - }, - .joystick_axes_tune_samples = 100, - .joystick_axes_tuned = false, - }; - } +/// @brief ESP32-DJC Hardware Periphery +struct Periphery final : -private: - static constexpr AxisInput::Config axisDefaults(bool inverted) noexcept { - return AxisInput::Config{ - .inverted = inverted, - .dead_zone = 200, - .range_positive = 2000, - .range_negative = 2000, - }; - } -}; + kf::mixin::NonCopyable, + kf::mixin::Initable -}// namespace internal +{ -/// @brief ESP32-DJC Hardware Periphery -struct Periphery final : kf::mixin::NonCopyable, kf::mixin::Initable, kf::mixin::Configurable { - using Config = internal::PeripheryConfig; + using GPIO = kf::gpio::ArduinoGPIO; - using Configurable::Configurable; + using ButtonListener = kf::input::LogicalLevelListener; - ButtonListener left_button_listener{ - this->config().button, - DigitalInput{ - GPIO_NUM_14, - DigitalInput::Pull::InternalUp, - }, + using AxisInput = kf::drivers::sensors::NormalizedAdcInput; + + using Joystick = kf::drivers::sensors::Joystick; + + using IicBus = kf::bus::iic::ArduinoIIC; + + using SSD1306 = kf::drivers::display::SSD1306; + + using SpiBus = kf::bus::spi::ArduinoSPI; + + using ST7735 = kf::drivers::display::ST7735; + +#if defined(DJC_DISPLAY_DRIVER_ST7735) + + using DisplayDriver = ST7735; + +#elif defined(DJC_DISPLAY_DRIVER_SSD1306) + + using DisplayDriver = SSD1306; + +#else + +#error DJC_DISPLAY_DRIVER_* Must be defined! + +#endif + + static constexpr gpio_num_t + + // inputs + + gpio_button_left{GPIO_NUM_26}, + gpio_button_right{GPIO_NUM_25}, + + gpio_joystick_left_x{GPIO_NUM_32}, + gpio_joystick_left_y{GPIO_NUM_33}, + gpio_joystick_right_x{GPIO_NUM_34}, + gpio_joystick_right_y{GPIO_NUM_35}, + + gpio_battery_level{GPIO_NUM_39}, + + // bus + + gpio_i2c_sda{GPIO_NUM_21}, + gpio_i2c_scl{GPIO_NUM_22}, + + gpio_spi_mosi{GPIO_NUM_23}, + gpio_spi_miso{GPIO_NUM_19}, + gpio_spi_sck{GPIO_NUM_18}, + + // display + + gpio_display_st7735_spi_cs{GPIO_NUM_5}, + gpio_display_st7735_data_command{GPIO_NUM_16}, + gpio_display_st7735_reset{GPIO_NUM_17} + + ; + + struct Config : kf::mixin::Resettable { + + // Input + + ButtonListener::Config button; + + AxisInput::FilterImpl::Config axis_filter; + + Joystick::Config left_joystick, right_joystick; + + // I2C + + IicBus::Config iic_bus; + + IicBus::Node::Config ssd1306_iic_node; + + // SPI + + SpiBus::Config spi_bus; + + SpiBus::Node::Config st7735_spi_node; + + ST7735::Config st7735; + + // Other + + kf::u16 joystick_axes_tune_samples; + + bool joystick_axes_tuned; + + private: + static void resetAxis(AxisInput::Config &axis, bool inverted) noexcept { + axis.inverted = inverted; + axis.dead_zone = 200; + axis.range_positive = 2000; + axis.range_negative = 2000; + } + + KF_IMPL_RESETTABLE(Config); + void resetImpl() noexcept { + button.debounce = 0; + + axis_filter.factor = 0.5f; + + resetAxis(left_joystick.x, true); + resetAxis(left_joystick.y, false); + + resetAxis(right_joystick.x, false); + resetAxis(right_joystick.y, true); + + iic_bus.clock_hz = 400'000; + iic_bus.timeout = 0; // wire default + iic_bus.buffer_size = 0;// + iic_bus.pin_sda = -1; // + iic_bus.pin_scl = -1; // + + ssd1306_iic_node.address = SSD1306::default_address; + + spi_bus.pin_mosi = gpio_spi_mosi; + spi_bus.pin_miso = gpio_spi_miso; + spi_bus.pin_sck = gpio_spi_sck; + + st7735_spi_node.clock_hz = 27'000'000; + st7735_spi_node.pin_cs = gpio_display_st7735_spi_cs; + st7735_spi_node.bit_order = SpiBus::Node::Config::BitOrder::MostSignificant; + st7735_spi_node.clock_bits = SpiBus::Node::Config::ClockBits::None; + + st7735.init_orientation = kf::drivers::display::Orientation::ClockWise; + + joystick_axes_tune_samples = 100; + joystick_axes_tuned = false; + } }; - Joystick left_joystick{ - this->config().left_joystick, - this->config().axis_filter, - AdcInput{GPIO_NUM_32}, - AdcInput{GPIO_NUM_33}, + explicit Periphery(const Config &config) noexcept : + config{config} {} + + const Config &config; + + ButtonListener left_button_listener{ + config.button, + GPIO::DigitalInput{ + gpio_button_left, + GPIO::DigitalInput::Pull::InternalUp, + }, }; ButtonListener right_button_listener{ - this->config().button, - DigitalInput{ - GPIO_NUM_4, - DigitalInput::Pull::InternalUp, + config.button, + GPIO::DigitalInput{ + gpio_button_right, + GPIO::DigitalInput::Pull::InternalUp, }, }; + Joystick left_joystick{ + config.left_joystick, + config.axis_filter, + GPIO::AdcInput{gpio_joystick_left_x}, + GPIO::AdcInput{gpio_joystick_left_y}, + }; + Joystick right_joystick{ - this->config().right_joystick, - this->config().axis_filter, - AdcInput{GPIO_NUM_34}, - AdcInput{GPIO_NUM_35}, + config.right_joystick, + config.axis_filter, + GPIO::AdcInput{gpio_joystick_right_x}, + GPIO::AdcInput{gpio_joystick_right_y}, }; - Bus bus{ - this->config().bus, + SpiBus spi_bus{ + config.spi_bus, SPI, }; - DisplayDriver display{ - this->config().display, - bus.createNode(this->config().bus_node), - DigitalOutput{GPIO_NUM_22},// DC - DigitalOutput{GPIO_NUM_17},// RESET + IicBus iic_bus{ + config.iic_bus, + Wire, + }; + + DisplayDriver display_driver{ + +#if defined(DJC_DISPLAY_DRIVER_ST7735) + + config.st7735, + spi_bus.createNode(config.st7735_spi_node), + GPIO::DigitalOutput{gpio_display_st7735_data_command}, + GPIO::DigitalOutput{gpio_display_st7735_reset}, + +#elif defined(DJC_DISPLAY_DRIVER_SSD1306) + + iic_bus.createNode(config.ssd1306_iic_node), + +#else + +#endif + }; // Analog axis calibration - void tune(Config &mut_config) noexcept { - Joystick::Tuner left_tuner{mut_config.left_joystick, left_joystick, mut_config.joystick_axes_tune_samples}; - Joystick::Tuner right_tuner{mut_config.right_joystick, right_joystick, mut_config.joystick_axes_tune_samples}; + void tune(Config &mutable_config) noexcept { + Joystick::Tuner left_tuner{mutable_config.left_joystick, left_joystick, mutable_config.joystick_axes_tune_samples}; + Joystick::Tuner right_tuner{mutable_config.right_joystick, right_joystick, mutable_config.joystick_axes_tune_samples}; left_tuner.reset(); right_tuner.reset(); @@ -137,15 +246,14 @@ struct Periphery final : kf::mixin::NonCopyable, kf::mixin::Initable +#include +#include +#include + +namespace djc::config { + +struct ConfigTag {}; + +/// @brief Persistent configuration structure +template struct Config : + + ConfigTag, + kf::mixin::Resettable + +{ + kf::u8 version; + + [[nodiscard]] bool isLatest() const noexcept { + return version == latest_version; + } + + [[nodiscard]] static constexpr auto interpret(kf::Slice view) noexcept -> kf::Option { + return (view.size() == sizeof(Impl)) ? kf::someRef(*reinterpret_cast(view.data())) : kf::none; + } + + [[nodiscard]] constexpr auto view() noexcept -> kf::Slice { + return { + reinterpret_cast(this), + sizeof(Impl), + }; + } + + [[nodiscard]] constexpr auto view() const noexcept -> kf::Slice { + return const_cast(this)->view(); + } + + [[nodiscard]] static constexpr auto defaults() noexcept { + Impl ret{}; + + ret.version = latest_version; + ret.reset(); + + return ret; + } +}; + +}// namespace djc::config \ No newline at end of file diff --git a/DJC-Firmware/src/djc/config/DeviceConfig.hpp b/DJC-Firmware/src/djc/config/DeviceConfig.hpp new file mode 100644 index 0000000..0c1870c --- /dev/null +++ b/DJC-Firmware/src/djc/config/DeviceConfig.hpp @@ -0,0 +1,51 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "djc/Periphery.hpp" +#include "djc/config/Config.hpp" +#include "djc/protocol/ProtocolLink.hpp" +#include "djc/protocol/ProtocolRegistry.hpp" +#include "djc/service/AutoConnectService.hpp" +#include "djc/service/InputHandler.hpp" +#include "djc/service/PeerScanningService.hpp" +#include "djc/transport/TransportLink.hpp" + +namespace djc::config { + +/// @brief Device-related configutation +struct DeviceConfig : Config { + + // periphery + + Periphery::Config periphery; + + // network + + transport::TransportLink::Config transport_link; + protocol::ProtocolLink::Config protocol_link; + protocol::ProtocolRegistry::Config protocol_registry; + + // service configs + + service::InputHandler::Config input_handler; + service::PeerScanningService::Config peer_scanner; + service::AutoConnectService::Config auto_connect_service; + +private: + KF_IMPL_RESETTABLE(DeviceConfig); + void resetImpl() noexcept { + periphery.reset(); + + transport_link.reset(); + protocol_link.reset(); + protocol_registry.reset(); + + input_handler.reset(); + peer_scanner.reset(); + auto_connect_service.reset(); + } +}; + +}// namespace djc::config \ No newline at end of file diff --git a/DJC-Firmware/src/djc/config/UserConfig.hpp b/DJC-Firmware/src/djc/config/UserConfig.hpp new file mode 100644 index 0000000..7ce14a2 --- /dev/null +++ b/DJC-Firmware/src/djc/config/UserConfig.hpp @@ -0,0 +1,51 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +#include "djc/PeerFavoritesRegistry.hpp" +#include "djc/config/Config.hpp" +#include "djc/protocol/ProtocolRegistry.hpp" +#include "djc/transport/Kind.hpp" +#include "djc/ui/UI.hpp" + +namespace djc::config { + +/// @brief User Configuration +struct UserConfig : Config { + + static constexpr auto max_peer_favorites{8u}; + + /// @brief Protocol mode that sent after init + protocol::ProtocolRegistry::Mode init_protocol_mode; + + /// @brief Transport kind that sent after init + transport::Kind init_transport_kind; + + /// @brief Human-readable string + kf::memory::Array device_name; + + /// @brief Peer favorites registry entries + kf::memory::Array, max_peer_favorites> peer_favorites; + + /// @brief UI Renderer mics config + ui::UI::Traits::RendererImpl::Config ui_renderer; + +private: + KF_IMPL_RESETTABLE(UserConfig); + void resetImpl() noexcept { + init_protocol_mode = protocol::ProtocolRegistry::Mode::Mavlink; + init_transport_kind = transport::Kind::EspNow; + + device_name = decltype(device_name){"ESP32-DJC"}; + + peer_favorites = {}; + + ui_renderer = ui::UI::Traits::RendererImpl::Config::defaults(); + } +}; + +}// namespace djc::config \ No newline at end of file diff --git a/DJC-Firmware/src/djc/input/InputHandler.hpp b/DJC-Firmware/src/djc/input/InputHandler.hpp deleted file mode 100644 index f4a7c18..0000000 --- a/DJC-Firmware/src/djc/input/InputHandler.hpp +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright (c) 2026 KiraFlux -// SPDX-License-Identifier: GPL-3.0-or-later - -#pragma once - -#include - -#include -#include -#include -#include -#include -#include - -#include "djc/prelude.hpp" - -namespace djc { - -struct InputHandler final : kf::mixin::NonCopyable, kf::mixin::TimedPollable { - using JoystickListener = kf::input::JoystickListener; - - using ClickCallback = kf::Function; - using DirectionCallback = kf::Function; - - struct Config final : kf::mixin::NonCopyable { - JoystickListener::Config joystick_listener; - - static constexpr Config defaults() noexcept { - return Config{ - .joystick_listener = JoystickListener::Config{ - .threshold = 0.6f, - .repeat_timeout = 100,// ms - .delay = 400, // ms - }, - }; - } - }; - - explicit InputHandler( - const Config &config, - Joystick &primary_joystick, - ButtonListener &left_button_listener, - ButtonListener &right_button_listener) noexcept : - _joystick_listener{primary_joystick, config.joystick_listener}, - _left_button_listener{left_button_listener}, - _right_button_listener{right_button_listener} {} - - void onRightButton(ClickCallback &&callback) noexcept { _right_click_callback = std::move(callback); } - - void onLeftButton(ClickCallback &&callback) noexcept { _left_click_callback = std::move(callback); } - - void onDirection(DirectionCallback &&callback) noexcept { _direction_callback = std::move(callback); } - -private: - JoystickListener _joystick_listener; - DirectionCallback _direction_callback{}; - - ButtonListener &_left_button_listener; - ClickCallback _left_click_callback{}; - - ButtonListener &_right_button_listener; - ClickCallback _right_click_callback{}; - - // impl - - KF_IMPL_TIMED_POLLABLE(InputHandler); - void pollImpl(kf::math::Milliseconds now) noexcept { - _left_button_listener.poll(now); - if (_left_click_callback and _left_button_listener.clicked()) { - _left_click_callback(); - } - - _right_button_listener.poll(now); - if (_right_click_callback and _right_button_listener.clicked()) { - _right_click_callback(); - } - - _joystick_listener.poll(now); - if (_direction_callback and (_joystick_listener.direction() != JoystickListener::Direction::Home) and _joystick_listener.changed()) { - _direction_callback(_joystick_listener.direction()); - } - } -}; - -}// namespace djc \ No newline at end of file diff --git a/DJC-Firmware/src/djc/input/LogicalLevelListener.hpp b/DJC-Firmware/src/djc/input/LogicalLevelListener.hpp deleted file mode 100644 index 563c2b8..0000000 --- a/DJC-Firmware/src/djc/input/LogicalLevelListener.hpp +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (c) 2026 KiraFlux -// SPDX-License-Identifier: GPL-3.0-or-later - -#pragma once - -#include - -#include -#include -#include -#include -#include -#include -#include - -namespace djc::input { - -namespace internal { - -struct ButtonConfig final : kf::mixin::NonCopyable { - kf::math::Milliseconds debounce; -}; - -}// namespace internal - -/// @brief Minimal button with press detection only -template -struct LogicalLevelListener : kf::mixin::Initable, void>, - kf::mixin::NonCopyable, - kf::mixin::TimedPollable>, - kf::mixin::Configurable { - KF_CHECK_IMPL(I, kf::gpio::DigitalInputTag); - - using PinImpl = I; - using Config = internal::ButtonConfig; - - explicit LogicalLevelListener(const Config &config, PinImpl &&pin) noexcept : - kf::mixin::Configurable{config}, _pin{std::move(pin)} {} - - /// @brief Check if button was clicked (consumes the click) - /// @return true if button was pressed since last call - [[nodiscard]] bool clicked() noexcept { - if (_click_ready) { - _click_ready = false; - return true; - } - return false; - } - - /// @brief Check current button state - /// @return true if button is currently pressed (after debounce) - [[nodiscard]] bool pressed() const noexcept { return _last_stable; } - -private: - kf::math::Milliseconds _next{0}; - PinImpl _pin; - bool _last_stable{false}; - bool _last_raw{false}; - bool _click_ready{false}; - bool _first{true}; - - // impl - using This = LogicalLevelListener; - - KF_IMPL_INITABLE(This, void); - void initImpl() noexcept { - _pin.init(); - } - - KF_IMPL_TIMED_POLLABLE(This); - void pollImpl(kf::math::Milliseconds now) noexcept { - const bool state = _pin.read(); - - if (_first) { - _first = false; - _last_raw = state; - _last_stable = state; - } - - // todo use Timer here - - if (state != _last_raw) { - _last_raw = state; - _next = now + this->config().debounce; - } - - if (now >= _next) { - if (_last_stable != state) { - _last_stable = state; - - if (_last_stable) { - _click_ready = true; - } - } - } - } -}; - -}// namespace djc::input \ No newline at end of file diff --git a/DJC-Firmware/src/djc/math.hpp b/DJC-Firmware/src/djc/math.hpp new file mode 100644 index 0000000..7571cde --- /dev/null +++ b/DJC-Firmware/src/djc/math.hpp @@ -0,0 +1,32 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +namespace djc::math { + +/// @brief CRC-32 (IEEE 802.3) checksum +[[nodiscard]] kf::u32 crc32(kf::Slice data) { + constexpr auto reflected_polynomial{0xEDB88320u}; + + auto crc = static_cast(-1); + + for (auto byte: data) { + crc ^= byte; + + for (auto j = 0u; j < 8; j += 1) { + if (crc & 1) { + crc = (crc >> 1) ^ reflected_polynomial; + } else { + crc >>= 1; + } + } + } + + return ~crc; +} + +}// namespace djc::math \ No newline at end of file diff --git a/DJC-Firmware/src/djc/memory/Box.hpp b/DJC-Firmware/src/djc/memory/Box.hpp deleted file mode 100644 index 67d558f..0000000 --- a/DJC-Firmware/src/djc/memory/Box.hpp +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) 2026 KiraFlux -// SPDX-License-Identifier: GPL-3.0-or-later - -#pragma once - -#include - -namespace djc::memory { - -template struct Box { - kf::memory::Array items; - Index items_saved, selected_index;// 0..N - - constexpr Index maxItems() const noexcept { return N; } - - const T &selected() const noexcept { return items[selected_index]; } - - bool add(const T &item) noexcept { - if (items_saved >= maxItems()) { return false; } - - items[items_saved] = item; - items_saved += 1; - - return true; - } - - static constexpr Box defaults() noexcept { - return Box{ - .items = {}, - .items_saved = 0, - .selected_index = 0, - }; - } -}; - -}// namespace djc::memory diff --git a/DJC-Firmware/src/djc/memory/NVS.hpp b/DJC-Firmware/src/djc/memory/NVS.hpp new file mode 100644 index 0000000..ae5762e --- /dev/null +++ b/DJC-Firmware/src/djc/memory/NVS.hpp @@ -0,0 +1,144 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace djc::internal { + +struct NvsError { + enum Kind : char { + InitFailed, + ReadFailed, + WriteFailed, + CommitFailed, + InvalidLength, + NotFound, + NotEnoughSpace, + Unknown, + } kind; + + static constexpr auto fromEsp(const esp_err_t e) -> kf::internal::ResultErrorWrapper { + switch (e) { + case ESP_ERR_NVS_NOT_FOUND: + return {NotFound}; + + case ESP_ERR_NVS_INVALID_LENGTH: + return {InvalidLength}; + + case ESP_ERR_NVS_NOT_ENOUGH_SPACE: + return {NotEnoughSpace}; + + case ESP_ERR_NVS_INVALID_HANDLE: + case ESP_ERR_NVS_READ_ONLY: + case ESP_ERR_NVS_INVALID_NAME: + default: + return {Unknown}; + } + } +}; + +}// namespace djc::internal + +namespace djc::memory { + +/// @brief NVS wrapper +struct NVS final : + + kf::mixin::NonCopyable, + kf::mixin::Initable()>, + kf::mixin::Quitable + +{ + + using Error = internal::NvsError; + + using ResultType = kf::Result; + + /// @brief Construct NVS storage component + explicit constexpr NVS(const char *nvs_namespace) noexcept : + _namespace{nvs_namespace} {} + + [[nodiscard]] constexpr const char *name() const noexcept { + return _namespace; + } + + [[nodiscard]] ResultType getBlob(const char *key, kf::Slice buffer) noexcept { + auto len = buffer.size(); + return wrap(nvs_get_blob(_handle.unwrap(), key, static_cast(buffer.data()), &len)); + } + + [[nodiscard]] ResultType setBlob(const char *key, kf::Slice buffer) noexcept { + return wrap(nvs_set_blob(_handle.unwrap(), key, buffer.data(), buffer.size())); + } + + [[nodiscard]] ResultType commit() noexcept { + return wrap(nvs_commit(_handle.unwrap())); + } + +private: + const char *_namespace; + kf::TrivialOption _handle{kf::none}; + + [[nodiscard]] static ResultType wrap(esp_err_t e) noexcept { + if (ESP_OK == e) { + return kf::ok(); + } else { + return Error::fromEsp(e); + } + } + + KF_IMPL_INITABLE(NVS, kf::Result()); + auto initImpl() noexcept -> kf::Result { + if (_handle.isSome()) { + // Already initialised + return kf::ok(); + } + + esp_err_t e; + nvs_handle_t handle; + + // Ensure NVS flash is initialised (idempotent) + e = nvs_flash_init(); + if (e == ESP_ERR_NVS_NO_FREE_PAGES or e == ESP_ERR_NVS_NEW_VERSION_FOUND) { + // NVS partition was truncated, need to erase and retry + e = nvs_flash_erase(); + if (e != ESP_OK) { + return Error::fromEsp(e); + } + e = nvs_flash_init(); + } + + if (e != ESP_OK) { + return Error::fromEsp(e); + } + + e = nvs_open(_namespace, NVS_READWRITE, &handle); + if (ESP_OK != e) { + return Error::fromEsp(e); + } + + _handle = kf::someTrivial(handle); + return kf::ok(); + } + + KF_IMPL_QUITABLE(NVS); + void quitImpl() noexcept { + if (_handle.isSome()) { + nvs_close(_handle.unwrap()); + _handle.reset(); + } + } +}; + +}// namespace djc::memory \ No newline at end of file diff --git a/DJC-Firmware/src/djc/mixin/ServiceOwner.hpp b/DJC-Firmware/src/djc/mixin/ServiceOwner.hpp new file mode 100644 index 0000000..c59e698 --- /dev/null +++ b/DJC-Firmware/src/djc/mixin/ServiceOwner.hpp @@ -0,0 +1,37 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include + +#include "djc/service/Service.hpp" + +namespace djc::mixin { + +struct ServiceOwnerTag {}; + +/// @brief Mixin represents that class owns one service +template struct ServiceOwner : ServiceOwnerTag { + KF_CHECK_IMPL(ServiceType, ::djc::service::ServiceTag); + + explicit ServiceOwner(ServiceType &&service) noexcept : + _service{std::move(service)} {} + + /// @brief Get mutable access to the service + ServiceType &service() noexcept { + return _service; + } + + /// @brief Get readonly access to the service + constexpr const ServiceType &service() const noexcept { + return _service; + } + +private: + ServiceType _service; +}; + +}// namespace djc::mixin \ No newline at end of file diff --git a/DJC-Firmware/src/djc/prelude.hpp b/DJC-Firmware/src/djc/prelude.hpp deleted file mode 100644 index 96ea722..0000000 --- a/DJC-Firmware/src/djc/prelude.hpp +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2026 KiraFlux -// SPDX-License-Identifier: GPL-3.0-or-later - -#pragma once - -#include -#include -#include -#include -#include -#include - -#include "djc/input/LogicalLevelListener.hpp" - -namespace djc { - -using namespace kf::gpio::arduino; - -using ButtonListener = djc::input::LogicalLevelListener; - -using AxisInput = kf::drivers::sensors::NormalizedAdcInput; -using Joystick = kf::drivers::sensors::Joystick; - -using Bus = kf::bus::spi::ArduinoSPI; -using DisplayDriver = kf::drivers::display::ST7735; - -using EspNow = kf::network::EspNow; - -}// namespace djc \ No newline at end of file diff --git a/DJC-Firmware/src/djc/protocol/MavlinkProtocol.hpp b/DJC-Firmware/src/djc/protocol/MavlinkProtocol.hpp new file mode 100644 index 0000000..5ee7369 --- /dev/null +++ b/DJC-Firmware/src/djc/protocol/MavlinkProtocol.hpp @@ -0,0 +1,147 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "djc/ManualInput.hpp" +#include "djc/protocol/Protocol.hpp" +#include "djc/transport/TransportLink.hpp" + +namespace djc::internal { + +/// @brief Configuration for the MAVLink protocol +struct MavlinkProtocolConfig : kf::mixin::Resettable { + + /// @brief Timer for HEARTBEAT messages (ms) + kf::math::Timer::Config heartbeat_timer; + + kf::u8 + + /// @brief MAVLink system ID of this controller + system_id_self, + + /// @brief MAVLink system ID of the target drone (0 = broadcast) + system_id_target, + + // components + + component_id_heartbeat, + component_id_manual_control; + +private: + KF_IMPL_RESETTABLE(MavlinkProtocolConfig); + void resetImpl() noexcept { + heartbeat_timer.period = 2'000;// ms + + system_id_self = 0x7f; + system_id_target = 0x01; + component_id_heartbeat = MAV_COMP_ID_USER1; + component_id_manual_control = MAV_COMP_ID_USER2; + } +}; + +}// namespace djc::internal + +namespace djc::protocol { + +/// @brief MAVLink protocol - sends MANUAL_CONTROL and HEARTBEAT packets, invokes callback on received MAVLink messages +/// @note +/// A manual control message is sent on every `poll()`, carrying the current stick values. +/// A heartbeat is sent immediately on activation and then at the configured period. +/// Incoming data is parsed as MAVLink and forwarded via callback. +struct MavlinkProtocol : + + Protocol, + kf::mixin::Callbacked, + kf::mixin::Configurable + +{ + using Config = internal::MavlinkProtocolConfig; + + using kf::mixin::Configurable::Configurable; + + /// @brief Serialize and send a MAVLink message through the given transport. + /// @param transport_link Transport to use for sending. + /// @param message The message to send. + /// @return true if the transport reported success, false otherwise. + /// @note + /// The return value reflects the transport-level result. + [[nodiscard]] static bool sendMessage(transport::TransportLink &transport_link, const mavlink_message_t &message) noexcept { + kf::u8 buffer[MAVLINK_MAX_PACKET_LEN]; + const auto len = mavlink_msg_to_send_buffer(buffer, &message); + + return transport_link.send({buffer, len}); + } + + // impl dynamic + + void poll(kf::math::Milliseconds now, const ManualInput &input, transport::TransportLink &transport_link) noexcept override { + if (_heartbeat_timer.expired(now)) { + _heartbeat_timer.start(now); + + (void) sendHeartbeat(transport_link); + } + + (void) sendManualControl(transport_link, input); + } + + void receive(kf::Slice buffer) noexcept override { + mavlink_message_t message; + mavlink_status_t status; + + for (auto b: buffer) { + if (mavlink_parse_char(MAVLINK_COMM_0, b, &message, &status) != 0) { + this->invoke(message); + } + } + } + +private: + kf::math::Timer _heartbeat_timer{this->config().heartbeat_timer}; + + [[nodiscard]] bool sendHeartbeat(transport::TransportLink &transport_link) const noexcept { + mavlink_message_t message; + (void) mavlink_msg_heartbeat_pack( + this->config().system_id_self, + this->config().component_id_heartbeat, + &message, + MAV_TYPE_QUADROTOR, + MAV_AUTOPILOT_GENERIC, + 0, 0, 0// Base mode, Custom mode, system status + ); + + return sendMessage(transport_link, message); + } + + [[nodiscard]] bool sendManualControl(transport::TransportLink &transport_link, const ManualInput &input) const noexcept { + mavlink_message_t message; + (void) mavlink_msg_manual_control_pack( + this->config().system_id_self, + this->config().component_id_manual_control, + &message, + this->config().system_id_target, + input.right_y, // x: pitch (right Y) + input.right_x, // y: roll (right X) + input.left_y, // z: thrust (left Y) + input.left_x, // r: yaw (left X) + 0, 0, // buttons + 0, // extensions + 0, 0, // roll/pitch only axes + 0, 0, 0, 0, 0, 0// aux: 0..6 + ); + + return sendMessage(transport_link, message); + } +}; + +}// namespace djc::protocol \ No newline at end of file diff --git a/DJC-Firmware/src/djc/protocol/Protocol.hpp b/DJC-Firmware/src/djc/protocol/Protocol.hpp new file mode 100644 index 0000000..b6fcd0c --- /dev/null +++ b/DJC-Firmware/src/djc/protocol/Protocol.hpp @@ -0,0 +1,32 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include + +#include "djc/ManualInput.hpp" +#include "djc/transport/TransportLink.hpp" + +namespace djc::protocol { + +/// @brief Abstract protocol for sending control data and processing incoming packets +struct Protocol : kf::mixin::NonCopyable { + + /// @brief Called periodically when the protocol is active and the link is connected + /// @note + /// The implementation must serialize the current stick values and send them through the transport. + /// Periodic tasks such as heartbeat or keep‑alive messages are also handled here, using `now` to maintain internal timers. + virtual void poll(kf::math::Milliseconds now, const ManualInput &input, transport::TransportLink &transport_link) noexcept = 0; + + /// @brief Process an incoming data buffer from the connected peer + /// @note + /// The implementation must parse the data according to its protocol and notify the appropriate callback + /// This method is called from the transport callback; it must be fast and never block. + virtual void receive(kf::Slice buffer) noexcept = 0; +}; + +}// namespace djc::protocol \ No newline at end of file diff --git a/DJC-Firmware/src/djc/protocol/ProtocolLink.hpp b/DJC-Firmware/src/djc/protocol/ProtocolLink.hpp new file mode 100644 index 0000000..8930a1e --- /dev/null +++ b/DJC-Firmware/src/djc/protocol/ProtocolLink.hpp @@ -0,0 +1,98 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "djc/ManualInput.hpp" +#include "djc/protocol/Protocol.hpp" +#include "djc/transport/TransportLink.hpp" + +namespace djc::internal { + +/// @brief Configuration for the ProtocolLink +struct ProtocolLinkConfig : kf::mixin::Resettable { + + ///@brief Interval between calls to the active protocol's `poll()` method + kf::math::Timer::Config poll_timer; + +private: + KF_IMPL_RESETTABLE(ProtocolLinkConfig); + void resetImpl() noexcept { + poll_timer.period = static_cast(1000 / 50); + } +}; + +}// namespace djc::internal + +namespace djc::protocol { + +/// @brief Manages the active protocol and calls its `poll()` method at fixed intervals +/// @note +/// Holds a pointer to a `Protocol` instance. +/// On every `poll()` call, checks a timer and invokes `_protocol.unwrap().poll()` if the period has expired. +/// Forwards incoming data to the active protocol via `receive()`. +struct ProtocolLink : + + kf::mixin::NonCopyable, + kf::mixin::Configurable + +{ + using Config = internal::ProtocolLinkConfig; + + using kf::mixin::Configurable::Configurable; + + /// @brief Set the active protocol implementation. + /// @param new_protocol Reference to a protocol instance (must outlive this object). + void protocol(Protocol &new_protocol) noexcept { + _protocol = kf::someRef(new_protocol); + } + + /// @brief Called periodically to drive the active protocol. + /// @param now Current timestamp in milliseconds. + /// @param input Current manual control values. + /// @param transport_link Transport to use for sending data. + /// @note The call is forwarded to the active protocol only when the poll period expires. + void poll(kf::math::Milliseconds now, const ManualInput &input, transport::TransportLink &transport_link) noexcept { + if (_protocol.isNone()) { + logger.error("poll: no protocol set"); + return; + } + + if (_poll_timer.expired(now) or _poll_timer_reset_required) { + _poll_timer.start(now); + _poll_timer_reset_required = false; + + _protocol.unwrap().poll(now, input, transport_link); + } + } + + /// @brief Forward a received data buffer to the active protocol. + /// @param buffer Raw data received from the transport. + void receive(kf::Slice buffer) noexcept { + if (_protocol.isNone()) { + logger.error("receive: no protocol set"); + return; + } + + _protocol.unwrap().receive(buffer); + } + +private: + static constexpr auto logger{kf::Logger::create("ProtocolLink")}; + + kf::Option _protocol{kf::none}; + kf::math::Timer _poll_timer{this->config().poll_timer}; + bool _poll_timer_reset_required{true}; +}; + +}// namespace djc::protocol \ No newline at end of file diff --git a/DJC-Firmware/src/djc/protocol/ProtocolRegistry.hpp b/DJC-Firmware/src/djc/protocol/ProtocolRegistry.hpp new file mode 100644 index 0000000..8b8d5d5 --- /dev/null +++ b/DJC-Firmware/src/djc/protocol/ProtocolRegistry.hpp @@ -0,0 +1,79 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +#include "djc/protocol/MavlinkProtocol.hpp" +#include "djc/protocol/Protocol.hpp" +#include "djc/protocol/RawProtocol.hpp" + +namespace djc::internal { + +/// @brief Configuration container for the ProtocolRegistry +struct ProtocolRegistryConfig : kf::mixin::Resettable { + + /// @brief MAVLink protocol configuration + protocol::MavlinkProtocol::Config mavlink; + +private: + KF_IMPL_RESETTABLE(ProtocolRegistryConfig); + void resetImpl() noexcept { + mavlink.reset(); + } +}; + +}// namespace djc::internal + +namespace djc::protocol { + +/// @brief Storage for all available protocol implementations +struct ProtocolRegistry final : + + kf::mixin::NonCopyable, + kf::mixin::Configurable + +{ + using Config = internal::ProtocolRegistryConfig; + + /// @brief Available protocol modes + enum class Mode : char { + Raw = 0x00, ///< Raw binary protocol (sends ManualInput as-is) + Mavlink = 0x01,///< MAVLink protocol (sends MANUAL_CONTROL and HEARTBEAT) + }; + + using kf::mixin::Configurable::Configurable; + + /// @brief Retrieve a protocol instance by mode + /// @param mode Requested protocol mode + /// @return Reference to the corresponding protocol object. + [[nodiscard]] Protocol &get(Mode mode) noexcept { + switch (mode) { + case Mode::Mavlink: + return _mavlink_protocol; + + case Mode::Raw: + default: + return _raw_protocol; + } + } + + /// @brief Direct access to the Raw protocol instance + [[nodiscard]] RawProtocol &raw() noexcept { + return _raw_protocol; + } + + /// @brief Direct access to the MAVLink protocol instance + [[nodiscard]] MavlinkProtocol &mavlink() noexcept { + return _mavlink_protocol; + } + +private: + RawProtocol _raw_protocol{}; + MavlinkProtocol _mavlink_protocol{this->config().mavlink}; +}; + +}// namespace djc::protocol \ No newline at end of file diff --git a/DJC-Firmware/src/djc/protocol/RawProtocol.hpp b/DJC-Firmware/src/djc/protocol/RawProtocol.hpp new file mode 100644 index 0000000..909a0ba --- /dev/null +++ b/DJC-Firmware/src/djc/protocol/RawProtocol.hpp @@ -0,0 +1,32 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include + +#include "djc/ManualInput.hpp" +#include "djc/protocol/Protocol.hpp" +#include "djc/transport/TransportLink.hpp" + +namespace djc::protocol { + +/// @brief Raw protocol – sends ManualInput as a binary blob and invokes a callback on received data +/// @note +/// The protocol simply sends the `ManualInput` struct verbatim. +/// Incoming data is forwarded directly to the callback as is`. +struct RawProtocol : Protocol, kf::mixin::Callbacked> { + + void poll(kf::math::Milliseconds, const ManualInput &input, transport::TransportLink &transport_link) noexcept override { + (void) transport_link.send({reinterpret_cast(&input), sizeof(ManualInput)}); + } + + void receive(kf::Slice buffer) noexcept override { + this->invoke(buffer); + } +}; + +}// namespace djc::protocol \ No newline at end of file diff --git a/DJC-Firmware/src/djc/service/AutoConnectService.hpp b/DJC-Firmware/src/djc/service/AutoConnectService.hpp new file mode 100644 index 0000000..a08c250 --- /dev/null +++ b/DJC-Firmware/src/djc/service/AutoConnectService.hpp @@ -0,0 +1,92 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "djc/service/Service.hpp" +#include "djc/transport/PeerAddress.hpp" +#include "djc/transport/TransportLink.hpp" + +namespace djc::internal { + +struct AutoConnectServiceConfig : kf::mixin::Resettable { + + /// @brief Delay before the service reacts to a new target + kf::math::Timer::Config cooldown_timer; + + /// @brief Whether the service is active + bool enabled; + +private: + KF_IMPL_RESETTABLE(AutoConnectServiceConfig); + void resetImpl() noexcept { + cooldown_timer.period = 10'000; + enabled = true; + } +}; + +}// namespace djc::internal + +namespace djc::service { + +/// @brief Service that automatically connects to a trusted peer after a configurable delay +/// @note +/// Receives a target from outside. +/// When the timeout expires, the service invokes its callback with the peer address. +/// After the callback, the target is cleared and the service waits for a new one. +struct AutoConnectService final : + + Service, + kf::mixin::Configurable, + kf::mixin::Callbacked + +{ + /// @brief Configuration for the AutoConnectService + using Config = internal::AutoConnectServiceConfig; + + explicit AutoConnectService(const Config &config, const transport::TransportLink &transport_link) noexcept : + kf::mixin::Configurable{config}, _transport_link{transport_link} { + _cooldown_timer.start(0); + } + + [[nodiscard]] auto target() const noexcept -> const kf::TrivialOption & { + return _target; + } + + /// @brief Assign a new target for automatic connection + /// @note + /// The target is ignored if the transport is already connected. + /// This prevents interrupting an active connection. + void target(const transport::PeerAddress &new_target) noexcept { + if (not _transport_link.connected()) { + _target = kf::someTrivial(new_target); + } + } + +private: + const transport::TransportLink &_transport_link; + kf::TrivialOption _target{}; + kf::math::Timer _cooldown_timer{this->config().cooldown_timer}; + + KF_IMPL_TIMED_POLLABLE(AutoConnectService); + void pollImpl(kf::math::Milliseconds now) noexcept { + if (not this->config().enabled) { return; } + if (_target.isNone()) { return; } + + if (not _cooldown_timer.expired(now)) { return; } + + this->invoke(_target.unwrap()); + _target.reset(); + + _cooldown_timer.start(now); + } +}; + +}// namespace djc::service \ No newline at end of file diff --git a/DJC-Firmware/src/djc/service/ConfigService.hpp b/DJC-Firmware/src/djc/service/ConfigService.hpp new file mode 100644 index 0000000..8c38d3d --- /dev/null +++ b/DJC-Firmware/src/djc/service/ConfigService.hpp @@ -0,0 +1,150 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "djc/math.hpp" +#include "djc/memory/NVS.hpp" +#include "djc/service/Service.hpp" + +namespace djc::internal { + +using ConfigView = kf::Slice; + +using CallbackedByConfigView = kf::mixin::Callbacked; + +struct ConfigServiceOnLoadCallbacked : private CallbackedByConfigView { + + /// @brief Set config service behavior on load + template void onLoad(F &&f) noexcept { + this->callback(std::forward(f)); + } + +protected: + void invokeOnLoad(ConfigView view) noexcept { + this->invoke(view); + } +}; + +struct ConfigServiceResettingStrategy : private CallbackedByConfigView { + + /// @brief Set config service resetting strategy + template void resettingStrategy(F &&f) noexcept { + this->callback(std::forward(f)); + } + +protected: + void invokeResetStrategy(ConfigView view) noexcept { + this->invoke(view); + } +}; + +}// namespace djc::internal + +namespace djc::service { + +/// @brief Config service with delayed NVS operations +/// @note Requests are batched and executed on a 5-second timer from the main loop. +struct ConfigService : + + Service, + internal::ConfigServiceOnLoadCallbacked, + internal::ConfigServiceResettingStrategy + +{ + explicit constexpr ConfigService(const char *nvs_namespace, const kf::math::Timer::Config &sync_timer_config, kf::Slice config_view) noexcept : + _nvs{nvs_namespace}, _sync_timer{sync_timer_config}, _config_view{config_view} {} + + /// @brief Requests an deferred load of the config from NVS + void requestLoad() noexcept { + _load_requested = true; + logger.debug("Load requested"); + } + + /// @brief Requests an deferred reset of the config to defaults + void requestReset() noexcept { + _reset_requested = true; + logger.debug("Reset requested"); + } + + /// @brief Calculate CRC32 for config view + [[nodiscard]] kf::u32 crc() const noexcept { + return djc::math::crc32(_config_view); + } + + /// @brief Force sync now + void sync() noexcept { + using LogString = kf::memory::StaticString<64>; + + // init is idempotent + if (_nvs.init().isError()) { + logger.error(LogString::formatted("NVS(%s) init failed", _nvs.name())); + } + + if (_load_requested) { + _load_requested = false; + + logger.info(LogString::formatted("Loading config '%s' from NVS...", _nvs.name())); + + if (_nvs.getBlob(blob_key, _config_view).isOk()) { + _stored_crc = crc(); + logger.info(LogString::formatted("Config '%s' loaded from NVS (CRC: %u)", _nvs.name(), _stored_crc).view()); + + this->invokeOnLoad(_config_view); + + } else { + logger.error(LogString::formatted("Config '%s' load failed", _nvs.name())); + requestReset(); + } + } + + if (_reset_requested) { + _reset_requested = false; + + this->invokeResetStrategy(_config_view); + logger.info(LogString::formatted("Config reset '%s' to defaults", _nvs.name())); + } + + if (const auto current_crc = crc(); current_crc != _stored_crc) { + logger.info(LogString::formatted("Config '%s' changed, saving (CRC: %u -> %u)...", _nvs.name(), _stored_crc, current_crc).view()); + + if (_nvs.setBlob(blob_key, _config_view).isOk() and _nvs.commit().isOk()) { + _stored_crc = current_crc; + logger.info(LogString::formatted("Config '%s' saved, CRC updated", _nvs.name())); + } else { + logger.error(LogString::formatted("Config '%s' save failed", _nvs.name())); + } + } + } + +private: + static constexpr auto logger{kf::Logger::create("ConfigService")}; + + static constexpr auto blob_key{"blob"}; + + memory::NVS _nvs; + kf::Slice _config_view; + kf::math::Timer _sync_timer; + kf::u32 _stored_crc{}; + bool _load_requested{false}, _reset_requested{false}; + + KF_IMPL_TIMED_POLLABLE(ConfigService); + void pollImpl(kf::math::Milliseconds now) noexcept { + if (_sync_timer.expired(now)) { + _sync_timer.start(now); + sync(); + } + } +}; + +}// namespace djc::service \ No newline at end of file diff --git a/DJC-Firmware/src/djc/service/ControlService.hpp b/DJC-Firmware/src/djc/service/ControlService.hpp new file mode 100644 index 0000000..1e0fa64 --- /dev/null +++ b/DJC-Firmware/src/djc/service/ControlService.hpp @@ -0,0 +1,58 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include "djc/ManualInput.hpp" +#include "djc/protocol/ProtocolLink.hpp" +#include "djc/service/Service.hpp" +#include "djc/transport/TransportLink.hpp" + +namespace djc::service { + +/// @brief Thin bridge between transport and protocol layers, enabling manual control transmission. +/// @note +/// Stores the current joystick input and a flag that enables/disables output. +/// When enabled and the transport is connected, `pollImpl()` forwards the input to the active protocol via `ProtocolLink::poll()`. +struct ControlService final : Service { + + explicit ControlService(transport::TransportLink &transport_link, protocol::ProtocolLink &protocol_link) noexcept : + _transport_link{transport_link}, _protocol_link{protocol_link} {} + + /// @brief Returns the current manual input values. + [[nodiscard]] const ManualInput &input() const noexcept { + return _manual_input; + } + + /// @brief Updates the manual input to be transmitted. + void input(const ManualInput &new_input) noexcept { + _manual_input = new_input; + } + + /// @brief Checks whether control output is enabled. + [[nodiscard]] bool enabled() const noexcept { + return _enabled; + } + + /// @brief Enables or disables control output. + void enabled(bool is_enabled) noexcept { + _enabled = is_enabled; + } + +private: + transport::TransportLink &_transport_link; + protocol::ProtocolLink &_protocol_link; + ManualInput _manual_input{}; + bool _enabled{false}; + + KF_IMPL_TIMED_POLLABLE(ControlService); + void pollImpl(kf::math::Milliseconds now) noexcept { + if (_enabled and _transport_link.connected()) { + _protocol_link.poll(now, _manual_input, _transport_link); + } + } +}; + +}// namespace djc::service \ No newline at end of file diff --git a/DJC-Firmware/src/djc/service/DisplayService.hpp b/DJC-Firmware/src/djc/service/DisplayService.hpp new file mode 100644 index 0000000..e521866 --- /dev/null +++ b/DJC-Firmware/src/djc/service/DisplayService.hpp @@ -0,0 +1,38 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include "djc/service/Service.hpp" + +namespace djc::service { + +/// @brief Service that manages display sending +/// @tparam I DisplayDriver Implementation class +template struct DisplayService : Service> { + KF_CHECK_IMPL(I, ::kf::drivers::display::DisplayDriverTag); + + explicit constexpr DisplayService(I &display_driver) noexcept : + _display_driver{display_driver} {} + + /// @brief Requests an deferred send of RAM image to display via driver + void requestSend() noexcept { + _send_requested = true; + } + +private: + I &_display_driver; + bool _send_requested{false}; + + KF_IMPL_TIMED_POLLABLE(DisplayService); + void pollImpl(kf::math::Milliseconds now) noexcept { + if (_send_requested) { + _send_requested = false; + (void) _display_driver.send(); + } + } +}; + +}// namespace djc::service \ No newline at end of file diff --git a/DJC-Firmware/src/djc/service/InputHandler.hpp b/DJC-Firmware/src/djc/service/InputHandler.hpp new file mode 100644 index 0000000..558ae1f --- /dev/null +++ b/DJC-Firmware/src/djc/service/InputHandler.hpp @@ -0,0 +1,94 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +#include "djc/Periphery.hpp" +#include "djc/service/Service.hpp" + +namespace djc::service { + +// TODO: make implements 3x callbacked +struct InputHandler : Service { + using JoystickListener = kf::input::JoystickListener; + + using ClickCallback = kf::Function; + using DirectionCallback = kf::Function; + + struct Config : kf::mixin::Resettable { + JoystickListener::Config joystick_listener; + + private: + KF_IMPL_RESETTABLE(Config); + void resetImpl() noexcept { + joystick_listener.repeat_timer.period = 100;// ms + joystick_listener.delay_timer.period = 400; // ms + joystick_listener.threshold = 0.6f; + } + }; + + explicit InputHandler(const Config &config, Periphery &periphery) noexcept : + _joystick_listener{periphery.right_joystick, config.joystick_listener}, + _left_button_listener{periphery.left_button_listener}, + _right_button_listener{periphery.right_button_listener} {} + + template void onRightButton(F &&callback) noexcept { + _right_click_callback = kf::some(ClickCallback{std::forward(callback)}); + } + + void onRightButton(kf::NoneType) { + _right_click_callback.reset(); + } + + template void onLeftButton(F &&callback) noexcept { + _left_click_callback = kf::some(ClickCallback{std::forward(callback)}); + } + + void onLeftButton(kf::NoneType) { + _left_click_callback.reset(); + } + + template void onDirection(F &&callback) noexcept { + _direction_callback = kf::some(DirectionCallback{std::forward(callback)}); + } + + void onDirection(kf::NoneType) noexcept { + _direction_callback.reset(); + } + +private: + JoystickListener _joystick_listener; + Periphery::ButtonListener &_left_button_listener, &_right_button_listener; + + kf::Option _direction_callback{kf::none}; + kf::Option _left_click_callback{kf::none}, _right_click_callback{kf::none}; + + KF_IMPL_TIMED_POLLABLE(InputHandler); + void pollImpl(kf::math::Milliseconds now) noexcept { + _left_button_listener.poll(now); + if (_left_click_callback.isSome() and _left_button_listener.clicked()) { + _left_click_callback.unwrap()(); + } + + _right_button_listener.poll(now); + if (_right_click_callback.isSome() and _right_button_listener.clicked()) { + _right_click_callback.unwrap()(); + } + + _joystick_listener.poll(now); + if (_direction_callback.isSome() and (_joystick_listener.direction() != JoystickListener::Direction::Home) and _joystick_listener.changed()) { + _direction_callback.unwrap()(_joystick_listener.direction()); + } + } +}; + +}// namespace djc::service \ No newline at end of file diff --git a/DJC-Firmware/src/djc/service/PeerScanningService.hpp b/DJC-Firmware/src/djc/service/PeerScanningService.hpp new file mode 100644 index 0000000..c402a33 --- /dev/null +++ b/DJC-Firmware/src/djc/service/PeerScanningService.hpp @@ -0,0 +1,158 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "djc/service/Service.hpp" +#include "djc/transport/PeerAddress.hpp" +#include "djc/transport/TransportLink.hpp" + +namespace djc::internal { + +/// @brief Configuration parameters for the PeerScanningService service +struct PeerScannerConfig : kf::mixin::Resettable { + + /// @brief How long an entry stays in the list without being refreshed + kf::math::Milliseconds entry_max_life_time; + + /// @brief Interval between periodic clean-ups and list compaction + kf::math::Timer::Config entries_list_update_timer; + +private: + KF_IMPL_RESETTABLE(PeerScannerConfig); + void resetImpl() noexcept { + entry_max_life_time = 8'000; + entries_list_update_timer.period = 100; + } +}; + +}// namespace djc::internal + +namespace djc::service { + +/// @brief Background service that listens for foreign (broadcast) packets and maintains a list of visible peers +/// @note +/// The scanner subscribes to `TransportLink::onReceiveForeign` and keeps up to `max_entries` entries +/// Entries are refreshed every time a packet from the corresponding peer is received. +/// Periodically, expired entries are removed and the list is compacted so that the first `peers().size()` elements are always valid. +struct PeerScanningService : + + Service, + kf::mixin::Initable, + kf::mixin::Configurable + +{ + using Config = internal::PeerScannerConfig; + + /// @brief A single entry in the peer list. + struct Entry final { + transport::PeerAddress address; ///< Address of the peer. + kf::math::Milliseconds last_seen;///< Timestamp of the last received packet (millis). + }; + + /// @brief Maximum number of peers the scanner can remember simultaneously + static constexpr auto max_entries{8}; + + explicit constexpr PeerScanningService(const Config &config, transport::TransportLink &transport_link) noexcept : + Configurable{config}, _transport_link{transport_link} {} + + /// @brief Returns a slice of the currently active peer entries. + /// @return A contiguous view of the first `_active_count` elements of the internal array. + /// @note The slice is valid only until the next call to `poll()`. + /// The entries are sorted in order of registration (oldest first). + [[nodiscard]] kf::Slice> peers() const noexcept { + return {_entries.data(), _active_count}; + } + +private: + kf::memory::Array, max_entries> _entries{}; + kf::math::Timer _update_poll_timer{this->config().entries_list_update_timer}; + transport::TransportLink &_transport_link; + kf::math::Milliseconds _last_poll_time{0}; + kf::usize _active_count{0}; + + KF_IMPL_INITABLE(PeerScanningService, void()); + void initImpl() noexcept { + _update_poll_timer.start(0);// enable timer + + _transport_link.onReceiveForeign([this](const transport::PeerAddress &address, kf::Slice buffer) -> void { + // search for mathing entry + for (auto &entry: _entries) { + if (entry.isSome() and entry.unwrap().address == address) { + entry.unwrap().last_seen = _last_poll_time; + return; + } + } + + // search for first empty entry + for (auto &entry: _entries) { + if (entry.isNone()) { + entry = kf::someTrivial(Entry{ + .address = address, + .last_seen = _last_poll_time, + }); + return; + } + } + + // no available entries -> replace oldest with newest + kf::usize oldest_entry_index{0}; + for (auto i = 1u; i < max_entries; i += 1) { + if (_entries[i].unwrap().last_seen < _entries[oldest_entry_index].unwrap().last_seen) { + oldest_entry_index = i; + } + } + _entries[oldest_entry_index] = kf::someTrivial(Entry{address, _last_poll_time}); + }); + } + + KF_IMPL_TIMED_POLLABLE(PeerScanningService); + void pollImpl(kf::math::Milliseconds now) noexcept { + _last_poll_time = now; + + if (_update_poll_timer.expired(now)) { + _update_poll_timer.start(now); + + if (_transport_link.connected()) { + const auto &active_address = _transport_link.activePeerAddress().unwrap(); + for (auto &entry: _entries) { + if (entry.isSome() and entry.unwrap().address == active_address) { + entry.reset(); + break; + } + } + } + + auto write_index = 0u; + for (auto read_index = 0u; read_index < max_entries; read_index += 1) { + if (_entries[read_index].isSome()) { + if (now > _entries[read_index].unwrap().last_seen + this->config().entry_max_life_time) { + _entries[read_index].reset(); + } else { + if (write_index != read_index) { + _entries[write_index] = _entries[read_index]; + } + write_index += 1; + } + } + } + + for (auto i = write_index; i < max_entries; i += 1) { + _entries[i].reset(); + } + _active_count = write_index; + } + } +}; + +}// namespace djc::service \ No newline at end of file diff --git a/DJC-Firmware/src/djc/service/Service.hpp b/DJC-Firmware/src/djc/service/Service.hpp new file mode 100644 index 0000000..4d7556e --- /dev/null +++ b/DJC-Firmware/src/djc/service/Service.hpp @@ -0,0 +1,20 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +namespace djc::service { + +struct ServiceTag {}; + +/// @brief Service Static inteface, provides NonCopyable & Pollable mixins +template struct Service : + + ServiceTag, + kf::mixin::NonCopyable, + kf::mixin::TimedPollable {}; + +}// namespace djc::service \ No newline at end of file diff --git a/DJC-Firmware/src/djc/system/ConfigSystem.hpp b/DJC-Firmware/src/djc/system/ConfigSystem.hpp new file mode 100644 index 0000000..919d806 --- /dev/null +++ b/DJC-Firmware/src/djc/system/ConfigSystem.hpp @@ -0,0 +1,74 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "djc/config/DeviceConfig.hpp" +#include "djc/config/UserConfig.hpp" +#include "djc/mixin/ServiceOwner.hpp" +#include "djc/service/ConfigService.hpp" +#include "djc/system/System.hpp" + +namespace djc::system { + +/// @brief System owning the config service, handling deferred NVS operations +/// @tparam I Config Implementation (Must inherit from `::djc::config::ConfigTag`) +/// @note Wraps ConfigService, load on init, and polls it periodically +template struct ConfigSystem : + + System, void()>, + mixin::ServiceOwner + +{ + KF_CHECK_IMPL(I, ::djc::config::ConfigTag); + using ConfigImpl = I; + + explicit ConfigSystem(const char *nvs_namespace) noexcept : + mixin::ServiceOwner{service::ConfigService{nvs_namespace, _sync_timer_config, _config.view()}} {} + + /// @brief Get readonly reference to the current configuration + [[nodiscard]] constexpr const ConfigImpl &config() const noexcept { + return _config; + } + + /// @brief Get mutable reference to the current configuration + [[nodiscard]] ConfigImpl &config() noexcept { + return _config; + } + +private: + static constexpr kf::math::Timer::Config _sync_timer_config{ + .period = 10'000, + }; + + ConfigImpl _config{ConfigImpl::defaults()}; + + using This = ConfigSystem; + + KF_IMPL_INITABLE(This, void()); + void initImpl() noexcept { + this->service().resettingStrategy([](kf::Slice view) { + if (auto c = ConfigImpl::interpret(view); c.isSome()) { + c.unwrap().reset(); + } + }); + + this->service().onLoad([this](kf::Slice view) { + if (auto c = ConfigImpl::interpret(view); c.isSome()) { + if (not c.unwrap().isLatest()) { + this->service().requestReset(); + } + } + }); + + this->service().requestLoad(); + this->service().sync(); + } + + KF_IMPL_TIMED_POLLABLE(This); + void pollImpl(kf::math::Milliseconds now) noexcept { + this->service().poll(now); + } +}; + +}// namespace djc::system \ No newline at end of file diff --git a/DJC-Firmware/src/djc/system/ControlSystem.hpp b/DJC-Firmware/src/djc/system/ControlSystem.hpp new file mode 100644 index 0000000..feb4f75 --- /dev/null +++ b/DJC-Firmware/src/djc/system/ControlSystem.hpp @@ -0,0 +1,50 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "djc/ManualInput.hpp" +#include "djc/Periphery.hpp" +#include "djc/mixin/ServiceOwner.hpp" +#include "djc/protocol/ProtocolLink.hpp" +#include "djc/service/ControlService.hpp" +#include "djc/system/System.hpp" +#include "djc/transport/TransportLink.hpp" + +namespace djc::system { + +/// @brief System managing manual control output +/// @note Owns Control service. Depends Periphery (joysticks), TransportLink and ProtocolLink. +struct ControlSystem : + + System, + mixin::ServiceOwner + +{ + explicit ControlSystem(Periphery &periphery, transport::TransportLink &transport_link, protocol::ProtocolLink &protocol_link) noexcept : + mixin::ServiceOwner{service::ControlService{transport_link, protocol_link}}, _periphery{periphery} {} + +private: + Periphery &_periphery; + + KF_IMPL_INITABLE(ControlSystem, void()); + void initImpl() noexcept {} + + KF_IMPL_TIMED_POLLABLE(ControlSystem); + void pollImpl(kf::math::Milliseconds now) noexcept { + if (this->service().enabled()) { + using I = ManualInput; + + this->service().input(I{ + .left_x = I::fromNormalized(_periphery.left_joystick.axis_x.read()), + .left_y = I::fromNormalized(_periphery.left_joystick.axis_y.read()), + .right_x = I::fromNormalized(_periphery.right_joystick.axis_x.read()), + .right_y = I::fromNormalized(_periphery.right_joystick.axis_y.read()), + }); + } + + this->service().poll(now); + } +}; + +}// namespace djc::system \ No newline at end of file diff --git a/DJC-Firmware/src/djc/system/GraphicsSystem.hpp b/DJC-Firmware/src/djc/system/GraphicsSystem.hpp new file mode 100644 index 0000000..0e63d43 --- /dev/null +++ b/DJC-Firmware/src/djc/system/GraphicsSystem.hpp @@ -0,0 +1,152 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "djc/mixin/ServiceOwner.hpp" +#include "djc/service/DisplayService.hpp" +#include "djc/system/System.hpp" +#include "djc/ui/VirtualKeyboard.hpp" + +namespace djc::system { + +/// @brief System managing display output, canvas, and virtual keyboard. +template struct GraphicsSystem : + + System, void(I &)>, + mixin::ServiceOwner> + +{ + using DisplayDriverImpl = I; + using DisplayServiceImpl = service::DisplayService; + + using Pixel = typename DisplayDriverImpl::PixelImpl; + using Color = typename Pixel::ColorType; + using Canvas = typename kf::gfx::Canvas; + using Palette = kf::gfx::Palette; + + explicit GraphicsSystem(I &display_driver, const ui::VirtualKeyboard &virtual_keyboard) noexcept : + mixin::ServiceOwner{DisplayServiceImpl{display_driver}}, _virtual_keyboard{virtual_keyboard} {} + + [[nodiscard]] auto canvas() const noexcept -> const kf::Option & { + return _canvas; + } + + void overlay(kf::memory::StringView new_overlay, Color color) noexcept { + _overlay = new_overlay; + _overlay_color = color; + this->service().requestSend(); + } + + void onRender(kf::memory::StringView str) noexcept { + if (_canvas.isNone()) { return; } + auto &canvas = _canvas.unwrap(); + + _canvas.unwrap().background(Palette::black); + _canvas.unwrap().foreground(Palette::white); + + _canvas.unwrap().fill(); + + if (_virtual_keyboard.active()) { + renderVirtualKeyboard(canvas); + } else { + renderUi(canvas, str); + } + + this->service().requestSend(); + } + +private: + static constexpr auto overlay_text_padding{1}; + + const ui::VirtualKeyboard &_virtual_keyboard; + kf::Option _canvas{kf::none}; + kf::memory::StringView _overlay{}; + Color _overlay_color{}; + + void renderUi(Canvas &canvas, kf::memory::StringView str) noexcept { + canvas.background(Palette::black); + canvas.foreground(Palette::white); + canvas.text(0, 0, str); + + if (not _overlay.empty()) { + const auto rows = 1 + (_overlay.size() / canvas.widthInGlyphs()); + const auto y = static_cast(canvas.maxY() - rows * canvas.font().heightTotal()); + + canvas.foreground(_overlay_color); + canvas.rect(0, y, canvas.maxX(), canvas.maxY(), true); + + canvas.background(_overlay_color); + canvas.foreground(Palette::black); + canvas.text(overlay_text_padding, y, _overlay); + } + } + + void renderVirtualKeyboard(Canvas &canvas) noexcept { + const auto longest_row = ui::VirtualKeyboard::rows[0].size(); + const auto key_width = canvas.width() / longest_row; + const auto key_height = canvas.font().heightTotal(); + const auto keyboard_offset_y = canvas.maxY() - key_height * _virtual_keyboard.rowsTotal(); + const auto glyph_offset_x = (key_width - canvas.font().widthTotal()) / 2; + + canvas.text(0, 0, kf::memory::StaticString<32>::formatted("\xBC\xF0Text Input: %d / %d\x80\n", _virtual_keyboard.capacity(), _virtual_keyboard.text().size()).data()); + canvas.text(0, canvas.font().heightTotal(), _virtual_keyboard.text()); + + canvas.background(Palette::dark_gray); + canvas.foreground(Palette::dark_gray); + canvas.rect(0, keyboard_offset_y, canvas.maxX(), canvas.maxY(), true); + + for (auto row = 0; row < _virtual_keyboard.rowsTotal(); row += 1) { + const auto y = keyboard_offset_y + row * key_height; + const auto cols = ui::VirtualKeyboard::rows[row].size(); + + const auto x_offset = ((longest_row - cols) * key_width) / 2; + + for (auto col = 0; col < cols; col += 1) { + const auto x = col * key_width + x_offset; + + if (row == _virtual_keyboard.cursorRow() and col == _virtual_keyboard.cursorCol()) { + canvas.foreground(Palette::dark_blue); + canvas.rect(x, y, x + key_width, y + key_height - 1, true); + + canvas.background(Palette::dark_blue); + canvas.foreground(Palette::white); + } else { + canvas.background(Palette::dark_gray); + canvas.foreground(Palette::black); + } + + const auto &key = ui::VirtualKeyboard::keyAt(row, col); + + canvas.glyph(x + glyph_offset_x, y, key.isCommon() ? key.value(_virtual_keyboard.shifted()) : '?'); + } + } + } + + using This = GraphicsSystem; + + KF_IMPL_INITABLE(This, void(I &)); + void initImpl(I &display_driver) noexcept { + _canvas = kf::some(Canvas{ + kf::image::DynamicImage{display_driver.image()}, + typename Canvas::State{ + .active_font = kf::someRef(kf::gfx::fonts::gyver_5x7_en), + .auto_next_line = true, + }, + }); + } + + KF_IMPL_TIMED_POLLABLE(This); + void pollImpl(kf::math::Milliseconds now) noexcept { + this->service().poll(now); + } +}; + +}// namespace djc::system \ No newline at end of file diff --git a/DJC-Firmware/src/djc/system/InputSystem.hpp b/DJC-Firmware/src/djc/system/InputSystem.hpp new file mode 100644 index 0000000..802de06 --- /dev/null +++ b/DJC-Firmware/src/djc/system/InputSystem.hpp @@ -0,0 +1,35 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "djc/Periphery.hpp" +#include "djc/config/DeviceConfig.hpp" +#include "djc/mixin/ServiceOwner.hpp" +#include "djc/service/InputHandler.hpp" +#include "djc/system/System.hpp" + +namespace djc::system { + +/// @brief System managing user input +/// @note Owns InputHandler service. Depends on DeviceConfig (read‑only) and Periphery. +struct InputSystem : + + System, + mixin::ServiceOwner + +{ + explicit InputSystem(const config::DeviceConfig &config, Periphery &periphery) noexcept : + mixin::ServiceOwner{service::InputHandler{config.input_handler, periphery}} {} + +private: + KF_IMPL_INITABLE(InputSystem, void()); + void initImpl() noexcept {} + + KF_IMPL_TIMED_POLLABLE(InputSystem); + void pollImpl(kf::math::Milliseconds now) noexcept { + this->service().poll(now); + } +}; + +}// namespace djc::system \ No newline at end of file diff --git a/DJC-Firmware/src/djc/system/PeerSystem.hpp b/DJC-Firmware/src/djc/system/PeerSystem.hpp new file mode 100644 index 0000000..903fd57 --- /dev/null +++ b/DJC-Firmware/src/djc/system/PeerSystem.hpp @@ -0,0 +1,106 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include + +#include "djc/PeerFavoritesRegistry.hpp" +#include "djc/config/DeviceConfig.hpp" +#include "djc/service/AutoConnectService.hpp" +#include "djc/service/PeerScanningService.hpp" +#include "djc/system/System.hpp" +#include "djc/transport/TransportLink.hpp" + +namespace djc::system { + +/// @brief System managing peer favorites, scanning and auto-connection +/// @note Owns PeerFavoritesRegistry, PeerScanningService, AutoConnectService. +/// @note Depends on DeviceConfig (readonly) and TransportLink (for scanning and connection). +/// @note On each poll, scans visible peers and triggers auto-connection to the most trusted visible favorite. +/// @note Peer favorites registry entries source should set externally +struct PeerSystem : + + System + +{ + explicit PeerSystem(const config::DeviceConfig &config, transport::TransportLink &transport_link) noexcept : + _peer_scanning_service{config.peer_scanner, transport_link}, + _auto_connect_service{config.auto_connect_service, transport_link} {} + + /// @brief Get mutable access to peer favorites registry component + PeerFavoritesRegistry &favoritesRegistry() noexcept { + return _peer_favorites_registry; + } + + /// @brief Get readonly access to peer favorites registry component + constexpr const PeerFavoritesRegistry &favoritesRegistry() const noexcept { + return _peer_favorites_registry; + } + + /// @brief Get mutable access to peer scanning service component + service::PeerScanningService &scanningService() noexcept { + return _peer_scanning_service; + } + + /// @brief Get readonly access to peer scanning service component + constexpr const service::PeerScanningService &scanningService() const noexcept { + return _peer_scanning_service; + } + + /// @brief Get mutable access to auto‑connect service component + service::AutoConnectService &autoConnectService() noexcept { + return _auto_connect_service; + } + + /// @brief Get readonly access to auto‑connect service component + constexpr const service::AutoConnectService &autoConnectService() const noexcept { + return _auto_connect_service; + } + +private: + static constexpr auto logger{kf::Logger::create("PeerSystem")}; + + PeerFavoritesRegistry _peer_favorites_registry{}; + service::PeerScanningService _peer_scanning_service; + service::AutoConnectService _auto_connect_service; + + KF_IMPL_INITABLE(PeerSystem, void()); + void initImpl() noexcept { + _peer_scanning_service.init(); + } + + KF_IMPL_TIMED_POLLABLE(PeerSystem); + void pollImpl(kf::math::Milliseconds now) noexcept { + _peer_scanning_service.poll(now); + + if (_auto_connect_service.config().enabled and _auto_connect_service.target().isNone()) { + const auto favorites = _peer_favorites_registry.all(); + + if (favorites.size() > 0) { + auto most_trusted_favorite_index = 0u; + + for (auto index = 1u; index < favorites.size(); index += 1) { + if (favorites[index].isSome() and favorites[most_trusted_favorite_index].isSome() and favorites[index].unwrap().trust > favorites[most_trusted_favorite_index].unwrap().trust) { + most_trusted_favorite_index = index; + } + } + + if (const auto &most_trusted = favorites[most_trusted_favorite_index]; most_trusted.isSome()) { + for (const auto &peer: _peer_scanning_service.peers()) { + if (peer.isSome() and peer.unwrap().address == most_trusted.unwrap().address) { + _auto_connect_service.target(most_trusted.unwrap().address); + break; + } + } + } + } + } + + _auto_connect_service.poll(now); + } +}; + +}// namespace djc::system \ No newline at end of file diff --git a/DJC-Firmware/src/djc/system/PeripherySystem.hpp b/DJC-Firmware/src/djc/system/PeripherySystem.hpp new file mode 100644 index 0000000..0d76f70 --- /dev/null +++ b/DJC-Firmware/src/djc/system/PeripherySystem.hpp @@ -0,0 +1,41 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "djc/Periphery.hpp" +#include "djc/config/DeviceConfig.hpp" +#include "djc/system/System.hpp" + +namespace djc::system { + +/// @brief System that owns and initializes hardware peripherals +/// @note Provides access to peripherals for other systems via getters; no periodic polling needed. +struct PeripherySystem : System { + + explicit PeripherySystem(const config::DeviceConfig &config) noexcept : + _periphery{config.periphery} {} + + /// @brief Get mutable access to periphery component + Periphery &periphery() noexcept { + return _periphery; + } + + /// @brief Get readonly access to periphery component + constexpr const Periphery &periphery() const noexcept { + return _periphery; + } + +private: + Periphery _periphery; + + KF_IMPL_INITABLE(PeripherySystem, void()); + void initImpl() noexcept { + _periphery.init(); + } + + KF_IMPL_TIMED_POLLABLE(PeripherySystem); + void pollImpl(kf::math::Milliseconds now) noexcept {} +}; + +}// namespace djc::system \ No newline at end of file diff --git a/DJC-Firmware/src/djc/system/ProtocolSystem.hpp b/DJC-Firmware/src/djc/system/ProtocolSystem.hpp new file mode 100644 index 0000000..3db0aa4 --- /dev/null +++ b/DJC-Firmware/src/djc/system/ProtocolSystem.hpp @@ -0,0 +1,81 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include "djc/MavlinkTelemetryRegistry.hpp" +#include "djc/config/DeviceConfig.hpp" +#include "djc/protocol/ProtocolLink.hpp" +#include "djc/protocol/ProtocolRegistry.hpp" +#include "djc/system/System.hpp" + +namespace djc::system { + +/// @brief System managing communication protocols and telemetry registry. +/// @note Owns ProtocolRegistry, ProtocolLink, and MavlinkTelemetryRegistry. +/// @note Protocol polling is handled externally. +/// @note MAVLink callback is configured in init(). +struct ProtocolSystem : System { + + explicit ProtocolSystem(const config::DeviceConfig &config) noexcept : + _protocol_registry{config.protocol_registry}, + _protocol_link{config.protocol_link} {} + + /// @brief Get mutable access to protocol link component + protocol::ProtocolLink &link() noexcept { + return _protocol_link; + } + + /// @brief Get readonly access to protocol link component + constexpr const protocol::ProtocolLink &link() const noexcept { + return _protocol_link; + } + + /// @brief Get mutable access to protocol registry component + protocol::ProtocolRegistry &protocolRegistry() noexcept { + return _protocol_registry; + } + + /// @brief Get readonly access to protocol registry component + constexpr const protocol::ProtocolRegistry &protocolRegistry() const noexcept { + return _protocol_registry; + } + + /// @brief Get mutable access to MAVLink telemetry registry component + MavlinkTelemetryRegistry &mavlinkTelemetryRegistry() noexcept { + return _mavlink_telemetry_registry; + } + + /// @brief Get readonly access to MAVLink telemetry registry component + constexpr const MavlinkTelemetryRegistry &mavlinkTelemetryRegistry() const noexcept { + return _mavlink_telemetry_registry; + } + +private: + static constexpr auto logger{kf::Logger::create("ProtocolSystem")}; + + MavlinkTelemetryRegistry _mavlink_telemetry_registry{}; + protocol::ProtocolRegistry _protocol_registry; + protocol::ProtocolLink _protocol_link; + kf::math::Milliseconds _poll_time{}; + + KF_IMPL_INITABLE(ProtocolSystem, void(protocol::ProtocolRegistry::Mode)); + void initImpl(protocol::ProtocolRegistry::Mode mode) noexcept { + _protocol_registry.mavlink().callback([this](const auto &message) { + _mavlink_telemetry_registry.update(static_cast(_poll_time), message); + }); + + _protocol_link.protocol(_protocol_registry.get(mode)); + } + + KF_IMPL_TIMED_POLLABLE(ProtocolSystem); + void pollImpl(kf::math::Milliseconds now) noexcept { + _poll_time = now; + + // protocol link polling in control component + } +}; + +}// namespace djc::system \ No newline at end of file diff --git a/DJC-Firmware/src/djc/system/System.hpp b/DJC-Firmware/src/djc/system/System.hpp new file mode 100644 index 0000000..910ec48 --- /dev/null +++ b/DJC-Firmware/src/djc/system/System.hpp @@ -0,0 +1,23 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +namespace djc::system { + +struct SystemTag {}; + +/// @brief System static inteface +/// @note System owns a group of related services and components, provides `init()` and `poll(now)`, and is orchestrated from main. +template struct System : + + SystemTag, + kf::mixin::NonCopyable, + kf::mixin::Initable, + kf::mixin::TimedPollable {}; + +}// namespace djc::system \ No newline at end of file diff --git a/DJC-Firmware/src/djc/system/TransportSystem.hpp b/DJC-Firmware/src/djc/system/TransportSystem.hpp new file mode 100644 index 0000000..9d0b750 --- /dev/null +++ b/DJC-Firmware/src/djc/system/TransportSystem.hpp @@ -0,0 +1,68 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include + +#include "djc/config/DeviceConfig.hpp" +#include "djc/system/System.hpp" +#include "djc/transport/Kind.hpp" +#include "djc/transport/TransportLink.hpp" +#include "djc/transport/TransportRegistry.hpp" + +namespace djc::system { + +/// @brief System managing transport, Wraps TransportRegistry and TransportLink +/// @note Initializes WiFi STA mode, and polls the link for connection timeouts. +struct TransportSystem : System { + + explicit TransportSystem(const config::DeviceConfig &config) noexcept : + _transport_link{config.transport_link} {} + + /// @brief Get mutable access to transport link component + transport::TransportLink &link() noexcept { + return _transport_link; + } + + /// @brief Get readonly access to transport link component + constexpr const transport::TransportLink &link() const noexcept { + return _transport_link; + } + + /// @brief Get mutable access to transport registry component + transport::TransportRegistry ®istry() noexcept { + return _transport_registry; + } + + /// @brief Get readonly access to transport registry component + constexpr const transport::TransportRegistry ®istry() const noexcept { + return _transport_registry; + } + +private: + static constexpr auto logger{kf::Logger::create("TransportSystem")}; + + transport::TransportRegistry _transport_registry{}; + transport::TransportLink _transport_link; + + KF_IMPL_INITABLE(TransportSystem, void(transport::Kind)); + void initImpl(transport::Kind kind) noexcept { + WiFi.mode(WIFI_MODE_STA); + + if (not _transport_registry.espnow().init()) { + logger.error("failed to initialize espnow transport"); + } + + _transport_link.transport(_transport_registry.get(kind)); + } + + KF_IMPL_TIMED_POLLABLE(TransportSystem); + void pollImpl(kf::math::Milliseconds now) noexcept { + _transport_link.poll(now); + } +}; + +}// namespace djc::system \ No newline at end of file diff --git a/DJC-Firmware/src/djc/system/UiSystem.hpp b/DJC-Firmware/src/djc/system/UiSystem.hpp new file mode 100644 index 0000000..57b2aa9 --- /dev/null +++ b/DJC-Firmware/src/djc/system/UiSystem.hpp @@ -0,0 +1,88 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include +#include + +#include "djc/config/UserConfig.hpp" +#include "djc/mixin/ServiceOwner.hpp" +#include "djc/system/System.hpp" +#include "djc/ui/UI.hpp" +#include "djc/ui/VirtualKeyboard.hpp" +#include "djc/ui/pages/RootPage.hpp" + +namespace djc::system { + +/// @brief System that owns UI components: renderer, virtual keyboard, UI instance and root page +/// @note Initialises the UI with the root page and triggers an initial update event. Polls the UI on every main loop iteration +struct UiSystem : + + System)>, + mixin::ServiceOwner + +{ + using Renderer = ui::UI::Traits::RendererImpl; + + explicit UiSystem(const config::UserConfig &config) noexcept : + mixin::ServiceOwner{djc::ui::UI{_renderer, _virtual_keyboard}}, + _renderer{config.ui_renderer, _buffer.slice()} {} + + /// @brief Get mutable access to renderer component + Renderer &renderer() noexcept { + return _renderer; + } + + /// @brief Get readonly access to renderer component + constexpr const Renderer &renderer() const noexcept { + return _renderer; + } + + /// @brief Get mutable access to virtual keyboard component + ui::VirtualKeyboard &virtualKeyboard() noexcept { + return _virtual_keyboard; + } + + /// @brief Get readonly access to virtual keyboard component + constexpr const ui::VirtualKeyboard &virtualKeyboard() const noexcept { + return _virtual_keyboard; + } + + /// @brief Get mutable access to ui service + ui::pages::RootPage &rootPage() noexcept { + return _root_page; + } + + /// @brief Get readonly access to ui service + constexpr const ui::pages::RootPage &rootPage() const noexcept { + return _root_page; + } + +private: + static constexpr auto logger{kf::Logger::create("UiSystem")}; + + kf::memory::Array _buffer; + Renderer _renderer; + ui::VirtualKeyboard _virtual_keyboard{}; + ui::pages::RootPage _root_page{this->service()}; + + KF_IMPL_INITABLE(UiSystem, void(std::initializer_list)); + void initImpl(std::initializer_list pages) noexcept { + for (auto page: pages) { + _root_page.attach(*page); + } + + this->service().activePage(_root_page); + this->service().requestRender(); + } + + KF_IMPL_TIMED_POLLABLE(UiSystem); + void pollImpl(kf::math::Milliseconds now) noexcept { + this->service().poll(now); + } +}; + +}// namespace djc::system \ No newline at end of file diff --git a/DJC-Firmware/src/djc/transport/EspNowTransport.hpp b/DJC-Firmware/src/djc/transport/EspNowTransport.hpp new file mode 100644 index 0000000..52c6a0e --- /dev/null +++ b/DJC-Firmware/src/djc/transport/EspNowTransport.hpp @@ -0,0 +1,105 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "djc/transport/PeerAddress.hpp" +#include "djc/transport/Transport.hpp" + +namespace djc::transport { + +/// @brief ESP‑NOW transport implementation +/// @note Manages ESP‑NOW peer connections. uses dedicated active peer for communication +struct EspNowTransport : Transport, kf::mixin::Initable { + + [[nodiscard]] bool send(kf::Slice buffer) noexcept override { + if (_active_peer.isSome()) { + return _active_peer.unwrap().writeBuffer(buffer).isOk(); + } else { + return false; + } + } + +protected: + /// @brief Establish a connection to a peer + /// @param address The peer's address (must be of kind `EspNow`) + /// @return true on success, false on failure + /// @note Adds the peer to ESP‑NOW and sets up a receive callback + [[nodiscard]] bool doConnect(const PeerAddress &address) noexcept override { + if (address.kind() != Kind::EspNow) { return false; } + + auto peer_result = EspNow::Peer::create(EspNow::Peer::Config{ + .mac_address = address.mac(), + .wifi_interface_sta = true, + }); + + if (peer_result.isError()) { + logger.error(LogString::formatted("Connect to '%s' failed: %s", address.mac().toString().data(), peer_result.error().toString().data()).view()); + return false; + } + + _active_peer = kf::some(std::move(peer_result.ok())); + + logger.info(LogString::formatted("Connected: primary peer set '%s'", address.mac().toString().data()).view()); + return true; + } + + /// @brief Disconnect from the current peer + /// @note Removes the peer from ESP‑NOW and clears internal state + void doDisconnect() noexcept override { + if (not connected()) { + logger.warn("Disconnect failed: No active peer"); + return; + } + + auto &peer = _active_peer.unwrap(); + if (not peer.exist()) { + logger.error("Disconnect failed: Peer not exit"); + return; + } + + _active_peer.reset(); + logger.info("Disconnected: OK"); + } + +private: + using EspNow = kf::network::EspNow; + using LogString = kf::memory::StaticString<128>; + + static constexpr auto logger{kf::Logger::create("EspNowTransport")}; + + kf::Option _active_peer{kf::none}; + + KF_IMPL_INITABLE(EspNowTransport, bool()); + bool initImpl() noexcept { + logger.info("init"); + + auto &espnow = EspNow::instance(); + + const auto result = espnow.init(); + if (result.isError()) { + logger.error(LogString::formatted("Failed to initialize ESP-NOW: %s", result.error().toString().data())); + return false; + } + + espnow.callback([this](const kf::network::MacAddress &mac, kf::Slice buffer) { + if (_active_peer.isSome() and _active_peer.unwrap().mac() == mac) { + invokeReceive(buffer); + } else { + invokeReceiveForeign(PeerAddress::fromEspnowMac(mac), buffer); + } + }); + + return true; + } +}; + +}// namespace djc::transport \ No newline at end of file diff --git a/DJC-Firmware/src/djc/transport/Kind.hpp b/DJC-Firmware/src/djc/transport/Kind.hpp new file mode 100644 index 0000000..f85738a --- /dev/null +++ b/DJC-Firmware/src/djc/transport/Kind.hpp @@ -0,0 +1,15 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +namespace djc::transport { + +/// @brief Identifies the underlying transport technology of a peer address. +enum class Kind : char { + + /// @brief ESP‑NOW protocol (built‑in WiFi, peer‑to‑peer frames). + EspNow = 0x00, +}; + +}// namespace djc::transport \ No newline at end of file diff --git a/DJC-Firmware/src/djc/transport/PeerAddress.hpp b/DJC-Firmware/src/djc/transport/PeerAddress.hpp new file mode 100644 index 0000000..87080b0 --- /dev/null +++ b/DJC-Firmware/src/djc/transport/PeerAddress.hpp @@ -0,0 +1,86 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +#include "djc/transport/Kind.hpp" + +namespace djc::internal { + +using PeerAddressStringType = kf::memory::StaticString<32>; + +} + +namespace djc::transport { + +/// @brief Unified address of a peer, independent of the underlying transport. +/// +/// Holds either a MAC address (ESP‑NOW). +/// The active kind is stored in a tag field; the union contains the actual address. +struct PeerAddress : kf::mixin::StringRepresentable { + + using StringType = internal::PeerAddressStringType; + + /// @brief create an ESP‑NOW peer address from a MAC. + /// @param mac 6‑byte MAC address (EspNow::Mac). + static constexpr PeerAddress fromEspnowMac(const kf::network::MacAddress &mac) noexcept { + PeerAddress ret{}; + ret._kind = Kind::EspNow, + ret._mac = mac; + return ret; + } + + /// @brief Return the kind of transport this address belongs to. + [[nodiscard]] Kind kind() const noexcept { + return _kind; + } + + /// @brief Return the stored MAC address. + /// @note available if kind() == Kind::EspNow. + [[nodiscard]] kf::network::MacAddress mac() const noexcept { + return _mac; + } + + /// @brief Equality comparison. + /// @note Two addresses are equal if they have the same kind and the same underlying value. + [[nodiscard]] bool operator==(const PeerAddress &other) const noexcept { + if (other.kind() != _kind) { return false; } + + switch (_kind) { + case Kind::EspNow: + return other.mac() == this->mac(); + + default: + return false; + } + } + + /// @brief Inequality comparison (delegates to operator==). + [[nodiscard]] bool operator!=(const PeerAddress &other) const noexcept { + return not this->operator==(other); + } + +private: + Kind _kind; + + union { + kf::network::MacAddress _mac; + }; + + KF_IMPL_STRING_REPRESENTABLE(PeerAddress, StringType); + auto toStringImpl() const noexcept { + switch (_kind) { + case Kind::EspNow: + return StringType::formatted("%s@EspNow", _mac.toString().data()); + + default: + return StringType{}; + } + } +}; + +}// namespace djc::transport \ No newline at end of file diff --git a/DJC-Firmware/src/djc/transport/Transport.hpp b/DJC-Firmware/src/djc/transport/Transport.hpp new file mode 100644 index 0000000..1f92ec2 --- /dev/null +++ b/DJC-Firmware/src/djc/transport/Transport.hpp @@ -0,0 +1,111 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include + +#include "djc/transport/PeerAddress.hpp" + +namespace djc::transport { + +/// @brief Abstract transport layer for peer-to-peer communication. +/// +/// Manages the lifecycle of a connection to a single peer. Subclasses implement +/// the actual communication hardware +/// +/// @note The connection state (`_active_peer`) is managed by the base class. +struct Transport : kf::mixin::NonCopyable { + + /// @brief Callback invoked when data is received from a peer. + using ReceiveCallback = kf::Function)>; + + /// @brief Send raw data to the currently connected peer. + /// @param buffer Raw payload. + /// @return true if the data was sent successfully, false otherwise. + /// @note Must only be called when connected. + [[nodiscard]] virtual bool send(kf::Slice buffer) noexcept = 0; + +protected: + /// @brief Hardware‑specific connection procedure. + /// @param addr Address of the peer. + /// @return true on success, false on failure. + /// @note Implementations should handle invalid or incompatible address types. + [[nodiscard]] virtual bool doConnect(const PeerAddress &address) noexcept = 0; + + /// @brief Hardware‑specific disconnection procedure. + /// @note Called even if not connected; implementations must be safe. + virtual void doDisconnect() noexcept = 0; + +public: + /// @brief Register a callback for incoming data. + /// @param callback Functor invoked on each received packet. + void onReceive(ReceiveCallback &&callback) noexcept { + _receive_callback = kf::some(std::move(callback)); + } + + /// @brief Register a callback for incoming data from non-primary peer + /// @param callback Functor invoked on each received packet. + void onReceiveForeign(ReceiveCallback &&callback) noexcept { + _broadcast_receive_callback = kf::some(std::move(callback)); + } + + /// @brief Check whether the transport is currently connected to a peer. + /// @return true if a peer is active, false otherwise. + [[nodiscard]] bool connected() const noexcept { + return _active_peer_address.isSome(); + } + + /// @brief Get the address of the currently connected peer. + /// @return Option containing the peer address if connected, empty Option otherwise. + [[nodiscard]] auto activePeerAddress() const noexcept -> kf::Option { + return _active_peer_address.isNone() ? kf::none : kf::someRef(_active_peer_address.unwrap()); + } + + /// @brief Connect to a remote peer. + /// @param peer_address Address of the peer to connect to. + /// @return true on success, false on failure. + [[nodiscard]] bool connect(const PeerAddress &address) noexcept { + if (connected()) { + if (_active_peer_address.unwrap() == address) { return true; }// already on this peer + + disconnect(); + } + + if (not doConnect(address)) { return false; } + + _active_peer_address = kf::someTrivial(address); + + return true; + } + + /// @brief Disconnect from the current peer. + /// @note Safe to call even if not connected. + void disconnect() noexcept { + doDisconnect(); + _active_peer_address.reset(); + } + +protected: + void invokeReceive(kf::Slice buffer) noexcept { + if (_active_peer_address.isSome() and _receive_callback.isSome()) { + _receive_callback.unwrap()(_active_peer_address.unwrap(), buffer); + } + } + + void invokeReceiveForeign(const PeerAddress &address, kf::Slice buffer) noexcept { + if (_broadcast_receive_callback.isSome()) { + _broadcast_receive_callback.unwrap()(address, buffer); + } + } + +private: + kf::Option _receive_callback{kf::none}, _broadcast_receive_callback{kf::none}; + kf::TrivialOption _active_peer_address{kf::none}; +}; + +}// namespace djc::transport \ No newline at end of file diff --git a/DJC-Firmware/src/djc/transport/TransportLink.hpp b/DJC-Firmware/src/djc/transport/TransportLink.hpp new file mode 100644 index 0000000..4f737ca --- /dev/null +++ b/DJC-Firmware/src/djc/transport/TransportLink.hpp @@ -0,0 +1,156 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "djc/transport/PeerAddress.hpp" +#include "djc/transport/Transport.hpp" + +namespace djc::internal { + +/// @brief Configuration parameters for the Transport Link +struct TransportLinkConfig : kf::mixin::Resettable { + kf::math::Timer::Config disconnect_timer; + +private: + KF_IMPL_RESETTABLE(TransportLinkConfig); + void resetImpl() noexcept { + disconnect_timer.period = 15'000; + } +}; + +}// namespace djc::internal + +namespace djc::transport { + +/// @brief Connection manager for a single transport +/// @note Separates connection lifecycle and inactivity timeout from higher‑level logic. +/// Keeps the transport abstract: the rest of the firmware only talks to `TransportLink`, never to a concrete transport. +struct TransportLink : + + kf::mixin::NonCopyable, + kf::mixin::Configurable, + kf::mixin::TimedPollable + +{ + using Config = internal::TransportLinkConfig; + + using Configurable::Configurable; + + /// @brief Set the active transport, disconnecting any previous connection first. + /// @note Ensures that changing the transport does not leave a stale connection open, + /// which would silently keep receiving data on the old transport. + void transport(Transport &new_transport) noexcept { + if (_transport.isSome() and _transport.unwrap().connected()) { + _transport.unwrap().disconnect(); + } + + _transport = kf::someRef(new_transport); + } + + /// @brief Forward a data buffer to the underlying transport. + /// @return true if the transport reported success, false on error or if no transport is set. + [[nodiscard]] bool send(kf::Slice buffer) noexcept { + if (_transport.isNone()) { + logger.error("send failed: no transport set"); + return false; + } + + return _transport.unwrap().send(buffer); + } + + /// @brief Register a callback for incoming data. + /// @note The callback is invoked for every received packet. + /// This method overwrites the transport‑level receive handler so that each incoming packet also resets the inactivity timer. + void onReceive(Transport::ReceiveCallback &&callback) noexcept { + if (_transport.isNone()) { + logger.error("onReceive failed: no transport set"); + return; + } + + _receive_callback = kf::some(std::move(callback)); + + _transport.unwrap().onReceive([this](const PeerAddress &address, kf::Slice buffer) { + if (_receive_callback.isSome()) { + _receive_callback.unwrap()(address, buffer); + } + _disconnect_timer_reset_required = true; + }); + } + + /// @brief Register a callback for incoming data from other peers (non-primary) + void onReceiveForeign(Transport::ReceiveCallback &&callback) noexcept { + if (_transport.isNone()) { + logger.error("onReceiveForeign failed: no transport set"); + return; + } + + _transport.unwrap().onReceiveForeign(std::move(callback)); + } + + /// @brief Check whether the transport is currently connected. + [[nodiscard]] bool connected() const noexcept { + return _transport.isSome() and _transport.unwrap().connected(); + } + + /// @brief Return the address of the active peer, if any. + /// @return Reference to an empty option when no transport is set. + [[nodiscard]] auto activePeerAddress() const noexcept -> kf::Option { + return _transport.isNone() ? kf::none : _transport.unwrap().activePeerAddress(); + } + + /// @brief Initiate a connection to a peer. + [[nodiscard]] bool connect(const PeerAddress &address) noexcept { + if (_transport.isNone()) { + logger.error("connect failed: no transport set"); + return false; + } + + _disconnect_timer_reset_required = true; + + return _transport.unwrap().connect(address); + } + + /// @brief Disconnect from the current peer. + void disconnect() noexcept { + if (_transport.isNone()) { + logger.error("disconnect failed: no transport set"); + return; + } + + _transport.unwrap().disconnect(); + } + +private: + static constexpr auto logger{kf::Logger::create("TransportLink")}; + + kf::Option _transport{kf::none}; ///< Currently active transport (optional) + kf::Option _receive_callback{kf::none};///< User‑supplied callback for incoming data. + kf::math::Timer _disconnect_timer{this->config().disconnect_timer};///< Inactivity timer. + volatile bool _disconnect_timer_reset_required{false}; ///< Flag: reset timer on next poll. + + KF_IMPL_TIMED_POLLABLE(TransportLink); + void pollImpl(kf::math::Milliseconds now) noexcept { + if (not connected()) { return; } + + if (_disconnect_timer_reset_required) { + _disconnect_timer_reset_required = false; + _disconnect_timer.start(now); + } + + if (_disconnect_timer.expired(now)) { + disconnect(); + logger.info("Disconnect by timeout"); + } + } +}; + +}// namespace djc::transport \ No newline at end of file diff --git a/DJC-Firmware/src/djc/transport/TransportRegistry.hpp b/DJC-Firmware/src/djc/transport/TransportRegistry.hpp new file mode 100644 index 0000000..7534ba4 --- /dev/null +++ b/DJC-Firmware/src/djc/transport/TransportRegistry.hpp @@ -0,0 +1,32 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "djc/transport/EspNowTransport.hpp" +#include "djc/transport/Kind.hpp" +#include "djc/transport/Transport.hpp" + +namespace djc::transport { + +/// @brief Registry providing access to available transport implementations. +struct TransportRegistry { + + /// @brief Returns a transport instance by kind (ignored, always returns ESP-NOW). + /// @param kind Requested transport kind (not used, kept for future extension). + /// @return Reference to the ESP‑NOW transport. + [[nodiscard]] Transport &get(Kind kind) noexcept { + (void) kind; + return _espnow_transport; + } + + /// @brief Direct access to the ESP‑NOW transport instance. + [[nodiscard]] EspNowTransport &espnow() noexcept { + return _espnow_transport; + } + +private: + EspNowTransport _espnow_transport{}; +}; + +}// namespace djc::transport \ No newline at end of file diff --git a/DJC-Firmware/src/djc/ui/UI.hpp b/DJC-Firmware/src/djc/ui/UI.hpp index 1abc0fc..eb0fb80 100644 --- a/DJC-Firmware/src/djc/ui/UI.hpp +++ b/DJC-Firmware/src/djc/ui/UI.hpp @@ -3,16 +3,99 @@ #pragma once +#include +#include #include +#include +#include #include -#include +#include -namespace djc::ui { +#include "djc/service/Service.hpp" +#include "djc/ui/UiTraits.hpp" +#include "djc/ui/VirtualKeyboard.hpp" +#include "djc/ui/widgets/PeerDisplay.hpp" +#include "djc/ui/widgets/TextInput.hpp" + +#ifdef DJC_UI_RENDERER_IMPL_TEXTUAL_COLORED + +#include + +namespace djc::internal { + +/// @brief Render Engine: Buffered Colored Text UI render engine +using Renderer = ::kf::ui::render::ColoredTextRenderer; + +}// namespace djc::internal + +#else -// KiraFlux-Toolkit UI specialization for ESP32-DJC -using UI = kf::ui::UI< - kf::ui::render::ColoredTextRender<256>,// Render Engine: Buffered Colored Text UI render engine - kf::ui::Event<6> // Event: 6-bit Event value encoding +#include + +namespace djc::internal { + +using Renderer = ::kf::ui::render::PlainTextRenderer; + +} + +#endif + +namespace djc::internal { + +using WidgetBase = ::kf::ui::widgets::Widget< + Renderer, + ::kf::ui::Event<6>// Event: 6-bit Event value encoding >; -}// namespace djc \ No newline at end of file +using UiBase = ::kf::ui::UI<::djc::ui::UiTraits>; + +}// namespace djc::internal + +namespace djc::ui { + +/// @brief ESP32-DJC extended UI specialization +/// @note djc::pages must use fields from this service (`UI::Color`, `UI::Widget`, etc.) +struct UI : + + service::ServiceTag, + internal::UiBase + +{ + explicit constexpr UI(Traits::RendererImpl &render_system, VirtualKeyboard &virtual_keyboard) noexcept : + internal::UiBase{render_system}, _virtual_keyboard{virtual_keyboard} {} + + /// @brief UI Semantic Color + using Color = kf::ui::Color; + + /// @brief UI Widget Style + using Style = kf::ui::Style; + + /// @brief UI Page Layout + using Layout = kf::ui::Layout; + + /// @brief UI Widget Base + using Widget = internal::UiBase::Widget; + + /// @brief Transport Peer display Widget + struct PeerDisplay : widgets::PeerDisplay { + using widgets::PeerDisplay::PeerDisplay; + }; + + /// @brief Text input area via Virtual Keyboard + struct TextInput : widgets::TextInput { + using widgets::TextInput::TextInput; + }; + + /// @brief Create text input widget with virtual keyboard binding + [[nodiscard]] TextInput createTextInput(kf::Slice source = {}) noexcept { + return TextInput{ + _virtual_keyboard, + source, + }; + } + +private: + VirtualKeyboard &_virtual_keyboard; +}; + +}// namespace djc::ui \ No newline at end of file diff --git a/DJC-Firmware/src/djc/ui/UiTraits.hpp b/DJC-Firmware/src/djc/ui/UiTraits.hpp new file mode 100644 index 0000000..6c5cc6f --- /dev/null +++ b/DJC-Firmware/src/djc/ui/UiTraits.hpp @@ -0,0 +1,22 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +namespace djc::ui { + +struct UiTraitsTag {}; + +/// @brief ESP32-DJC extended UI Traits specialization +/// @tparam W Widget base class +/// @note Any Widget from `djc::widgets` should be noted that `` implements `djc::ui::UiTraits` +template struct UiTraits : + + UiTraitsTag, + kf::ui::UiTraits + +{}; + +}// namespace djc::ui \ No newline at end of file diff --git a/DJC-Firmware/src/djc/input/VirtualKeyboard.hpp b/DJC-Firmware/src/djc/ui/VirtualKeyboard.hpp similarity index 73% rename from DJC-Firmware/src/djc/input/VirtualKeyboard.hpp rename to DJC-Firmware/src/djc/ui/VirtualKeyboard.hpp index 64c8cec..c650fd4 100644 --- a/DJC-Firmware/src/djc/input/VirtualKeyboard.hpp +++ b/DJC-Firmware/src/djc/ui/VirtualKeyboard.hpp @@ -3,19 +3,16 @@ #pragma once +#include #include -#include #include -#include #include #include -#include +#include -namespace djc::input { +namespace djc::internal { -namespace internal { - -struct Key { +struct Key : kf::mixin::NonCopyable { enum class Kind : kf::u8 { Common, @@ -35,18 +32,17 @@ struct Key { constexpr char value(bool shifted = false) const noexcept { return shifted ? shift_value : normal_value; } + + constexpr bool isCommon() const noexcept { + return kind == Kind::Common; + } }; -} +}// namespace djc::internal -struct VirtualKeyboard final : kf::mixin::Singleton { +namespace djc::ui { - enum class Direction : kf::u8 { - Up = 0, - Down = 1, - Left = 2, - Right = 3, - }; +struct VirtualKeyboard final : kf::mixin::NonCopyable { enum class State : kf::u8 { Normal, @@ -58,7 +54,7 @@ struct VirtualKeyboard final : kf::mixin::Singleton { template using KeyRow = kf::memory::Array; - static constexpr KeyRow<14> row_0{{ + static constexpr KeyRow<14> row_0{{{ {'`', '~'}, {'1', '!'}, {'2', '@'}, @@ -72,10 +68,10 @@ struct VirtualKeyboard final : kf::mixin::Singleton { {'0', ')'}, {'-', '_'}, {'=', '+'}, - {Key::Kind::Backspace, 0}, - }}; + {Key::Kind::Backspace, 0}, + }}}; - static constexpr KeyRow<13> row_1{{ + static constexpr KeyRow<13> row_1{{{ {'q', 'Q'}, {'w', 'W'}, {'e', 'E'}, @@ -89,9 +85,9 @@ struct VirtualKeyboard final : kf::mixin::Singleton { {'[', '{'}, {']', '}'}, {'\\', '|'}, - }}; + }}}; - static constexpr KeyRow<12> row_2{{ + static constexpr KeyRow<12> row_2{{{ {'a', 'A'}, {'s', 'S'}, {'d', 'D'}, @@ -104,9 +100,9 @@ struct VirtualKeyboard final : kf::mixin::Singleton { {';', ':'}, {'\'', '"'}, {Key::Kind::Enter, '\n'}, - }}; + }}}; - static constexpr KeyRow<10> row_3{{ + static constexpr KeyRow<10> row_3{{{ {Key::Kind::Shift, 0}, {'z', 'Z'}, {'x', 'X'}, @@ -117,19 +113,19 @@ struct VirtualKeyboard final : kf::mixin::Singleton { {'m', 'M'}, {',', '<'}, {'.', '>'}, - }}; + }}}; - static constexpr KeyRow<1> row_4{{ + static constexpr KeyRow<1> row_4{{{ {Key::Kind::Space, ' '}, - }}; + }}}; - static constexpr kf::memory::Array, 5> rows{{ - {row_0.data(), row_0.size()}, - {row_1.data(), row_1.size()}, - {row_2.data(), row_2.size()}, - {row_3.data(), row_3.size()}, - {row_4.data(), row_4.size()}, - }}; + static constexpr kf::memory::Array, 5> rows{{{ + row_0.slice(), + row_1.slice(), + row_2.slice(), + row_3.slice(), + row_4.slice(), + }}}; [[nodiscard]] kf::u8 rowsTotal() const noexcept { return rows.size(); } @@ -143,40 +139,46 @@ struct VirtualKeyboard final : kf::mixin::Singleton { [[nodiscard]] bool active() const noexcept { return _active; } - [[nodiscard]] kf::memory::StringView text() const noexcept { return {_text_source.data(), _text_source.size()}; } + [[nodiscard]] kf::memory::StringView text() const noexcept { + return kf::memory::StringView{_text_source.data(), static_cast(_text_cursor)}; + } [[nodiscard]] static const Key &keyAt(kf::i8 row, kf::i8 col) noexcept { return rows[row][col]; } + [[nodiscard]] kf::usize capacity() const noexcept { + return _text_source.size(); + } + [[nodiscard]] kf::usize available() const noexcept { if (_text_cursor < _text_source.size()) { return _text_source.size() - _text_cursor; - } else { - return 0; } + return 0; } - void begin(kf::memory::Slice text_source) noexcept { + void begin(kf::Slice text_source) noexcept { _active = true; - _text_source = text_source; - _text_cursor = text().find('\0').value(); + _text_cursor = 0; + while (_text_cursor < _text_source.size() and _text_source[_text_cursor] != '\0') { + _text_cursor += 1; + } } - void quit() noexcept { _active = false; } void click() noexcept { - if (available() == 0) { return; } - const auto &key = rows[_cursor_row][_cursor_row_index]; switch (key.kind) { case Key::Kind::Space: case Key::Kind::Enter: case Key::Kind::Common: { + if (available() == 0) { return; } + _text_source[_text_cursor] = key.value(shifted()); _text_cursor += 1; _text_source[_text_cursor] = '\0'; @@ -198,33 +200,6 @@ struct VirtualKeyboard final : kf::mixin::Singleton { } } - void move(Direction direction) noexcept { - switch (direction) { - case Direction::Down: - moveCursorRow(+1); - return; - - case Direction::Up: - moveCursorRow(-1); - return; - - case Direction::Left: - moveCursorCol(-1); - return; - - case Direction::Right: - moveCursorCol(+1); - return; - } - } - -private: - kf::memory::Slice _text_source{}; - kf::isize _text_cursor{}; - kf::i8 _cursor_row{0}, _cursor_row_index{0}; - bool _active{false}; - State _state{State::Normal}; - void moveCursorRow(kf::i8 delta) noexcept { _cursor_row = (_cursor_row + delta + rowsTotal()) % rowsTotal(); _cursor_row_index = kf::clamp(_cursor_row_index, 0, colsTotal() - 1); @@ -234,6 +209,13 @@ struct VirtualKeyboard final : kf::mixin::Singleton { _cursor_row_index = (_cursor_row_index + delta + colsTotal()) % colsTotal(); } +private: + kf::Slice _text_source{}; + kf::isize _text_cursor{}; + kf::i8 _cursor_row{0}, _cursor_row_index{0}; + bool _active{false}; + State _state{State::Normal}; + static State evolvedState(State state) noexcept { switch (state) { case State::Normal: @@ -247,4 +229,4 @@ struct VirtualKeyboard final : kf::mixin::Singleton { } }; -}// namespace djc::input +}// namespace djc::ui diff --git a/DJC-Firmware/src/djc/ui/pages/ConfigPage.hpp b/DJC-Firmware/src/djc/ui/pages/ConfigPage.hpp index dc4da96..b418dd2 100644 --- a/DJC-Firmware/src/djc/ui/pages/ConfigPage.hpp +++ b/DJC-Firmware/src/djc/ui/pages/ConfigPage.hpp @@ -4,73 +4,235 @@ #pragma once #include - -#include "djc/ConfigManager.hpp" +#include + +#include "djc/PeerFavoritesRegistry.hpp" +#include "djc/config/DeviceConfig.hpp" +#include "djc/config/UserConfig.hpp" +#include "djc/protocol/ProtocolRegistry.hpp" +#include "djc/service/ConfigService.hpp" +#include "djc/transport/Kind.hpp" #include "djc/ui/UI.hpp" -#include "djc/ui/widgets/TextInput.hpp" +#include "djc/ui/pages/PeerFavoritePage.hpp" namespace djc::ui::pages { struct ConfigPage : UI::Page { - explicit ConfigPage(UI::Page &root) noexcept : - Page{"Config"}, + explicit ConfigPage( + UI &ui, + UI::Page &root, + config::DeviceConfig &device_config, + djc::service::ConfigService &device_config_service, + config::UserConfig &user_config, + djc::service::ConfigService &user_config_service, + PeerFavoritesRegistry &peer_favorites_registry) noexcept : + Page{ui}, + _device_config{device_config}, + _device_config_service{device_config_service}, + _user_config{user_config}, + _user_config_service{user_config_service}, + _peer_favorite_page{ui, *this, _peer_favorites_registry}, + _peer_favorites_registry{peer_favorites_registry}, + _device_name_input{ui.createTextInput()}, _layout{{ &root.link(), + &_save_config_button, + &_load_config_button, + &_reset_config_button, &_device_name_input, - &_init_mode_selector_label, - &_save_storage, - &_load_storage, - &_reset_storage, + &_labeled_autoconnect_enabled_input, + &_labeled_default_transport_kind_selector, + &_labeled_default_protocol_mode_selector, + &_favorite_peers_fold_toggle_button, }} { - widgets({_layout.data(), _layout.size()}); + this->label("Config"); + widgets(layout(0)); + + this->link().hint("Open Configuration page"); - _device_name_input.source({storage.config().device_name.data(), storage.config().device_name.size()}); + _device_name_input.hint("Device name"); + _device_name_input.source(_user_config.device_name.slice()); + + _save_config_button.hint("Force to sync now"); + _save_config_button.callback([this]() { + _device_config_service.sync(); + _user_config_service.sync(); + }); - _save_storage.callback([]() { - storage.save(); + _load_config_button.hint("Request Load config from NVS into RAM"); + _load_config_button.callback([this]() { + _device_config_service.requestLoad(); + _user_config_service.requestLoad(); }); - _load_storage.callback([]() { - storage.load(); + _reset_config_button.hint("Request reset RAM config"); + _reset_config_button.callback([this]() { + _device_config_service.requestReset(); }); - _reset_storage.callback([]() { - storage.reset(); + _favorite_peers_fold_toggle_button.hint("Toggle folding"); + _favorite_peers_fold_toggle_button.callback([this]() { + show_favorites = not show_favorites; + this->onEntry(); + _ui.requestRender(); }); - _init_mode_selector.callback([](Control::Mode init_mode) { - storage.config().control.init_mode = init_mode; + _labeled_default_transport_kind_selector.hint("Define transport select after init"); + _default_transport_kind_selector.callback([this](auto item) { + _user_config.init_transport_kind = item.value(); }); + + _labeled_default_protocol_mode_selector.hint("Define protocol select after init"); + _default_protocol_mode_selector.callback([this](auto item) { + _user_config.init_protocol_mode = item.value(); + }); + + _labeled_autoconnect_enabled_input.hint("Auto connect to most trusted peer"); + _autoconnect_enabled_input.callback([this](bool value) { + _device_config.auto_connect_service.enabled = value; + }); + + for (auto i = 0u; i < _peer_favorite_displays.size(); i += 1) { + auto &display = _peer_favorite_displays[i]; + _layout[layout_regular_widgets + i] = &display; + + display.hint("Open peer config"); + display.callback([this](const transport::PeerAddress &address) -> void { + _peer_favorite_page.bindPeer(address); + _ui.activePage(_peer_favorite_page); + }); + } + } + + void onEntry() noexcept override { + _default_protocol_mode_selector.value(_user_config.init_protocol_mode); + _default_transport_kind_selector.value(_user_config.init_transport_kind); + _autoconnect_enabled_input.value(_device_config.auto_connect_service.enabled); + + const auto all_favorites = _peer_favorites_registry.all(); + + (void) _label_favorites_buffer.format( + "[%c] Peer Favorites (%d/%d)", + (show_favorites ? 'V' : '>'), + all_favorites.size(), + config::UserConfig::max_peer_favorites); + _favorite_peers_fold_toggle_button.label(_label_favorites_buffer.view()); + _favorite_peers_fold_toggle_button.background(show_favorites ? UI::Color::Secondary : UI::Color::Primary); + + if (show_favorites) { + for (auto i = 0u; i < all_favorites.size(); i += 1) { + const auto &favorite = all_favorites[i]; + if (favorite.isSome()) { + _peer_favorite_displays[i].state(kf::some(UI::PeerDisplay::State{ + .address = favorite.unwrap().address, + .name = kf::some(kf::memory::StringView{favorite.unwrap().name.data(), favorite.unwrap().name.size()}), + })); + _peer_favorite_displays[i].foreground(UI::Color::Primary); + } + } + } + + widgets(layout(show_favorites ? all_favorites.size() : 0)); } private: - using ControlModeSelectWidget = UI::ComboBox; + using TransportKindSelector = UI::ComboBox; + + using Mode = protocol::ProtocolRegistry::Mode; + using ProtocolModeSelector = UI::ComboBox; - inline static auto &storage{djc::ConfigManager::instance()}; + static constexpr auto layout_regular_widgets{9u}; + + // state + + config::DeviceConfig &_device_config; + djc::service::ConfigService &_device_config_service; + + config::UserConfig &_user_config; + djc::service::ConfigService &_user_config_service; + + PeerFavoritesRegistry &_peer_favorites_registry; + + kf::memory::StaticString<32> _label_favorites_buffer{}; + bool show_favorites{true}; // widgets - widgets::TextInput _device_name_input{}; - UI::Button _save_storage{"Save"}; - UI::Button _load_storage{"Load"}; - UI::Button _reset_storage{"Reset"}; - kf::memory::Array _control_mode_options{{ - {Control::stringFromMode(Control::Mode::MavLink), Control::Mode::MavLink}, - {Control::stringFromMode(Control::Mode::Raw), Control::Mode::Raw}, - }}; + kf::memory::Array _transport_kind_options{{{ + { + "EspNow", + transport::Kind::EspNow, + UI::Style{ + .foreground_color = UI::Color::Highlight, + }, + }, + }}}; + + TransportKindSelector::Config _transport_kind_config{ + .items = {_transport_kind_options.data(), _transport_kind_options.size()}, + }; - ControlModeSelectWidget::Config _control_mode_config{ + kf::memory::Array _control_mode_options{{{ + { + "Mavlink", + Mode::Mavlink, + UI::Style{ + .foreground_color = UI::Color::Highlight, + }, + }, + { + "Raw", + Mode::Raw, + }, + }}}; + + ProtocolModeSelector::Config _control_mode_config{ .items = {_control_mode_options.data(), _control_mode_options.size()}, }; - // TODO: set init value from storage - ControlModeSelectWidget _init_mode_selector{_control_mode_config}; - - UI::Labeled _init_mode_selector_label{"Init Control", _init_mode_selector}; + UI::TextInput _device_name_input; + + UI::Button + _save_config_button{ + "Sync now", + UI::Style{ + .foreground_color = UI::Color::Primary, + }, + }, + _load_config_button{ + "Load", + }, + _reset_config_button{ + "Reset", + UI::Style{ + .foreground_color = UI::Color::Danger, + }, + }, + _favorite_peers_fold_toggle_button{{}}; + + TransportKindSelector _default_transport_kind_selector{_transport_kind_config}; + UI::Labeled _labeled_default_transport_kind_selector{"Init Transport", _default_transport_kind_selector}; + + ProtocolModeSelector _default_protocol_mode_selector{_control_mode_config}; + UI::Labeled _labeled_default_protocol_mode_selector{"Init Protocol", _default_protocol_mode_selector}; + + kf::memory::Array _peer_favorite_displays{}; + + UI::CheckBox _autoconnect_enabled_input{false}; + UI::Labeled _labeled_autoconnect_enabled_input{"Autoconnect", _autoconnect_enabled_input}; // layout - kf::memory::Array _layout; + + kf::memory::Array _layout; + + // child pages + + PeerFavoritePage _peer_favorite_page; + + kf::Slice layout(kf::usize displayed_peers) noexcept { + return _layout.slice().first(layout_regular_widgets + displayed_peers); + } }; -}// namespace djc::ui::pages +}// namespace djc::ui::pages \ No newline at end of file diff --git a/DJC-Firmware/src/djc/ui/pages/MavLinkPage.hpp b/DJC-Firmware/src/djc/ui/pages/MavLinkPage.hpp deleted file mode 100644 index 1c4ed99..0000000 --- a/DJC-Firmware/src/djc/ui/pages/MavLinkPage.hpp +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright (c) 2026 KiraFlux -// SPDX-License-Identifier: GPL-3.0-or-later - -#pragma once - -#include - -#include -#include -#include -#include -#include - -#include "djc/Control.hpp" -#include "djc/ui/UI.hpp" - -namespace djc::ui::pages { - -/// @brief MAVLink telemetry page -struct MavLinkPage : UI::Page { - explicit MavLinkPage(UI::Page &root, Control &control) noexcept : - Page{"MAV Link"}, _control{control}, - _layout{{ - &root.link(), - &_imu_display, - &_attitude_display, - }} { - widgets({_layout.data(), _layout.size()}); - } - - void onEntry() noexcept override { - _control.mode(Control::Mode::MavLink); - _control.onMavlinkMessage([this](mavlink_message_t *message) { - _need_update |= onMavLinkMessage(message); - }); - } - - void onExit() noexcept override { - _control.onMavlinkMessage(Control::MavLinkMessageCallback{nullptr}); - } - - void onUpdate(kf::math::Milliseconds now) noexcept override { - if (_need_update) { - _need_update = false; - UI::instance().addEvent(UI::Event::update()); - } - } - -private: - static constexpr auto logger{kf::Logger::create("MavLinkPage")}; - - Control &_control; - bool _need_update{false}; - - // widgets - - kf::memory::ArrayString<64> _attitude_buffer{"..."}; - kf::memory::ArrayString<64> _imu_display_buffer{"..."}; - - UI::Display _attitude_display{_attitude_buffer.view()}; - UI::Display _imu_display{_imu_display_buffer.view()}; - - kf::memory::Array _layout; - - [[nodiscard]] bool onMavLinkMessage(mavlink_message_t *message) noexcept { - switch (message->msgid) { - case MAVLINK_MSG_ID_ATTITUDE_QUATERNION: { - mavlink_attitude_quaternion_t attitude_quaternion; - mavlink_msg_attitude_quaternion_decode(message, &attitude_quaternion); - - (void) _attitude_buffer.format( - "AtQ %+.2f %+.2f %+.2f %+.2f", - float(attitude_quaternion.q1), - float(attitude_quaternion.q2), - float(attitude_quaternion.q3), - float(attitude_quaternion.q4)); - _attitude_display.value(_attitude_buffer.view()); - - return true; - } - - case MAVLINK_MSG_ID_SERIAL_CONTROL: { - mavlink_serial_control_t serial_control; - mavlink_msg_serial_control_decode(message, &serial_control); - - constexpr auto len{sizeof(serial_control.data)}; - serial_control.data[len - 1] = '\0'; - - logger.info({reinterpret_cast(serial_control.data), static_cast(serial_control.count)}); - - return false; - } - - case MAVLINK_MSG_ID_SCALED_IMU: { - mavlink_scaled_imu_t imu; - mavlink_msg_scaled_imu_decode(message, &imu); - - (void) _imu_display_buffer.format( - "Acc %+.3f %+.3f %+.3f", - float(imu.xacc * 0.001f), - float(imu.yacc * 0.001f), - float(imu.zacc * 0.001f)); - _imu_display.value(_imu_display_buffer.view()); - - return true; - } - - default: - // Unhandled message type - return false; - } - } -}; - -}// namespace djc::ui::pages \ No newline at end of file diff --git a/DJC-Firmware/src/djc/ui/pages/MavlinkTelemetryPage.hpp b/DJC-Firmware/src/djc/ui/pages/MavlinkTelemetryPage.hpp new file mode 100644 index 0000000..d716ccb --- /dev/null +++ b/DJC-Firmware/src/djc/ui/pages/MavlinkTelemetryPage.hpp @@ -0,0 +1,114 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include +#include +#include +#include +#include + +#include "djc/MavlinkTelemetryRegistry.hpp" +#include "djc/protocol/MavlinkProtocol.hpp" +#include "djc/protocol/ProtocolLink.hpp" +#include "djc/protocol/ProtocolRegistry.hpp" +#include "djc/ui/UI.hpp" + +namespace djc::ui::pages { + +/// @brief MAVLink telemetry page +struct MavlinkTelemetryPage : UI::Page { + explicit MavlinkTelemetryPage( + UI &ui, + UI::Page &root, + protocol::ProtocolRegistry &protocol_registry, + protocol::ProtocolLink &protocol_link, + MavlinkTelemetryRegistry &mavlink_telemetry_registry) noexcept : + Page{ui}, + _protocol_registry{protocol_registry}, + _protocol_link{protocol_link}, + _mavlink_telemetry_registry{mavlink_telemetry_registry}, + + _layout{{ + &root.link(), + &_imu_display, + &_attitude_display, + }} { + this->label("Mavlink: Telemetry"); + widgets(_layout.slice()); + + this->link().hint("Open MAVLink page"); + + _imu_display.hint("IMU accel vector"); + _attitude_display.hint("attitude quaternion"); + } + + void onEntry() noexcept override { + _protocol_link.protocol(_protocol_registry.mavlink()); + _last_imu = _last_attitude = _last_serial_control = 0; + } + + void onPoll(kf::math::Milliseconds now) noexcept override { + bool need_update{false}; + + if (_mavlink_telemetry_registry.scaled_imu.updatedSince(_last_imu)) { + _last_imu = now; + const auto &imu = _mavlink_telemetry_registry.scaled_imu.value(); + + (void) _imu_buffer.format( + "Acc %+.3f %+.3f %+.3f", + float(imu.xacc * 0.001f), + float(imu.yacc * 0.001f), + float(imu.zacc * 0.001f)); + _imu_display.value(_imu_buffer.view()); + + need_update = true; + } + + if (_mavlink_telemetry_registry.attitude_quaternion.updatedSince(_last_attitude)) { + _last_attitude = now; + const auto &attitude = _mavlink_telemetry_registry.attitude_quaternion.value(); + + (void) _attitude_buffer.format( + "Q %+.2f %+.2f %+.2f %+.2f", + attitude.q1, + attitude.q2, + attitude.q3, + attitude.q4); + _attitude_display.value(_attitude_buffer.view()); + + need_update = true; + } + + if (_mavlink_telemetry_registry.serial_control.updatedSince(_last_serial_control)) { + _last_serial_control = now; + const auto &s = _mavlink_telemetry_registry.serial_control.value(); + + logger.debug({reinterpret_cast(s.data), s.count});// temp. + } + + if (need_update) { + _ui.requestRender(); + } + } + +private: + static constexpr auto logger{kf::Logger::create("MavlinkTelemetryPage")}; + + protocol::ProtocolRegistry &_protocol_registry; + protocol::ProtocolLink &_protocol_link; + MavlinkTelemetryRegistry &_mavlink_telemetry_registry; + kf::math::Milliseconds _last_imu{}, _last_attitude{}, _last_serial_control{}; + + // widgets + kf::memory::StaticString<64> _attitude_buffer{"..."}, _imu_buffer{"..."}; + + UI::Display _attitude_display{_attitude_buffer.view()}, _imu_display{_imu_buffer.view()}; + + kf::memory::Array _layout; +}; + +}// namespace djc::ui::pages \ No newline at end of file diff --git a/DJC-Firmware/src/djc/ui/pages/PeerDetailPage.hpp b/DJC-Firmware/src/djc/ui/pages/PeerDetailPage.hpp new file mode 100644 index 0000000..8d51757 --- /dev/null +++ b/DJC-Firmware/src/djc/ui/pages/PeerDetailPage.hpp @@ -0,0 +1,97 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +#include "djc/PeerFavoritesRegistry.hpp" +#include "djc/transport/TransportLink.hpp" +#include "djc/ui/UI.hpp" +#include "djc/ui/pages/PeerFavoritePage.hpp" + +namespace djc::ui::pages { + +struct PeerDetailPage final : UI::Page { + + explicit PeerDetailPage( + UI &ui, + UI::Page &root, + transport::TransportLink &transport_link, + PeerFavoritesRegistry &peer_favorites_registry) noexcept : + Page{ui}, + _transport_link{transport_link}, + _peer_favorites_registry{peer_favorites_registry}, + _layout{{ + &root.link(), + &_connection_button, + &_peer_favorite_button, + }}, + _peer_favorite_page{ui, *this, peer_favorites_registry} + + { + widgets(_layout.slice()); + + _connection_button.callback([this, &root]() -> void { + if (_peer_address.isNone()) { return; } + + if (_transport_link.connect(_peer_address.unwrap())) { + _ui.activePage(root); + } else { + _connection_button.label("Failed to connect"); + _connection_button.style(UI::Style{ + .background_color = UI::Color::Error, + }); + } + }); + + _peer_favorite_button.hint("Open peer favorites edit"); + _peer_favorite_button.callback([this]() -> void { + if (_peer_address.isNone()) { return; } + this->label("Back"); + _peer_favorite_page.bindPeer(_peer_address.unwrap()); + _ui.activePage(_peer_favorite_page); + _ui.requestRender(); + }); + } + + void bindPeer(const transport::PeerAddress &address) noexcept { + _peer_address = kf::someTrivial(address); + } + + void onEntry() noexcept override { + _connection_button.label("Connect"); + _connection_button.style(UI::Style{ + .foreground_color = UI::Color::Primary, + }); + + if (_peer_address.isSome()) { + _label_buffer = _peer_address.unwrap().toString(); + this->label(_label_buffer.view()); + + _peer_favorite_button.label(_peer_favorites_registry.exists(_peer_address.unwrap()) ? "Edit" : "Add to favorites"); + } + } + +private: + // state + + transport::TransportLink &_transport_link; + kf::TrivialOption _peer_address{}; + PeerFavoritesRegistry &_peer_favorites_registry; + + // widgets + + transport::PeerAddress::StringType _label_buffer{}; + UI::Button _connection_button{{}}, _peer_favorite_button{{}}; + + kf::memory::Array _layout; + + // child pages + + PeerFavoritePage _peer_favorite_page; +}; + +}// namespace djc::ui::pages diff --git a/DJC-Firmware/src/djc/ui/pages/PeerExplorerPage.hpp b/DJC-Firmware/src/djc/ui/pages/PeerExplorerPage.hpp index 55e556b..5970b92 100644 --- a/DJC-Firmware/src/djc/ui/pages/PeerExplorerPage.hpp +++ b/DJC-Firmware/src/djc/ui/pages/PeerExplorerPage.hpp @@ -3,125 +3,148 @@ #pragma once -#include // for millis - -#include +#include #include #include #include -#include -#include +#include -#include "djc/Control.hpp" +#include "djc/PeerFavoritesRegistry.hpp" +#include "djc/service/PeerScanningService.hpp" +#include "djc/transport/PeerAddress.hpp" +#include "djc/transport/TransportLink.hpp" #include "djc/ui/UI.hpp" -#include "djc/ui/widgets/PeerDisplay.hpp" -#include "djc/prelude.hpp" +#include "djc/ui/pages/PeerDetailPage.hpp" namespace djc::ui::pages { struct PeerExplorerPage : UI::Page { - static constexpr auto max_peer_display{8}; - static constexpr auto peer_display_start_index{3}; - static constexpr kf::math::Milliseconds redraw_period{500}; - - explicit constexpr PeerExplorerPage(UI::Page &root, Control &control) noexcept : - Page{"Peer Explorer"}, - _control{control}, + explicit PeerExplorerPage( + UI &ui, + UI::Page &root, + transport::TransportLink &transport_link, + service::PeerScanningService &peer_scanner, + PeerFavoritesRegistry &peer_favorites_registry) noexcept : + Page{ui}, + _transport_link{transport_link}, + _peer_scanner{peer_scanner}, + _peer_favorites_registry{peer_favorites_registry}, + _peer_detail_page{ui, *this, _transport_link, _peer_favorites_registry}, _layout{{ &root.link(), - &_connection_button, + &_primary_connection_status_button, &_available_label, - }} + }} { + this->label("Peer Explorer"); + this->link().hint("Open Peer explorer"); + + _available_label.hint("Available peer will show below"); - { - for (auto i = 0; i < _peer_displays.size(); i += 1) { - _peer_displays[i].control(_control); - _layout[i + peer_display_start_index] = &_peer_displays[i]; + for (auto i = 0u; i < _peer_displays.size(); i += 1) { + auto &display = _peer_displays[i]; + _layout[i + peer_display_start_index] = &display; + + display.hint("Click for details"); + display.callback([this](const transport::PeerAddress &address) -> void { + _peer_detail_page.bindPeer(address); + _ui.activePage(_peer_detail_page); + }); } - _connection_button.callback([this]() { - if (_control.activeMac().hasValue()) { - _control.disconnect(); + _primary_connection_status_button.callback([this]() { + if (_transport_link.connected()) { + _transport_link.disconnect(); } }); - widgets({_layout.data(), _layout.size()}); - - _redraw_timer.start(millis()); - } - - void onEntry() noexcept override { - _control.onReceiveFromUnknown([this](const EspNow::Mac &mac, kf::memory::Slice data) { - logger.debug( - kf::memory::ArrayString<64>::formatted( - "Got %d bytes from %s", - data.size(), - EspNow::stringFromMac(mac).data())); - - getMatched(mac).update(mac, millis()); - }); - } + widgets(layout(0)); - void onExit() noexcept override { - _control.onReceiveFromUnknown(Control::ReceiveFromUnknownCallback{nullptr}); + _redraw_timer.start(0);// enable timer } - void onUpdate(kf::math::Milliseconds now) noexcept override { - for (auto &_peer_display: _peer_displays) { - _peer_display.checkForClear(now); + void onPoll(kf::math::Milliseconds now) noexcept override { + if (not _redraw_timer.expired(now)) { return; } + _redraw_timer.start(now); + + if (_transport_link.activePeerAddress().isSome()) { + (void) _connection_button_buffer.format("%s", _transport_link.activePeerAddress().unwrap().toString().data()); + _primary_connection_status_button.label(_connection_button_buffer.view()); + _primary_connection_status_button.hint("Click to disconnect"); + _primary_connection_status_button.style({UI::Color::Normal, UI::Color::Success}); + } else { + _primary_connection_status_button.label("Disconnected"); + _primary_connection_status_button.hint("Primary peer not set"); + _primary_connection_status_button.style({UI::Color::Disabled, UI::Color::Normal}); } - if (_redraw_timer.expired(now)) { - _redraw_timer.start(now); + const auto available_peers = _peer_scanner.peers(); + (void) _available_label_buffer.format(" Available: %d", available_peers.size()); + _available_label.value(_available_label_buffer.view()); - if (_control.activeMac().hasValue()) { - (void) _connection_button_label.format( - "\xFC""OK: %s\x80", - EspNow::stringFromMac(_control.activeMac().value()).data()); - _connection_button.label(_connection_button_label.view()); - } else { - _connection_button.label("\xF9""Disconnected\x80"); - } + for (auto i = 0u; i < available_peers.size(); i += 1) { + const auto &entry = available_peers[i]; + _peer_displays[i].state(createPeerDisplayState(entry, now)); - (void) _available_label_value.format(" Available: %d", countAvailablePeers()); - _available_label.value(_available_label_value.view()); + if (entry.isSome()) { + constexpr auto extreme_age_factor{0.75f}; + const auto extreme_age = _peer_scanner.config().entry_max_life_time * extreme_age_factor; + const auto age = now - entry.unwrap().last_seen; - UI::instance().addEvent(UI::Event::update()); + _peer_displays[i].foreground((age < extreme_age) ? UI::Color::Primary : UI::Color::Warning); + } } + + widgets(layout(available_peers.size())); + _ui.requestRender(); } private: - static constexpr auto logger{kf::Logger::create("PeerExplorerPage")}; + static constexpr auto peer_display_start_index{3u}; - Control &_control; - kf::math::Timer _redraw_timer{redraw_period}; - kf::memory::ArrayString<16> _available_label_value{""}; - kf::memory::ArrayString<64> _connection_button_label{}; + transport::TransportLink &_transport_link; + service::PeerScanningService &_peer_scanner; + PeerFavoritesRegistry &_peer_favorites_registry; + kf::math::Timer::Config _redraw_timer_config{ + .period = 500, + }; + kf::math::Timer _redraw_timer{_redraw_timer_config}; - // widgets - UI::Button _connection_button{""}; - UI::Display _available_label{_available_label_value.view()}; - kf::memory::Array _peer_displays{}; + kf::memory::StaticString<64> _available_label_buffer{}, _connection_button_buffer{}; - // layout - kf::memory::Array _layout; + UI::Button _primary_connection_status_button{{}}; + UI::Display _available_label{_available_label_buffer.view()}; + kf::memory::Array _peer_displays{}; - widgets::PeerDisplay &getMatched(const EspNow::Mac &mac) noexcept { - for (auto &_peer_display: _peer_displays) { - if (not _peer_display.mac().hasValue()) { return _peer_display; } - if (_peer_display.mac().value() == mac) { return _peer_display; } - } + kf::memory::Array _layout; - return _peer_displays[0]; + // child pages + PeerDetailPage _peer_detail_page; + + kf::Slice layout(kf::usize displayed_peers) noexcept { + return _layout.slice().first(peer_display_start_index + displayed_peers); } - int countAvailablePeers() const noexcept { - int available = 0; - for (auto &_peer_display: _peer_displays) { - available += int(_peer_display.mac().hasValue()); + kf::Option createPeerDisplayState(const kf::TrivialOption &entry, kf::math::Milliseconds now) const noexcept { + using P = UI::PeerDisplay; + + const auto map_record = [](kf::Option record) -> kf::Option { + if (record.isSome()) { + const auto &name = record.unwrap().name; + return kf::some(kf::memory::StringView{name.data(), name.size()}); + } else { + return kf::none; + } + }; + + if (entry.isSome()) { + return kf::some(P::State{ + .address = entry.unwrap().address, + .name = map_record(_peer_favorites_registry.get(entry.unwrap().address)), + }); + } else { + return kf::none; } - return available; } }; diff --git a/DJC-Firmware/src/djc/ui/pages/PeerFavoritePage.hpp b/DJC-Firmware/src/djc/ui/pages/PeerFavoritePage.hpp new file mode 100644 index 0000000..4ebd0df --- /dev/null +++ b/DJC-Firmware/src/djc/ui/pages/PeerFavoritePage.hpp @@ -0,0 +1,100 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +#include "djc/PeerFavoritesRegistry.hpp" +#include "djc/transport/TransportLink.hpp" +#include "djc/ui/UI.hpp" + +namespace djc::ui::pages { + +struct PeerFavoritePage final : UI::Page { + + explicit PeerFavoritePage(UI &ui, UI::Page &root, PeerFavoritesRegistry &peer_favorites_registry) noexcept : + Page{ui}, + _peer_favorites_registry{peer_favorites_registry}, + _description_input{ui.createTextInput()}, + _layout{{ + // address and transport shows in title + &_labeled_trust_input, + &_labeled_description_input, + &_confirm_button, + &root.link(),// quit without save + &_delete_button, + }} + + { + _labeled_trust_input.hint("Set priority for auto connect"); + _labeled_description_input.hint("Will shown as human-readable alias"); + + _confirm_button.hint("Write to registry"); + _confirm_button.callback([this]() -> void { + if (_temp_entry.isNone()) { return; } + _temp_entry.unwrap().trust = _trust_input.value(); + + const bool write_ok = _peer_favorites_registry.put(_temp_entry.unwrap()); + _confirm_button.label(write_ok ? "Written" : "Write failed"); + _confirm_button.style(UI::Style{ + .foreground_color = UI::Color::Normal, + .background_color = (write_ok ? UI::Color::Success : UI::Color::Error), + }); + + _ui.requestRender(); + }); + + _delete_button.hint("Remove from registry"); + _delete_button.callback([this, &root]() -> void { + if (_temp_entry.isNone()) { return; } + + (void) _peer_favorites_registry.remove(_temp_entry.unwrap().address); + + _ui.activePage(root); + _ui.requestRender(); + }); + } + + void bindPeer(const transport::PeerAddress &address) noexcept { + const auto &entry_option = _peer_favorites_registry.get(address); + + _temp_entry = kf::someTrivial(entry_option.unwrapOr(PeerFavoritesRegistry::Entry::create(address))); + + (void) _label_buffer.format("%s Peer favorite\n%s", (entry_option.isSome() ? "Edit" : "Add"), address.toString().data()); + this->label(_label_buffer.view()); + + _description_input.source({_temp_entry.unwrap().name.data(), _temp_entry.unwrap().name.size()}); + _trust_input.value(_temp_entry.unwrap().trust); + _confirm_button.label("Confirm"); + _confirm_button.style({UI::Color::Primary, UI::Color::Normal}); + widgets(_layout.slice().first(_layout.size() - (entry_option.isSome() ? 0 : 1))); + } + +private: + PeerFavoritesRegistry &_peer_favorites_registry; + kf::TrivialOption _temp_entry{}; + + kf::memory::StaticString<64> _label_buffer{}; + + using TrustInput = UI::Slider; + + TrustInput::Config _trust_input_config{ + .value_range = PeerFavoritesRegistry::Entry::trust_range, + .default_value = PeerFavoritesRegistry::Entry::trust_range.start, + .step = static_cast(1), + .init_show_value = true, + }; + + TrustInput _trust_input{_trust_input_config}; + UI::TextInput _description_input; + + UI::Labeled _labeled_trust_input{"Trust", _trust_input}; + UI::Labeled _labeled_description_input{"Name", _description_input}; + UI::Button _confirm_button{{}}, _delete_button{"Delete", UI::Style{.background_color = UI::Color::Danger}}; + kf::memory::Array _layout; +}; + +}// namespace djc::ui::pages \ No newline at end of file diff --git a/DJC-Firmware/src/djc/ui/pages/RawControlPage.hpp b/DJC-Firmware/src/djc/ui/pages/RawControlPage.hpp deleted file mode 100644 index 6750ece..0000000 --- a/DJC-Firmware/src/djc/ui/pages/RawControlPage.hpp +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) 2026 KiraFlux -// SPDX-License-Identifier: GPL-3.0-or-later - -#pragma once - -#include -#include -#include - -#include "djc/Control.hpp" -#include "djc/ui/UI.hpp" -#include "djc/ui/widgets/TextInput.hpp" - -namespace djc::ui::pages { - -struct RawControlPage : UI::Page { - explicit RawControlPage(UI::Page &root, Control &control) noexcept : - Page{"Raw Control"}, _control{control}, - _layout{{ - &root.link(), - &_message_input, - &_send_button, - }} { - widgets({_layout.data(), _layout.size()}); - - _send_button.callback([this]() { - kf::memory::StringView s{_message.data(), _message.size()}; - s = s.sub(0, s.find('\0').valueOr(s.size())); - - logger.debug(s); - - _control.sendRawMessage({reinterpret_cast(s.data()), s.size()}); - }); - } - - void onEntry() noexcept override { - _control.mode(Control::Mode::Raw); - _control.onRawMessage([](kf::memory::Slice buffer) { - logger.info( - kf::memory::ArrayString<64>::formatted( - "Got %d bytes from primary peer", - buffer.size()) - .view()); - }); - } - - void onExit() noexcept override { - _control.onRawMessage(Control::RawMessageCallback{nullptr}); - } - -private: - static constexpr auto logger{kf::Logger::create("RawControlPage")}; - - kf::memory::Array _message{}; - Control &_control; - - // widgets - - widgets::TextInput _message_input{{_message.data(), _message.size()}}; - UI::Button _send_button{"Send"}; - - kf::memory::Array _layout; -}; - -}// namespace djc::ui::pages \ No newline at end of file diff --git a/DJC-Firmware/src/djc/ui/pages/RawProtocolPage.hpp b/DJC-Firmware/src/djc/ui/pages/RawProtocolPage.hpp new file mode 100644 index 0000000..759b725 --- /dev/null +++ b/DJC-Firmware/src/djc/ui/pages/RawProtocolPage.hpp @@ -0,0 +1,81 @@ +// Copyright (c) 2026 KiraFlux +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +#include "djc/protocol/ProtocolLink.hpp" +#include "djc/protocol/ProtocolRegistry.hpp" +#include "djc/transport/TransportLink.hpp" +#include "djc/ui/UI.hpp" + +namespace djc::ui::pages { + +struct RawProtocolPage : UI::Page { + explicit RawProtocolPage( + UI &ui, + UI::Page &root, + protocol::ProtocolRegistry &protocol_registry, + protocol::ProtocolLink &protocol_link, + transport::TransportLink &transport_link) noexcept : + Page{ui}, _protocol_registry{protocol_registry}, _protocol_link{protocol_link}, _transport_link{transport_link}, + _message_input{ui.createTextInput({_message.data(), _message.size()})}, + _layout{{ + &root.link(), + &_message_input, + &_send_button, + }} { + this->label("Raw Protocol"); + widgets(_layout.slice()); + + this->link().hint("Open raw protocol page"); + + _message_input.hint("Edit message"); + + _send_button.hint("Send raw buffer as is"); + _send_button.callback([this]() { + kf::memory::StringView s{_message.data(), _message.size()}; + s = s.sub(0, s.find('\0').unwrapOr(s.size())); + + logger.debug(s); + + (void) _transport_link.send({reinterpret_cast(s.data()), s.size()}); + }); + } + + void onEntry() noexcept override { + _protocol_link.protocol(_protocol_registry.raw()); + + _protocol_registry.raw().callback([](kf::Slice buffer) { + logger.info( + kf::memory::StaticString<64>::formatted( + "Got %d bytes from primary peer", + buffer.size()) + .view()); + }); + } + + void onExit() noexcept override { + _protocol_registry.raw().callback(kf::none); + } + +private: + static constexpr auto logger{kf::Logger::create("RawControlPage")}; + + kf::memory::Array _message{}; + protocol::ProtocolRegistry &_protocol_registry; + protocol::ProtocolLink &_protocol_link; + transport::TransportLink &_transport_link; + + // widgets + + UI::TextInput _message_input; + UI::Button _send_button{"Send"}; + + kf::memory::Array _layout; +}; + +}// namespace djc::ui::pages \ No newline at end of file diff --git a/DJC-Firmware/src/djc/ui/pages/RootPage.hpp b/DJC-Firmware/src/djc/ui/pages/RootPage.hpp index c29a5b7..ee1cf0d 100644 --- a/DJC-Firmware/src/djc/ui/pages/RootPage.hpp +++ b/DJC-Firmware/src/djc/ui/pages/RootPage.hpp @@ -13,7 +13,10 @@ namespace djc::ui::pages { struct RootPage : UI::Page { static constexpr auto max_items{4}; - explicit constexpr RootPage() noexcept : Page{"Main"} {} + explicit RootPage(UI &ui) noexcept : Page{ui} { + this->label("Main"); + this->link().hint("Return to Main page"); + } void attach(UI::Page &page) noexcept { if (_items >= _layout.size()) { return; } diff --git a/DJC-Firmware/src/djc/ui/widgets/PeerDisplay.hpp b/DJC-Firmware/src/djc/ui/widgets/PeerDisplay.hpp index b2ca0b9..eac817a 100644 --- a/DJC-Firmware/src/djc/ui/widgets/PeerDisplay.hpp +++ b/DJC-Firmware/src/djc/ui/widgets/PeerDisplay.hpp @@ -4,93 +4,54 @@ #pragma once #include -#include -#include #include +#include +#include +#include -#include "djc/Control.hpp" -#include "djc/ui/UI.hpp" +#include "djc/transport/PeerAddress.hpp" namespace djc::ui::widgets { -struct PeerDisplay final : UI::Widget { +template struct PeerDisplay : - enum class State : kf::u8 { - Cleared, - NewConnection, - Stable, - PreCleared, - }; - - static constexpr kf::math::Milliseconds clear_timeout{8000}, new_highlight_timespan{clear_timeout - 600}, pre_cleared_highligt_timespan{2000}; - - void control(Control &control) noexcept { _control = &control; } - - const kf::Option &mac() const noexcept { return _mac_option; } + U::Widget, + kf::mixin::Callbacked - void update(const EspNow::Mac &mac, kf::math::Milliseconds now) noexcept { - _mac_clear_timer.start(now); - _mac_option.value(mac); - } +{ + struct State final { + transport::PeerAddress address; + kf::Option name; - void checkForClear(kf::math::Milliseconds now) noexcept { - if (_mac_clear_timer.expired(now)) { - _mac_option = {}; + kf::memory::StringView displayName() const noexcept { + return name.isSome() ? name.unwrap().data() : address.toString().data(); } + }; - if (_mac_option.hasValue()) { - - if (_mac_clear_timer.remaining(now) > new_highlight_timespan) { - _state = State::NewConnection; - } else if (_mac_clear_timer.remaining(now) < pre_cleared_highligt_timespan) { - _state = State::PreCleared; - } else { - _state = State::Stable; - } + explicit constexpr PeerDisplay(kf::Option state = kf::none, kf::ui::Style style = kf::ui::Style::defaults()) noexcept : + U::Widget{style}, _state{state} {} - } else { - _state = State::Cleared; - } + void state(const kf::Option &new_state) noexcept { + _state = new_state; } - void doRender(UI::RenderImpl &render) const noexcept override { - render.beginBlock(); - - if (_state == State::NewConnection) { - render.value(kf::memory::StringView{"\xFC"}); - } else if (_state == State::PreCleared) { - render.value(kf::memory::StringView{"\xF9"}); - } - - if (_mac_option.hasValue()) { - render.value(EspNow::stringFromMac(_mac_option.value()).view()); - } else { - render.value(kf::memory::StringView{"\xF8 - - "}); - } - - if (_state != State::Stable) { - render.value(kf::memory::StringView{"\x80"}); + void doRender(typename U::RendererImpl &render) const noexcept override { + render.beginBlock(kf::ui::Block::Alternative); + if (_state.isSome()) { + render.value(_state.unwrap().displayName()); } - - render.endBlock(); + render.endBlock(kf::ui::Block::Alternative); } bool onClick() noexcept override { - if (not _mac_option.hasValue()) { return false; } - - if (_control != nullptr) { - _control->connect(_mac_option.value()); - _mac_option = {}; + if (_state.isSome()) { + this->invoke(_state.unwrap().address); } - - return true; + return _state.isSome(); } private: - Control *_control{nullptr}; - kf::Option _mac_option{}; - kf::math::Timer _mac_clear_timer{clear_timeout}; - State _state{State::Cleared}; + kf::Option _state; }; }// namespace djc::ui::widgets \ No newline at end of file diff --git a/DJC-Firmware/src/djc/ui/widgets/TextInput.hpp b/DJC-Firmware/src/djc/ui/widgets/TextInput.hpp index f6b9200..bc38667 100644 --- a/DJC-Firmware/src/djc/ui/widgets/TextInput.hpp +++ b/DJC-Firmware/src/djc/ui/widgets/TextInput.hpp @@ -3,61 +3,76 @@ #pragma once -#include -#include +#include #include +#include +#include -#include "djc/input/VirtualKeyboard.hpp" -#include "djc/ui/UI.hpp" +#include "djc/ui/VirtualKeyboard.hpp" namespace djc::ui::widgets { -struct TextInput final : UI::Widget { +template struct TextInput : - constexpr TextInput() noexcept : _text_source{} {} + U::Widget - explicit constexpr TextInput(kf::memory::Slice source) noexcept : _text_source{source} {} +{ + explicit TextInput(VirtualKeyboard &virtual_keyboard, kf::Slice source, kf::ui::Style style = kf::ui::Style{.foreground_color = kf::ui::Color::Info}) noexcept : + U::Widget{style}, _virtual_keyboard{virtual_keyboard}, _text_source{source} {} - void source(kf::memory::Slice new_source) noexcept { _text_source = new_source; } - - bool available() const noexcept { return nullptr != _text_source.data(); } + void source(kf::Slice new_source) noexcept { + _text_source = new_source; + } - void doRender(UI::RenderImpl &render) const noexcept override { - if (not available()) { - render.value(kf::memory::StringView{"not available"}); - return; - } + bool available() const noexcept { + return nullptr != _text_source.data(); + } - const kf::memory::StringView s{_text_source.data(), _text_source.size()}; - const auto end_index = s.find('\0'); - render.value(end_index.hasValue() ? s.sub(0, end_index.value()) : s); + void doRender(typename U::RendererImpl &render) const noexcept override { + render.value('\"'); + render.value(string()); + render.value('\"'); } bool onClick() noexcept override { if (not available()) { return false; } - if (virtual_keyboard.active()) { - virtual_keyboard.click(); + if (_virtual_keyboard.active()) { + _virtual_keyboard.click(); } else { - virtual_keyboard.begin(_text_source); + _virtual_keyboard.begin(_text_source); } return true; } - bool onEventValue(UI::Event::Value event_value) noexcept { - if (virtual_keyboard.active()) { - virtual_keyboard.move(static_cast(event_value)); - return true; + bool onEventValue(typename U::EventImpl::Value event_value) noexcept { + if (not _virtual_keyboard.active()) { + return false; + } + + switch (event_value) { + case 0: _virtual_keyboard.moveCursorRow(-1); break; + case 1: _virtual_keyboard.moveCursorRow(+1); break; + case 2: _virtual_keyboard.moveCursorCol(-1); break; + case 3: _virtual_keyboard.moveCursorCol(+1); break; } - return false; + return true; } private: - inline static auto &virtual_keyboard{input::VirtualKeyboard::instance()}; + VirtualKeyboard &_virtual_keyboard; + kf::Slice _text_source; - kf::memory::Slice _text_source; + [[nodiscard]] kf::memory::StringView string() const noexcept { + if (available()) { + const kf::memory::StringView s{_text_source.data(), _text_source.size()}; + return s.sub(0, s.find('\0').unwrapOr(s.size())); + } else { + return kf::memory::StringView{"not available"}; + } + } }; }// namespace djc::ui::widgets \ No newline at end of file diff --git a/DJC-Firmware/src/main.cpp b/DJC-Firmware/src/main.cpp index 200a00f..83ac8cf 100644 --- a/DJC-Firmware/src/main.cpp +++ b/DJC-Firmware/src/main.cpp @@ -1,170 +1,278 @@ // Copyright (c) 2026 KiraFlux // SPDX-License-Identifier: GPL-3.0-or-later +// framework #include +// toolkit #include -#include +#include #include -#include "djc/ConfigManager.hpp" -#include "djc/Control.hpp" -#include "djc/DisplayManager.hpp" -#include "djc/Periphery.hpp" -#include "djc/input/InputHandler.hpp" -#include "djc/input/VirtualKeyboard.hpp" +// djc::config +#include "djc/config/DeviceConfig.hpp" +#include "djc/config/UserConfig.hpp" + +// djc::system +#include "djc/system/ConfigSystem.hpp" +#include "djc/system/ControlSystem.hpp" +#include "djc/system/GraphicsSystem.hpp" +#include "djc/system/InputSystem.hpp" +#include "djc/system/PeerSystem.hpp" +#include "djc/system/PeripherySystem.hpp" +#include "djc/system/ProtocolSystem.hpp" +#include "djc/system/TransportSystem.hpp" +#include "djc/system/UiSystem.hpp" + +// djc::ui +#include "djc/ui/UI.hpp" #include "djc/ui/pages/ConfigPage.hpp" -#include "djc/ui/pages/MavLinkPage.hpp" +#include "djc/ui/pages/MavlinkTelemetryPage.hpp" #include "djc/ui/pages/PeerExplorerPage.hpp" -#include "djc/ui/pages/RawControlPage.hpp" +#include "djc/ui/pages/RawProtocolPage.hpp" #include "djc/ui/pages/RootPage.hpp" -static auto &ui{djc::ui::UI::instance()}; +static constexpr auto loop_rate_hz{50}; -static auto &storage{djc::ConfigManager::instance()}; +static constexpr auto logger{kf::Logger::create("main")}; -static auto &virtual_keyboard{djc::input::VirtualKeyboard::instance()}; +// systems -// services +static djc::system::ConfigSystem device_config_system{ + "device", +}; + +static auto &device_config{device_config_system.config()}; -static djc::Periphery periphery{ - storage.config().periphery, +static djc::system::ConfigSystem user_config_system{ + "user", }; -static djc::InputHandler input_handler{ - storage.config().input_handler, - periphery.right_joystick, - periphery.left_button_listener, - periphery.right_button_listener, +static auto &user_config{user_config_system.config()}; + +static djc::system::PeripherySystem periphery_system{device_config}; + +static djc::system::TransportSystem transport_system{device_config}; + +static djc::system::ProtocolSystem protocol_system{device_config}; + +static djc::system::ControlSystem control_system{ + periphery_system.periphery(), + transport_system.link(), + protocol_system.link(), }; -static djc::Control control{ - storage.config().control, +static djc::system::PeerSystem peer_system{ + device_config, + transport_system.link(), }; -static djc::DisplayManager display_manager{ - periphery.display, - control, +static djc::system::InputSystem input_system{ + device_config, + periphery_system.periphery(), }; -// pages +static djc::system::UiSystem ui_system{user_config}; -static djc::ui::pages::RootPage root_page{}; +static djc::system::GraphicsSystem graphics_system{ + periphery_system.periphery().display_driver, + ui_system.virtualKeyboard(), +}; -static djc::ui::pages::MavLinkPage mavlink_page{ - root_page, - control, +// ui pages + +static djc::ui::pages::PeerExplorerPage peer_explorer_page{ + ui_system.service(), + ui_system.rootPage(), + transport_system.link(), + peer_system.scanningService(), + peer_system.favoritesRegistry(), }; -static djc::ui::pages::RawControlPage raw_control_page{ - root_page, - control, +static djc::ui::pages::MavlinkTelemetryPage mavlink_telemetry_page{ + ui_system.service(), + ui_system.rootPage(), + protocol_system.protocolRegistry(), + protocol_system.link(), + protocol_system.mavlinkTelemetryRegistry(), }; -static djc::ui::pages::PeerExplorerPage peer_explorer_page{ - root_page, - control, +static djc::ui::pages::RawProtocolPage raw_protocol_page{ + ui_system.service(), + ui_system.rootPage(), + protocol_system.protocolRegistry(), + protocol_system.link(), + transport_system.link(), }; static djc::ui::pages::ConfigPage config_page{ - root_page, + ui_system.service(), + ui_system.rootPage(), + device_config, + device_config_system.service(), + user_config, + user_config_system.service(), + peer_system.favoritesRegistry(), }; -void setup() { - static constexpr auto logger{kf::Logger::create("setup")}; +// navigation to event maps - Serial.begin(115200); - kf::Logger::writer = [](kf::memory::StringView str) { Serial.write(str.data(), str.size()); }; +using UiEvent = djc::ui::UI::Traits::EventImpl; + +static constexpr UiEvent navigation_event_from_direction[4]{ + UiEvent::pageCursorMove(-1),// Up + UiEvent::pageCursorMove(+1),// Down + UiEvent::widgetValue(-1), // Left + UiEvent::widgetValue(+1), // Right +}; - storage.load(); +static constexpr UiEvent virtual_keyboard_event_from_direction[4]{ + UiEvent::widgetValue(0),// Up + UiEvent::widgetValue(1),// Down + UiEvent::widgetValue(2),// Left + UiEvent::widgetValue(3),// Right +}; - if (not periphery.init()) { - logger.error("Periphery init failed. Resseting periphery config to defaults"); - storage.config().periphery = djc::Periphery::Config::defaults(); - storage.modified(true); - } +// callbacks - if (not storage.config().periphery.joystick_axes_tuned) { - logger.debug("Tunning axes.."); - periphery.tune(storage.config().periphery); - storage.modified(true); +static void onReceiveFromPeer(const djc::transport::PeerAddress &address, kf::Slice buffer) noexcept { + (void) address; + + protocol_system.link().receive(buffer); +} + +static void onTrustedPeerDiscovered(const djc::transport::PeerAddress &address) noexcept { + logger.info("Auto Connect"); + + (void) transport_system.link().connect(address); +} + +static void onReceiveFromLogger(kf::memory::StringView str) noexcept { + Serial.write(str.data(), str.size()); +} + +static void onPrimaryButtonClick() noexcept { + if (control_system.service().enabled()) { return; } + + ui_system.service().addEvent(UiEvent::widgetClick()); +} + +static void onSecondaryButtonClick() noexcept { + if (ui_system.virtualKeyboard().active()) { + ui_system.virtualKeyboard().quit(); } else { - logger.debug("Axes already tuned"); + control_system.service().enabled(not control_system.service().enabled()); } - (void) control.init();// TODO: implement halt on error? - display_manager.init(); + ui_system.service().requestRender(); +} - { - using E = djc::ui::UI::Event; +static void onPrimaryJoystickDirection(djc::service::InputHandler::JoystickListener::Direction direction) noexcept { + if (control_system.service().enabled()) { return; } - input_handler.onLeftButton([]() { - if (virtual_keyboard.active()) { - virtual_keyboard.quit(); - } else { - control.enabled(not control.enabled()); + const auto table = ui_system.virtualKeyboard().active() ? virtual_keyboard_event_from_direction : navigation_event_from_direction; + ui_system.service().addEvent(table[static_cast(direction)]); +} + +static void onUiRendered(kf::memory::StringView str) { + using Palette = std::decay_t::Palette; + + if (control_system.service().enabled()) { + if (transport_system.link().connected()) { + graphics_system.overlay(transport_system.link().activePeerAddress().unwrap().toString().view(), Palette::light_green); + } else { + graphics_system.overlay("Disconnected", Palette::light_yellow); + } + } else { + if (const auto &p = ui_system.service().activePage(); p.isSome()) { + if (const auto &widget = p.unwrap().selectedWidget(); widget.isSome()) { + graphics_system.overlay(widget.unwrap().hint(), Palette::light_gray); } + } + } + + graphics_system.onRender(str); +} + +// setups + +static void setupPeriphery(djc::config::DeviceConfig &config) noexcept { + if (not config.periphery.joystick_axes_tuned) { + logger.debug("Tunning axes.."); + periphery_system.periphery().tune(config.periphery); + } +} - ui.addEvent(E::update()); - }); +static void setupGraphics(djc::config::UserConfig &config) noexcept { + if (const auto &canvas = graphics_system.canvas(); canvas.isSome()) { - input_handler.onRightButton([]() { - if (control.enabled()) { return; } + auto &textual_renderer_config{ +#ifdef DJC_UI_RENDERER_IMPL_TEXTUAL_COLORED + config.ui_renderer.text +#else + config.ui_renderer +#endif + }; - ui.addEvent(E::widgetClick()); - }); + textual_renderer_config.row_max_length = canvas.unwrap().widthInGlyphs(); + textual_renderer_config.rows_total = canvas.unwrap().heightInGlyphs() - 1; + } +} - input_handler.onDirection([](djc::InputHandler::JoystickListener::Direction direction) { - static constexpr E navigation_event_from_direction[4] = { - E::pageCursorMove(-1),// Up - E::pageCursorMove(+1),// Down - E::widgetValue(-1), // Left - E::widgetValue(+1), // Right - }; +#define DJC_SYSTEM_INIT(__system_instance__, ...) \ + KF_CHECK_IMPL(decltype(__system_instance__), ::djc::system::SystemTag); \ + __system_instance__.init(__VA_ARGS__); \ + logger.info("done: '" #__system_instance__ "'") - static constexpr E VirtualKeyboard_event_from_direction[4] = { - E::widgetValue(0),// Up - E::widgetValue(1),// Down - E::widgetValue(2),// Left - E::widgetValue(3),// Right - }; +void setup() { + Serial.begin(115200); + kf::Logger::writer = onReceiveFromLogger; - if (control.enabled()) { return; } + // init - const auto table = virtual_keyboard.active() ? VirtualKeyboard_event_from_direction : navigation_event_from_direction; - ui.addEvent(table[static_cast(direction)]); - }); + DJC_SYSTEM_INIT(device_config_system); + DJC_SYSTEM_INIT(user_config_system); + DJC_SYSTEM_INIT(periphery_system); + DJC_SYSTEM_INIT(transport_system, user_config.init_transport_kind); + DJC_SYSTEM_INIT(protocol_system, user_config.init_protocol_mode); + DJC_SYSTEM_INIT(peer_system); + DJC_SYSTEM_INIT(input_system); + DJC_SYSTEM_INIT(graphics_system, periphery_system.periphery().display_driver); + DJC_SYSTEM_INIT(control_system); + DJC_SYSTEM_INIT(ui_system, {&peer_explorer_page, &mavlink_telemetry_page, &raw_protocol_page, &config_page}); - // apply page links - root_page.attach(mavlink_page); - root_page.attach(raw_control_page); - root_page.attach(peer_explorer_page); - root_page.attach(config_page); + // orchestration - ui.bindPage(root_page); - ui.addEvent(E::update()); - } + transport_system.link().onReceive(onReceiveFromPeer); - if (storage.modified()) { storage.save(); } + peer_system.autoConnectService().callback(onTrustedPeerDiscovered); + peer_system.favoritesRegistry().entries(user_config.peer_favorites.slice()); + + input_system.service().onLeftButton(onSecondaryButtonClick); + input_system.service().onRightButton(onPrimaryButtonClick); + input_system.service().onDirection(onPrimaryJoystickDirection); + + ui_system.renderer().callback(onUiRendered); + + setupPeriphery(device_config); + setupGraphics(user_config); } void loop() { - constexpr kf::math::Milliseconds loop_period{1000 / 50};// 50 Hz - delay(loop_period); + constexpr kf::math::Milliseconds loop_period{1000 / loop_rate_hz}; - const auto now = millis(); - input_handler.poll(now); + const auto now = static_cast(millis()); - if (control.enabled()) { - using I = djc::Control::Input; + device_config_system.poll(now); + user_config_system.poll(now); + periphery_system.poll(now); + transport_system.poll(now); + protocol_system.poll(now); + peer_system.poll(now); + input_system.poll(now); + control_system.poll(now); + ui_system.poll(now); + graphics_system.poll(now); - control.input({ - .left_x = I::fromReal(periphery.left_joystick.axis_x.read()), - .left_y = I::fromReal(periphery.left_joystick.axis_y.read()), - .right_x = I::fromReal(periphery.right_joystick.axis_x.read()), - .right_y = I::fromReal(periphery.right_joystick.axis_y.read()), - }); - } - control.poll(now); - ui.poll(now); + delay(loop_period); } \ No newline at end of file diff --git a/README.MD b/README.MD index 85d92ef..961af1f 100644 --- a/README.MD +++ b/README.MD @@ -1,6 +1,132 @@ + + # ESP32 Dual Joystick Controller (DJC) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) + An open-source remote controller based on ESP32 with dual analog joysticks, designed for DIY robotics projects. diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 8001fd8..0000000 --- a/TODO.md +++ /dev/null @@ -1,103 +0,0 @@ -# TODO - -## 1. Индикация активного пира на всех страницах -**Проблема:** Сейчас активный MAC виден только в `PeerExplorerPage` (звёздочка). На `MavLinkPage`, `RawControlPage`, `RootPage` пользователь не знает, кому отправляет команды. -**Решение:** Добавить в `DisplayManager` статусную строку (например, внизу или вверху) с отображением активного пира. Можно последние 4 байта MAC или имя, если добавить заметки. -**Где:** `DisplayManager::onRender()` запрашивает `control.activeMac()` и выводит справа. - -## 2. Подтверждение опасных действий (Reset, Delete peer) -**Проблема:** Кнопка Reset в `ConfigPage` сразу сбрасывает конфиг и перезагружает устройство. Нет диалога подтверждения. -**Решение:** Реализовать двухэтапное нажатие: первый клик → меняем текст кнопки на "Press again to reset" и запускаем таймер 3 секунды; второй клик в течение таймаута → `storage.reset()`. Или использовать модальное окно, если UI поддерживает. -**Где:** В `ConfigPage`, обработчик `_reset_storage.callback()`. - -## 3. Таймер бездействия и light sleep (энергосбережение) -**Проблема:** ESP32 никогда не спит, батарея садится за несколько часов. -**Решение:** Добавить `IdleSleepManager`, который сбрасывает счётчик при любом движении стиков (через `InputHandler::controllerValues()`) или нажатии кнопок. При бездействии > N минут (например, 10) — `esp_light_sleep_start()`. При пробуждении переинициализировать ESP-NOW и восстановить соединение к активному пиру. -**Где:** Новый модуль `src/djc/IdleSleepManager.hpp`, вызывать его `poll()` в `loop()`. - -## 4. Визуальный фидбек калибровки джойстиков -**Проблема:** При первом запуске или после сброса вызывается `periphery.tune()`, которая блокирует всё на ~100 мс, а пользователь видит застывший экран. -**Решение:** Показывать на дисплее сообщение "Calibrating... move joysticks", возможно прогресс-символами. После калибровки — автоматически сохранить конфиг. -**Где:** В `main.cpp` перед вызовом `tune()` добавить принудительный рендеринг через `DisplayManager` (минуя UI) или отправить событие UI. - -## 5. Полноценный MAVLink: телеметрия и управление -**Проблема:** `MavLinkPage` обрабатывает только `SCALED_IMU` и `SERIAL_CONTROL`. Нет отображения высоты, напряжения, спутников, режима полёта. -**Решение:** Добавить парсинг: -- `GLOBAL_POSITION_INT` → относительная высота, lat/lon. -- `SYS_STATUS` → напряжение батареи, ток, заряд. -- `HEARTBEAT` → режим автопилота (стабилизация, альтхолд, loiter и т.д.). -Обновлять соответствующие виджеты на странице. -**Где:** `MavLinkPage::onMavLinkMessage()`. - -## 6. CLI (Command Line Interface) через Serial -**Проблема:** Отладка и настройка без экрана невозможны. Нужен простой shell. -**Решение:** Интегрировать легковесную библиотеку (например, ESP-Shell) и добавить команды: -- `peers` — список обнаруженных MAC из `PeerExplorer` (последние активные). -- `connect ` — подключиться к пиру. -- `disconnect` — отключиться. -- `mode [raw|mavlink]` — переключить текущий режим. -- `reset` — сбросить конфиг. -- `save` — сохранить конфиг. -- `info` — статус (активный MAC, режим, напряжение батареи). -**Где:** Новый модуль `src/djc/CLI.hpp`, инициализация в `setup()`. - -## 7. Заметки о пирах (имена для MAC) -**Проблема:** Пользователь видит только MAC-адреса, что неудобно для идентификации (дрон, робот, второй дрон). -**Решение:** Добавить в `Config` структуру `PeerNote { Mac mac; char name[16]; }` и `Box`. На странице "Peer Notes" отображать список с именами, возможность добавлять/редактировать/удалять. В `PeerExplorerPage` для известных MAC подставлять имя. -**Где:** `src/djc/Config.hpp`, новая страница `PeerNotesPage`, изменения в `PeerExplorerPage` для отображения имени. - -## 8. Автоматическое добавление устройств по Heartbeat -**Проблема:** Сейчас пользователь должен кликнуть на устройство в `PeerExplorerPage`, чтобы подключиться. Хорошо бы при получении heartbeat от нового устройства показывать уведомление с предложением добавить в избранное/заметки. -**Решение:** В `Control` при получении пакета от неизвестного MAC анализировать, является ли он heartbeat (по сигнатуре). Если да — сохранять MAC во временный буфер и через UI показывать всплывающее сообщение (например, через `ui.addEvent()` и специальный виджет уведомлений). -**Где:** `Control::onReceiveFromUnknown`, интеграция с `DisplayManager` для отображения уведомлений. - -## 9. Выбор типа дисплея через флаг компиляции -**Проблема:** Сейчас жёстко привязан ST7735. Хочется поддержать SSD1306. -**Решение:** В `platformio.ini` добавить `-DDISPLAY_TYPE=ST7735` или `SSD1306`. В `prelude.hpp` условно подключать нужный драйвер и определять пины. -**Где:** `prelude.hpp`, `Periphery.hpp` (пины DC/RESET). - -## 10. Документация по питанию (сейчас только USB, нет шилда) -**Проблема:** В проекте отсутствует описание автономного питания, пользователи вынуждены питать плату только через USB. -**Решение:** Дополнить README разделом "Power Supply" с описанием текущего способа (USB) и планов по аккумулятору. Указать требования к току, напряжению. -**Где:** `README.md`, раздел Hardware или новый подраздел Power. - -## 11. Автономное питание от аккумулятора 18650 (1S1P) с boost-конвертером до 5В -**Проблема:** Устройство не может работать от батареи, что ограничивает его применение в полевых условиях. -**Решение:** Спроектировать цепь питания: защита от переразряда, зарядный контроллер (TP4056 или аналоги), boost-конвертер (MT3608, SX1308 и т.п.) до 5В для подачи на devkit-плату. -**Где:** Схема в KiCad, тестовый макет, затем интеграция в шилд. - -## 12. Разработка шилда для devkit-платы (ESP32 DevKit) -**Проблема:** Подключение джойстиков, кнопок, дисплея выполнено макетными проводами — ненадёжно и неудобно. -**Решение:** Спроектировать одностороннюю печатную плату (ЛУТ, тонерная технология) для установки на ESP32 DevKit. На шилде разместить: -- разъёмы для двух HW-504, -- разъёмы для двух микропереключателей, -- посадочное место для ST7735 (или гибкий шлейф), -- цепь питания (boost + защита), -- кнопку сброса (опционально). -**Где:** Новый каталог `Hardware/Shield/` с файлами KiCad (схема, плата), Gerber-файлы, BOM. - -## 13. Принципиальная схема всего устройства в KiCad -**Проблема:** Принципиальная схема отсутствует, что затрудняет отладку, повторение проекта и модификации. -**Решение:** Создать проект KiCad, нарисовать полную принципиальную схему: ESP32 DevKit (как модуль), подключение периферии (джойстики, кнопки, дисплей), цепь питания, разъёмы. -**Где:** `Hardware/Schematic/DJC_Controller.sch`, экспорт в PDF и PNG для документации. - ---- - -## Порядок выполнения (рекомендованный) - -**Программная часть:** -1. Индикация активного пира (п.1) -2. Таймер бездействия + light sleep (п.3) -3. Подтверждение Reset (п.2) -4. Фидбек калибровки (п.4) -5. MAVLink телеметрия (п.5) -6. CLI (п.6) -7. Заметки о пирах (п.7) – опционально -8. Автоматическое добавление (п.8) -9. Выбор дисплея (п.9) - -**Аппаратная часть (параллельно или после стабилизации прошивки):** -10. Документация по питанию (п.10) – быстро -11. Принципиальная схема в KiCad (п.13) – основа для шилда -12. Автономное питание (п.11) – макет, отладка -13. Разработка шилда (п.12) – разводка, изготовление ЛУТ diff --git a/shapshot.py b/snapshot.py similarity index 95% rename from shapshot.py rename to snapshot.py index 7100d55..a573979 100644 --- a/shapshot.py +++ b/snapshot.py @@ -10,7 +10,7 @@ ) target_dirs = ( - "examples", "src", "test", ".github" + "DJC-Firmware/src", ".github" ) def _write(f: Path):