# tui_menu 0.2.2 - переход на Cursive

This commit is contained in:
Dmitry Akimov 2026-06-02 22:55:03 +03:00
parent 0922c81c0a
commit 80b100ad66
35 changed files with 1325 additions and 584 deletions

BIN
.DS_Store vendored

Binary file not shown.

820
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,7 @@ members = [
"menu_tui",
"protocol_lib",
"rk_nku_sync",
"rk_nku_menu",
"rk_nku_menu", "logger",
]
[workspace.package]

View file

@ -5,3 +5,8 @@ default-target = "aarch64-unknown-linux-gnu"
[build.zig]
enable = true # Сборка с участием линковщика Zig
version = "2.31" # Версия glibc, установленная на RK3399-ROC-PC-PRO
# [target.aarch64-unknown-linux-gnu]
# pre-build = [
# "apt-get update && apt-get install --assume-yes libxkbcommon"
# ]

BIN
config_lib/.DS_Store vendored

Binary file not shown.

View file

@ -1,6 +1,6 @@
[package]
name = "config_lib"
version = "0.1.0"
version = "0.1.1"
edition = "2024"
authors.workspace = true

View file

@ -26,12 +26,24 @@ impl DeviceConfig {
debug!("Parameter list: {:#?}", parameter_list);
let mut parameter_map = HashMap::new();
for parameter in parameter_list {
'mapify: for parameter in parameter_list {
// Пропускаем параметры, которые не требуют синхронизации с внешним MCU
// Актуально для {protocol_name}_scheme.toml
if let Ok(should_sync) = toml_config.get_string_value(&parameter, "should_sync") {
match should_sync.as_str() {
"false" => {
continue 'mapify;
}
_ => (),
}
}
let device_parameter = Parameter {
description: toml_config.get_string_value(&parameter, "name")?,
value: toml_config.get_string_value(&parameter, "current")?,
possible_values: toml_config.get_array_value(&parameter, "possible_values")?,
};
parameter_map.insert(parameter, device_parameter);
}
@ -44,6 +56,7 @@ impl DeviceConfig {
}
/// ## Сохранение текущих значений параметров в TOML-файл `self.schema_path`
/// TODO: параметры с `should_sync = false` не сохраняются???
pub fn save_parameters_values(&self) -> Result<(), String> {
let mut toml_config =
TomlScheme::new(&self.scheme.get_path_to_scheme_file()).map_err(|e| e.to_string())?;

View file

@ -1,2 +1,3 @@
pub mod device_config;
pub mod serial_config;
mod toml_parser;

View file

@ -0,0 +1,38 @@
use crate::device_config::DeviceConfig;
use log::debug;
pub struct SerialPortConfig {
serial_name: String,
serial_baudrate: u32,
}
impl SerialPortConfig {
/// Чтение параметров последовательного порта из файла-схемы TOML
pub fn new(path_to_scheme: &str) -> Result<Self, String> {
let smart_serial_parameters = DeviceConfig::create_parameter_list(path_to_scheme)?;
let serial_name = smart_serial_parameters.get_parameter_value("device")?;
let serial_baudrate = smart_serial_parameters
.get_parameter_value("baudrate")?
.parse::<u32>()
.map_err(|e: std::num::ParseIntError| e.to_string())?;
debug!("serial port_name: {}", serial_name);
debug!("serial baudrate: {}", serial_baudrate);
Ok(SerialPortConfig {
serial_name,
serial_baudrate,
})
}
/// Чтение имени последовательного порта из конфига
pub fn get_serial_name(&self) -> String {
self.serial_name.clone()
}
/// Чтение скорости последовательного порта из конфига
pub fn get_serial_baudrate(&self) -> u32 {
self.serial_baudrate
}
}

View file

@ -1,20 +0,0 @@
# Настройка рабочего окружения
## Кросскомпиляция
- [cargo-zigbuild](https://github.com/rust-cross/cargo-zigbuild)
- [cross-rs](https://github.com/cross-rs/cross)
### Сборка
> Важно: необходимо использовать версию линковщика, совпадающую с версией, установленной на целевом устройстве
- cross-rs: `cross build --target=arm-unknown-linux-gnueabihf --bin <crate> --release`
- zigbuild: `cargo zigbuild --target=arm-unknown-linux-gnueabihf.[версия линкера на целевом устройстве] --bin <crate> --release`
> Для кросскомпиляции `serialport` (libudev не поддерживается на MacOS & WIN64) указать фича-флаг `default-features = false`
## Загрузка на целевое устройство
`scp -P [port_number] [path/to/bin] [hostname]@[ip_address]:/home/[user-name]`

View file

@ -1,5 +0,0 @@
# Установка необходимых расширений
```bash
cat vs_code_extensions_list.txt | xargs -n 1 code --install-extension
```

14
logger/Cargo.toml Normal file
View file

@ -0,0 +1,14 @@
[package]
name = "logger"
version = "0.1.0"
edition = "2024"
authors.workspace = true
[dependencies]
chrono = "0.4"
protocol_lib = { path = "../protocol_lib" }
config_lib = { path = "../config_lib" }
log = { workspace = true }
env_logger = { workspace = true }
serialport = { workspace = true }
signal-hook = "0.3"

179
logger/src/logger.rs Normal file
View file

@ -0,0 +1,179 @@
/// ### Модуль логгирования в файл
/// - Предоставляет функционал для записи логов в файл с временными метками
/// - Каждые сутки создает новый файл для записи
/// - Предоставляет статистику: количество корректных сообщений, количество ошибок, количество созданных файлов
use chrono::{Local, NaiveDate};
use log::{trace, warn};
use protocol_lib::mu_frame::MUFrame;
use std::fs::{File, OpenOptions};
use std::io::{self, BufWriter, Write};
pub struct RollingFileLogger {
/// Директория для хранения логов
log_dir: String,
/// Текущее время с начала старта
current_date: NaiveDate,
/// Текущий файл, в который ведётся запись
current_file: Option<BufWriter<File>>,
/// Буфер для промежуточного хранения логов
buffer: Vec<String>,
/// Емкость буфера (хранит логи перед записью в файл)
max_buffer_size: usize,
/// Статистика логгера
stats: LoggerStats,
}
#[derive(Debug, Clone, Default)]
/// Статистика текущей сессии логгирования
pub struct LoggerStats {
/// Корректно принятые, распакованные сообщения
pub total_messages: u64,
/// Количество возникших ошибок (парсинг, таймаут приема)
pub total_errors: u64,
/// Количество созданных файлов
pub files_rotated: u64,
}
impl RollingFileLogger {
pub fn new(log_dir: &str, max_buffer_size: usize) -> io::Result<Self> {
std::fs::create_dir_all(log_dir)?;
let logger = Self {
log_dir: log_dir.to_string(),
current_date: Local::now().date_naive(),
current_file: None,
buffer: Vec::new(),
max_buffer_size,
stats: LoggerStats::default(),
};
Ok(logger)
}
/// Логгирование распакованного сообщения
pub fn log_frame(&mut self, opcode: u8, payload: &[u8]) -> io::Result<()> {
let payload_str = String::from_utf8_lossy(payload);
let message = format!(
"СООБЩЕНИЕ | ТИП: {} | ДАННЫЕ: {}",
MUFrame::get_opcode_description(opcode),
payload_str
);
trace!("{}", message);
self.log(message)?;
self.stats.total_messages += 1;
Ok(())
}
/// Логгирование ошибки
pub fn log_error(&mut self, error: &str) -> io::Result<()> {
let message = format!("ОШИБКА | {}", error);
self.log(message)?;
self.stats.total_errors += 1;
Ok(())
}
fn log(&mut self, message: String) -> io::Result<()> {
let now = Local::now();
let new_date = now.date_naive();
// Прошли сутки - создаем новый файл для записи
if new_date != self.current_date {
self.flush()?;
self.current_file = None;
self.current_date = new_date;
self.stats.files_rotated += 1;
warn!("[LOG] Date changed, new log file created: {}", new_date);
}
if self.current_file.is_none() {
let file = self.open_log_file()?;
self.current_file = Some(BufWriter::new(file));
}
let timestamped = format!("[{}] {}", now.format("%Y-%m-%d %H:%M:%S%.3f"), message);
self.buffer.push(timestamped);
// Запись в файл после заполнения буфера
if self.buffer.len() >= self.max_buffer_size {
self.flush()?;
}
Ok(())
}
/// **Открытие файла для записи**
/// > Файл будет создан, если он не существует
fn open_log_file(&self) -> io::Result<File> {
let filename = format!(
"{}/uart_log_{}.txt",
self.log_dir,
self.current_date.format("%Y-%m-%d")
);
OpenOptions::new().create(true).append(true).open(&filename)
}
pub fn flush(&mut self) -> io::Result<()> {
if let Some(ref mut file) = self.current_file {
for line in self.buffer.drain(..) {
writeln!(file, "{}", line)?;
}
file.flush()?;
}
Ok(())
}
/// Получение статистики текущей сессии логгирования
pub fn stats(&self) -> &LoggerStats {
&self.stats
}
/// Получение имени файла, в который в данный момент ведётся запись
pub fn current_log_file(&self) -> String {
format!(
"{}/uart_log_{}.txt",
self.log_dir,
self.current_date.format("%Y-%m-%d")
)
}
}
impl Drop for RollingFileLogger {
fn drop(&mut self) {
let _ = self.flush();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_log_error() {
let mut logger = RollingFileLogger::new("tests_output", 10).unwrap();
logger.log_error("Test error").unwrap();
assert_eq!(logger.stats.total_errors, 1);
}
#[test]
fn test_log_frame() {
let mut logger = RollingFileLogger::new("tests_output", 10).unwrap();
logger
.log_frame(0x01, "That was test frame #1".as_bytes())
.unwrap();
assert_eq!(logger.stats.total_messages, 1);
}
#[test]
fn test_flush() {
let mut logger = RollingFileLogger::new("tests_output", 10).unwrap();
logger
.log_frame(0x01, "That was test frame #2".as_bytes())
.unwrap();
logger.flush().unwrap();
assert_eq!(logger.stats.total_messages, 1);
}
}

135
logger/src/main.rs Normal file
View file

@ -0,0 +1,135 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use crate::logger::RollingFileLogger;
use config_lib::serial_config::SerialPortConfig;
use log::{error, info, warn};
use protocol_lib::inc_parser::{FrameParser, ParseResult};
use signal_hook::consts::{SIGINT, SIGTERM};
use signal_hook::iterator::Signals;
use std::time::Duration;
/// Путь до файла-схемы параметров Serial порта устройства
const SERIAL_PORT_CONFIG_PATH: &str = "rk_nku_configs/rk3399_scheme.toml";
/// Максимальное время ожидания ответа от устройства (`Heartbeat устройства - 60 секунд`)
const BOARD_RESPONSE_TIMEOUT_MS: std::time::Duration = std::time::Duration::from_secs(65);
/// Размер буфера для хранения принятых данных перед записью в файл
const BUFFER_SIZE: usize = 20;
/// Путь директории для хранения логов
const LOG_DIR: &str = "logs";
/// Интервал между записями в лог файл
const FLUSH_INTERVAL_MS: u64 = 100;
mod logger;
fn main() -> Result<(), Box<dyn std::error::Error>> {
env_logger::init();
// Инициализация обработчиков сигналов ОС
let mut signals = Signals::new(&[SIGINT, SIGTERM])?;
let handle = signals.handle();
let running = Arc::new(AtomicBool::new(true));
let running_clone = Arc::clone(&running);
// Создание потока для обработки сигналов
let signal_thread = thread::spawn(move || {
for sig in signals.forever() {
match sig {
SIGINT => {
warn!("\n[*] Получен сигнал SIGINT.");
running_clone.store(false, Ordering::SeqCst);
break;
}
SIGTERM => {
warn!("\n[*] Получен сигнал SIGTERM.");
running_clone.store(false, Ordering::SeqCst);
break;
}
_ => {}
}
}
});
// Настройка последовательного порта
let serial_port_config = SerialPortConfig::new(SERIAL_PORT_CONFIG_PATH)?;
let port_name = serial_port_config.get_serial_name();
let baudrate = serial_port_config.get_serial_baudrate();
info!("{}: {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
info!("Порт: {:<30}", port_name);
info!("Скорость: {:<24}", baudrate);
info!("Место хранения логов: {:<27}", LOG_DIR);
let mut serial_port = serialport::new(port_name, baudrate)
.timeout(BOARD_RESPONSE_TIMEOUT_MS)
.open()?;
let mut logger = RollingFileLogger::new(LOG_DIR, BUFFER_SIZE)?;
let mut parser = FrameParser::new();
let mut buffer = [0u8; 512];
let mut last_flush = std::time::Instant::now();
let mut frame_counter = 0;
while running.load(Ordering::SeqCst) {
match serial_port.read(&mut buffer) {
Ok(n) if n > 0 => {
// Обработка принятого сообщения побайтно
for &byte in &buffer[0..n] {
match parser.process_raw_byte(byte) {
ParseResult::FrameReady(frame) => {
logger.log_frame(frame.get_opcode(), frame.get_data().as_slice())?;
frame_counter += 1;
if frame_counter % 10 == 0 {
info!("[*] Записано {} сообщений", frame_counter);
}
}
ParseResult::Error(e) => {
logger.log_error(&e)?;
error!("[!] Ошибка парсинга: {}", e);
}
ParseResult::Incomplete => {}
}
}
last_flush = std::time::Instant::now();
}
Ok(_) => {}
Err(e) => {
let error_msg = format!("Ошибка при чтении порта: {}", e);
logger.log_error(&error_msg)?;
error!("[!] {}", error_msg);
// Try to reconnect
thread::sleep(Duration::from_millis(500));
}
}
// Periodic flush
if last_flush.elapsed() > Duration::from_millis(FLUSH_INTERVAL_MS) {
logger.flush()?;
}
}
// Graceful shutdown
warn!("\n[*] Ожидания закрытие потока чтения сигналов ОС...");
handle.close();
let _ = signal_thread.join();
// Final flush
logger.flush()?;
let stats = logger.stats();
info!("########################################################");
info!(". Статистика сессии .");
info!("Всего сообщений: {:<26}в•‘", stats.total_messages);
info!("Всего ошибок: {:<26}в•‘", stats.total_errors);
info!("Создано файлов с логами: {:<25}в•‘", stats.files_rotated);
info!("Текущий файл: {:<30}в•‘", logger.current_log_file());
info!("########################################################");
Ok(())
}

View file

@ -0,0 +1,5 @@
[2025-12-10 14:34:29.234] ERROR | Test error
[2025-12-10 14:34:29.234] FRAME | OPCODE: 0x01 | CRC: 0x1234 | PAYLOAD: That was test frame
[2025-12-10 14:36:07.753] ERROR | Test error
[2025-12-10 14:36:07.753] FRAME | OPCODE: 0x01 | PAYLOAD: That was test frame #1
[2025-12-10 14:36:07.753] FRAME | OPCODE: 0x01 | PAYLOAD: That was test frame #2

View file

@ -0,0 +1,3 @@
[2025-12-11 11:26:46.696] СООБЩЕНИЕ | ТИП: ОШИБКА | ДАННЫЕ: That was test frame #2
[2025-12-11 11:26:46.696] ОШИБКА | Test error
[2025-12-11 11:26:46.696] СООБЩЕНИЕ | ТИП: ОШИБКА | ДАННЫЕ: That was test frame #1

View file

@ -1,6 +1,6 @@
[package]
name = "menu_tui"
version = "0.2.0"
version = "0.2.2"
edition = "2024"
authors.workspace = true

View file

@ -1,9 +0,0 @@
# Рекомендации по улучшению
1.Рефакторинг обработки ошибок: Заменить все  `unwrap()` / `expect()`  на proper error handling с кастомным error enum
2.Добавить логирование: Интегрировать  `log` / `defmt`  с выводом на UART для embedded-отладки
3.Типобезопасные идентификаторы: Использовать `enum` вместо строк для имен `view`
4.Оптимизация памяти: Рассмотреть  `Rc<RefCell<...>>`  для state management
5.Unit-тесты: Покрыть тестами критичные модули (`DeviceParameters`, `NavigationManager`)
6.Реализовать `update_view`: Добавить функционал обновления прошивки через HTTP/UART
7.Документация: Дополнить `rustdoc` комментариями и примерами использования

View file

@ -57,7 +57,7 @@ fn simulate_gpio_handlers(nav_manager: NavigationManager) {
thread::sleep(Duration::from_secs(3));
loop {
thread::sleep(Duration::from_millis(3000));
thread::sleep(Duration::from_millis(4000));
// Симуляция нажатия кнопки GPIO
nav_select.select_item();

View file

@ -12,26 +12,6 @@ use cursive::event::{Event, EventResult, Key};
use cursive::views::{CircularFocus, ListView, SelectView};
use cursive::{CbSink, Cursive, View};
// TODO: Перенести в отдельный модуль
enum ActiveMenuView {
MainView,
ConfigView,
ExitView,
UpdateView,
}
impl From<&str> for ActiveMenuView {
fn from(value: &str) -> Self {
match value {
"main_menu" => ActiveMenuView::MainView,
"config_menu" => ActiveMenuView::ConfigView,
"exit_menu" => ActiveMenuView::ExitView,
"update_menu" => ActiveMenuView::UpdateView,
_ => ActiveMenuView::MainView,
}
}
}
/// Менеджер навигации для управления меню c вызывающей стороны
pub struct NavigationManager {
// CbSink  — это thread-safe канал, позволяющий отправлять callback-функции из любого потока.

View file

@ -63,7 +63,7 @@ pub(crate) fn show_config_view(siv: &mut Cursive) {
// Кнопка возврата в главное меню
parameter_list.add_child(
"",
Button::new("Назад в главное меню", |s| {
Button::new("Назад", |s| {
let state: MenuAppState = s
.take_user_data()
.expect("Не удалось выполнить take_user_data");

BIN
protocol_lib/.DS_Store vendored Normal file

Binary file not shown.

View file

@ -1,93 +0,0 @@
# **MU_protocol — Протокол обмена данными между микроконтроллером и одноплатным компьютером**
## **1. Общее описание**
Протокол предназначен для передачи данных между **Python Server App** и **одноплатным компьютером** по UART. Протокол обеспечивает:
- Синхронизацию пакетов
- Целостность данных (через CRC16)
- Гибкость в передаче данных разных типов (определяется через `opcode`)
---
## **2. Структура пакета**
Каждый пакет состоит из следующих полей:
| Поле | Размер | Тип | Описание |
|------------|--------|----------|----------|
| `sync1` | 1 байт | uint8 | Стартовый байт синхронизации, значение `0xAA` |
| `size` | 1 байт | uint8 | Размер поля `data` в байтах (от 0 до 255) |
| `opcode` | 1 байт | uint8 | Код операции (например, 0xDA для передачи данных о состоянии лифта) |
| `data` | N байт | массив | Полезная нагрузка |
| `crc16` | 2 байта| uint16 | CRC-16 контрольная сумма |
| `sync2` | 1 байт | uint8 | Конечный байт синхронизации, значение `0xBB` |
### **Общая длина пакета**:
```
Длина = 1 (sync1) + 1 (size) + 1 (opcode) + N (data) + 2 (crc16) + 1 (sync2) = 6 + N байт
```
---
## **3. Поля пакета**
### **3.1. `sync1` и `sync2`**
- Используются для синхронизации начала и конца пакета
- Значения: `0xAA` и `0xBB` соответственно
### **3.2. `size`**
- Определяет длину поля `data`
- Допустимые значения: от 0 до 255
### **3.3. `opcode`**
- Код операции, определяющий тип данных в поле `data`
- Примеры:
- `0xDA` — данные о состоянии лифта
- `0xDB` — команда от компьютера к микроконтроллеру
- `0xDC` — подтверждение приёма
- `0xDD` — запрос состояния
- и т.д.
### **3.4. `data`**
- Полезная нагрузка, формат которой зависит от `opcode`
- Для `opcode: 0xDA` — строка вида `#STM:L%d:R%d:A%d:S%d:M%d:E#\r\n`
### **3.5. `crc16`**
- CRC-16 контрольная сумма, рассчитанная по следующим параметрам:
| Параметр | Значение |
|------------|------------------|
| Название | CRC-16 CCITT |
| Полином | `0x1021` (`x^16 + x^12 + x^5 + 1`) |
| Начальное значение | `0xFFFF` |
| Реверс входных данных | Нет |
| Реверс результата | Нет |
| Выходное XOR (`XorOut`)| `0x0000` |
| Контрольное значение (`"123456789"`) | `0x29B1` |
| Максимальная длина блока | 4095 байт (32767 бит) |
---
## **4. Пример пакета**
### **4.1. Исходные данные**
```c
opcode = 0xDA
data = "#STM:L10:R3:A1:S4:M0:E#\r\n"
```
### **4.2. Шаги формирования пакета**
1. Вычислить длину `data`: `len(data) = 24`
2. Установить `size = 24`
3. Вычислить CRC16 от последовательности: `opcode + data`
4. Сформировать пакет:
```
[0xAA] [0x18] [0xDA] [data bytes...] [CRC16_H] [CRC16_L] [0xBB]
```
---

View file

@ -1,4 +1,4 @@
use log::{debug, error, warn};
use log::{error, warn};
use crate::mu_frame::MUFrame;
use std::time::Duration;

View file

@ -0,0 +1,158 @@
use crate::{
MUFrame,
mu_frame::{SYNC1, SYNC2},
};
/// Состояния парсера фреймов
#[derive(Debug, Clone, Copy, PartialEq)]
enum ParserState {
WaitingForSync1,
WaitingForLength,
WaitingForOpcode,
WaitingForPayload { length: u8, read: u8 },
WaitingForCrcLow,
WaitingForCrcHigh,
WaitingForSync2,
}
/// Контекст парсера фрейма
pub struct FrameParser {
state: ParserState,
frame_buffer: Vec<u8>,
}
/// Результат парсинга данных принятых по UART
pub enum ParseResult {
/// Фрейм полностью принят
FrameReady(MUFrame),
/// Ошибка парсинга
Error(String),
/// Фрейм принят не полностью
Incomplete,
}
impl FrameParser {
pub fn new() -> Self {
Self {
state: ParserState::WaitingForSync1,
frame_buffer: Vec::with_capacity(256),
}
}
pub fn process_raw_byte(&mut self, byte: u8) -> ParseResult {
match self.state {
// Чтение SYNC1 байта
ParserState::WaitingForSync1 => {
if byte == SYNC1 {
self.frame_buffer.clear();
self.frame_buffer.push(byte);
self.state = ParserState::WaitingForLength;
}
ParseResult::Incomplete
}
// Чтение длины payload
ParserState::WaitingForLength => {
if byte == 0 {
self.state = ParserState::WaitingForSync1;
return ParseResult::Error("Invalid payload length".to_string());
}
self.frame_buffer.push(byte);
self.state = ParserState::WaitingForOpcode;
ParseResult::Incomplete
}
// Чтение opcode
ParserState::WaitingForOpcode => {
if !MUFrame::is_opcode_correct(byte) {
self.state = ParserState::WaitingForSync1;
return ParseResult::Error("Invalid opcode".to_string());
}
self.frame_buffer.push(byte);
let length = self.frame_buffer[1];
self.state = ParserState::WaitingForPayload { length, read: 0 };
ParseResult::Incomplete
}
// Чтение payload
ParserState::WaitingForPayload { length, read } => {
self.frame_buffer.push(byte);
let new_read = read + 1;
self.state = if new_read >= length {
ParserState::WaitingForCrcLow
} else {
ParserState::WaitingForPayload {
length,
read: new_read,
}
};
ParseResult::Incomplete
}
// Чтение CRC - LSB
ParserState::WaitingForCrcLow => {
self.frame_buffer.push(byte);
self.state = ParserState::WaitingForCrcHigh;
ParseResult::Incomplete
}
// Чтение CRC - MSB
ParserState::WaitingForCrcHigh => {
self.frame_buffer.push(byte);
self.state = ParserState::WaitingForSync2;
ParseResult::Incomplete
}
// Чтение SYNC2
ParserState::WaitingForSync2 => {
self.frame_buffer.push(byte);
if byte != SYNC2 {
self.state = ParserState::WaitingForSync1;
return ParseResult::Error("Invalid SYNC2 byte".to_string());
}
// Попытка десериализации в MuFrame
let result = match MUFrame::deserialize(&self.frame_buffer) {
Ok(frame) => ParseResult::FrameReady(frame),
Err(e) => ParseResult::Error(e),
};
self.state = ParserState::WaitingForSync1;
result
}
}
}
/// Сброс состояния парсера в начальное
pub fn reset(&mut self) {
self.state = ParserState::WaitingForSync1;
self.frame_buffer.clear();
}
}
impl Default for FrameParser {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::MUFrame;
#[test]
fn test_parsing() {
let serialized_vec = vec![
0xAA, 0x1A, 0xC0, 0x23, 0x53, 0x54, 0x4D, 0x3A, 0x4C, 0x30, 0x3A, 0x52, 0x31, 0x36,
0x3A, 0x41, 0x31, 0x3A, 0x53, 0x30, 0x3A, 0x4D, 0x30, 0x3A, 0x45, 0x23, 0x0D, 0x0A,
0x00, 0x80, 0x77, 0xBB,
];
let mut parser = FrameParser::new();
let deserilized_frame = MUFrame::deserialize(&serialized_vec).unwrap();
assert!(deserilized_frame.validate_frame().is_ok());
for byte in serialized_vec {
let result = parser.process_raw_byte(byte);
if let ParseResult::FrameReady(frame) = result {
assert_eq!(frame, deserilized_frame);
}
}
}
}

View file

@ -1,9 +1,11 @@
pub mod client;
mod mu_frame;
pub mod inc_parser;
pub mod mu_frame;
use crate::inc_parser::{FrameParser, ParseResult};
use crate::mu_frame::MUFrame;
use log::trace;
use log::{trace, warn};
use std::{
io::{Read, Write},
thread,
@ -23,6 +25,35 @@ fn send_proto_message<Writer: Write>(data: MUFrame, mut writer: Writer) -> Resul
Ok(())
}
/// Прием сообщения
fn _recv_proto_messages<Reader: Read>(mut reader: Reader) -> Result<Vec<MUFrame>, String> {
let mut read_buffer = [0; 512];
let mut parser = FrameParser::new();
let mut result = Vec::new();
match reader.read(&mut read_buffer) {
Ok(n) if n > 0 => {
trace!("Received raw message: {read_buffer:?}");
for byte in &read_buffer[0..n] {
match parser.process_raw_byte(*byte) {
ParseResult::FrameReady(frame) => {
result.push(frame);
parser.reset();
}
ParseResult::Error(e) => {
warn!("Error parsing frame: {}", e);
}
ParseResult::Incomplete => {}
}
}
}
Ok(_) => {}
Err(e) => return Err(e.to_string()),
};
Ok(result)
}
/// Прием сообщения
fn recv_proto_message<Reader: Read>(mut reader: Reader) -> Result<MUFrame, String> {
let mut raw_frame = Vec::new();
@ -81,4 +112,28 @@ mod tests {
assert_eq!(received_frame.get_data(), frame_to_send.get_data());
assert_eq!(received_frame, frame_to_send);
}
#[test]
fn test_send_and_multiple_recv() {
let mut frame_to_send_1 = MUFrame::new();
frame_to_send_1
.set_data(b"get server_info\n".to_vec())
.unwrap();
let mut frame_to_send_2 = MUFrame::new();
frame_to_send_2
.set_data(b"Just for test\n".to_vec())
.unwrap();
let mut buf_1 = Vec::new();
let mut buf_2 = Vec::new();
send_proto_message(frame_to_send_1.clone(), &mut buf_1).unwrap();
send_proto_message(frame_to_send_2.clone(), &mut buf_2).unwrap();
let doubled_buf = [buf_1.clone(), buf_2.clone()].concat();
let received_frames = _recv_proto_messages(&doubled_buf[..]).unwrap();
assert_eq!(received_frames.len(), 2);
assert_eq!(received_frames[0], frame_to_send_1);
assert_eq!(received_frames[1], frame_to_send_2);
}
}

View file

@ -1,10 +1,14 @@
use std::fmt::Display;
const SYNC1: u8 = 0xAA;
const SYNC2: u8 = 0xBB;
const MAX_DATA_SIZE: u8 = u8::MAX;
const CONSOLE_OPCODE: u8 = 0xC0;
pub const SYNC1: u8 = 0xAA;
pub const SYNC2: u8 = 0xBB;
pub const MAX_DATA_SIZE: u8 = u8::MAX;
pub const OPCODE_ERROR: u8 = 0x01;
pub const OPCODE_LOG: u8 = 0x19;
pub const OPCODE_DATA: u8 = 0xDA;
pub const OPCODE_CONSOLE: u8 = 0xC0;
pub const OPCODE_BOOTLOADER: u8 = 0xB1;
/// Пакет данных протокола "МЮ"
///
///
@ -33,7 +37,7 @@ impl MUFrame {
Self {
prefix: SYNC1,
length: 0,
opcode: CONSOLE_OPCODE,
opcode: OPCODE_CONSOLE,
data: Vec::with_capacity(MAX_DATA_SIZE as usize),
crc_low: 0x00,
crc_high: 0x00,
@ -41,10 +45,30 @@ impl MUFrame {
}
}
pub(crate) fn get_data(&self) -> &Vec<u8> {
pub fn get_data(&self) -> &Vec<u8> {
&self.data
}
pub fn get_opcode(&self) -> u8 {
self.opcode
}
/// Проверка корректности опкода (ERROR, LOG, DATA, CONSOLE)
pub fn is_opcode_correct(opcode: u8) -> bool {
return [OPCODE_ERROR, OPCODE_LOG, OPCODE_DATA, OPCODE_CONSOLE].contains(&opcode);
}
/// Получение текстового описания опкода фрейма
pub fn get_opcode_description(opcode: u8) -> String {
match opcode {
OPCODE_ERROR => "ОШИБКА".to_string(),
OPCODE_LOG => "СТАТУС".to_string(),
OPCODE_DATA => "ДАННЫЕ".to_string(),
OPCODE_CONSOLE => "КОНСОЛЬ".to_string(),
_ => "UNKNOWN".to_string(),
}
}
/// Загрузка данных в фрейм, вычисление CRC и длины
pub(crate) fn set_data(&mut self, data: Vec<u8>) -> Result<(), String> {
if data.len() > MAX_DATA_SIZE as usize || data.is_empty() {
@ -78,7 +102,7 @@ impl MUFrame {
frame.suffix = data[5 + frame.length as usize];
frame.invalidate_frame()?;
frame.validate_frame()?;
Ok(frame)
}
@ -98,7 +122,7 @@ impl MUFrame {
}
/// Проверка валидности фрейма
fn invalidate_frame(&self) -> Result<(), String> {
pub(crate) fn validate_frame(&self) -> Result<(), String> {
if !self.is_prefix_correct() {
return Err("Bad prefix".to_string());
}
@ -203,7 +227,7 @@ mod tests {
assert!(frame.is_prefix_correct());
assert!(frame.is_postfix_correct());
assert!(frame.is_crc_valid(0x7780));
assert_eq!(frame.opcode, CONSOLE_OPCODE);
assert_eq!(frame.opcode, OPCODE_CONSOLE);
assert_eq!(frame.data, b"#STM:L0:R16:A1:S0:M0:E#\r\n\0");
}
@ -218,8 +242,8 @@ mod tests {
assert!(frame.is_prefix_correct());
assert!(frame.is_postfix_correct());
assert!(frame.is_crc_valid(0xB6BB));
assert_eq!(frame.opcode, CONSOLE_OPCODE);
assert_eq!(frame.opcode, OPCODE_CONSOLE);
assert_eq!(frame.data, b"#STM:L16:R16:A1:S2:M0:E#\r\n\0");
frame.invalidate_frame().unwrap();
frame.validate_frame().unwrap();
}
}

View file

@ -0,0 +1,25 @@
# First come some various options
shadow = false # Don't draw shadows around stacked views
borders = "simple" # Alternatives are "none" and "outset"
# Here we define the color palette.
[colors]
background = "#000000"
# If the value is an array, the first valid color will be used.
# If the terminal doesn't support custom color,
# non-base colors will be skipped.
shadow = ["#000000", "black"]
view = "#d3d7cf"
# Array and simple values have the same effect.
primary = ["#111111"]
secondary = "#EEEEEE"
tertiary = "#444444"
# Hex values can use lower or uppercase.
title_primary = "#0000FF"
title_secondary = "#ffff55"
# Lower precision values can use only 3 digits.
highlight = "#00F"
highlight_inactive = "#5555FF"

View file

@ -3,61 +3,100 @@
# possible_values - список возможных значений параметра
# default - значение по умолчанию (одно из possible_values)
# current - актуальное текущее значение параметра (одно из possible_values). Если не указано или некорректно, то используется значение по умолчанию
# should_sync - true, если параметр необходимо синхронизировать (pull & push) с внешним микроконтроллером
[groupnumber]
name = "Номер в группе"
possible_values = [
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"11",
"12",
"13",
"14",
"15",
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"11",
"12",
"13",
"14",
"15",
]
default = "0"
current = "6"
default = "1"
current = "1"
should_sync = "true"
[soundvolume]
name = "Громкость звука"
possible_values = ["0%", "25%", "50%", "75%", "100%"]
possible_values = [
"0%",
"25%",
"50%",
"75%",
"100%"
]
default = "50%"
current = "100%"
current = "50%"
should_sync = "true"
[musicvolume]
name = "Громкость музыки"
possible_values = ["0%", "25%", "50%", "75%", "100%"]
possible_values = [
"0%",
"25%",
"50%",
"75%",
"100%"
]
default = "0%"
current = "100%"
current = "25%"
should_sync = "true"
[loadcapacity]
name = "Грузоподъемность"
possible_values = [
"СКРЫТО",
"240кгел.",
"320кгел.",
"400кгел.",
"450кгел.",
"525кгел.",
"630кгел.",
"800кг 10чел.",
"800кг 11чел.",
"1000кг 13чел.",
"1150кг 15чел.",
"1275кг 16чел.",
"1275кг 17чел.",
"1425кг 18чел.",
"1600кг 20чел.",
"1600кг 21чел.",
"СКРЫТО",
"240кгел.",
"320кгел.",
"400кгел.",
"450кгел.",
"525кгел.",
"630кгел.",
"800кг 10чел.",
"800кг 11чел.",
"1000кг 13чел.",
"1150кг 15чел.",
"1275кг 16чел.",
"1275кг 17чел.",
"1425кг 18чел.",
"1600кг 20чел.",
"1600кг 21чел.",
]
default = "СКРЫТО"
current = "240кгел."
current = "400кгел."
should_sync = "true"
[cubicmedia]
name = "Cubic Media"
possible_values = [
"ВЫКЛ",
"ВКЛ"
]
default = "ВЫКЛ"
current = "ВКЛ"
should_sync = "true"
[network_indicator_poll_interval]
name = "Опрос статуса сети"
possible_values = [
"5s",
"10s",
"30s",
"60s"
]
default = "5s"
current = "5s"
should_sync = "false"

View file

@ -1,13 +1,12 @@
[package]
name = "rk_smart_menu"
version = "0.1.0"
name = "rk_nku_menu"
version = "0.1.1"
edition = "2024"
authors.workspace = true
[dependencies]
thiserror = "2.0"
gpiocdev-embedded-hal = "0.1.2"
gpiocdev = "0.7.3"
sysfs_gpio = { version = "0.6.2" }
log = { workspace = true }
env_logger = { workspace = true }
config_lib = { path = "../config_lib" }

View file

@ -1,11 +1,12 @@
use gpiocdev::Request;
use std::time::{Duration, Instant};
use log::debug;
use menu_tui::menu_navigation::NavigationManager;
use menu_tui::menu_process::DeviceMenu;
use std::time::Duration;
use sysfs_gpio::{Direction, Edge, Pin};
/// ### Путь к файлу-схемы параметров устройства
const NKU_DEVICE_CONFIG_PATH: &str = "rk_smart_configs/smart_scheme.toml";
const TUI_APP_CONFIG_PATH: &str = "rk_smart_configs/menu_style.toml";
const NKU_DEVICE_CONFIG_PATH: &str = "rk_nku_configs/nku_scheme.toml";
const TUI_APP_CONFIG_PATH: &str = "rk_nku_configs/menu_style.toml";
/// Максимальное время бездействия [c], после достижения которого происходит выход из меню
const IDLE_TIMEOUT_SEC: u64 = 30;
@ -42,72 +43,75 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
return Ok(());
}
/// ligpiod номер порта кнопки ввода
const S1_BTN: (&str, u8) = ("/dev/gpiochip3", 16);
/// ligpiod номер порта кнопки выбора
const S2_BTN: (&str, u8) = ("/dev/gpiochip2", 1);
/// ## Симуляция обработчиков GPIO кнопок
/// ### Расчёт номера пина в порядке libgpiod
/// Расчёт номера пина в порядке SYSFS
/// ```
/// bank = 0; // GPIO0_B5 => 0, bank ∈ [0,4]
/// group = 1; // GPIO0_B5 => 1, group ∈ {(A=0), (B=1), (C=2), (D=3)}
/// X = 5; // GPIO0_B5 => 5, X ∈ [0,7]
/// number = group * 8 + X = 1 * 8 + 5 = 13;
/// pin = bank * 32 + number = 0 * 32 + 13 = 13;
///
/// gpiochip_number = pin_number / 32; // Номер банка
/// line_offset = pin_number % 32; // Номер пина в банке
/// ```
///
/// SYSFS номер порта кнопки ввода
const S1_BTN: u8 = 124; //124
/// SYSFS номер порта кнопки выбора
const S2_BTN: u8 = 125; //125
fn gpio_navigation_handlers(nav_manager: NavigationManager) {
// Кнопка 1 (GPIO): Навигация вниз
let nav_down = nav_manager.clone();
std::thread::spawn(move || {
let down_btn_req = Request::builder()
.on_chip(S1_BTN.0)
.with_line(S1_BTN.1 as u32)
.as_input()
.with_edge_detection(gpiocdev::line::EdgeDetection::RisingEdge)
.request()
.unwrap();
debug!("down_irq_thread launched!");
let input = Pin::new(S1_BTN as u64);
debug!("return_irq_thread launched!");
input.with_exported(|| {
input.set_direction(Direction::In)?;
input.set_edge(Edge::RisingEdge)?;
let mut poller = input.get_poller()?;
let mut last_press = Instant::now() - Duration::from_millis(1000);
let debounce = Duration::from_millis(350);
loop {
if down_btn_req.has_edge_event().unwrap() {
if let Ok(edge) = down_btn_req.read_edge_event() {
if edge.kind == gpiocdev::line::EdgeKind::Rising {
debug!("DownArrow pressed");
// Симуляция нажатия кнопки DOWN
if let Some(pin_value) = poller.poll(1000)? {
if pin_value == 1 {
let now = Instant::now();
if now.duration_since(last_press) >= debounce {
last_press = now;
nav_down.navigate_down();
std::thread::sleep(Duration::from_millis(400));
} else {
// Событие проигнорировано как дребезг
}
}
}
}
})
});
// Кнопка 2 (GPIO): Выбор/подтверждение
let nav_select = nav_manager.clone();
let nav_down = nav_manager.clone();
std::thread::spawn(move || {
let return_btn_req = Request::builder()
.on_chip(S2_BTN.0)
.with_line(S2_BTN.1 as u32)
.as_input()
.with_edge_detection(gpiocdev::line::EdgeDetection::RisingEdge)
.request()
.unwrap();
let input = Pin::new(S2_BTN as u64);
debug!("return_irq_thread launched!");
input.with_exported(|| {
input.set_direction(Direction::In)?;
input.set_edge(Edge::RisingEdge)?;
let mut poller = input.get_poller()?;
let mut last_press = Instant::now() - Duration::from_millis(1000);
let debounce = Duration::from_millis(350);
loop {
if return_btn_req.has_edge_event().unwrap() {
if let Ok(edge) = return_btn_req.read_edge_event() {
if edge.kind == gpiocdev::line::EdgeKind::Rising {
debug!("Return pressed");
// Симуляция нажатия кнопки ENTER
nav_select.select_item();
std::thread::sleep(Duration::from_millis(400));
if let Some(pin_value) = poller.poll(1000)? {
if pin_value == 1 {
let now = Instant::now();
if now.duration_since(last_press) >= debounce {
last_press = now;
nav_down.select_item();
} else {
// Событие проигнорировано как дребезг
}
}
}
}
})
});
}

View file

@ -1,6 +1,6 @@
[package]
name = "rk_nku_sync"
version = "0.1.0"
version = "0.1.1"
edition = "2024"
authors.workspace = true
@ -10,3 +10,4 @@ config_lib = { path = "../config_lib" }
clap = { workspace = true }
log = { workspace = true }
env_logger = { workspace = true }

View file

@ -4,13 +4,13 @@ mod serial_config;
use std::str::FromStr;
use clap::Parser;
use log::{error, warn};
use log::{error, info, warn};
/// Количество попыток выполнить запрос
const REQUEST_ATTEMPTS: u8 = 5;
#[derive(Parser)]
#[command(author = "Akimov Dmitry MU LLC", name = "nku_sync", version = "0.1.0", about, long_about = None)]
#[command(author = "Akimov Dmitry MU LLC", name = "smart_sync", version = "0.1.0", about, long_about = None)]
struct Args {
/// Тип команды: pull - запрос сохраненных в устройстве настроек, push - отправка новых настроек
#[arg(short = 'm', long = "mode")]
@ -21,7 +21,7 @@ fn main() -> Result<(), String> {
let args = Args::parse();
env_logger::init();
warn!("rk_nku_sync> command mode: {:?}", args.mode);
info!("rk_nku_sync> command mode: {:?}", args.mode);
let mut nku_client = nku_client::NkuClient::new()?;
@ -60,6 +60,8 @@ fn push_parameters(client: &mut nku_client::NkuClient) -> Result<(), String> {
}
}
info!("Parameters have been pushed to device!");
// Запуск стриминга данных от станции
attempts = 1;
'start_streaming_loop: loop {
@ -80,7 +82,7 @@ fn push_parameters(client: &mut nku_client::NkuClient) -> Result<(), String> {
return Err("Start streaming failed!".to_string());
}
}
info!("Data streaming from MCU has been started!");
Ok(())
}
@ -106,7 +108,7 @@ fn pull_parameters(client: &mut nku_client::NkuClient) -> Result<(), String> {
return Err("Pull request failed!".to_string());
}
}
info!("Parameters have been pulled from device!");
Ok(())
}

View file

@ -9,10 +9,10 @@ pub struct SerialPortConfig {
impl SerialPortConfig {
/// Чтение параметров последовательного порта из файла-схемы TOML
pub fn new(path_to_scheme: &str) -> Result<Self, String> {
let nku_serial_parameters = DeviceConfig::create_parameter_list(path_to_scheme)?;
let smart_serial_parameters = DeviceConfig::create_parameter_list(path_to_scheme)?;
let serial_name = nku_serial_parameters.get_parameter_value("device")?;
let serial_baudrate = nku_serial_parameters
let serial_name = smart_serial_parameters.get_parameter_value("device")?;
let serial_baudrate = smart_serial_parameters
.get_parameter_value("baudrate")?
.parse::<u32>()
.map_err(|e: std::num::ParseIntError| e.to_string())?;