diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6dc24b1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog + +Все заметные изменения проекта фиксируются в этом файле. + +## [0.2.0] - 2026-08-16 + +### Added + +- Добавлен crate-ready API `escpos-vfd` с импортом `escpos_vfd`. +- Добавлены `VfdConfig`, `SerialSettings`, `DisplaySettings`, `TextEncoding` и пресет + `Preset::Epson20x2Cp866` для прежнего 20x2 CP866 дисплея. +- Добавлен ручной выбор baud rate, serial-режима, геометрии, кодировки, `ESC t` + таблицы, диапазона яркости и ёмкости worker queue. +- Добавлены sync worker с bounded queue, подтверждением после I/O и graceful shutdown. +- Добавлен optional Tokio API через feature `tokio`: `AsyncVfd`, `AsyncVfdWorker`, + `AsyncVfdHandle`. +- Добавлены typed errors `ConfigError` и `VfdError`. +- Добавлены русская rustdoc-документация, README, примеры preset/manual/Tokio и + расширенные аппаратные demo-примеры. + +### Changed + +- BREAKING: прежние фиксированные настройки `TABLE_CYR` и `FIXED_BAUD` заменены + явной конфигурацией и пресетом. +- BREAKING: ошибки публичного API теперь типизированы, вместо универсального + `anyhow::Result`. +- Логика строк и worker-кэш больше не предполагают строго две строки дисплея. +- `print_line_diff` и `print_at` документированы как операции с 1-based координатами. + +### Fixed + +- Исправлена рассинхронизация default-паузы marquee в taskfile: теперь везде `1500 ms`. +- Документация методов и публичных типов проходит строгий `missing_docs` rustdoc gate. diff --git a/Cargo.lock b/Cargo.lock index da39aa8..24a2b4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "anyhow" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" - [[package]] name = "bitflags" version = "1.3.2" @@ -20,12 +14,24 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "core-foundation" version = "0.10.1" @@ -51,6 +57,28 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "escpos-vfd" +version = "0.2.0" +dependencies = [ + "encoding_rs", + "serialport", + "tokio", + "tokio-serial", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "io-kit-sys" version = "0.4.1" @@ -88,13 +116,10 @@ dependencies = [ ] [[package]] -name = "m" -version = "0.1.0" -dependencies = [ - "anyhow", - "encoding_rs", - "serialport", -] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "mach2" @@ -105,6 +130,31 @@ dependencies = [ "libc", ] +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mio-serial" +version = "5.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d4ba3f20276f21b7cad3f1b54c97489cf096a3894fd627cc6951cb3abdd4c60" +dependencies = [ + "log", + "mio", + "nix 0.31.3", + "serialport", + "windows-sys 0.61.2", +] + [[package]] name = "nix" version = "0.26.4" @@ -116,6 +166,24 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "pkg-config" version = "0.3.34" @@ -159,10 +227,20 @@ dependencies = [ "io-kit-sys", "libudev", "mach2", - "nix", + "nix 0.26.4", "scopeguard", "unescaper", - "windows-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", ] [[package]] @@ -196,6 +274,47 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-serial" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd00f5f8b1e01c3e5afccd9e42ed80c2ad2df6d007877f29f8592c62e69cd116" +dependencies = [ + "cfg-if", + "futures-core", + "futures-sink", + "log", + "mio-serial", + "serialport", + "tokio", +] + [[package]] name = "unescaper" version = "0.1.10" @@ -211,6 +330,18 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.52.0" @@ -220,6 +351,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index 40dde89..1b8fc27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,37 @@ [package] -name = "m" -version = "0.1.0" +name = "escpos-vfd" +version = "0.2.0" edition = "2024" +rust-version = "1.85" +description = "ESC/POS-compatible VFD customer display driver with sync and optional Tokio APIs" +license = "MIT OR Apache-2.0" +readme = "README.md" +repository = "https://git.belvedersky.ru/belvedersky/vfd.git" +documentation = "https://docs.rs/escpos-vfd" +categories = ["hardware-support", "api-bindings"] +keywords = ["vfd", "escpos", "serial", "customer-display", "tokio"] +publish = ["crates-io"] +exclude = [ + "/docs/*.pdf", + "/taskfile.yml", +] [dependencies] serialport = "4" -anyhow = "1" encoding_rs = "0.8" +tokio = { version = "1", optional = true, default-features = false, features = ["io-util", "macros", "rt", "sync", "time"] } +tokio-serial = { version = "5.5.0", optional = true, default-features = false } + +[dev-dependencies] +tokio = { version = "1", default-features = false, features = ["io-util", "macros", "rt", "sync", "test-util", "time"] } + +[features] +default = [] +tokio = ["dep:tokio", "dep:tokio-serial"] + +[[example]] +name = "tokio_worker" +required-features = ["tokio"] + +[package.metadata.docs.rs] +all-features = true diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..1a1331c --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 escpos-vfd contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 7e4ffe0..482f9eb 100644 --- a/README.md +++ b/README.md @@ -1,236 +1,338 @@ -# VFD — управление дисплеями в Epson-совместимом режиме +# escpos-vfd -Небольшая Rust-библиотека для двухстрочных VFD-дисплеев покупателя, работающих через -serial-порт в Epson/ESC-совместимом режиме. Проект ориентирован на дисплеи семейства -PD-2600/PD-2800 и похожие модели с поддержкой команд позиционирования, яркости и -таблицы символов CP866. +`escpos-vfd` — Rust-библиотека для символьных VFD-дисплеев покупателя (customer display), +которые работают через serial-порт и понимают Epson/ESC/POS-совместимые команды. -Библиотека подходит для часов, локальных дашбордов, кассовых приложений, уведомлений, -индикаторов состояния и других проектов, где данные нужно обновлять без мерцания всей -строки. +Настройки порта и дисплея разделены: можно независимо задать скорость, геометрию, +кодировку, аппаратную таблицу символов и яркость. Для проверенного 20×2 дисплея с CP866 +есть готовый пресет `Preset::Epson20x2Cp866`. -## Возможности +![VFD-дисплей покупателя с кириллическим текстом](docs/display.jpg) -- вывод текста в первую или вторую строку; -- печать с координаты `(x, y)`; -- обновление только изменившихся диапазонов строки через `print_line_diff`; -- фоновая бегущая строка с регулируемой скоростью и паузой; -- четыре уровня яркости; -- кириллица через CP866; -- потокобезопасный интерфейс `VfdHandle`, который можно клонировать; -- автоматическая обрезка текста по правому краю и дополнение строк пробелами. - -## Поддерживаемый протокол - -При открытии устройства библиотека использует следующие параметры и команды: - -| Параметр | Значение | -| --- | --- | -| Скорость serial-порта | `9600 baud` | -| Таблица символов | CP866, команда `ESC t 6` | -| Инициализация | `ESC @` | -| Очистка | `FF` (`0x0C`) | -| Координаты | `US $ x y`, нумерация от 1 | -| Яркость | `US X n`, где `n = 1..4` | -| Число строк | 2 | -| Допустимая ширина | `1..=255` символов | - -Перед запуском убедитесь, что дисплей переведён DIP-переключателями в совместимый -режим. Точные настройки конкретной модели смотрите в документации из каталога -[`docs`](docs/). - -## Подключение - -Для локального проекта добавьте библиотеку как path-зависимость: +## Установка ```toml [dependencies] -m = { path = "../vfd" } -anyhow = "1" +escpos-vfd = "0.2" ``` -Имя crate сейчас — `m`, поэтому импорт начинается с `m::`. +Tokio API подключается отдельным feature: + +```toml +[dependencies] +escpos-vfd = { version = "0.2", features = ["tokio"] } +``` + +Без feature `tokio` async-зависимости не подключаются. ## Быстрый старт +Для 20×2 дисплея с `9600 8N1`, CP866 и таблицей `ESC t 6` достаточно готового пресета: + ```rust,no_run -use anyhow::Result; -use m::vfd::VfdConfig; -use m::worker::VfdWorker; -use std::time::Duration; +use escpos_vfd::{Preset, Vfd, VfdConfig}; -fn main() -> Result<()> { - let config = VfdConfig::new("/dev/cu.usbmodem101") - .with_width(20); +fn main() -> escpos_vfd::Result<()> { + let config = VfdConfig::preset( + "/dev/cu.usbmodem101", + Preset::Epson20x2Cp866, + )?; - // Serial-порт открывается до запуска фонового потока, - // поэтому ошибка подключения вернётся из start(). - let worker = VfdWorker::start(config)?; - let display = worker.handle(); + let mut display = Vfd::open(config)?; - display.clear(); - display.set_brightness(2); - display.print_line_diff(1, "Привет!")?; - display.print_line_diff(2, "VFD готов")?; - - // Команды выполняются асинхронно: не завершаем программу сразу после отправки. - std::thread::sleep(Duration::from_secs(2)); + display.clear()?; + display.set_brightness(2)?; + display.print_line(1, "Привет!")?; + display.print_line(2, "escpos-vfd")?; Ok(()) } ``` -`VfdWorker` последовательно выполняет команды в отдельном потоке. При удалении worker -отправляет команду завершения и дожидается остановки потока. Сохраняйте `worker` в -области видимости, пока дисплей используется. +Полный пример: [examples/preset_sync.rs](examples/preset_sync.rs). -## Основной API +## Что выбрать -### Конфигурация +| Задача | API | +| --- | --- | +| Запустить проверенный 20×2 CP866 дисплей | `VfdConfig::preset(...)` | +| Настроить другой дисплей | `SerialSettings` + `DisplaySettings` | +| Писать напрямую из текущего потока | `Vfd` | +| Отправлять команды из нескольких потоков | `VfdWorker` + `VfdHandle` | +| Писать напрямую из Tokio task | `tokio::AsyncVfd` | +| Отправлять команды из нескольких Tokio tasks | `tokio::AsyncVfdWorker` | +| Отправить нестандартную команду | `write_raw(...)` | + +## Настройка другого дисплея + +Пресет не обязателен. Параметры serial-порта и дисплея можно задать вручную: ```rust,no_run -use m::vfd::VfdConfig; +use escpos_vfd::{DisplaySettings, SerialSettings, TextEncoding, Vfd, VfdConfig}; +use serialport::{DataBits, FlowControl, Parity, StopBits}; use std::time::Duration; -let config = VfdConfig::new("/dev/ttyUSB0") - .with_width(20) - .with_timeout(Duration::from_millis(250)); +fn main() -> escpos_vfd::Result<()> { + let mut serial = SerialSettings::new("/dev/ttyUSB0", 9_600); + serial.data_bits = DataBits::Eight; + serial.parity = Parity::None; + serial.stop_bits = StopBits::One; + serial.flow_control = FlowControl::None; + serial.timeout = Duration::from_millis(100); + + let mut display = DisplaySettings::new(20, 2, TextEncoding::Cp866); + display.code_table = Some(6); + display.brightness = Some(1..=4); + + let config = VfdConfig::new(serial, display)?; + let mut vfd = Vfd::open(config)?; + + vfd.print_line(1, "Ручной режим")?; + vfd.print_at(1, 2, "20x2, CP866")?; + + Ok(()) +} ``` -Ширина автоматически ограничивается диапазоном `1..=255`, потому что координаты -протокола передаются одним байтом. +Полный пример: [examples/manual_sync.rs](examples/manual_sync.rs). -### Полная и дифференциальная запись +### Serial-порт -```rust,ignore -display.print_line(1, "Полная перезапись")?; -display.print_line_diff(2, "Изменились цифры: 42")?; +`SerialSettings::new(port, baud_rate)` использует обычные значения `8N1`, без flow +control, с тайм-аутом `100 ms`. На Unix порт по умолчанию открывается эксклюзивно. +Все эти параметры можно изменить через поля `SerialSettings`. + +### Дисплей + +`DisplaySettings::new(columns, rows, encoding)` задаёт геометрию и текстовую кодировку. +Размеры должны быть в диапазоне `1..=255`. + +Дополнительно можно настроить: + +- `code_table` — аппаратную таблицу символов через `ESC t n`; +- `reset_on_open` — отправку `ESC @` при открытии; +- `brightness` — допустимый диапазон яркости; +- `brightness_settle` — паузу после изменения яркости. + +`VfdConfig` также содержит `queue_capacity` для worker API. По умолчанию очередь вмещает +32 команды; значение `0` запрещено. + +## Кодировка и таблица символов + +Кодировка текста и таблица символов самого дисплея — разные настройки. + +`TextEncoding` определяет, как Rust-строка превращается в байты: + +| Значение | Назначение | +| --- | --- | +| `Cp866` | CP866 / IBM866 | +| `Windows1251` | Windows-1251 / CP1251 | +| `Ascii` | только ASCII, остальные символы заменяются на `?` | +| `Utf8` | UTF-8 без перекодирования | + +`code_table` отвечает за команду `ESC t n`. Например, конкретному CP866-дисплею могут +одновременно понадобиться: + +```rust +let mut display = DisplaySettings::new(20, 2, TextEncoding::Cp866); +display.code_table = Some(6); ``` -`print_line_diff` хранит последний кадр каждой строки и группирует соседние изменения. -Если текст не изменился, serial-команда не отправляется. Это полезно для часов и -дашбордов, которые обновляются часто. +Если таблица уже выбрана DIP-переключателями или устройство использует другой механизм, +оставьте `code_table = None`. -### Печать по координатам +Перед обычным выводом библиотека заменяет некоторые типографские символы на более +безопасные для символьного VFD варианты: например, длинное тире на `-`, `№` на `#`, а +табуляцию на пробел. -```rust,ignore -display.print_at(7, 1, "21.50")?; -display.print_at(16, 1, "1200")?; +## Вывод текста + +Все публичные координаты начинаются с **1**: + +```rust +vfd.print_line(1, "Первая строка")?; +vfd.print_at(6, 2, "текст")?; ``` -Координаты начинаются с единицы. Допустимы строки `1` и `2`; текст, выходящий за правую -границу, обрезается по символам без повреждения UTF-8. +`print_line()` обрезает слишком длинный текст и дополняет короткий пробелами до ширины +дисплея. Это позволяет полностью перезаписать строку без остатка предыдущего текста. + +`print_at()` пишет с указанной позиции и обрезает текст по правому краю. Автоматического +переноса на следующую строку нет. + +Неверные координаты возвращают `VfdError::InvalidLine` или +`VfdError::InvalidCoordinate`. + +## Worker и частичные обновления + +`VfdWorker` владеет дисплеем в отдельном потоке, а `VfdHandle` можно клонировать и +передавать между потоками. + +Очередь ограничена по размеру: если она заполнена, отправитель ждёт свободное место вместо +неограниченного накопления команд. Вызов метода handle завершается после фактического +выполнения команды или ошибки I/O. + +```rust,no_run +use escpos_vfd::{Preset, VfdConfig, VfdWorker}; + +fn main() -> escpos_vfd::Result<()> { + let config = VfdConfig::preset( + "/dev/cu.usbmodem101", + Preset::Epson20x2Cp866, + )?; + + let worker = VfdWorker::start(config)?; + let display = worker.handle(); + + display.print_line_diff(1, "Temp: 21.5 C")?; + display.print_line_diff(2, "Humidity: 42%")?; + + worker.shutdown()?; + Ok(()) +} +``` + +`print_line_diff()` хранит кэш последнего содержимого строк. Если текст не изменился, +запись не выполняется; если изменился только фрагмент, worker отправляет только изменённые +смежные диапазоны. Это удобно для часов, статусов и других часто обновляемых значений. + +Для нормального завершения используйте `VfdWorker::shutdown()`. ### Бегущая строка -```rust,ignore +Worker также умеет обновлять marquee по таймеру: + +```rust use std::time::Duration; -display.set_marquee_text("Длинное сообщение для посетителя"); -display.start_marquee( - 2, // строка - 8, // символов в секунду - Duration::from_millis(1500), // пауза после полного прохода -); +display.set_marquee_text("Длинный текст для бегущей строки")?; +display.start_marquee(1, 8, Duration::from_millis(1500))?; -// При необходимости: -display.stop_marquee(); +// ... + +display.stop_marquee()?; ``` -Обычные команды записи в строку, занятую marquee, игнорируются, чтобы два источника -не перезаписывали друг друга. +`cps` задаёт скорость в символах в секунду, `end_pause` — паузу после полного прохода. +`stop_marquee()` останавливает анимацию, но не очищает уже отображённый текст. + +## Tokio + +Feature `tokio` добавляет два варианта API: + +- `AsyncVfd` — прямой драйвер поверх `AsyncWrite`; +- `AsyncVfdWorker` — одна задача-писатель, bounded queue и клонируемый + `AsyncVfdHandle`. + +```rust,no_run +use escpos_vfd::tokio::AsyncVfdWorker; +use escpos_vfd::{Preset, VfdConfig}; + +#[tokio::main] +async fn main() -> escpos_vfd::Result<()> { + let config = VfdConfig::preset( + "/dev/cu.usbmodem101", + Preset::Epson20x2Cp866, + )?; + + let worker = AsyncVfdWorker::start(config).await?; + let display = worker.handle(); + + display.print_line_diff(1, "Tokio worker").await?; + display.print_line_diff(2, "async I/O").await?; + + worker.shutdown().await?; + Ok(()) +} +``` + +После попадания команды в очередь отмена ожидающего future не отменяет уже поставленную +запись в устройство. Для корректного завершения используйте +`AsyncVfdWorker::shutdown().await`. + +## Низкоуровневая запись + +Если нужной команды нет в типизированном API, байты можно отправить напрямую: + +```rust +vfd.write_raw(&[0x1b, 0x40])?; +``` + +`write_raw()` не кодирует и не интерпретирует данные. В worker API такая запись также не +обновляет строковый кэш, поэтому после raw-команд, меняющих текст на экране, лучше +выполнить `clear()` или `print_line()`. + +Для нестандартных транспортов и диагностики доступен публичный +`escpos_vfd::codec::EpsonCodec`, который формирует байты команд без открытия serial-порта. + +## Ошибки + +Библиотека использует `escpos_vfd::Result = Result` и разделяет ошибки по +смыслу: + +| Ошибка | Когда возникает | +| --- | --- | +| `VfdError::Config(...)` | неверная конфигурация | +| `VfdError::Serial(...)` | ошибка открытия или настройки serial-порта | +| `VfdError::Io(...)` | ошибка записи или `flush` | +| `VfdError::InvalidCoordinate` | координата вне дисплея | +| `VfdError::InvalidLine` | строка вне `1..=rows` | +| `VfdError::UnsupportedBrightness` | неподдерживаемый уровень яркости | +| `VfdError::QueueClosed` | очередь worker-а закрыта | +| `VfdError::WorkerStopped` | worker остановился до подтверждения команды | +| `VfdError::WorkerPanicked` | sync worker завершился с panic | +| `VfdError::WorkerCancelled` | Tokio worker был отменён | ## Примеры -Все примеры принимают два общих аргумента: +В репозитории есть небольшие аппаратные примеры для основных сценариев: -1. serial-порт, по умолчанию `/dev/cu.usbmodem101`; -2. ширина дисплея, по умолчанию `20`. +| Пример | Что показывает | +| --- | --- | +| `preset_sync` | запуск с готовым пресетом | +| `manual_sync` | ручную конфигурацию | +| `tokio_worker` | Tokio worker | +| `clock` | частые diff-обновления | +| `marquee` | бегущую строку | +| `brightness` | яркость | +| `position` | позиционирование | +| `update_at` | частичные обновления | +| `blink` | повторную запись в позицию | -Дополнительные аргументы зависят от примера: - -| Пример | Что демонстрирует | Дополнительные аргументы | -| --- | --- | --- | -| `clock` | Дату, время, день недели и часть суток | `[brightness=2]` | -| `marquee` | Фоновую бегущую строку | `[cps=8] [pause_ms=1500] [brightness=2]` | -| `brightness` | Перебор уровней яркости `1..=4` | `[delay_ms=800]` | -| `position` | Движение символа по второй строке | `[delay_ms=80]` | -| `update_at` | Частичное обновление полей дашборда | `[delay_ms=200]` | -| `blink` | Мигание текста в заданной позиции | `[delay_ms=400] [x=1] [y=1] [text=BLINK]` | - -Примеры запуска: +Например: ```bash -cargo run --example clock -- /dev/cu.usbmodem101 20 2 +cargo run --example preset_sync -- /dev/cu.usbmodem101 cargo run --example marquee -- /dev/cu.usbmodem101 20 8 1500 2 -cargo run --example brightness -- /dev/cu.usbmodem101 20 800 -cargo run --example position -- /dev/cu.usbmodem101 20 80 -cargo run --example update_at -- /dev/cu.usbmodem101 20 200 -cargo run --example blink -- /dev/cu.usbmodem101 20 400 1 2 BLINK +cargo run --features tokio --example tokio_worker -- /dev/cu.usbmodem101 20 ``` -`update_at` использует фиксированную 20-символьную раскладку и завершится с понятной -ошибкой, если передать меньшую ширину. +## Проверка на реальном дисплее -Те же программы доступны через [Task](https://taskfile.dev/): +Автоматические тесты проверяют формирование команд, кодировки, координаты, кэш строк, +worker queue, marquee и async-поведение на тестовых транспортах. Конкретное устройство +всё равно стоит проверить отдельно: -```bash -task vfd:clock -task vfd:marquee -task vfd:brightness -task vfd:position -task vfd:update_at -task vfd:blink -``` +1. Выставьте правильный serial/DIP-режим. +2. Запустите `cargo run --example preset_sync -- `. +3. Проверьте очистку, строки, кириллицу и яркость. +4. Проверьте `update_at` и `marquee`. +5. При использовании Tokio запустите пример `tokio_worker`. -Значения можно переопределять переменными, например: - -```bash -task vfd:marquee VFD_PORT=/dev/ttyUSB0 VFD_WIDTH=20 VFD_CPS=10 -``` - -## Кодировка текста - -Перед записью библиотека заменяет несколько типографских символов на совместимые -аналоги: - -- `…` → `.`; -- `—` и `–` → `-`; -- `№` → `#`; -- типографские кавычки → обычные кавычки; -- табуляция → пробел. - -Остальной текст кодируется с помощью `encoding_rs::IBM866`. - -## Структура проекта - -```text -src/vfd.rs низкоуровневые команды и CP866 -src/worker.rs очередь команд, diff и marquee -examples/common/mod.rs общие CLI-утилиты примеров -examples/*.rs демонстрационные программы -docs/ руководства для дисплеев -taskfile.yml команды запуска примеров -``` - -## Разработка и проверка +## Разработка ```bash cargo fmt --all -- --check +cargo clippy --all-targets --no-default-features --locked -- -D warnings cargo clippy --all-targets --all-features --locked -- -D warnings -cargo test --all-targets --locked -cargo check --examples --locked +cargo test --all-targets --no-default-features --locked +cargo test --all-targets --all-features --locked +RUSTDOCFLAGS='-D warnings -D missing_docs -D rustdoc::broken_intra_doc_links' \ + cargo doc --all-features --no-deps --locked ``` -Автоматические тесты проверяют Unicode, CP866-санацию, ширину строк, обновление кэша, -группировку diff-диапазонов, таймер marquee и функции примера часов. Для полной проверки -нужен отдельный smoke-test с физическим дисплеем и фактическим serial-портом. +Подробная документация публичного API: [docs.rs/escpos-vfd](https://docs.rs/escpos-vfd). -## Ограничения +## Лицензия -- поддерживаются только две строки; -- скорость подключения фиксирована на `9600 baud`; -- очередь команд в worker не ограничена по размеру; -- аппаратные ошибки после запуска worker выводятся в stderr из фонового потока; -- пример `clock` использует системную команду `date` и рассчитан на Unix-подобную среду; -- библиотека не определяет serial-порт автоматически. +`escpos-vfd` распространяется под двойной лицензией `MIT OR Apache-2.0`. diff --git a/docs/display.jpg b/docs/display.jpg new file mode 100644 index 0000000..8662af9 Binary files /dev/null and b/docs/display.jpg differ diff --git a/examples/blink.rs b/examples/blink.rs index b5a23e1..08af328 100644 --- a/examples/blink.rs +++ b/examples/blink.rs @@ -2,43 +2,52 @@ mod common; -use anyhow::Result; +use std::error::Error; -use m::worker::VfdWorker; +use escpos_vfd::VfdWorker; -fn main() -> Result<()> { - // args: - // 1) port (default: /dev/cu.usbmodem101) - // 2) width (default: 20) - // 3) delay_ms (default: 400) - // 4) x (default: 1) 1-based - // 5) y (default: 1) 1-based line - // 6) text (default: "BLINK") - let mut args = common::ExampleArgs::from_env(); +fn main() -> Result<(), Box> { + // Аргументы: + // 1) port - serial-порт дисплея; + // 2) width - ширина строки, нужна для проверки координат; + // 3) delay_ms - пауза между состояниями "видно/пусто"; + // 4) x - колонка от единицы; + // 5) y - строка от единицы; + // 6) text - текст, который мигает. + let mut args = common::ExampleArgs::from_env()?; let delay_ms: u64 = args.parse_or(400); let x: u8 = args.parse_or(1); let y: u8 = args.parse_or(1); let text = args.string_or("BLINK"); let text_len = text.chars().count(); + // Worker нужен, чтобы все команды шли в serial-порт последовательно. Даже в одном + // потоке пример получает те же semantics, что и приложение с несколькими producer-ами. let worker = VfdWorker::start(args.config)?; let vfd = worker.handle(); - vfd.clear(); + vfd.clear()?; - let _ = vfd.print_line_diff(1, "blink demo (print_at)"); + // Первая строка остаётся статичной, мигает только выбранная область. + vfd.print_line_diff(1, "blink demo (print_at)")?; if y != 1 { - let _ = vfd.print_at(1, y, ""); // просто чтобы “активировать” строку у некоторых дисплеев + // Пустая запись в начало строки полезна на некоторых дисплеях, которые лениво + // переключают видимую строку только после позиционирования курсора. + vfd.print_at(1, y, "")?; } + // Стираем ровно столько символов, сколько было выведено. Так справа не остаются + // хвосты при следующем включении текста. let blank = " ".repeat(text_len); let mut on = false; loop { + // Все ошибки (`InvalidCoordinate`, I/O, закрытый worker) сразу выходят из main + // через оператор `?`, что удобно для аппаратного smoke-test. if on { - let _ = vfd.print_at(x, y, &blank); + vfd.print_at(x, y, &blank)?; } else { - let _ = vfd.print_at(x, y, &text); + vfd.print_at(x, y, &text)?; } on = !on; common::sleep_ms(delay_ms); diff --git a/examples/brightness.rs b/examples/brightness.rs index 513185c..821ef9c 100644 --- a/examples/brightness.rs +++ b/examples/brightness.rs @@ -1,36 +1,42 @@ -//! Демонстрирует четыре уровня яркости и обновление строки без полного мерцания. +//! Демонстрирует четыре уровня яркости и обновление строки mod common; -use anyhow::Result; +use std::error::Error; -use m::worker::VfdWorker; +use escpos_vfd::VfdWorker; -fn main() -> Result<()> { - // args: - // 1) port (default: /dev/cu.usbmodem101) - // 2) width (default: 20) - // 3) delay_ms (default: 800) - let mut args = common::ExampleArgs::from_env(); +fn main() -> Result<(), Box> { + // Аргументы: + // 1) port - serial-порт дисплея (по умолчанию /dev/cu.usbmodem101); + // 2) width - ширина дисплея в символах (по умолчанию 20); + // 3) delay_ms - пауза между уровнями яркости (по умолчанию 800). + let mut args = common::ExampleArgs::from_env()?; let delay_ms: u64 = args.parse_or(800); + + // Worker держит serial-порт в отдельном потоке. Важно сохранить `worker` в + // переменной: если он будет уничтожен, Drop остановит фоновую запись. let worker = VfdWorker::start(args.config)?; let vfd = worker.handle(); - vfd.clear(); + vfd.clear()?; - // шапка один раз (не обязательно, но удобно) - let _ = vfd.print_line_diff(1, "Яркость"); + // Шапку пишем один раз. Дальше меняем только вторую строку через diff, чтобы + // дисплей не мерцал от полной перерисовки. + vfd.print_line_diff(1, "Яркость")?; loop { for level in 1u8..=4u8 { - vfd.set_brightness(level); + // Пресет текущего дисплея объявляет поддерживаемый диапазон яркости 1..=4. + // Если уровень вне диапазона, метод вернёт typed error. + vfd.set_brightness(level)?; - // обновляем строку медленно, через print_line_diff - // можно сделать “индикатор” уровня (*****) + // Индикатор обновляется с той же паузой, чтобы глазами было видно, какой + // уровень сейчас активен. let bar = "*".repeat(level as usize); let line2 = format!("Уровень: {} {}", level, bar); - let _ = vfd.print_line_diff(2, line2); + vfd.print_line_diff(2, line2)?; common::sleep_ms(delay_ms); } diff --git a/examples/clock.rs b/examples/clock.rs index 3cd28e7..a848386 100644 --- a/examples/clock.rs +++ b/examples/clock.rs @@ -2,13 +2,14 @@ mod common; -use anyhow::Result; +use std::error::Error; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use m::vfd::fit_to_width; -use m::worker::VfdWorker; +use escpos_vfd::{VfdWorker, fit_to_width}; fn run_date(args: &[&str]) -> Option { + // Используем системную `date`, чтобы пример оставался без дополнительных зависимостей. + // Если команда недоступна или вернула ошибку, ниже покажем безопасную заглушку. let out = std::process::Command::new("date") .args(args) .output() @@ -20,6 +21,7 @@ fn run_date(args: &[&str]) -> Option { } fn parse_datetime_and_weekday(value: &str) -> Option<(String, u8)> { + // Формат одной строки: "DD.MM.YYYY HH:MM:SS|N", где N - номер дня недели 1..=7. let (datetime, weekday) = value.split_once('|')?; let weekday = weekday.parse::().ok()?; (1..=7) @@ -28,6 +30,8 @@ fn parse_datetime_and_weekday(value: &str) -> Option<(String, u8)> { } fn local_datetime_and_weekday() -> (String, u8) { + // Один вызов `date` важен: дата, время и день недели приходят из одного snapshot. + // Это убирает редкие рассинхронизации на границе полуночи. run_date(&["+%d.%m.%Y %H:%M:%S|%u"]) .as_deref() .and_then(parse_datetime_and_weekday) @@ -70,8 +74,8 @@ fn time_of_day_ru(hour: u8) -> &'static str { } fn extract_hour(datetime: &str) -> u8 { - // ожидаем "DD.MM.YYYY HH:MM:SS" - // берём HH как 2 символа после пробела + // Ожидаем "DD.MM.YYYY HH:MM:SS" и берём HH как два символа после пробела. + // При неожиданном формате возвращаем 12, чтобы пример продолжал работать. datetime .split_whitespace() .nth(1) @@ -80,43 +84,48 @@ fn extract_hour(datetime: &str) -> u8 { .unwrap_or(12) } -fn main() -> Result<()> { - // args: - // 1) port (default: /dev/cu.usbmodem101) - // 2) width (default: 20) - // 3) brightness 1..4 (default: 2) - let mut args = common::ExampleArgs::from_env(); +fn main() -> Result<(), Box> { + // Аргументы: + // 1) port - serial-порт дисплея; + // 2) width - ширина строки, нужна для подгонки текста; + // 3) brightness - яркость из диапазона пресета 1..=4. + let mut args = common::ExampleArgs::from_env()?; let brightness: u8 = args.parse_or(2); - let width = args.width; + let columns = args.columns; + + // Worker остаётся жить до конца процесса. Handle можно клонировать, но сам worker + // владеет serial-портом и фоновым потоком записи. let worker = VfdWorker::start(args.config)?; let vfd = worker.handle(); - vfd.clear(); - vfd.set_brightness(brightness); + vfd.clear()?; + vfd.set_brightness(brightness)?; loop { let (dt, wd) = local_datetime_and_weekday(); let hour = extract_hour(&dt); let tod = time_of_day_ru(hour); - // 1 строка: "28.01.2006 12:03:34" - let line1 = fit_to_width(&dt, width); + // 1 строка: "28.01.2006 12:03:34". `fit_to_width` обрезает по символам и + // дополняет пробелами, чтобы старый текст справа не оставался на дисплее. + let line1 = fit_to_width(&dt, columns); - // 2 строка: "Понедельник сейчас день" - // но если не влазит — "Пн сейчас день" + // 2 строка: сначала пробуем полное название дня недели, если не влазит - + // короткое. Это показывает, как приложение может адаптироваться к ширине VFD. let full = format!("{} сейчас {}", weekday_ru_full(wd), tod); let mut line2 = full; - if line2.chars().count() > width { + if line2.chars().count() > columns { line2 = format!("{} сейчас {}", weekday_ru_short(wd), tod); } - line2 = fit_to_width(&line2, width); + line2 = fit_to_width(&line2, columns); vfd.print_line_diff(1, line1)?; vfd.print_line_diff(2, line2)?; - // чтобы обновлялось ровно раз в секунду (плюс/минус), можно “привязать” к UNIX time + // Привязываем паузу к следующей границе секунды. Тогда часы не "уплывают" из-за + // времени, потраченного на форматирование и serial-запись. let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() diff --git a/examples/common/mod.rs b/examples/common/mod.rs index 84b74c2..ce83215 100644 --- a/examples/common/mod.rs +++ b/examples/common/mod.rs @@ -1,39 +1,52 @@ -//! Общие утилиты командной строки для всех демонстрационных программ. +//! Общие утилиты командной строки для демонстрационных программ. +//! +//! Каждый пример компилируется как отдельный binary. Этот модуль убирает повторение: +//! первый аргумент всегда serial-порт, второй - ширина дисплея, остальные параметры +//! зависят от конкретного примера. #![allow( dead_code, reason = "модуль компилируется отдельно для каждого примера, поэтому часть API используется в соседних примерах" )] -use m::vfd::VfdConfig; +use escpos_vfd::{Preset, VfdConfig}; +use std::error::Error; use std::str::FromStr; use std::vec::IntoIter; const DEFAULT_PORT: &str = "/dev/cu.usbmodem101"; const DEFAULT_WIDTH: usize = 20; -/// Общие аргументы примера: serial-порт, ширина дисплея и оставшиеся параметры. +/// Общие аргументы примера: готовая конфигурация, ширина дисплея и оставшиеся параметры. pub struct ExampleArgs { + /// Конфигурация VFD на основе пресета текущего дисплея. pub config: VfdConfig, - pub width: usize, + /// Ширина дисплея в символах. + pub columns: usize, remaining: IntoIter, } impl ExampleArgs { - /// Читает общие аргументы из командной строки и применяет безопасные значения по умолчанию. - pub fn from_env() -> Self { + /// Читает общие аргументы из командной строки и применяет значения по умолчанию. + pub fn from_env() -> Result> { let mut values = std::env::args().skip(1); + + // Примеры должны запускаться без длинной CLI-команды на авторском стенде, но + // первый аргумент позволяет сразу проверить другой USB/COM-порт. let port_name = values.next().unwrap_or_else(|| DEFAULT_PORT.to_string()); - let width = values + + // Геометрия дисплея участвует в проверке координат и обрезке строк. Меняем + // только ширину, потому что демонстрации ниже рассчитаны на две строки. + let columns = values .next() .and_then(|value| value.parse().ok()) .unwrap_or(DEFAULT_WIDTH); - let config = VfdConfig::new(port_name).with_width(width); + let config = preset_config(port_name, columns)?; - Self { - width: config.width, + Ok(Self { + columns, config, remaining: values.collect::>().into_iter(), - } + }) } /// Читает следующий аргумент нужного типа или возвращает переданное значение по умолчанию. @@ -57,3 +70,17 @@ impl ExampleArgs { pub fn sleep_ms(delay_ms: u64) { std::thread::sleep(std::time::Duration::from_millis(delay_ms)); } + +/// Создаёт конфигурацию пресета текущего дисплея с переопределяемой шириной. +pub fn preset_config( + port_name: impl Into, + columns: usize, +) -> Result> { + let mut config = VfdConfig::preset(port_name, Preset::Epson20x2Cp866)?; + + // Пресет сохраняет baud/code table/кодировку, а ширину даём менять из CLI, чтобы + // теми же примерами проверять 16x2, 20x2 и другие ESC/POS-совместимые VFD. + config.display.columns = columns; + config.validate()?; + Ok(config) +} diff --git a/examples/manual_sync.rs b/examples/manual_sync.rs new file mode 100644 index 0000000..dcd6e15 --- /dev/null +++ b/examples/manual_sync.rs @@ -0,0 +1,49 @@ +//! Синхронный старт с полностью ручными serial/display настройками. + +use escpos_vfd::{DisplaySettings, SerialSettings, TextEncoding, Vfd, VfdConfig}; +use serialport::{DataBits, FlowControl, Parity, StopBits}; +use std::error::Error; +use std::time::Duration; + +fn main() -> Result<(), Box> { + // Ручной пример показывает, что библиотека не держит глобальных `FIXED_BAUD` или + // `TABLE_CYR`: каждый параметр задаёт приложение. + let port_name = std::env::args() + .nth(1) + .unwrap_or_else(|| "/dev/cu.usbmodem101".to_string()); + + // Все serial-параметры задаются явно, без скрытого FIXED_BAUD. + let mut serial = SerialSettings::new(port_name, 9_600); + serial.data_bits = DataBits::Eight; + serial.parity = Parity::None; + serial.stop_bits = StopBits::One; + serial.flow_control = FlowControl::None; + serial.timeout = Duration::from_millis(100); + + // `encoding` кодирует Rust-строку в байты, а `code_table` отвечает только за + // аппаратную команду `ESC t n`. Эти настройки часто должны совпадать по смыслу, + // но это два разных слоя протокола. + let mut display = DisplaySettings::new(20, 2, TextEncoding::Cp866); + + // Если дисплей уже настроен DIP-переключателями или использует другой протокол + // выбора таблицы, оставьте `None`, и библиотека не отправит `ESC t`. + display.code_table = Some(6); + display.reset_on_open = true; + display.brightness = Some(1..=4); + display.brightness_settle = Duration::from_millis(2); + + // `VfdConfig::new` валидирует геометрию, baud rate и queue capacity до открытия + // serial-порта, поэтому ошибки настроек видны сразу. + let config = VfdConfig::new(serial, display)?; + let mut display = Vfd::open(config)?; + + // Для обычного текста используйте типизированные методы; `write_raw` нужен + // только для команд, которых пока нет в публичном API. + display.clear()?; + display.print_line(1, "Ручной режим")?; + + // Координаты в библиотеке 1-based: первая колонка - x=1, первая строка - y=1. + display.print_at(1, 2, "20x2, CP866")?; + + Ok(()) +} diff --git a/examples/marquee.rs b/examples/marquee.rs index 53ec7db..cafdfa4 100644 --- a/examples/marquee.rs +++ b/examples/marquee.rs @@ -2,39 +2,47 @@ mod common; -use anyhow::Result; +use std::error::Error; use std::time::Duration; -use m::worker::VfdWorker; +use escpos_vfd::VfdWorker; -fn main() -> Result<()> { - // args: - // 1) port (default: /dev/cu.usbmodem101) - // 2) width (default: 20) - // 3) cps (chars per second, default: 8) - // 4) end_pause_ms (default: 1500) - // 5) brightness 1..4 (default: 2) - let mut args = common::ExampleArgs::from_env(); +fn main() -> Result<(), Box> { + // Аргументы: + // 1) port - serial-порт дисплея; + // 2) width - ширина строки, по ней worker строит кадры marquee; + // 3) cps - скорость в символах в секунду; + // 4) end_pause_ms - пауза после полного прохода текста; + // 5) brightness - яркость из диапазона пресета 1..=4. + let mut args = common::ExampleArgs::from_env()?; let cps: u32 = args.parse_or(8); let end_pause_ms: u64 = args.parse_or(1500); let brightness: u8 = args.parse_or(2); + + // Marquee живёт внутри worker-потока: основной поток может спать, а worker будет + // просыпаться по собственному таймеру и отправлять следующий кадр. let worker = VfdWorker::start(args.config)?; let vfd = worker.handle(); - vfd.clear(); - vfd.set_brightness(brightness); + vfd.clear()?; + vfd.set_brightness(brightness)?; let text = String::from( "Однозначно, базовые сценарии поведения пользователей призывают нас к новым свершениям, которые, в свою очередь, должны быть объявлены нарушающими общечеловеческие нормы этики и морали. С другой стороны, понимание сути ресурсосберегающих технологий не даёт нам иного выбора, кроме определения экономической целесообразности принимаемых решений. Задача организации, в особенности же укрепление и развитие внутренней структуры обеспечивает актуальность соответствующих условий активизации. Предварительные выводы неутешительны: укрепление и развитие внутренней структуры однозначно фиксирует необходимость укрепления моральных ценностей. Высокий уровень вовлечения представителей целевой аудитории является четким доказательством простого факта: повышение уровня гражданского сознания представляет собой интересный эксперимент проверки поэтапного и последовательного развития общества.", ); - // “рыба” + бесконечный цикл - vfd.set_marquee_text(text); - vfd.start_marquee(2, cps, Duration::from_millis(end_pause_ms)); - // можно подсветить вторую строку статикой - let _ = vfd.print_line_diff(1, "Бегущая строка"); + // Длинный текст сначала сохраняется в состоянии worker-а. `start_marquee` выбирает + // строку, скорость и паузу; сама анимация дальше идёт без ручного цикла кадров. + vfd.set_marquee_text(text)?; + vfd.start_marquee(2, cps, Duration::from_millis(end_pause_ms))?; + + // Первая строка остаётся статикой. Вторую строку теперь лучше не обновлять обычными + // командами, пока на ней активна marquee. + vfd.print_line_diff(1, "Бегущая строка")?; loop { + // Держим процесс живым. Если `main` завершится, `worker` попадёт в Drop и + // остановит фоновую анимацию. common::sleep_ms(3_600_000); } } diff --git a/examples/position.rs b/examples/position.rs index 204af53..a31f295 100644 --- a/examples/position.rs +++ b/examples/position.rs @@ -2,47 +2,51 @@ mod common; -use anyhow::Result; +use std::error::Error; -use m::worker::VfdWorker; +use escpos_vfd::VfdWorker; -fn main() -> Result<()> { - // args: - // 1) port (default: /dev/cu.usbmodem101) - // 2) width (default: 20) - // 3) delay_ms (default: 80) - let mut args = common::ExampleArgs::from_env(); +fn main() -> Result<(), Box> { + // Аргументы: + // 1) port - serial-порт дисплея; + // 2) width - ширина строки, по ней ограничиваем движение курсора; + // 3) delay_ms - скорость движения символа. + let mut args = common::ExampleArgs::from_env()?; let delay_ms: u64 = args.parse_or(80); - let width = args.width; + let columns = args.columns; + + // Worker сериализует `print_at`: сначала стираем старую позицию, затем рисуем новую. let worker = VfdWorker::start(args.config)?; let vfd = worker.handle(); - vfd.clear(); + vfd.clear()?; - // Статика - let _ = vfd.print_line_diff(1, "print_at demo"); - let _ = vfd.print_line_diff(2, "--------------------"); // будет обрезано по width + // Статичную шапку пишем через diff-метод. Вторая строка ниже станет рабочей областью. + vfd.print_line_diff(1, "print_at demo")?; + // Строка автоматически обрежется по настроенной ширине дисплея. + vfd.print_line_diff(2, "--------------------")?; - // “курсор” бегает по 2-й строке + // "Курсор" бегает по 2-й строке. Координаты в API начинаются с 1, не с 0. let y = 2u8; let mut x: u8 = 1; let mut dir: i8 = 1; - // чтобы не оставлять хвост — помним прошлую позицию и стираем её пробелом + // Чтобы не оставлять хвост, помним прошлую позицию и стираем её пробелом. let mut prev_x: u8 = x; loop { - // стереть прошлую позицию - let _ = vfd.print_at(prev_x, y, " "); + // Стереть прошлую позицию. + vfd.print_at(prev_x, y, " ")?; - // нарисовать новую - let _ = vfd.print_at(x, y, "█"); // можно заменить на "*" если надо + // Нарисовать новую. Если дисплей плохо показывает этот символ в выбранной + // таблице, замените его на ASCII "*". + vfd.print_at(x, y, "█")?; prev_x = x; - // шаг + // Шаг вправо/влево с отражением от границ настроенной ширины. if dir > 0 { - if (x as usize) >= width { + if (x as usize) >= columns { dir = -1; } else { x += 1; diff --git a/examples/preset_sync.rs b/examples/preset_sync.rs new file mode 100644 index 0000000..e567203 --- /dev/null +++ b/examples/preset_sync.rs @@ -0,0 +1,33 @@ +//! Минимальный синхронный старт через пресет Epson20x2Cp866. + +use escpos_vfd::{Preset, Vfd, VfdConfig}; +use std::error::Error; +use std::thread; +use std::time::Duration; + +fn main() -> Result<(), Box> { + // Первый аргумент - имя serial-порта. Значение по умолчанию удобно для локального + // macOS-стенда; на Linux чаще будет `/dev/ttyUSB0`, на Windows - `COM3`. + let port_name = std::env::args() + .nth(1) + .unwrap_or_else(|| "/dev/cu.usbmodem101".to_string()); + + // Пресет содержит все параметры текущего дисплея: serial 9600 8N1, + // геометрию 20x2, CP866, `ESC @` и `ESC t 6`. + let config = VfdConfig::preset(port_name, Preset::Epson20x2Cp866)?; + let mut display = Vfd::open(config)?; + + // Низкоуровневый `Vfd` пишет команды сразу в serial-порт в текущем потоке. Это + // самый простой путь для CLI-утилит и smoke-test на железе. + display.clear()?; + display.set_brightness(2)?; + + // `print_line` дополняет строку пробелами до ширины дисплея, поэтому старый текст + // справа не остаётся на экране. + display.print_line(1, "Пресет CP866")?; + display.print_line(2, "escpos-vfd")?; + + // Даём физическому дисплею время показать результат до завершения процесса. + thread::sleep(Duration::from_secs(2)); + Ok(()) +} diff --git a/examples/tokio_worker.rs b/examples/tokio_worker.rs new file mode 100644 index 0000000..d778b66 --- /dev/null +++ b/examples/tokio_worker.rs @@ -0,0 +1,34 @@ +//! Асинхронный worker на Tokio с bounded queue и явным shutdown. + +mod common; + +use escpos_vfd::tokio::AsyncVfdWorker; +use std::error::Error; +use std::time::Duration; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + // В Cargo.toml у примера указан `required-features = ["tokio"]`. Поэтому sync-only + // пользователи не подтягивают async-зависимости, а сам файл остаётся обычным + // понятным Tokio-примером без cfg-обёрток. + let config = common::ExampleArgs::from_env()?.config; + + // Async worker открывает serial-порт через `tokio-serial` и запускает одну task, + // которая последовательно пишет команды в устройство. + let worker = AsyncVfdWorker::start(config).await?; + let display = worker.handle(); + + // Каждый await завершается после записи команды worker-ом или возвращает ошибку I/O. + // Если очередь заполнена, `send` внутри handle даст backpressure через `.await`. + display.clear().await?; + display.set_brightness(2).await?; + display.print_line_diff(1, "Tokio worker").await?; + display.print_line_diff(2, "bounded queue").await?; + + // Держим текст на физическом дисплее пару секунд, затем делаем graceful shutdown: + // ранее принятые команды завершатся, task остановится, serial transport вернётся. + tokio::time::sleep(Duration::from_secs(2)).await; + worker.shutdown().await?; + + Ok(()) +} diff --git a/examples/update_at.rs b/examples/update_at.rs index 0c1942c..76a0514 100644 --- a/examples/update_at.rs +++ b/examples/update_at.rs @@ -2,12 +2,13 @@ mod common; -use anyhow::Result; +use std::error::Error; -use m::worker::VfdWorker; +use escpos_vfd::VfdWorker; fn pad_left(s: &str, width: usize) -> String { - // простая подгонка ширины, чтобы при уменьшении числа не оставались “хвосты” + // Подгоняем ширину поля, чтобы при уменьшении числа не оставались "хвосты" от + // предыдущего значения. Например, после 1000 должно появиться " 999", а не "9990". if s.chars().count() >= width { s.chars().take(width).collect() } else { @@ -15,28 +16,34 @@ fn pad_left(s: &str, width: usize) -> String { } } -fn main() -> Result<()> { - // args: - // 1) port (default: /dev/cu.usbmodem101) - // 2) width (default: 20) - // 3) delay_ms (default: 200) - let mut args = common::ExampleArgs::from_env(); +fn main() -> Result<(), Box> { + // Аргументы: + // 1) port - serial-порт дисплея; + // 2) width - ширина строки; + // 3) delay_ms - пауза между изменениями данных. + let mut args = common::ExampleArgs::from_env()?; let delay_ms: u64 = args.parse_or(200); - let width = args.width; - if width < 20 { - anyhow::bail!("update_at requires a display width of at least 20 columns"); + let columns = args.columns; + + // Координаты ниже подобраны под 20 символов. Для более узких дисплеев лучше + // сделать другой layout, поэтому пример явно сообщает об ограничении. + if columns < 20 { + return Err("update_at requires a display width of at least 20 columns".into()); } + + // Worker сохраняет кэш строк. Благодаря этому `print_at` и `print_line_diff` + // согласованно обновляют только изменившиеся области. let worker = VfdWorker::start(args.config)?; let vfd = worker.handle(); - vfd.clear(); + vfd.clear()?; - // Рисуем “шаблон” один раз (как UI) + // Рисуем "шаблон" один раз. Дальше меняем только числовые поля внутри шаблона. // 12345678901234567890 // Temp: __.__C RPM:____ // Load: ___% Uptime:____ - let _ = vfd.print_line_diff(1, "Temp: 00.00C RPM:0000"); - let _ = vfd.print_line_diff(2, "Load: 000% Up:0000s"); + vfd.print_line_diff(1, "Temp: 00.00C RPM:0000")?; + vfd.print_line_diff(2, "Load: 000% Up:0000s")?; // Координаты (1-based): // "Temp: 00.00C ..." -> числа начинаются с x=7, длина 5 (00.00) @@ -64,7 +71,7 @@ fn main() -> Result<()> { let mut si = 0usize; loop { - // чуть “шевелим” данные + // Чуть "шевелим" данные, чтобы на дисплее были видны частичные обновления. t += 0.03; if t > 29.99 { t = 21.50; @@ -74,21 +81,22 @@ fn main() -> Result<()> { load = (load + 3) % 100; uptime = uptime.wrapping_add(1); - // Важно: обновляем только куски строки в фиксированных местах + // Важно: обновляем только куски строки в фиксированных местах. Это меньше + // нагружает serial-линию и обычно выглядит спокойнее, чем полная перерисовка. let temp_s = pad_left(&format!("{:.2}", t), temp_w); let rpm_s = pad_left(&format!("{}", rpm), rpm_w); let load_s = pad_left(&format!("{}", load), load_w); let up_s = pad_left(&format!("{}", uptime % 10000), up_w); - let _ = vfd.print_at(temp_x, 1, temp_s); - let _ = vfd.print_at(rpm_x, 1, rpm_s); + vfd.print_at(temp_x, 1, temp_s)?; + vfd.print_at(rpm_x, 1, rpm_s)?; - let _ = vfd.print_at(load_x, 2, load_s); - let _ = vfd.print_at(up_x, 2, up_s); + vfd.print_at(load_x, 2, load_s)?; + vfd.print_at(up_x, 2, up_s)?; - // “живой” индикатор справа (если ширина позволяет) - if width >= 20 { - let _ = vfd.print_at(20, 2, spinner[si]); + // "Живой" индикатор справа показывает, что цикл продолжает выполняться. + if columns >= 20 { + vfd.print_at(20, 2, spinner[si])?; si = (si + 1) % spinner.len(); } diff --git a/src/codec.rs b/src/codec.rs new file mode 100644 index 0000000..125edb4 --- /dev/null +++ b/src/codec.rs @@ -0,0 +1,343 @@ +//! Формирование байтов Epson/ESC/POS-команд без привязки к конкретному serial-порту. +//! +//! Этот модуль полезен для тестов, нестандартных транспортов и проверки того, какие +//! байты будут отправлены устройству при выбранных [`DisplaySettings`]. Обычным +//! приложениям чаще достаточно [`crate::Vfd`] или [`crate::VfdWorker`], но codec +//! остаётся публичным для диагностики и интеграции с собственным транспортом. + +use crate::config::{DisplaySettings, TextEncoding}; +use crate::error::{Result, VfdError}; +use encoding_rs::{IBM866, WINDOWS_1251}; + +/// Кодировщик Epson/ESC/POS-команд для выбранной геометрии и таблицы символов. +/// +/// `EpsonCodec` не открывает порт и не выполняет I/O. Он только валидирует координаты, +/// собирает управляющие последовательности и кодирует текст в выбранный [`TextEncoding`]. +#[derive(Debug, Clone)] +pub struct EpsonCodec { + display: DisplaySettings, +} + +impl EpsonCodec { + /// Создаёт codec для заданных настроек дисплея. + /// + /// Значение `display.code_table` влияет только на байты инициализации `ESC t n`; + /// кодировка текста берётся из `display.encoding`. + /// + /// Метод не валидирует настройки. Если codec создаётся не через [`crate::Vfd`], + /// вызовите [`DisplaySettings::validate`] самостоятельно. + pub fn new(display: DisplaySettings) -> Self { + Self { display } + } + + /// Настройки дисплея, для которых работает codec. + pub fn display(&self) -> &DisplaySettings { + &self.display + } + + /// Команды инициализации при открытии. + /// + /// Возвращает `ESC @`, если включён `reset_on_open`, и `ESC t n`, если задана + /// аппаратная таблица символов. При полностью ручной конфигурации `code_table = None` + /// команда выбора таблицы не добавляется. + pub fn init(&self) -> Vec { + let mut out = Vec::with_capacity(5); + if self.display.reset_on_open { + out.extend_from_slice(&[0x1B, 0x40]); + } + if let Some(table) = self.display.code_table { + out.extend_from_slice(&[0x1B, 0x74, table]); + } + out + } + + /// Команда очистки дисплея. + /// + /// Возвращает один байт `0x0C`. Запись в транспорт выполняет вызывающий код. + pub fn clear(&self) -> [u8; 1] { + [0x0C] + } + + /// Команда позиционирования курсора в координатах от единицы. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::InvalidCoordinate`], если `x` или `y` равны нулю либо + /// выходят за `columns`/`rows`. + pub fn goto_xy(&self, x: u8, y: u8) -> Result<[u8; 4]> { + self.validate_xy(x, y)?; + Ok([0x1F, 0x24, x, y]) + } + + /// Команда установки яркости. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::UnsupportedBrightness`], если яркость выключена в + /// конфигурации (`brightness = None`) или уровень вне настроенного диапазона. + pub fn brightness(&self, level: u8) -> Result<[u8; 3]> { + if let Some(range) = &self.display.brightness { + if range.contains(&level) { + return Ok([0x1F, 0x58, level]); + } + return Err(VfdError::UnsupportedBrightness { + level, + min: *range.start(), + max: *range.end(), + }); + } + Err(VfdError::UnsupportedBrightness { + level, + min: 0, + max: 0, + }) + } + + /// Кодирует текст в настроенной кодировке. + /// + /// Для `Ascii` символы вне ASCII заменяются на `?`. Для CP866 и Windows-1251 + /// используется `encoding_rs`, поэтому неподдерживаемые символы проходят стандартную + /// замену этой библиотеки. + pub fn encode_text(&self, text: &str) -> Vec { + match self.display.encoding { + TextEncoding::Cp866 => IBM866.encode(text).0.into_owned(), + TextEncoding::Windows1251 => WINDOWS_1251.encode(text).0.into_owned(), + TextEncoding::Ascii => text + .chars() + .map(|ch| if ch.is_ascii() { ch as u8 } else { b'?' }) + .collect(), + TextEncoding::Utf8 => text.as_bytes().to_vec(), + } + } + + /// Нормализует строку до фиксированной ширины. + /// + /// Метод сначала применяет [`sanitize_text`], затем обрезает по числу символов и + /// дополняет пробелами до `display.columns`. + pub fn fit_line(&self, text: &str) -> String { + fit_to_width(&sanitize_text(text), self.display.columns) + } + + /// Обрезает текст по правому краю от координаты `x`. + /// + /// Проверяется только колонка `x`; строка для этой операции не нужна, поэтому для + /// проверки используется первая строка. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::InvalidCoordinate`], если `x` вне дисплея. + pub fn clip_from(&self, x: u8, text: &str) -> Result { + self.validate_xy(x, 1)?; + let remaining = self.display.columns - usize::from(x) + 1; + Ok(truncate_chars(&sanitize_text(text), remaining).to_string()) + } + + /// Проверяет координаты относительно геометрии. + /// + /// Координаты задаются от единицы. Значение `0` всегда ошибка. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::InvalidCoordinate`] с запрошенными координатами и текущей + /// геометрией дисплея. + pub fn validate_xy(&self, x: u8, y: u8) -> Result<()> { + if x == 0 + || y == 0 + || usize::from(x) > self.display.columns + || usize::from(y) > self.display.rows + { + return Err(VfdError::InvalidCoordinate { + x, + y, + columns: self.display.columns, + rows: self.display.rows, + }); + } + Ok(()) + } + + /// Проверяет строку относительно геометрии. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::InvalidLine`], если `line = 0` или строка больше + /// настроенного `rows`. + pub fn validate_line(&self, line: u8) -> Result<()> { + if line == 0 || usize::from(line) > self.display.rows { + return Err(VfdError::InvalidLine { + line, + rows: self.display.rows, + }); + } + Ok(()) + } +} + +/// Возвращает срез не длиннее заданного числа символов, не разрывая UTF-8. +/// +/// # Примеры +/// +/// ``` +/// assert_eq!(escpos_vfd::codec::truncate_chars("ёжик", 2), "ёж"); +/// ``` +pub fn truncate_chars(s: &str, max_chars: usize) -> &str { + s.char_indices() + .nth(max_chars) + .map_or(s, |(byte_index, _)| &s[..byte_index]) +} + +/// Заменяет типографские символы на безопасные аналоги для однобайтовых таблиц. +/// +/// Функция намеренно не выбирает кодировку. Она только убирает символы вроде длинного +/// тире, табуляции и `№`, которые часто плохо представлены на VFD-дисплеях. +pub fn sanitize_text(s: &str) -> String { + s.chars() + .map(|c| match c { + '…' => '.', + '—' | '–' => '-', + '№' => '#', + '\t' => ' ', + '“' | '”' => '"', + '‘' | '’' => '\'', + _ => c, + }) + .collect() +} + +/// Совместимый алиас для старого helper. +/// +/// Новому коду лучше использовать [`sanitize_text`]: санация теперь общая для разных +/// однобайтовых кодировок, а не только для CP866. +pub fn sanitize_for_cp866(s: &str) -> String { + sanitize_text(s) +} + +/// Обрезает строку по числу символов и дополняет пробелами до заданной ширины. +/// +/// Функция считает Unicode scalar values, а не байты. Это важно для кириллицы: +/// строка не будет обрезана посередине UTF-8 последовательности. +/// +/// # Примеры +/// +/// ``` +/// use escpos_vfd::fit_to_width; +/// +/// assert_eq!(fit_to_width("Привет", 4), "Прив"); +/// assert_eq!(fit_to_width("да", 4), "да "); +/// ``` +pub fn fit_to_width(s: &str, width: usize) -> String { + let mut out = String::with_capacity(width); + let mut len = 0; + for ch in s.chars().take(width) { + out.push(ch); + len += 1; + } + out.extend(std::iter::repeat_n(' ', width.saturating_sub(len))); + out +} + +pub(crate) fn changed_runs(current: &str, next: &str) -> Vec<(u8, String)> { + let mut runs = Vec::new(); + let mut run_start = None; + let mut run_text = String::new(); + + for (index, (old, new)) in current.chars().zip(next.chars()).enumerate() { + if old != new { + run_start.get_or_insert((index + 1) as u8); + run_text.push(new); + } else if let Some(start) = run_start.take() { + runs.push((start, std::mem::take(&mut run_text))); + } + } + + if let Some(start) = run_start { + runs.push((start, run_text)); + } + + runs +} + +pub(crate) fn replace_cached_range(line: &mut String, x: u8, text: &str, width: usize) { + if x == 0 || usize::from(x) > width || text.is_empty() { + return; + } + + let mut chars: Vec = line.chars().take(width).collect(); + chars.resize(width, ' '); + let start = usize::from(x) - 1; + for (slot, ch) in chars[start..].iter_mut().zip(text.chars()) { + *slot = ch; + } + + line.clear(); + line.extend(chars); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Preset, VfdConfig}; + + #[test] + fn preset_init_matches_legacy_reset_and_cp866_table() { + let cfg = VfdConfig::preset("test", Preset::Epson20x2Cp866).unwrap(); + let codec = EpsonCodec::new(cfg.display); + + assert_eq!(codec.init(), vec![0x1B, 0x40, 0x1B, 0x74, 6]); + } + + #[test] + fn optional_code_table_can_be_omitted() { + let display = DisplaySettings::new(20, 2, TextEncoding::Cp866); + let codec = EpsonCodec::new(display); + + assert_eq!(codec.init(), vec![0x1B, 0x40]); + } + + #[test] + fn validates_geometry_instead_of_ignoring_invalid_coordinates() { + let codec = EpsonCodec::new(DisplaySettings::new(20, 4, TextEncoding::Cp866)); + + assert!(codec.goto_xy(1, 4).is_ok()); + assert!(matches!( + codec.goto_xy(21, 1), + Err(VfdError::InvalidCoordinate { .. }) + )); + assert!(matches!( + codec.validate_line(5), + Err(VfdError::InvalidLine { .. }) + )); + } + + #[test] + fn sanitizes_and_fits_unicode_by_characters() { + assert_eq!(sanitize_for_cp866("№1\t“тест”—‘да’…"), "#1 \"тест\"-'да'."); + assert_eq!(fit_to_width("Привет", 4), "Прив"); + assert_eq!(fit_to_width("да", 4), "да "); + assert_eq!(fit_to_width("text", 0), ""); + assert_eq!(truncate_chars("ёжик", 3), "ёжи"); + } + + #[test] + fn cache_update_replaces_the_complete_fragment() { + let mut line = "Temp: 00.00C ".to_string(); + replace_cached_range(&mut line, 7, "21.50", 20); + assert_eq!(line, "Temp: 21.50C "); + } + + #[test] + fn cache_update_handles_unicode_and_clips_at_the_right_edge() { + let mut line = String::new(); + replace_cached_range(&mut line, 3, "ёжик", 5); + assert_eq!(line, " ёжи"); + } + + #[test] + fn diff_groups_adjacent_changes_into_minimal_runs() { + assert_eq!( + changed_runs("abcd efgh", "abXY eZZh"), + vec![(3, "XY".to_string()), (7, "ZZ".to_string())] + ); + assert!(changed_runs("без перемен", "без перемен").is_empty()); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..1495902 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,405 @@ +//! Типизированная конфигурация serial-транспорта и параметров дисплея. +//! +//! Вручную созданный [`VfdConfig`] не содержит скрытых значений baud rate, кодовой +//! таблицы или размера экрана. Единственный путь с заранее выбранными настройками - +//! [`VfdConfig::preset`]. +//! +//! Главная идея конфигурации: `SerialSettings` описывает только способ подключиться к +//! порту, а `DisplaySettings` описывает геометрию и ESC/POS-поведение устройства. Это +//! позволяет использовать один и тот же дисплей на разных портах или один serial-режим +//! с разными моделями дисплеев без глобальных констант. + +use crate::error::ConfigError; +use serialport::{DataBits, FlowControl, Parity, StopBits}; +use std::time::Duration; + +/// Преднастроенные профили известных ESC/POS-совместимых дисплеев. +/// +/// Пресеты нужны для сохранения проверенных наборов настроек, но не ограничивают ручную +/// конфигурацию. Если устройство отличается хотя бы одним параметром, используйте +/// [`SerialSettings`], [`DisplaySettings`] и [`VfdConfig::new`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Preset { + /// Старое поведение этой библиотеки: 20x2, 9600 8N1, CP866, `ESC t 6`. + /// + /// Пресет подходит для Epson/ESC/POS-совместимых VFD, которые ожидают кириллицу в + /// CP866 и выбирают нужную аппаратную таблицу командой `ESC t 6`. + Epson20x2Cp866, +} + +/// Настройки serial-подключения. +/// +/// Все поля публичные, чтобы приложение могло точно выставить режим конкретного +/// устройства. Значения проверяются перед открытием порта через [`SerialSettings::validate`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SerialSettings { + /// Имя serial-порта, например `/dev/cu.usbmodem101`, `/dev/ttyUSB0` или `COM3`. + pub port_name: String, + /// Скорость serial-порта в бодах. + /// + /// Значение должно быть больше нуля. Типичные значения для VFD: `9600`, `19200`, + /// `38400`, но библиотека не ограничивает список скоростей. + pub baud_rate: u32, + /// Количество бит данных. + pub data_bits: DataBits, + /// Проверка чётности. + pub parity: Parity, + /// Количество stop bits. + pub stop_bits: StopBits, + /// Управление потоком. + pub flow_control: FlowControl, + /// Тайм-аут операций serial-порта. + /// + /// Значение передаётся в `serialport`; для worker это время ожидания одной + /// блокирующей операции на устройстве, а не timeout всей очереди команд. + pub timeout: Duration, + /// Эксклюзивное открытие serial-порта на Unix. + /// + /// По умолчанию включено, чтобы второй процесс не смог случайно писать в тот же + /// дисплей. Поле доступно только на Unix-платформах. + #[cfg(unix)] + pub exclusive: bool, +} + +impl SerialSettings { + /// Создаёт настройки serial-порта с явным baud rate. + /// + /// Остальные параметры получают распространённые значения `8N1`, без flow control, + /// timeout `100 ms` и exclusive mode на Unix. Их можно изменить напрямую в полях + /// структуры до создания [`VfdConfig`]. + /// + /// # Ошибки + /// + /// Метод сам не возвращает ошибку. Проверка выполняется в [`SerialSettings::validate`] + /// или при создании [`VfdConfig`]. + /// + /// # Примеры + /// + /// ``` + /// use escpos_vfd::SerialSettings; + /// use std::time::Duration; + /// + /// let mut serial = SerialSettings::new("/dev/ttyUSB0", 9_600); + /// serial.timeout = Duration::from_millis(250); + /// assert!(serial.validate().is_ok()); + /// ``` + pub fn new(port_name: impl Into, baud_rate: u32) -> Self { + Self { + port_name: port_name.into(), + baud_rate, + data_bits: DataBits::Eight, + parity: Parity::None, + stop_bits: StopBits::One, + flow_control: FlowControl::None, + timeout: Duration::from_millis(100), + #[cfg(unix)] + exclusive: true, + } + } + + /// Проверяет, что настройки можно применить к serial-порту. + /// + /// Метод не открывает устройство. Он только ловит ошибки, которые библиотека может + /// определить заранее: пустое имя порта и нулевой baud rate. + /// + /// # Ошибки + /// + /// Возвращает [`ConfigError::EmptyPortName`] или [`ConfigError::ZeroBaudRate`]. + pub fn validate(&self) -> std::result::Result<(), ConfigError> { + if self.port_name.trim().is_empty() { + return Err(ConfigError::EmptyPortName); + } + if self.baud_rate == 0 { + return Err(ConfigError::ZeroBaudRate); + } + Ok(()) + } +} + +/// Текстовая кодировка, используемая для байтов дисплея. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TextEncoding { + /// IBM866 / CP866. + /// + /// Частый выбор для русской кириллицы на Epson/АТОЛ-совместимых дисплеях. + Cp866, + /// Windows-1251. + /// + /// Используйте, если руководство дисплея явно указывает Windows-1251 или CP1251. + Windows1251, + /// Только ASCII, всё вне ASCII заменяется на `?`. + Ascii, + /// UTF-8 без перекодирования. + /// + /// Подходит только устройствам, которые действительно принимают UTF-8 байты. + Utf8, +} + +/// Настройки геометрии и ESC/POS-команд дисплея. +/// +/// `columns` и `rows` задают координатную сетку, используемую для проверки `print_line` +/// и `print_at`. `code_table` управляет только аппаратной командой `ESC t n`, а +/// `encoding` определяет программное преобразование текста в байты. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DisplaySettings { + /// Количество символов в строке. + /// + /// Допустимый диапазон: `1..=255`. Значение используется для обрезки, заполнения + /// пробелами и проверки координаты `x`. + pub columns: usize, + /// Количество строк. + /// + /// Допустимый диапазон: `1..=255`. Значение используется для проверки `line` и `y` + /// во всех методах печати. + pub rows: usize, + /// Кодировка текстовых байтов. + /// + /// Это программное преобразование Rust-строки в байты транспорта. Оно не выбирает + /// аппаратную таблицу дисплея. + pub encoding: TextEncoding, + /// Таблица символов для `ESC t n`; `None` отключает отправку команды. + /// + /// Это отдельная аппаратная команда протокола. Для многих дисплеев CP866 работает + /// только когда одновременно выбраны `encoding = TextEncoding::Cp866` и нужное + /// значение `code_table`. + pub code_table: Option, + /// Отправлять `ESC @` при открытии. + /// + /// Сброс полезен для predictable startup, но его можно выключить, если приложение + /// намеренно сохраняет состояние дисплея между открытиями. + pub reset_on_open: bool, + /// Поддерживаемый диапазон яркости для `US X n`. + /// + /// `None` означает, что типизированная установка яркости запрещена и + /// [`crate::Vfd::set_brightness`] вернёт [`crate::VfdError::UnsupportedBrightness`]. + pub brightness: Option>, + /// Задержка после изменения яркости. + /// + /// Некоторые дисплеи требуют короткую паузу после `US X n`. Sync API блокирует + /// текущий поток на это время; Tokio API ждёт через async sleep. + pub brightness_settle: Duration, +} + +impl DisplaySettings { + /// Создаёт ручные настройки дисплея. + /// + /// По умолчанию включён `ESC @` при открытии, яркость `1..=4` и короткая задержка + /// после её изменения. Аппаратная таблица символов не выбирается автоматически: + /// задайте `code_table = Some(n)`, если вашему дисплею нужна команда `ESC t n`. + /// + /// # Ошибки + /// + /// Метод сам не возвращает ошибку. Проверка выполняется в + /// [`DisplaySettings::validate`] или при создании [`VfdConfig`]. + /// + /// # Примеры + /// + /// ``` + /// use escpos_vfd::{DisplaySettings, TextEncoding}; + /// + /// let mut display = DisplaySettings::new(20, 2, TextEncoding::Cp866); + /// display.code_table = Some(6); + /// assert!(display.validate().is_ok()); + /// ``` + pub fn new(columns: usize, rows: usize, encoding: TextEncoding) -> Self { + Self { + columns, + rows, + encoding, + code_table: None, + reset_on_open: true, + brightness: Some(1..=4), + brightness_settle: Duration::from_millis(2), + } + } + + /// Проверяет геометрию и диапазоны настроек дисплея. + /// + /// # Ошибки + /// + /// Возвращает [`ConfigError::InvalidColumns`], [`ConfigError::InvalidRows`] или + /// [`ConfigError::InvalidBrightnessRange`]. + pub fn validate(&self) -> std::result::Result<(), ConfigError> { + if !(1..=u8::MAX as usize).contains(&self.columns) { + return Err(ConfigError::InvalidColumns(self.columns)); + } + if !(1..=u8::MAX as usize).contains(&self.rows) { + return Err(ConfigError::InvalidRows(self.rows)); + } + if let Some(range) = &self.brightness { + let min = *range.start(); + let max = *range.end(); + if min == 0 || min > max { + return Err(ConfigError::InvalidBrightnessRange { min, max }); + } + } + Ok(()) + } +} + +/// Полная конфигурация VFD: serial transport плюс геометрия/протокол дисплея. +/// +/// Один и тот же `VfdConfig` используется sync и Tokio API. `queue_capacity` влияет +/// только на worker-обёртки; низкоуровневый [`crate::Vfd`] открывает порт напрямую. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VfdConfig { + /// Настройки serial-порта. + pub serial: SerialSettings, + /// Настройки дисплея. + pub display: DisplaySettings, + /// Ёмкость очереди фонового worker. + /// + /// Используется только [`crate::VfdWorker`] и `escpos_vfd::tokio::AsyncVfdWorker`. + /// Значение `32` по умолчанию даёт backpressure без бесконтрольного роста памяти. + pub queue_capacity: usize, +} + +impl VfdConfig { + /// Создаёт полностью ручную конфигурацию. + /// + /// Метод сразу валидирует serial и display настройки, поэтому ошибки конфигурации + /// возвращаются до попытки открыть устройство. + /// + /// # Ошибки + /// + /// Возвращает [`ConfigError`], если serial, display или `queue_capacity` содержат + /// недопустимые значения. + pub fn new( + serial: SerialSettings, + display: DisplaySettings, + ) -> std::result::Result { + let cfg = Self { + serial, + display, + queue_capacity: 32, + }; + cfg.validate()?; + Ok(cfg) + } + + /// Создаёт конфигурацию из пресета. + /// + /// `Preset::Epson20x2Cp866` воспроизводит прежние настройки библиотеки: 20x2, + /// 9600 baud, CP866, `ESC @`, `ESC t 6` и яркость `1..=4`. + /// + /// # Ошибки + /// + /// Возвращает [`ConfigError::EmptyPortName`], если имя порта пустое. + pub fn preset( + port_name: impl Into, + preset: Preset, + ) -> std::result::Result { + match preset { + Preset::Epson20x2Cp866 => { + let serial = SerialSettings::new(port_name, 9600); + let mut display = DisplaySettings::new(20, 2, TextEncoding::Cp866); + display.code_table = Some(6); + display.reset_on_open = true; + display.brightness = Some(1..=4); + display.brightness_settle = Duration::from_millis(2); + Self::new(serial, display) + } + } + } + + /// Устанавливает ёмкость bounded-очереди worker. + /// + /// Значение `0` запрещено: такая очередь не смогла бы принять даже команду shutdown. + /// + /// # Ошибки + /// + /// Возвращает [`ConfigError::ZeroQueueCapacity`] при `capacity = 0`. + pub fn with_queue_capacity( + mut self, + capacity: usize, + ) -> std::result::Result { + self.queue_capacity = capacity; + self.validate()?; + Ok(self) + } + + /// Проверяет serial и display настройки. + /// + /// # Ошибки + /// + /// Возвращает первый найденный [`ConfigError`]. + pub fn validate(&self) -> std::result::Result<(), ConfigError> { + self.serial.validate()?; + self.display.validate()?; + if self.queue_capacity == 0 { + return Err(ConfigError::ZeroQueueCapacity); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn epson_preset_matches_legacy_settings() { + let cfg = VfdConfig::preset("COM1", Preset::Epson20x2Cp866).unwrap(); + + assert_eq!(cfg.serial.port_name, "COM1"); + assert_eq!(cfg.serial.baud_rate, 9600); + assert_eq!(cfg.serial.data_bits, DataBits::Eight); + assert_eq!(cfg.serial.parity, Parity::None); + assert_eq!(cfg.serial.stop_bits, StopBits::One); + assert_eq!(cfg.serial.flow_control, FlowControl::None); + assert_eq!(cfg.serial.timeout, Duration::from_millis(100)); + assert_eq!(cfg.display.columns, 20); + assert_eq!(cfg.display.rows, 2); + assert_eq!(cfg.display.encoding, TextEncoding::Cp866); + assert_eq!(cfg.display.code_table, Some(6)); + assert!(cfg.display.reset_on_open); + assert_eq!(cfg.display.brightness, Some(1..=4)); + } + + #[test] + fn manual_config_has_no_hidden_code_table_or_baud() { + let serial = SerialSettings::new("/tmp/tty", 19_200); + let display = DisplaySettings::new(16, 4, TextEncoding::Windows1251); + let cfg = VfdConfig::new(serial, display).unwrap(); + + assert_eq!(cfg.serial.baud_rate, 19_200); + assert_eq!(cfg.display.columns, 16); + assert_eq!(cfg.display.rows, 4); + assert_eq!(cfg.display.code_table, None); + } + + #[test] + fn invalid_config_values_are_rejected() { + assert!(matches!( + SerialSettings::new("", 9600).validate(), + Err(ConfigError::EmptyPortName) + )); + assert!(matches!( + SerialSettings::new("p", 0).validate(), + Err(ConfigError::ZeroBaudRate) + )); + assert!(matches!( + DisplaySettings::new(0, 2, TextEncoding::Cp866).validate(), + Err(ConfigError::InvalidColumns(0)) + )); + assert!(matches!( + DisplaySettings::new(20, 256, TextEncoding::Cp866).validate(), + Err(ConfigError::InvalidRows(256)) + )); + + let mut display = DisplaySettings::new(20, 2, TextEncoding::Cp866); + let min = 4; + let max = 1; + display.brightness = Some(min..=max); + assert!(matches!( + display.validate(), + Err(ConfigError::InvalidBrightnessRange { min: 4, max: 1 }) + )); + + let cfg = VfdConfig::preset("COM1", Preset::Epson20x2Cp866).unwrap(); + assert!(matches!( + cfg.with_queue_capacity(0), + Err(ConfigError::ZeroQueueCapacity) + )); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..58eaf58 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,184 @@ +//! Ошибки конфигурации, I/O и жизненного цикла worker. +//! +//! Библиотека возвращает типизированные ошибки вместо `anyhow`, чтобы вызывающий код +//! мог отдельно обработать неверные настройки, недоступный serial-порт, ошибку записи, +//! неправильные координаты и остановленный worker. Текст [`std::fmt::Display`] +//! ориентирован на диагностику, а варианты enum - на машинную обработку. + +use std::sync::mpsc; + +/// Ошибки проверки конфигурации до открытия serial-порта. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConfigError { + /// Имя serial-порта не задано или состоит только из пробельных символов. + EmptyPortName, + /// Скорость serial-порта равна нулю. + /// + /// Значение `baud_rate` задаётся в бодах, например `9600` или `115200`. + ZeroBaudRate, + /// Ширина дисплея не входит в диапазон `1..=255`. + /// + /// Поле содержит переданное количество колонок. + InvalidColumns(usize), + /// Высота дисплея не входит в диапазон `1..=255`. + /// + /// Поле содержит переданное количество строк. + InvalidRows(usize), + /// Диапазон яркости задан некорректно. + InvalidBrightnessRange { + /// Нижняя граница диапазона яркости. + min: u8, + /// Верхняя граница диапазона яркости. + max: u8, + }, + /// Ёмкость очереди worker равна нулю. + /// + /// Worker использует bounded queue, поэтому ему нужна ёмкость хотя бы `1`. + ZeroQueueCapacity, +} + +/// Ошибки выполнения команд дисплея. +#[derive(Debug)] +pub enum VfdError { + /// Конфигурация не прошла проверку до открытия транспорта. + Config(ConfigError), + /// Ошибка `serialport` при открытии или настройке устройства. + Serial(serialport::Error), + /// Ошибка записи или flush в транспорт. + Io(std::io::Error), + /// Координата находится вне геометрии дисплея. + InvalidCoordinate { + /// Запрошенная колонка в координатах от единицы. + x: u8, + /// Запрошенная строка в координатах от единицы. + y: u8, + /// Настроенное количество колонок дисплея. + columns: usize, + /// Настроенное количество строк дисплея. + rows: usize, + }, + /// Строка находится вне геометрии дисплея. + InvalidLine { + /// Запрошенная строка в координатах от единицы. + line: u8, + /// Настроенное количество строк дисплея. + rows: usize, + }, + /// Запрошенная яркость не поддерживается конфигурацией. + UnsupportedBrightness { + /// Запрошенный уровень яркости. + level: u8, + /// Минимальный поддерживаемый уровень. + min: u8, + /// Максимальный поддерживаемый уровень. + max: u8, + }, + /// Очередь фонового worker закрыта. + QueueClosed, + /// Worker остановлен до подтверждения команды. + WorkerStopped, + /// Фоновый поток завершился с panic. + WorkerPanicked, + /// Async task worker был отменён до завершения shutdown. + #[cfg(feature = "tokio")] + WorkerCancelled, +} + +impl std::fmt::Display for ConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::EmptyPortName => f.write_str("serial port name must not be empty"), + Self::ZeroBaudRate => f.write_str("baud rate must not be zero"), + Self::InvalidColumns(columns) => { + write!(f, "columns must be in 1..=255, got {columns}") + } + Self::InvalidRows(rows) => write!(f, "rows must be in 1..=255, got {rows}"), + Self::InvalidBrightnessRange { min, max } => { + write!( + f, + "brightness range must be ordered and non-zero, got {min}..={max}" + ) + } + Self::ZeroQueueCapacity => f.write_str("queue capacity must not be zero"), + } + } +} + +impl std::error::Error for ConfigError {} + +impl std::fmt::Display for VfdError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Config(error) => error.fmt(f), + Self::Serial(error) => error.fmt(f), + Self::Io(error) => error.fmt(f), + Self::InvalidCoordinate { + x, + y, + columns, + rows, + } => write!( + f, + "coordinate ({x}, {y}) is outside display geometry {columns}x{rows}" + ), + Self::InvalidLine { line, rows } => { + write!(f, "line {line} is outside display rows 1..={rows}") + } + Self::UnsupportedBrightness { level, min, max } => { + write!( + f, + "brightness {level} is outside supported range {min}..={max}" + ) + } + Self::QueueClosed => f.write_str("VFD worker queue is closed"), + Self::WorkerStopped => f.write_str("VFD worker stopped before acknowledging command"), + Self::WorkerPanicked => f.write_str("VFD worker thread panicked"), + #[cfg(feature = "tokio")] + Self::WorkerCancelled => f.write_str("VFD async worker task was cancelled"), + } + } +} + +impl std::error::Error for VfdError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Config(error) => Some(error), + Self::Serial(error) => Some(error), + Self::Io(error) => Some(error), + _ => None, + } + } +} + +impl From for VfdError { + fn from(value: ConfigError) -> Self { + Self::Config(value) + } +} + +impl From for VfdError { + fn from(value: serialport::Error) -> Self { + Self::Serial(value) + } +} + +impl From for VfdError { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} + +impl From> for VfdError { + fn from(_: mpsc::SendError) -> Self { + Self::QueueClosed + } +} + +impl From for VfdError { + fn from(_: mpsc::RecvError) -> Self { + Self::WorkerStopped + } +} + +/// Результат операций VFD. +pub type Result = std::result::Result; diff --git a/src/lib.rs b/src/lib.rs index 9404907..a3be0d7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,52 @@ -//! Библиотека управления двухстрочными VFD-дисплеями в Epson-совместимом режиме. +#![warn(missing_docs)] +//! ESC/POS-совместимая библиотека управления VFD-дисплеями покупателя. +//! +//! Библиотека отделяет настройки serial-порта от геометрии и протокола дисплея: +//! используйте готовый [`Preset`] для уже проверенного 20x2 CP866 устройства или +//! соберите [`VfdConfig`] вручную через [`SerialSettings`] и [`DisplaySettings`]. +//! +//! Sync API представлен низкоуровневым [`Vfd`] и фоновым [`VfdWorker`]. +//! Низкоуровневый драйвер пишет в транспорт сразу в вызывающем потоке, а worker +//! владеет транспортом в отдельном потоке и подтверждает команды после выполнения +//! записи. С feature `tokio` доступен модуль [`tokio`] с настоящим `AsyncWrite`, +//! bounded queue и async-подтверждениями после фактической записи в транспорт. +//! +//! Все публичные координаты задаются от единицы: первая колонка - `x = 1`, первая +//! строка - `line = 1` или `y = 1`. Размеры дисплея валидируются в диапазоне +//! `1..=255`, потому что ESC/POS-команды позиционирования передают координаты одним +//! байтом. Ошибки не скрываются: конфигурация, координаты, яркость, serial I/O и +//! состояние worker возвращаются разными вариантами [`VfdError`]. +//! +//! Основной путь для старого 20x2 CP866 дисплея: +//! +//! ```no_run +//! use escpos_vfd::{Preset, Vfd, VfdConfig}; +//! +//! # fn main() -> escpos_vfd::Result<()> { +//! let cfg = VfdConfig::preset("/dev/cu.usbmodem101", Preset::Epson20x2Cp866)?; +//! let mut vfd = Vfd::open(cfg)?; +//! vfd.print_line(1, "Привет")?; +//! # Ok(()) +//! # } +//! ``` -/// Низкоуровневая работа с serial-портом, кодировкой и командами дисплея. +/// Кодирование Epson/ESC/POS-команд. +pub mod codec; +/// Конфигурация serial-порта и дисплея. +pub mod config; +/// Типизированные ошибки библиотеки. +pub mod error; +/// Низкоуровневое соединение с serial-портом или произвольным транспортом. pub mod vfd; -/// Фоновый поток и потокобезопасный интерфейс для обновления дисплея. +/// Фоновый поток и потокобезопасный sync-интерфейс. pub mod worker; + +/// Tokio API поверх настоящего `AsyncWrite`. +#[cfg(feature = "tokio")] +pub mod tokio; + +pub use codec::{fit_to_width, sanitize_for_cp866, sanitize_text}; +pub use config::{DisplaySettings, Preset, SerialSettings, TextEncoding, VfdConfig}; +pub use error::{ConfigError, Result, VfdError}; +pub use vfd::Vfd; +pub use worker::{VfdHandle, VfdWorker}; diff --git a/src/tokio.rs b/src/tokio.rs new file mode 100644 index 0000000..2e30082 --- /dev/null +++ b/src/tokio.rs @@ -0,0 +1,975 @@ +//! Tokio API поверх настоящего `AsyncWrite`. +//! +//! Модуль доступен только с feature `tokio`. Он повторяет sync API, но использует +//! `tokio-serial`, `tokio::sync::mpsc`, `oneshot`-подтверждения и async sleep для +//! задержек яркости и бегущей строки. Низкоуровневый `AsyncVfd` работает с любым +//! `AsyncWrite`, поэтому тесты и нестандартные транспорты не требуют настоящего +//! serial-порта. + +use crate::codec::{EpsonCodec, changed_runs, fit_to_width, replace_cached_range, sanitize_text}; +use crate::config::{DisplaySettings, VfdConfig}; +use crate::error::{ConfigError, Result, VfdError}; +use ::tokio::io::{AsyncWrite, AsyncWriteExt}; +use ::tokio::sync::{mpsc, oneshot}; +use ::tokio::task::JoinHandle; +use ::tokio::time::{Duration, Instant, sleep, sleep_until}; +use serialport::SerialPortBuilder; +use tokio_serial::{SerialPortBuilderExt, SerialStream}; + +type Ack = oneshot::Sender>; + +enum Cmd { + Clear { + ack: Ack, + }, + PrintLine { + line: u8, + text: String, + ack: Ack, + }, + PrintLineDiff { + line: u8, + text: String, + ack: Ack, + }, + PrintAt { + x: u8, + y: u8, + text: String, + ack: Ack, + }, + WriteRaw { + bytes: Vec, + ack: Ack, + }, + SetMarqueeText { + text: String, + ack: Ack, + }, + StartMarquee { + line: u8, + cps: u32, + end_pause: Duration, + ack: Ack, + }, + StopMarquee { + ack: Ack, + }, + SetBrightness { + level: u8, + ack: Ack, + }, + Shutdown { + ack: Ack, + }, +} + +/// Async-драйвер VFD поверх `AsyncWrite`. +/// +/// Драйвер выполняет запись напрямую в текущем async task и не использует +/// `spawn_blocking`. Для сериализации команд из разных задач используйте +/// [`AsyncVfdWorker`]. Все координаты задаются от единицы. +pub struct AsyncVfd { + transport: T, + codec: EpsonCodec, +} + +impl AsyncVfd { + /// Открывает serial-порт через `tokio-serial` и инициализирует дисплей. + /// + /// Метод проверяет [`VfdConfig`], открывает serial-порт и отправляет init-команды + /// (`ESC @`, `ESC t n`) согласно [`DisplaySettings`]. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::Config`] при неверной конфигурации, + /// [`VfdError::Serial`] при ошибке открытия порта и [`VfdError::Io`] при ошибке + /// async-записи init-команд. + pub async fn open(cfg: VfdConfig) -> Result { + cfg.validate()?; + let serial = cfg.serial; + let display = cfg.display; + + let mut builder: SerialPortBuilder = tokio_serial::new(&serial.port_name, serial.baud_rate) + .data_bits(serial.data_bits) + .parity(serial.parity) + .stop_bits(serial.stop_bits) + .flow_control(serial.flow_control) + .timeout(serial.timeout); + #[cfg(unix)] + { + builder = builder.exclusive(serial.exclusive); + } + + let port = builder.open_native_async()?; + Self::from_transport(port, display).await + } +} + +impl AsyncVfd { + /// Создаёт async-драйвер поверх произвольного async-транспорта. + /// + /// Как и sync-вариант, метод отправляет init-последовательность сразу после проверки + /// [`DisplaySettings`]. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::Config`] для неверных настроек дисплея или + /// [`VfdError::Io`] при ошибке async-записи. + /// + /// # Примеры + /// + /// ``` + /// # #[cfg(feature = "tokio")] + /// # async fn demo() -> escpos_vfd::Result<()> { + /// use escpos_vfd::{DisplaySettings, TextEncoding}; + /// use escpos_vfd::tokio::AsyncVfd; + /// + /// let display = DisplaySettings::new(4, 1, TextEncoding::Ascii); + /// let mut vfd = AsyncVfd::from_transport(Vec::::new(), display).await?; + /// vfd.print_line(1, "OK").await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn from_transport(mut transport: T, display: DisplaySettings) -> Result { + display.validate()?; + let codec = EpsonCodec::new(display); + let init = codec.init(); + if !init.is_empty() { + transport.write_all(&init).await?; + } + Ok(Self { transport, codec }) + } + + /// Настройки дисплея. + pub fn display(&self) -> &DisplaySettings { + self.codec.display() + } + + /// Количество символов в строке. + pub fn columns(&self) -> usize { + self.display().columns + } + + /// Количество строк. + pub fn rows(&self) -> usize { + self.display().rows + } + + /// Очищает дисплей. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::Io`] при ошибке async-записи. + pub async fn clear(&mut self) -> Result<()> { + self.transport.write_all(&self.codec.clear()).await?; + Ok(()) + } + + /// Полностью перезаписывает строку. + /// + /// Текст санитизируется, обрезается и дополняется пробелами до ширины дисплея. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::InvalidLine`] или [`VfdError::Io`]. + pub async fn print_line(&mut self, line: u8, text: &str) -> Result<()> { + self.codec.validate_line(line)?; + self.goto_xy(1, line).await?; + let fitted = self.codec.fit_line(text); + self.write_text(&fitted).await + } + + /// Выводит подготовленный кадр. + /// + /// Метод не выполняет санацию текста. Используйте его для уже подготовленных кадров. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::InvalidLine`] или [`VfdError::Io`]. + pub async fn print_frame(&mut self, line: u8, frame: &str) -> Result<()> { + self.codec.validate_line(line)?; + self.goto_xy(1, line).await?; + let fitted = fit_to_width(frame, self.columns()); + self.write_text(&fitted).await + } + + /// Печатает текст с координаты `(x, y)`. + /// + /// Координаты задаются от единицы. Текст санитизируется и обрезается по правому краю + /// текущей строки. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::InvalidCoordinate`] или [`VfdError::Io`]. + pub async fn print_at(&mut self, x: u8, y: u8, text: &str) -> Result<()> { + let text = sanitize_text(text); + self.print_at_prepared(x, y, &text).await + } + + async fn print_at_prepared(&mut self, x: u8, y: u8, text: &str) -> Result<()> { + self.codec.validate_xy(x, y)?; + let remaining = self.columns() - usize::from(x) + 1; + let text: String = text.chars().take(remaining).collect(); + self.goto_xy(x, y).await?; + self.write_text(&text).await + } + + /// Записывает байты напрямую. + /// + /// Байты не кодируются и не интерпретируются библиотекой. Это escape hatch для + /// нестандартных ESC/POS-команд конкретного дисплея. + pub async fn write_raw(&mut self, bytes: &[u8]) -> Result<()> { + self.transport.write_all(bytes).await?; + Ok(()) + } + + /// Устанавливает яркость. + /// + /// После записи команды выполняется async `flush`, затем async sleep на + /// `display.brightness_settle`, если задержка не нулевая. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::UnsupportedBrightness`] или [`VfdError::Io`]. + pub async fn set_brightness(&mut self, level: u8) -> Result<()> { + let cmd = self.codec.brightness(level)?; + self.transport.write_all(&cmd).await?; + self.transport.flush().await?; + let settle = self.display().brightness_settle; + if !settle.is_zero() { + sleep(settle).await; + } + Ok(()) + } + + /// Flush async-транспорта. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::Io`], если внутренний транспорт не смог выполнить flush. + pub async fn flush(&mut self) -> Result<()> { + self.transport.flush().await?; + Ok(()) + } + + /// Возвращает внутренний транспорт. + /// + /// Метод потребляет драйвер и возвращает ownership транспорта вызывающему коду. + pub fn into_inner(self) -> T { + self.transport + } + + async fn goto_xy(&mut self, x: u8, y: u8) -> Result<()> { + let cmd = self.codec.goto_xy(x, y)?; + self.transport.write_all(&cmd).await?; + Ok(()) + } + + async fn write_text(&mut self, s: &str) -> Result<()> { + let bytes = self.codec.encode_text(s); + self.transport.write_all(&bytes).await?; + Ok(()) + } +} + +/// Async handle с bounded queue и подтверждением выполнения I/O. +/// +/// После того как команда принята очередью, отмена ожидающего future не отменяет уже +/// поставленную запись в устройство. Ошибка I/O возвращается через `Result`. +/// Клоны handle можно передавать в другие async tasks; backpressure создаёт `.await` +/// на отправке, когда очередь заполнена. +#[derive(Clone)] +pub struct AsyncVfdHandle { + tx: mpsc::Sender, +} + +impl AsyncVfdHandle { + /// Очищает дисплей. + /// + /// # Ошибки + /// + /// Возвращает ошибки очереди, остановленного worker-а или async I/O. + pub async fn clear(&self) -> Result<()> { + self.call(|ack| Cmd::Clear { ack }).await + } + + /// Устанавливает яркость. + /// + /// Future завершается после записи, flush и задержки `brightness_settle`. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::UnsupportedBrightness`], ошибки очереди или I/O. + pub async fn set_brightness(&self, level: u8) -> Result<()> { + self.call(|ack| Cmd::SetBrightness { level, ack }).await + } + + /// Полностью перезаписывает строку. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::InvalidLine`], ошибки очереди или I/O. + pub async fn print_line(&self, line: u8, text: impl Into) -> Result<()> { + let text = text.into(); + self.call(|ack| Cmd::PrintLine { line, text, ack }).await + } + + /// Обновляет только изменившиеся диапазоны строки. + /// + /// Worker хранит кэш строк и отправляет только изменившиеся смежные диапазоны. На + /// строке с активной marquee команда подтверждается без записи. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::InvalidLine`], ошибки очереди или I/O. + pub async fn print_line_diff(&self, line: u8, text: impl Into) -> Result<()> { + let text = text.into(); + self.call(|ack| Cmd::PrintLineDiff { line, text, ack }) + .await + } + + /// Печатает текст с координаты `(x, y)`. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::InvalidCoordinate`], ошибки очереди или I/O. + pub async fn print_at(&self, x: u8, y: u8, text: impl Into) -> Result<()> { + let text = text.into(); + self.call(|ack| Cmd::PrintAt { x, y, text, ack }).await + } + + /// Записывает байты напрямую. + /// + /// Raw-байты не обновляют строковый кэш worker. + /// + /// # Ошибки + /// + /// Возвращает ошибки очереди или [`VfdError::Io`]. + pub async fn write_raw(&self, bytes: impl Into>) -> Result<()> { + let bytes = bytes.into(); + self.call(|ack| Cmd::WriteRaw { bytes, ack }).await + } + + /// Заменяет текст бегущей строки. + /// + /// Если marquee уже активна, поток символов перестраивается сразу. Сам метод не + /// пишет кадр синхронно; запись произойдёт по таймеру worker-а. + /// + /// # Ошибки + /// + /// Возвращает ошибки очереди или остановленного worker-а. + pub async fn set_marquee_text(&self, text: impl Into) -> Result<()> { + let text = text.into(); + self.call(|ack| Cmd::SetMarqueeText { text, ack }).await + } + + /// Запускает бегущую строку. + /// + /// `cps` - скорость в символах в секунду; `0` приводится к `1`. `end_pause` задаёт + /// async-паузу в конце полного прохода текста. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::InvalidLine`] или ошибки очереди. + pub async fn start_marquee(&self, line: u8, cps: u32, end_pause: Duration) -> Result<()> { + self.call(|ack| Cmd::StartMarquee { + line, + cps, + end_pause, + ack, + }) + .await + } + + /// Останавливает бегущую строку. + /// + /// Останавливает таймер marquee. Видимый текст на дисплее не очищается автоматически. + /// + /// # Ошибки + /// + /// Возвращает ошибки очереди или остановленного worker-а. + pub async fn stop_marquee(&self) -> Result<()> { + self.call(|ack| Cmd::StopMarquee { ack }).await + } + + /// Завершает worker после ранее принятых команд. + /// + /// Чтобы дождаться завершения task и получить внутренний драйвер, используйте + /// [`AsyncVfdWorker::shutdown`]. + /// + /// # Ошибки + /// + /// Возвращает ошибки очереди или остановленного worker-а. + pub async fn shutdown(&self) -> Result<()> { + self.call(|ack| Cmd::Shutdown { ack }).await + } + + async fn call(&self, build: impl FnOnce(Ack) -> Cmd) -> Result<()> { + let (ack_tx, ack_rx) = oneshot::channel(); + self.tx + .send(build(ack_tx)) + .await + .map_err(|_| VfdError::QueueClosed)?; + ack_rx.await.map_err(|_| VfdError::WorkerStopped)? + } +} + +/// Владеет async task записи в дисплей. +/// +/// `AsyncVfdWorker` запускает одну задачу-писатель и возвращает клоны [`AsyncVfdHandle`] +/// для вызывающего кода. При `Drop` незавершённая задача отменяется без блокировки runtime. +pub struct AsyncVfdWorker { + handle: AsyncVfdHandle, + join: Option>>>, +} + +impl AsyncVfdWorker { + /// Открывает serial-порт и запускает async worker. + /// + /// # Ошибки + /// + /// Возвращает ошибки конфигурации, открытия serial-порта или init-записи из + /// [`AsyncVfd::open`]. + pub async fn start(cfg: VfdConfig) -> Result { + cfg.validate()?; + let capacity = cfg.queue_capacity; + let vfd = AsyncVfd::open(cfg).await?; + Self::from_vfd(vfd, capacity) + } +} + +impl AsyncVfdWorker { + /// Запускает worker поверх готового async-драйвера. + /// + /// Worker сначала очищает дисплей в своей task, затем обрабатывает очередь команд. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::Config`], если `queue_capacity = 0`. + pub fn from_vfd(vfd: AsyncVfd, queue_capacity: usize) -> Result { + if queue_capacity == 0 { + return Err(ConfigError::ZeroQueueCapacity.into()); + } + let (tx, rx) = mpsc::channel(queue_capacity); + let handle = AsyncVfdHandle { tx }; + let join = ::tokio::spawn(async move { writer_loop(vfd, rx).await }); + + Ok(Self { + handle, + join: Some(join), + }) + } + + /// Асинхронно создаёт worker поверх произвольного транспорта. + /// + /// Init-команды записываются до запуска worker task. + /// + /// # Ошибки + /// + /// Возвращает ошибки [`AsyncVfd::from_transport`] или + /// [`VfdError::Config`] при `queue_capacity = 0`. + pub async fn from_transport( + transport: T, + display: DisplaySettings, + queue_capacity: usize, + ) -> Result { + if queue_capacity == 0 { + return Err(ConfigError::ZeroQueueCapacity.into()); + } + let vfd = AsyncVfd::from_transport(transport, display).await?; + Self::from_vfd(vfd, queue_capacity) + } + + /// Возвращает handle. + /// + /// Клон можно передать в другие async tasks. Сам [`AsyncVfdWorker`] должен жить до + /// явного [`AsyncVfdWorker::shutdown`], иначе `Drop` отменит task. + pub fn handle(&self) -> AsyncVfdHandle { + self.handle.clone() + } + + /// Graceful shutdown с ожиданием task. + /// + /// Метод отправляет shutdown-команду, ждёт её подтверждения и затем ожидает join handle, + /// возвращая внутренний [`AsyncVfd`] вместе с транспортом. + /// + /// # Ошибки + /// + /// Возвращает ошибку отправки shutdown, последнюю ошибку worker-а или + /// [`VfdError::WorkerCancelled`], если task была отменена до завершения. + pub async fn shutdown(mut self) -> Result> { + let shutdown_result = self.handle.shutdown().await; + let worker_result = self + .join + .take() + .expect("join handle exists") + .await + .map_err(|_| VfdError::WorkerCancelled)?; + + let vfd = worker_result?; + shutdown_result?; + Ok(vfd) + } +} + +impl Drop for AsyncVfdWorker { + fn drop(&mut self) { + if let Some(join) = self.join.take() { + join.abort(); + } + } +} + +#[derive(Debug, Clone)] +struct MarqueeState { + active: bool, + line: u8, + cps: u32, + end_pause: Duration, + text: String, + stream: Vec, + offset: usize, + paused_until: Option, + next_step: Option, +} + +impl MarqueeState { + fn new() -> Self { + Self { + active: false, + line: 1, + cps: 5, + end_pause: Duration::from_millis(1500), + text: String::new(), + stream: Vec::new(), + offset: 0, + paused_until: None, + next_step: None, + } + } + + fn rebuild_stream(&mut self, width: usize) { + let text = sanitize_text(&self.text); + self.stream.clear(); + self.stream.reserve(width * 2 + text.chars().count()); + self.stream.extend(std::iter::repeat_n(' ', width)); + self.stream.extend(text.chars()); + self.stream.extend(std::iter::repeat_n(' ', width)); + self.offset = 0; + self.paused_until = None; + self.next_step = Some(Instant::now() + self.step_interval()); + } + + fn step_interval(&self) -> Duration { + let cps = u64::from(self.cps.max(1)); + Duration::from_nanos((1_000_000_000 / cps).max(1)) + } + + fn next_deadline(&self) -> Option { + if !self.active { + return None; + } + self.paused_until.or(self.next_step) + } +} + +async fn writer_loop( + mut vfd: AsyncVfd, + mut rx: mpsc::Receiver, +) -> Result> { + vfd.clear().await?; + let rows = vfd.rows(); + let mut marquee = MarqueeState::new(); + let mut last_lines = vec![String::new(); rows]; + + loop { + let event = match marquee.next_deadline() { + Some(deadline) => { + ::tokio::select! { + cmd = rx.recv() => match cmd { + Some(cmd) => WorkerEvent::Command(cmd), + None => WorkerEvent::Closed, + }, + _ = sleep_until(deadline) => WorkerEvent::Timer, + } + } + None => match rx.recv().await { + Some(cmd) => WorkerEvent::Command(cmd), + None => WorkerEvent::Closed, + }, + }; + + match event { + WorkerEvent::Command(cmd) => { + if handle_command(cmd, &mut vfd, &mut marquee, &mut last_lines).await? { + break; + } + } + WorkerEvent::Timer => { + render_marquee(&mut vfd, &mut marquee, &mut last_lines).await?; + } + WorkerEvent::Closed => break, + } + } + + Ok(vfd) +} + +enum WorkerEvent { + Command(Cmd), + Timer, + Closed, +} + +async fn handle_command( + cmd: Cmd, + vfd: &mut AsyncVfd, + marquee: &mut MarqueeState, + last_lines: &mut [String], +) -> Result { + let width = vfd.columns(); + let rows = vfd.rows(); + match cmd { + Cmd::Clear { ack } => { + let result = vfd.clear().await; + if result.is_ok() { + last_lines.fill(String::new()); + } + send_ack(ack, result); + } + Cmd::SetBrightness { level, ack } => send_ack(ack, vfd.set_brightness(level).await), + Cmd::PrintLine { line, text, ack } => { + let result = vfd.print_line(line, &text).await; + if result.is_ok() { + last_lines[(line - 1) as usize] = fit_to_width(&sanitize_text(&text), width); + } + send_ack(ack, result); + } + Cmd::PrintLineDiff { line, text, ack } => { + let result = print_line_diff(vfd, marquee, last_lines, line, &text).await; + send_ack(ack, result); + } + Cmd::PrintAt { x, y, text, ack } => { + let result = print_at_cached(vfd, marquee, last_lines, x, y, &text).await; + send_ack(ack, result); + } + Cmd::WriteRaw { bytes, ack } => send_ack(ack, vfd.write_raw(&bytes).await), + Cmd::SetMarqueeText { text, ack } => { + marquee.text = text; + if marquee.active { + marquee.rebuild_stream(width); + } + send_ack(ack, Ok(())); + } + Cmd::StartMarquee { + line, + cps, + end_pause, + ack, + } => { + let result = if line == 0 || usize::from(line) > rows { + Err(VfdError::InvalidLine { line, rows }) + } else { + last_lines[(line - 1) as usize].clear(); + marquee.active = true; + marquee.line = line; + marquee.cps = cps.max(1); + marquee.end_pause = end_pause; + marquee.rebuild_stream(width); + Ok(()) + }; + send_ack(ack, result); + } + Cmd::StopMarquee { ack } => { + if marquee.active && usize::from(marquee.line) <= last_lines.len() { + last_lines[(marquee.line - 1) as usize].clear(); + } + marquee.active = false; + marquee.paused_until = None; + marquee.next_step = None; + send_ack(ack, Ok(())); + } + Cmd::Shutdown { ack } => { + send_ack(ack, Ok(())); + return Ok(true); + } + } + Ok(false) +} + +async fn print_line_diff( + vfd: &mut AsyncVfd, + marquee: &MarqueeState, + last_lines: &mut [String], + line: u8, + text: &str, +) -> Result<()> { + if line == 0 || usize::from(line) > vfd.rows() { + return Err(VfdError::InvalidLine { + line, + rows: vfd.rows(), + }); + } + if marquee.active && marquee.line == line { + return Ok(()); + } + + let next = fit_to_width(&sanitize_text(text), vfd.columns()); + let idx = (line - 1) as usize; + if last_lines[idx] == next { + return Ok(()); + } + if last_lines[idx].is_empty() { + vfd.print_line(line, &next).await?; + last_lines[idx] = next; + return Ok(()); + } + for (x, text) in changed_runs(&last_lines[idx], &next) { + vfd.print_at_prepared(x, line, &text).await?; + } + last_lines[idx] = next; + Ok(()) +} + +async fn print_at_cached( + vfd: &mut AsyncVfd, + marquee: &MarqueeState, + last_lines: &mut [String], + x: u8, + y: u8, + text: &str, +) -> Result<()> { + if x == 0 || y == 0 || usize::from(x) > vfd.columns() || usize::from(y) > vfd.rows() { + return Err(VfdError::InvalidCoordinate { + x, + y, + columns: vfd.columns(), + rows: vfd.rows(), + }); + } + if marquee.active && marquee.line == y { + return Ok(()); + } + let remaining = vfd.columns() - usize::from(x) + 1; + let text: String = sanitize_text(text).chars().take(remaining).collect(); + vfd.print_at_prepared(x, y, &text).await?; + replace_cached_range(&mut last_lines[(y - 1) as usize], x, &text, vfd.columns()); + Ok(()) +} + +async fn render_marquee( + vfd: &mut AsyncVfd, + marquee: &mut MarqueeState, + last_lines: &mut [String], +) -> Result<()> { + if !marquee.active { + return Ok(()); + } + let now = Instant::now(); + if let Some(until) = marquee.paused_until { + if now < until { + return Ok(()); + } + marquee.paused_until = None; + marquee.next_step = Some(now + marquee.step_interval()); + return Ok(()); + } + if marquee.next_step.is_some_and(|deadline| now < deadline) { + return Ok(()); + } + + let width = vfd.columns(); + if marquee.stream.len() < width { + marquee.rebuild_stream(width); + } + let max_off = marquee.stream.len().saturating_sub(width); + let start = marquee.offset.min(max_off); + let end = (start + width).min(marquee.stream.len()); + let frame: String = marquee.stream[start..end].iter().collect(); + vfd.print_at_prepared(1, marquee.line, &frame).await?; + if usize::from(marquee.line) <= last_lines.len() { + last_lines[(marquee.line - 1) as usize] = frame; + } + + if marquee.offset >= max_off { + marquee.offset = 0; + marquee.paused_until = Some(now + marquee.end_pause); + marquee.next_step = None; + } else { + marquee.offset += 1; + marquee.next_step = Some(now + marquee.step_interval()); + } + Ok(()) +} + +fn send_ack(ack: Ack, result: Result<()>) { + let _ = ack.send(result); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{DisplaySettings, TextEncoding}; + use std::io; + use std::pin::Pin; + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }; + use std::task::{Context, Poll}; + + struct AsyncFailsAfterWrites { + writes_left: usize, + failed: Arc, + } + + impl AsyncWrite for AsyncFailsAfterWrites { + fn poll_write( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + if self.writes_left == 0 { + self.failed.store(true, Ordering::SeqCst); + return Poll::Ready(Err(io::Error::other("forced write failure"))); + } + self.writes_left -= 1; + Poll::Ready(Ok(buf.len())) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + } + + #[tokio::test] + async fn async_driver_writes_same_core_bytes() { + let display = DisplaySettings::new(6, 2, TextEncoding::Ascii); + let mut vfd = AsyncVfd::from_transport(Vec::::new(), display) + .await + .unwrap(); + + vfd.print_line(2, "abc").await.unwrap(); + vfd.write_raw(&[0xAA]).await.unwrap(); + + assert_eq!( + vfd.into_inner(), + vec![ + 0x1B, 0x40, 0x1F, 0x24, 1, 2, b'a', b'b', b'c', b' ', b' ', b' ', 0xAA + ] + ); + } + + #[tokio::test(start_paused = true)] + async fn async_worker_ack_and_marquee_scheduler() { + let display = DisplaySettings::new(5, 2, TextEncoding::Ascii); + let worker = AsyncVfdWorker::from_transport(Vec::::new(), display, 2) + .await + .unwrap(); + let handle = worker.handle(); + + handle.set_marquee_text("abc").await.unwrap(); + handle + .start_marquee(2, 10, Duration::from_millis(100)) + .await + .unwrap(); + ::tokio::time::advance(Duration::from_millis(100)).await; + ::tokio::task::yield_now().await; + handle.stop_marquee().await.unwrap(); + + let vfd = worker.shutdown().await.unwrap(); + assert!( + vfd.into_inner() + .windows(4) + .any(|window| window == [0x1F, 0x24, 1, 2]) + ); + } + + #[tokio::test] + async fn async_worker_rejects_zero_queue_capacity() { + let display = DisplaySettings::new(5, 2, TextEncoding::Ascii); + + assert!(matches!( + AsyncVfdWorker::from_transport(Vec::::new(), display, 0).await, + Err(VfdError::Config(ConfigError::ZeroQueueCapacity)) + )); + } + + #[tokio::test] + async fn async_worker_print_at_validates_coordinates_before_marquee_skip() { + let display = DisplaySettings::new(5, 2, TextEncoding::Ascii); + let worker = AsyncVfdWorker::from_transport(Vec::::new(), display, 2) + .await + .unwrap(); + let handle = worker.handle(); + + handle + .start_marquee(2, 10, Duration::from_millis(100)) + .await + .unwrap(); + + assert!(matches!( + handle.print_at(0, 2, "bad").await, + Err(VfdError::InvalidCoordinate { x: 0, y: 2, .. }) + )); + assert!(matches!( + handle.print_at(6, 2, "bad").await, + Err(VfdError::InvalidCoordinate { x: 6, y: 2, .. }) + )); + handle.print_at(1, 2, "skipped").await.unwrap(); + + worker.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn async_worker_shutdown_prefers_startup_io_error_over_closed_queue() { + let display = DisplaySettings::new(5, 2, TextEncoding::Ascii); + let failed = Arc::new(AtomicBool::new(false)); + let transport = AsyncFailsAfterWrites { + writes_left: 1, + failed: Arc::clone(&failed), + }; + let worker = AsyncVfdWorker::from_transport(transport, display, 2) + .await + .unwrap(); + + while !failed.load(Ordering::SeqCst) { + ::tokio::task::yield_now().await; + } + + assert!(matches!(worker.shutdown().await, Err(VfdError::Io(_)))); + } + + #[tokio::test(start_paused = true)] + async fn async_worker_exits_when_channel_closes_with_active_marquee() { + let display = DisplaySettings::new(5, 2, TextEncoding::Ascii); + let worker = AsyncVfdWorker::from_transport(Vec::::new(), display, 2) + .await + .unwrap(); + let handle = worker.handle(); + + handle.set_marquee_text("abc").await.unwrap(); + handle + .start_marquee(2, 10, Duration::from_millis(100)) + .await + .unwrap(); + drop(handle); + let mut worker = worker; + let join = worker.join.take().expect("join handle exists"); + drop(worker); + + let vfd = ::tokio::time::timeout(Duration::from_millis(1), async { + join.await.map_err(|_| VfdError::WorkerCancelled)? + }) + .await + .expect("closed worker channel should terminate task") + .unwrap(); + + assert_eq!(vfd.rows(), 2); + } +} diff --git a/src/vfd.rs b/src/vfd.rs index c554c43..4468ec9 100644 --- a/src/vfd.rs +++ b/src/vfd.rs @@ -1,203 +1,313 @@ -use anyhow::Result; -use encoding_rs::IBM866; +//! Синхронный низкоуровневый драйвер поверх любого [`std::io::Write`]. +//! +//! `Vfd` полезен, когда приложение само управляет потоками и хочет немедленно выполнять +//! команды на конкретном транспорте. Каждый метод выполняет запись до возврата из +//! функции. Для фоновой сериализации команд из разных частей приложения используйте +//! [`crate::VfdWorker`] или `escpos_vfd::tokio::AsyncVfdWorker`. + +use crate::codec::{EpsonCodec, fit_to_width, sanitize_text, truncate_chars}; +use crate::config::{DisplaySettings, VfdConfig}; +use crate::error::{Result, VfdError}; use serialport::SerialPort; use std::io::Write; -use std::time::Duration; -const TABLE_CYR: u8 = 6; // PD-2600: your Cyrillic table (ESC t 6) -const FIXED_BAUD: u32 = 9600; -const MAX_WIDTH: usize = u8::MAX as usize; - -#[derive(Debug, Clone)] -/// Настройки serial-подключения и геометрии VFD-дисплея. -pub struct VfdConfig { - /// Имя serial-порта, например `/dev/cu.usbmodem101`. - pub port_name: String, - /// Число символов в строке; ограничивается диапазоном `1..=255`. - pub width: usize, - /// Тайм-аут операций чтения и записи serial-порта. - pub timeout: Duration, +/// Низкоуровневое соединение с дисплеем, владеющее транспортом записи. +/// +/// Тип транспорта параметризован, поэтому в тестах можно использовать `Vec`, а в +/// приложении - serial-порт. Все координаты в публичных методах задаются от единицы. +/// Тип не синхронизирует доступ между потоками; если один дисплей используют несколько +/// producer-ов, берите [`crate::VfdWorker`]. +pub struct Vfd> { + transport: T, + codec: EpsonCodec, } -impl VfdConfig { - /// Создаёт конфигурацию с шириной 20 символов и тайм-аутом 100 мс. - pub fn new(port_name: impl Into) -> Self { - Self { - port_name: port_name.into(), - width: 20, - timeout: Duration::from_millis(100), - } - } - - /// Устанавливает ширину строки с учётом диапазона координат протокола. - pub fn with_width(mut self, width: usize) -> Self { - self.width = width.clamp(1, MAX_WIDTH); - self - } - - /// Устанавливает тайм-аут операций serial-порта. - pub fn with_timeout(mut self, timeout: Duration) -> Self { - self.timeout = timeout; - self - } -} - -/// Низкоуровневое соединение с дисплеем, владеющее serial-портом. -pub struct Vfd { - port: Box, - pub width: usize, -} - -impl Vfd { - /// Открывает serial-порт и инициализирует дисплей с таблицей CP866. +impl Vfd> { + /// Открывает serial-порт и инициализирует дисплей. + /// + /// Перед открытием выполняется полная проверка [`VfdConfig`]. После успешного + /// открытия драйвер отправляет команды инициализации из [`DisplaySettings`]: + /// опциональный `ESC @` и опциональный `ESC t n`. + /// + /// # Блокировка + /// + /// Метод блокирует текущий поток на открытии serial-порта и записи init-команд. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::Config`] при неверной конфигурации, + /// [`VfdError::Serial`] при ошибке открытия порта и [`VfdError::Io`] при ошибке + /// записи init-последовательности. pub fn open(cfg: VfdConfig) -> Result { - let mut port = serialport::new(cfg.port_name, FIXED_BAUD) - .timeout(cfg.timeout) - .open()?; + cfg.validate()?; + let serial = cfg.serial; + let display = cfg.display; - // ESC @ - port.write_all(&[0x1B, 0x40])?; - // ESC t 6 - port.write_all(&[0x1B, 0x74, TABLE_CYR])?; - - Ok(Self { - port, - width: cfg.width, - }) - } - - /// Очищает обе строки дисплея стандартной командой очистки. - pub fn clear(&mut self) -> std::io::Result<()> { - self.port.write_all(&[0x0C]) - } - - /// Полностью перезаписывает первую или вторую строку текстом фиксированной ширины. - pub fn print_line(&mut self, line: u8, text: &str) -> std::io::Result<()> { - if !(1..=2).contains(&line) { - return Ok(()); + let mut builder = serialport::new(&serial.port_name, serial.baud_rate) + .data_bits(serial.data_bits) + .parity(serial.parity) + .stop_bits(serial.stop_bits) + .flow_control(serial.flow_control) + .timeout(serial.timeout); + #[cfg(unix)] + { + builder = builder.exclusive(serial.exclusive); } - // Кадр фиксированной ширины сам перезаписывает остаток предыдущей строки. + + let port = builder.open()?; + Self::from_transport(port, display) + } +} + +impl Vfd { + /// Создаёт драйвер поверх произвольного транспорта. + /// + /// Метод сразу записывает init-последовательность в переданный транспорт. Это удобно + /// для mock transport в тестах и для нестандартных serial-обёрток. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::Config`], если настройки дисплея неверны, или + /// [`VfdError::Io`], если транспорт не принял init-байты. + /// + /// # Примеры + /// + /// ``` + /// use escpos_vfd::{DisplaySettings, TextEncoding, Vfd}; + /// + /// let display = DisplaySettings::new(4, 1, TextEncoding::Ascii); + /// let mut vfd = Vfd::from_transport(Vec::::new(), display)?; + /// vfd.print_line(1, "OK")?; + /// let bytes = vfd.into_inner(); + /// assert!(bytes.starts_with(&[0x1B, 0x40])); + /// # Ok::<(), escpos_vfd::VfdError>(()) + /// ``` + pub fn from_transport(mut transport: T, display: DisplaySettings) -> Result { + display.validate()?; + let codec = EpsonCodec::new(display); + let init = codec.init(); + if !init.is_empty() { + transport.write_all(&init)?; + } + Ok(Self { transport, codec }) + } + + /// Возвращает геометрию и настройки дисплея. + pub fn display(&self) -> &DisplaySettings { + self.codec.display() + } + + /// Количество символов в строке. + pub fn columns(&self) -> usize { + self.display().columns + } + + /// Количество строк дисплея. + pub fn rows(&self) -> usize { + self.display().rows + } + + /// Очищает дисплей стандартной командой очистки. + /// + /// Метод отправляет байт `0x0C`. Он не обновляет внешние кэши приложения и не + /// вызывает `flush`; при необходимости вызовите [`Vfd::flush`] явно. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::Io`] при ошибке записи. + pub fn clear(&mut self) -> Result<()> { + self.transport.write_all(&self.codec.clear())?; + Ok(()) + } + + /// Полностью перезаписывает строку текстом фиксированной ширины. + /// + /// Текст санитизируется, обрезается по числу символов и дополняется пробелами до + /// ширины дисплея, чтобы удалить остаток предыдущего содержимого строки. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::InvalidLine`], если `line` вне диапазона `1..=rows`, или + /// [`VfdError::Io`] при ошибке записи. + pub fn print_line(&mut self, line: u8, text: &str) -> Result<()> { + self.codec.validate_line(line)?; self.goto_xy(1, line)?; - let fitted = fit_to_width(&sanitize_for_cp866(text), self.width); - self.write_cp866(&fitted) + let fitted = self.codec.fit_line(text); + self.write_text(&fitted) } /// Выводит подготовленный кадр с начала строки без предварительной очистки. - pub fn print_frame(&mut self, line: u8, frame: &str) -> std::io::Result<()> { - if !(1..=2).contains(&line) { - // Некорректный номер строки игнорируется так же, как в остальных методах печати. - return Ok(()); - } - + /// + /// В отличие от [`Vfd::print_line`], метод не выполняет санацию текста. Используйте + /// его для заранее подготовленных кадров бегущей строки или тестовых байтовых + /// сценариев, когда содержимое уже нормализовано вызывающим кодом. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::InvalidLine`] или [`VfdError::Io`]. + pub fn print_frame(&mut self, line: u8, frame: &str) -> Result<()> { + self.codec.validate_line(line)?; self.goto_xy(1, line)?; - - // На случай короткого или длинного кадра приводим его к ширине дисплея. - let fitted = fit_to_width(frame, self.width); - self.write_cp866(&fitted) + let fitted = fit_to_width(frame, self.columns()); + self.write_text(&fitted) } /// Печатает текст с координаты `(x, y)`, выполняя санацию и обрезку по правому краю. - pub fn print_at(&mut self, x: u8, y: u8, text: &str) -> std::io::Result<()> { - let text = sanitize_for_cp866(text); + /// + /// Координаты задаются от единицы. Если текст длиннее оставшегося места в строке, + /// лишние символы отбрасываются, а следующая строка не затрагивается. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::InvalidCoordinate`] или [`VfdError::Io`]. + pub fn print_at(&mut self, x: u8, y: u8, text: &str) -> Result<()> { + let text = sanitize_text(text); self.print_at_prepared(x, y, &text) } /// Печатает уже подготовленный текст без повторной санации. - pub(crate) fn print_at_prepared(&mut self, x: u8, y: u8, text: &str) -> std::io::Result<()> { - if x == 0 || usize::from(x) > self.width || !(1..=2).contains(&y) { - return Ok(()); - } - - let remaining = self.width - usize::from(x) + 1; - let text = truncate_chars(text, remaining); + pub(crate) fn print_at_prepared(&mut self, x: u8, y: u8, text: &str) -> Result<()> { + self.codec.validate_xy(x, y)?; + let remaining = self.columns() - usize::from(x) + 1; + let text = truncate_chars(text, remaining).to_string(); self.goto_xy(x, y)?; - self.write_cp866(text) + self.write_text(&text) } - /// Перемещает аппаратный курсор в координаты дисплея, заданные от единицы. - fn goto_xy(&mut self, x: u8, y: u8) -> std::io::Result<()> { - // US $ x y - self.port.write_all(&[0x1F, 0x24, x, y]) + /// Записывает байты напрямую без кодировки и проверки содержимого. + /// + /// Используйте этот escape hatch только для команд, которых нет в типизированном API, + /// или для нестандартных таблиц символов конкретного устройства. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::Io`] при ошибке записи. + pub fn write_raw(&mut self, bytes: &[u8]) -> Result<()> { + self.transport.write_all(bytes)?; + Ok(()) } - /// Кодирует строку в CP866 и целиком записывает байты в serial-порт. - fn write_cp866(&mut self, s: &str) -> std::io::Result<()> { - let (bytes, _, _) = IBM866.encode(s); - self.port.write_all(&bytes) + /// Устанавливает яркость. + /// + /// Команда кодируется как `US X n` (`0x1F 0x58 level`). После записи выполняется + /// `flush`, затем sync sleep на `display.brightness_settle`, если задержка не нулевая. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::UnsupportedBrightness`], если уровень вне настроенного + /// диапазона или яркость отключена, и [`VfdError::Io`] при ошибке записи/flush. + pub fn set_brightness(&mut self, level: u8) -> Result<()> { + let cmd = self.codec.brightness(level)?; + self.transport.write_all(&cmd)?; + self.transport.flush()?; + let settle = self.display().brightness_settle; + if !settle.is_zero() { + std::thread::sleep(settle); + } + Ok(()) } - /// Устанавливает яркость; значение ограничивается диапазоном `1..=4`. - pub fn set_brightness(&mut self, n: u8) -> Result<()> { - let n = n.clamp(1, 4); - let cmd = [0x1F, 0x58, n]; // US 'X' n - self.port.write_all(&cmd)?; - self.port.flush()?; - std::thread::sleep(std::time::Duration::from_millis(2)); + + /// Завершает буферизированные записи транспорта. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::Io`], если внутренний транспорт не смог выполнить flush. + pub fn flush(&mut self) -> Result<()> { + self.transport.flush()?; + Ok(()) + } + + /// Возвращает внутренний транспорт. + /// + /// Метод потребляет драйвер. Это удобно в тестах, где внутренним транспортом служит + /// `Vec`, или при передаче ownership обратно вызывающему коду после shutdown. + pub fn into_inner(self) -> T { + self.transport + } + + fn goto_xy(&mut self, x: u8, y: u8) -> Result<()> { + let cmd = self.codec.goto_xy(x, y)?; + self.transport.write_all(&cmd)?; + Ok(()) + } + + fn write_text(&mut self, s: &str) -> Result<()> { + let bytes = self.codec.encode_text(s); + self.transport.write_all(&bytes)?; Ok(()) } } -/// Возвращает срез не длиннее заданного числа символов, не разрывая UTF-8. -fn truncate_chars(s: &str, max_chars: usize) -> &str { - s.char_indices() - .nth(max_chars) - .map_or(s, |(byte_index, _)| &s[..byte_index]) -} - -/// Заменяет типографские символы на безопасные аналоги, представимые в CP866. -pub fn sanitize_for_cp866(s: &str) -> String { - s.chars() - .map(|c| match c { - '…' => '.', // - '—' | '–' => '-', // - '№' => '#', // - '\t' => ' ', - '“' | '”' => '"', - '‘' | '’' => '\'', - _ => c, - }) - .collect() -} - -/// Обрезает строку по числу символов и дополняет пробелами до заданной ширины. -pub fn fit_to_width(s: &str, width: usize) -> String { - let mut out = String::with_capacity(width); - let mut len = 0; - for ch in s.chars().take(width) { - out.push(ch); - len += 1; +impl From for VfdError { + fn from(value: std::convert::Infallible) -> Self { + match value {} } - out.extend(std::iter::repeat_n(' ', width - len)); - out } #[cfg(test)] mod tests { use super::*; + use crate::config::{DisplaySettings, Preset, TextEncoding, VfdConfig}; #[test] - fn config_width_stays_within_protocol_coordinate_range() { - assert_eq!(VfdConfig::new("test").with_width(0).width, 1); - assert_eq!(VfdConfig::new("test").with_width(20).width, 20); - assert_eq!(VfdConfig::new("test").with_width(usize::MAX).width, 255); + fn mock_transport_gets_legacy_preset_bytes() { + let cfg = VfdConfig::preset("test", Preset::Epson20x2Cp866).unwrap(); + let mut vfd = Vfd::from_transport(Vec::::new(), cfg.display).unwrap(); + + vfd.clear().unwrap(); + vfd.print_line(1, "Привет").unwrap(); + vfd.print_at(20, 2, "X!").unwrap(); + vfd.set_brightness(4).unwrap(); + + let bytes = vfd.into_inner(); + let mut expected = vec![0x1B, 0x40, 0x1B, 0x74, 6, 0x0C]; + expected.extend_from_slice(&[0x1F, 0x24, 1, 1]); + expected.extend_from_slice(&encoding_rs::IBM866.encode("Привет ").0); + expected.extend_from_slice(&[0x1F, 0x24, 20, 2, b'X']); + expected.extend_from_slice(&[0x1F, 0x58, 4]); + + assert_eq!(bytes, expected); } #[test] - fn sanitizes_typographic_characters_without_changing_regular_text() { - assert_eq!(sanitize_for_cp866("№1\t“тест”—‘да’…"), "#1 \"тест\"-'да'."); - assert_eq!(sanitize_for_cp866("обычный text"), "обычный text"); + fn raw_write_bypasses_encoding() { + let display = DisplaySettings::new(20, 2, TextEncoding::Ascii); + let mut vfd = Vfd::from_transport(Vec::::new(), display).unwrap(); + + vfd.write_raw(&[0x1B, b'?', 0xFF]).unwrap(); + + assert_eq!(vfd.into_inner(), vec![0x1B, 0x40, 0x1B, b'?', 0xFF]); } #[test] - fn fits_unicode_by_characters_and_pads_short_input() { - assert_eq!(fit_to_width("Привет", 4), "Прив"); - assert_eq!(fit_to_width("да", 4), "да "); - assert_eq!(fit_to_width("text", 0), ""); + fn invalid_coordinates_and_brightness_are_errors() { + let display = DisplaySettings::new(20, 3, TextEncoding::Cp866); + let mut vfd = Vfd::from_transport(Vec::::new(), display).unwrap(); + + assert!(matches!( + vfd.print_line(4, "bad"), + Err(VfdError::InvalidLine { .. }) + )); + assert!(matches!( + vfd.print_at(21, 1, "bad"), + Err(VfdError::InvalidCoordinate { .. }) + )); + assert!(matches!( + vfd.set_brightness(5), + Err(VfdError::UnsupportedBrightness { .. }) + )); } #[test] - fn truncates_without_splitting_utf8_characters() { - assert_eq!(truncate_chars("ёжик", 3), "ёжи"); - assert_eq!(truncate_chars("ёжик", 10), "ёжик"); - assert_eq!(truncate_chars("ёжик", 0), ""); + fn alternate_encodings_are_selectable_manually() { + let display = DisplaySettings::new(4, 1, TextEncoding::Windows1251); + let mut vfd = Vfd::from_transport(Vec::::new(), display).unwrap(); + + vfd.print_line(1, "т").unwrap(); + + assert_eq!( + vfd.into_inner(), + vec![0x1B, 0x40, 0x1F, 0x24, 1, 1, 0xF2, b' ', b' ', b' '] + ); } } diff --git a/src/worker.rs b/src/worker.rs index 43c2b8d..665d7a8 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -1,149 +1,268 @@ -use crate::vfd::{Vfd, VfdConfig, sanitize_for_cp866}; -use anyhow::Result; -use std::sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - mpsc::{self, Receiver, Sender}, -}; +//! Синхронный worker для последовательной записи команд из bounded queue. +//! +//! Worker владеет [`crate::Vfd`] в отдельном потоке. Методы [`VfdHandle`] блокируются, +//! пока команда не будет записана в транспорт или пока worker не вернёт ошибку. +//! Очередь команд ограничена `queue_capacity`, поэтому быстрые producer-ы получают +//! backpressure вместо неограниченного роста памяти. + +use crate::codec::{changed_runs, fit_to_width, replace_cached_range, sanitize_text}; +use crate::config::{DisplaySettings, VfdConfig}; +use crate::error::{ConfigError, Result, VfdError}; +use crate::vfd::Vfd; +use serialport::SerialPort; +use std::io::Write; +use std::sync::mpsc::{self, Receiver, SyncSender}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; -#[derive(Debug, Clone)] -/// Команда, передаваемая фоновому потоку дисплея. -pub enum Cmd { - Clear, +type Ack = mpsc::SyncSender>; + +#[derive(Debug)] +enum Cmd { + Clear { + ack: Ack, + }, PrintLine { line: u8, text: String, + ack: Ack, }, - PrintLineDiff { line: u8, text: String, + ack: Ack, }, - PrintAt { x: u8, y: u8, text: String, + ack: Ack, + }, + WriteRaw { + bytes: Vec, + ack: Ack, }, - - // marquee control SetMarqueeText { text: String, + ack: Ack, }, - StartMarquee { line: u8, cps: u32, end_pause: Duration, + ack: Ack, + }, + StopMarquee { + ack: Ack, }, - - StopMarquee, - SetBrightness { - level: u8, // 1..4 + level: u8, + ack: Ack, + }, + Shutdown { + ack: Ack, }, - - // stop worker - Shutdown, } -#[derive(Clone)] /// Клонируемый потокобезопасный интерфейс отправки команд дисплею. +/// +/// Каждый вызов ставит команду в bounded queue и ждёт подтверждения от worker. Если +/// очередь заполнена, отправитель ждёт свободное место вместо бесконтрольного роста памяти. +/// Клон handle можно передавать в другие потоки, но порядок команд гарантируется только +/// порядком их фактического попадания в общую очередь. +#[derive(Clone)] pub struct VfdHandle { - tx: Sender, - stop: Arc, + tx: SyncSender, } impl VfdHandle { - /// Ставит в очередь очистку дисплея. - pub fn clear(&self) { - let _ = self.tx.send(Cmd::Clear); + /// Ставит в очередь очистку дисплея и ждёт выполнения I/O. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::QueueClosed`], если worker уже остановлен, + /// [`VfdError::WorkerStopped`], если worker завершился до подтверждения, или + /// ошибку команды, например [`VfdError::Io`]. + pub fn clear(&self) -> Result<()> { + self.call(|ack| Cmd::Clear { ack }) } - /// Ставит в очередь изменение яркости. - pub fn set_brightness(&self, level: u8) { - let _ = self.tx.send(Cmd::SetBrightness { level }); + /// Изменяет яркость и ждёт выполнения I/O. + /// + /// Метод возвращается после записи `US X n`, `flush` и задержки + /// `brightness_settle` внутри worker-потока. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::UnsupportedBrightness`], ошибки очереди или I/O. + pub fn set_brightness(&self, level: u8) -> Result<()> { + self.call(|ack| Cmd::SetBrightness { level, ack }) } - /// Ставит в очередь полную перезапись строки. - pub fn print_line(&self, line: u8, text: impl Into) -> anyhow::Result<()> { - self.tx.send(Cmd::PrintLine { - line, - text: text.into(), - })?; - Ok(()) + /// Полностью перезаписывает строку. + /// + /// Строка санитизируется, обрезается и дополняется пробелами до ширины дисплея. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::InvalidLine`], ошибки очереди или I/O. + pub fn print_line(&self, line: u8, text: impl Into) -> Result<()> { + let text = text.into(); + self.call(|ack| Cmd::PrintLine { line, text, ack }) } - /// Обновляет только изменившиеся диапазоны строки и уменьшает число serial-команд. - pub fn print_line_diff(&self, line: u8, text: impl Into) -> anyhow::Result<()> { - self.tx.send(Cmd::PrintLineDiff { - line, - text: text.into(), - })?; - Ok(()) + /// Обновляет только изменившиеся диапазоны строки. + /// + /// Worker хранит кэш последнего содержимого каждой строки. Если строка уже содержит + /// тот же текст, команда не пишет в устройство. Если изменилась часть строки, + /// отправляются только минимальные смежные диапазоны через позиционирование. + /// + /// Когда на той же строке активна бегущая строка, команда подтверждается без записи, + /// чтобы не ломать текущую анимацию. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::InvalidLine`], ошибки очереди или I/O. + pub fn print_line_diff(&self, line: u8, text: impl Into) -> Result<()> { + let text = text.into(); + self.call(|ack| Cmd::PrintLineDiff { line, text, ack }) } - /// Ставит в очередь печать текста с координаты `(x, y)`. - pub fn print_at(&self, x: u8, y: u8, text: impl Into) -> anyhow::Result<()> { - self.tx.send(Cmd::PrintAt { - x, - y, - text: text.into(), - })?; - Ok(()) + /// Печатает текст с координаты `(x, y)`. + /// + /// Координаты задаются от единицы. Worker обновляет кэш строки, поэтому следующие + /// [`VfdHandle::print_line_diff`] учитывают частичную запись. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::InvalidCoordinate`], ошибки очереди или I/O. + pub fn print_at(&self, x: u8, y: u8, text: impl Into) -> Result<()> { + let text = text.into(); + self.call(|ack| Cmd::PrintAt { x, y, text, ack }) } - /// Заменяет текст, используемый бегущей строкой. - pub fn set_marquee_text(&self, text: impl Into) { - let _ = self.tx.send(Cmd::SetMarqueeText { text: text.into() }); + /// Записывает байты напрямую. + /// + /// Байты не кодируются и не отражаются в строковом кэше worker. После raw-команд, + /// которые меняют видимый текст, лучше выполнить обычный `print_line` или `clear`, + /// чтобы синхронизировать кэш с дисплеем. + /// + /// # Ошибки + /// + /// Возвращает ошибки очереди или [`VfdError::Io`]. + pub fn write_raw(&self, bytes: impl Into>) -> Result<()> { + let bytes = bytes.into(); + self.call(|ack| Cmd::WriteRaw { bytes, ack }) } - /// Запускает бегущую строку на выбранной линии с заданной скоростью и паузой. - pub fn start_marquee(&self, line: u8, cps: u32, end_pause: Duration) { - let _ = self.tx.send(Cmd::StartMarquee { + /// Заменяет текст бегущей строки. + /// + /// Если marquee уже активна, поток символов перестраивается сразу. Метод сам ничего + /// не пишет в дисплей; следующий кадр будет записан по таймеру worker. + /// + /// # Ошибки + /// + /// Возвращает ошибки очереди, если worker закрыт или остановлен. + pub fn set_marquee_text(&self, text: impl Into) -> Result<()> { + let text = text.into(); + self.call(|ack| Cmd::SetMarqueeText { text, ack }) + } + + /// Запускает бегущую строку на выбранной линии. + /// + /// `cps` - скорость в символах в секунду. Значение `0` безопасно приводится к `1`, + /// чтобы таймер не схлопнулся в нулевой интервал. `end_pause` - пауза после полного + /// прохода текста перед следующим циклом. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::InvalidLine`], если строка вне дисплея, или ошибки очереди. + pub fn start_marquee(&self, line: u8, cps: u32, end_pause: Duration) -> Result<()> { + self.call(|ack| Cmd::StartMarquee { line, cps, end_pause, - }); + ack, + }) } /// Останавливает активную бегущую строку. - pub fn stop_marquee(&self) { - let _ = self.tx.send(Cmd::StopMarquee); + /// + /// Метод останавливает таймер marquee и сбрасывает кэш строки, на которой она была + /// активна. Видимый текст не очищается автоматически. + /// + /// # Ошибки + /// + /// Возвращает ошибки очереди, если worker закрыт или остановлен. + pub fn stop_marquee(&self) -> Result<()> { + self.call(|ack| Cmd::StopMarquee { ack }) } - /// Сигнализирует фоновому потоку о завершении работы. - pub fn shutdown(&self) { - self.stop.store(true, Ordering::Relaxed); - let _ = self.tx.send(Cmd::Shutdown); + /// Завершает worker после ранее принятых команд. + /// + /// Handle-метод только отправляет команду shutdown и ждёт подтверждения. Чтобы + /// дождаться завершения потока и вернуть внутренний [`crate::Vfd`], используйте + /// [`VfdWorker::shutdown`]. + /// + /// # Ошибки + /// + /// Возвращает ошибки очереди, если worker уже недоступен. + pub fn shutdown(&self) -> Result<()> { + self.call(|ack| Cmd::Shutdown { ack }) + } + + fn call(&self, build: impl FnOnce(Ack) -> Cmd) -> Result<()> { + let (ack_tx, ack_rx) = mpsc::sync_channel(1); + self.tx.send(build(ack_tx))?; + ack_rx.recv()? } } -/// Владеет фоновым потоком записи и дожидается его завершения при удалении. -pub struct VfdWorker { +/// Владеет фоновым потоком записи. +/// +/// Предпочитайте явный [`VfdWorker::shutdown`], чтобы дождаться завершения потока и +/// вернуть внутренний драйвер. `Drop` предназначен только для аварийного закрытия +/// забытых worker-ов и игнорирует ошибку завершения. +pub struct VfdWorker> { handle: VfdHandle, - join: Option>, + join: Option>>>, } -impl VfdWorker { +impl VfdWorker> { /// Открывает дисплей и запускает поток последовательной обработки команд. + /// + /// # Блокировка + /// + /// Метод блокирует текущий поток на открытии serial-порта и init-записи, затем + /// создаёт отдельный поток writer-а. + /// + /// # Ошибки + /// + /// Возвращает ошибки конфигурации, serial open или init I/O из [`crate::Vfd::open`]. pub fn start(cfg: VfdConfig) -> Result { + cfg.validate()?; + let capacity = cfg.queue_capacity; let vfd = Vfd::open(cfg)?; - let (tx, rx) = mpsc::channel::(); - let stop = Arc::new(AtomicBool::new(false)); + Self::from_vfd(vfd, capacity) + } +} - let handle = VfdHandle { - tx: tx.clone(), - stop: stop.clone(), - }; - - let join = thread::spawn(move || { - if let Err(e) = writer_loop(vfd, rx, stop) { - eprintln!("[vfd] writer loop error: {e:#}"); - } - }); +impl VfdWorker { + /// Запускает worker поверх уже созданного драйвера. + /// + /// Worker сначала очищает дисплей, затем начинает принимать команды. Ошибка первой + /// очистки завершит поток и вернётся ожидающему `shutdown`. + /// + /// # Ошибки + /// + /// Возвращает [`VfdError::Config`], если `queue_capacity = 0`. + pub fn from_vfd(vfd: Vfd, queue_capacity: usize) -> Result { + if queue_capacity == 0 { + return Err(ConfigError::ZeroQueueCapacity.into()); + } + let (tx, rx) = mpsc::sync_channel::(queue_capacity); + let handle = VfdHandle { tx }; + let join = thread::spawn(move || writer_loop(vfd, rx)); Ok(Self { handle, @@ -151,17 +270,62 @@ impl VfdWorker { }) } + /// Запускает worker поверх произвольного транспорта. + /// + /// Метод удобен для тестов и интеграций с собственным транспортом. Init-команды + /// записываются до запуска worker-потока. + /// + /// # Ошибки + /// + /// Возвращает ошибки [`crate::Vfd::from_transport`] или + /// [`VfdError::Config`] при `queue_capacity = 0`. + pub fn from_transport( + transport: T, + display: DisplaySettings, + queue_capacity: usize, + ) -> Result { + if queue_capacity == 0 { + return Err(ConfigError::ZeroQueueCapacity.into()); + } + let vfd = Vfd::from_transport(transport, display)?; + Self::from_vfd(vfd, queue_capacity) + } + /// Возвращает новый клон интерфейса отправки команд. + /// + /// Клон можно хранить отдельно от [`VfdWorker`]. Сам worker всё равно должен жить + /// дольше handle-ов, иначе команды начнут возвращать ошибки очереди. pub fn handle(&self) -> VfdHandle { self.handle.clone() } + + /// Корректно останавливает worker и возвращает драйвер с транспортом. + /// + /// Shutdown-команда обрабатывается после ранее принятых команд. После этого очередь + /// больше не используется, а join result возвращает накопленную ошибку worker-а. + /// + /// # Ошибки + /// + /// Возвращает ошибку отправки shutdown, последнюю ошибку worker-а или + /// [`VfdError::WorkerPanicked`], если поток завершился panic-ом. + pub fn shutdown(mut self) -> Result> { + let shutdown_result = self.handle.shutdown(); + let worker_result = self + .join + .take() + .expect("join handle exists") + .join() + .map_err(|_| VfdError::WorkerPanicked)?; + + let vfd = worker_result?; + shutdown_result?; + Ok(vfd) + } } -impl Drop for VfdWorker { - /// Корректно останавливает фоновый поток при выходе владельца из области видимости. +impl Drop for VfdWorker { fn drop(&mut self) { - // Сначала посылаем сигнал остановки, затем дожидаемся завершения потока. - self.handle.shutdown(); + let _ = self.handle.shutdown(); if let Some(j) = self.join.take() { let _ = j.join(); } @@ -175,16 +339,13 @@ struct MarqueeState { cps: u32, end_pause: Duration, text: String, - - // runtime stream: Vec, offset: usize, - last_step: Instant, paused_until: Option, + next_step: Option, } impl MarqueeState { - /// Создаёт неактивное состояние бегущей строки со значениями по умолчанию. fn new() -> Self { Self { active: false, @@ -194,14 +355,13 @@ impl MarqueeState { text: String::new(), stream: Vec::new(), offset: 0, - last_step: Instant::now(), paused_until: None, + next_step: None, } } - /// Перестраивает поток символов с пустыми полями до и после текста. fn rebuild_stream(&mut self, width: usize) { - let text = sanitize_for_cp866(&self.text); + let text = sanitize_text(&self.text); self.stream.clear(); self.stream.reserve(width * 2 + text.chars().count()); self.stream.extend(std::iter::repeat_n(' ', width)); @@ -209,259 +369,350 @@ impl MarqueeState { self.stream.extend(std::iter::repeat_n(' ', width)); self.offset = 0; self.paused_until = None; - self.last_step = Instant::now(); + self.next_step = Some(Instant::now() + self.step_interval()); } - /// Рассчитывает ненулевой интервал между сдвигами из скорости в символах в секунду. fn step_interval(&self) -> Duration { let cps = u64::from(self.cps.max(1)); Duration::from_nanos((1_000_000_000 / cps).max(1)) } -} -/// Группирует соседние изменившиеся символы в минимальное число диапазонов записи. -fn changed_runs(current: &str, next: &str) -> Vec<(u8, String)> { - let mut runs = Vec::new(); - let mut run_start = None; - let mut run_text = String::new(); - - for (index, (old, new)) in current.chars().zip(next.chars()).enumerate() { - if old != new { - run_start.get_or_insert((index + 1) as u8); - run_text.push(new); - } else if let Some(start) = run_start.take() { - runs.push((start, std::mem::take(&mut run_text))); + fn next_deadline(&self) -> Option { + if !self.active { + return None; } + self.paused_until.or(self.next_step) } - - if let Some(start) = run_start { - runs.push((start, run_text)); - } - - runs } -/// Обновляет фрагмент кэшированной строки с учётом Unicode и правой границы. -fn replace_cached_range(line: &mut String, x: u8, text: &str, width: usize) { - if x == 0 || usize::from(x) > width || text.is_empty() { - return; - } - - let mut chars: Vec = line.chars().take(width).collect(); - chars.resize(width, ' '); - let start = usize::from(x) - 1; - for (slot, ch) in chars[start..].iter_mut().zip(text.chars()) { - *slot = ch; - } - - line.clear(); - line.extend(chars); +fn send_ack(ack: Ack, result: Result<()>) { + let _ = ack.send(result); } -/// Последовательно обрабатывает команды и по таймеру формирует кадры бегущей строки. -fn writer_loop(mut vfd: Vfd, rx: Receiver, stop: Arc) -> Result<()> { - // optional initial clear: - let _ = vfd.clear(); +fn writer_loop(mut vfd: Vfd, rx: Receiver) -> Result> { + vfd.clear()?; + let rows = vfd.rows(); let mut marquee = MarqueeState::new(); - let mut last_lines = [String::new(), String::new()]; // line 1..2, fixed-width - let width = vfd.width; + let mut last_lines = vec![String::new(); rows]; - let normalize_line = - |text: &str| -> String { crate::vfd::fit_to_width(&sanitize_for_cp866(text), width) }; + loop { + let timeout = marquee + .next_deadline() + .map(|deadline| deadline.saturating_duration_since(Instant::now())); - let tick = Duration::from_millis(20); // internal scheduler tick + let command = match timeout { + Some(delay) => match rx.recv_timeout(delay) { + Ok(cmd) => Some(cmd), + Err(mpsc::RecvTimeoutError::Timeout) => None, + Err(mpsc::RecvTimeoutError::Disconnected) => break, + }, + None => match rx.recv() { + Ok(cmd) => Some(cmd), + Err(_) => break, + }, + }; - while !stop.load(Ordering::Relaxed) { - // 1) Drain commands (non-blocking) - loop { - match rx.try_recv() { - Ok(cmd) => match cmd { - Cmd::Clear => { - let _ = vfd.clear(); - last_lines = [String::new(), String::new()]; - } - Cmd::SetBrightness { level } => { - let _ = vfd.set_brightness(level); - } - Cmd::PrintLine { line, text } => { - let _ = vfd.print_line(line, &text); - - if (1..=2).contains(&line) { - let idx = (line - 1) as usize; - last_lines[idx] = normalize_line(&text); - } - } - Cmd::PrintLineDiff { line, text } => { - // конфликт с marquee - if !(1..=2).contains(&line) { - continue; - } - if marquee.active && marquee.line == line { - continue; - } - - let next = normalize_line(&text); - let idx = (line - 1) as usize; - - // если строка не поменялась — ничего не делаем - if last_lines[idx] == next { - continue; - } - - // первый кадр (или после clear) — лучше один раз вывести целиком - if last_lines[idx].is_empty() { - let _ = vfd.print_line(line, &next); - last_lines[idx] = next; - continue; - } - - for (x, text) in changed_runs(&last_lines[idx], &next) { - let _ = vfd.print_at_prepared(x, line, &text); - } - - last_lines[idx] = next; - } - Cmd::PrintAt { x, y, text } => { - // базовая валидация координат - if x == 0 || usize::from(x) > width { - continue; - } - - if !(1..=2).contains(&y) { - continue; - } - - // если marquee активен и пишет в эту строку — игнорируем, иначе будет “драка” - if marquee.active && marquee.line == y { - continue; - } - - let remaining = width - usize::from(x) + 1; - let text: String = - sanitize_for_cp866(&text).chars().take(remaining).collect(); - let _ = vfd.print_at_prepared(x, y, &text); - - let idx = (y - 1) as usize; - replace_cached_range(&mut last_lines[idx], x, &text, width); - } - Cmd::SetMarqueeText { text } => { - marquee.text = text; - if marquee.active { - marquee.rebuild_stream(width); - } - } - Cmd::StartMarquee { - line, - cps, - end_pause, - } => { - let line = if (1..=2).contains(&line) { line } else { 1 }; - - last_lines[(line - 1) as usize].clear(); - marquee.active = true; - marquee.line = line; - marquee.cps = cps.max(1); - marquee.end_pause = end_pause; - marquee.rebuild_stream(width); - } - Cmd::StopMarquee => { - if (1..=2).contains(&marquee.line) { - last_lines[(marquee.line - 1) as usize].clear(); - } - marquee.active = false; - marquee.paused_until = None; - } - Cmd::Shutdown => { - stop.store(true, Ordering::Relaxed); - } - }, - Err(std::sync::mpsc::TryRecvError::Empty) => break, - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - // All senders dropped => exit thread cleanly - stop.store(true, Ordering::Relaxed); - break; - } + if let Some(cmd) = command { + if handle_command(cmd, &mut vfd, &mut marquee, &mut last_lines)? { + break; } + } else { + render_marquee(&mut vfd, &mut marquee, &mut last_lines)?; } - - // 2) Render marquee if active - if marquee.active { - let now = Instant::now(); - - if let Some(until) = marquee.paused_until { - if now >= until { - marquee.paused_until = None; - marquee.last_step = now; - } - } else if now.duration_since(marquee.last_step) >= marquee.step_interval() { - marquee.last_step = now; - - if marquee.stream.len() >= width { - let max_off = marquee.stream.len() - width; - - let start = marquee.offset.min(max_off); - let end = (start + width).min(marquee.stream.len()); - - let frame: String = marquee.stream[start..end].iter().collect(); - let _ = vfd.print_at_prepared(1, marquee.line, &frame); - - if (1..=2).contains(&marquee.line) { - last_lines[(marquee.line - 1) as usize] = frame; - } - - if marquee.offset >= max_off { - marquee.offset = 0; - marquee.paused_until = Some(now + marquee.end_pause); - } else { - marquee.offset += 1; - } - } - } - } - - thread::sleep(tick); } + Ok(vfd) +} + +fn handle_command( + cmd: Cmd, + vfd: &mut Vfd, + marquee: &mut MarqueeState, + last_lines: &mut [String], +) -> Result { + let width = vfd.columns(); + let rows = vfd.rows(); + match cmd { + Cmd::Clear { ack } => { + let result = vfd.clear(); + if result.is_ok() { + last_lines.fill(String::new()); + } + send_ack(ack, result); + } + Cmd::SetBrightness { level, ack } => { + send_ack(ack, vfd.set_brightness(level)); + } + Cmd::PrintLine { line, text, ack } => { + let result = vfd.print_line(line, &text); + if result.is_ok() { + last_lines[(line - 1) as usize] = fit_to_width(&sanitize_text(&text), width); + } + send_ack(ack, result); + } + Cmd::PrintLineDiff { line, text, ack } => { + let result = print_line_diff(vfd, marquee, last_lines, line, &text); + send_ack(ack, result); + } + Cmd::PrintAt { x, y, text, ack } => { + let result = print_at_cached(vfd, marquee, last_lines, x, y, &text); + send_ack(ack, result); + } + Cmd::WriteRaw { bytes, ack } => { + send_ack(ack, vfd.write_raw(&bytes)); + } + Cmd::SetMarqueeText { text, ack } => { + marquee.text = text; + if marquee.active { + marquee.rebuild_stream(width); + } + send_ack(ack, Ok(())); + } + Cmd::StartMarquee { + line, + cps, + end_pause, + ack, + } => { + let result = if line == 0 || usize::from(line) > rows { + Err(VfdError::InvalidLine { line, rows }) + } else { + last_lines[(line - 1) as usize].clear(); + marquee.active = true; + marquee.line = line; + marquee.cps = cps.max(1); + marquee.end_pause = end_pause; + marquee.rebuild_stream(width); + Ok(()) + }; + send_ack(ack, result); + } + Cmd::StopMarquee { ack } => { + if marquee.active && usize::from(marquee.line) <= last_lines.len() { + last_lines[(marquee.line - 1) as usize].clear(); + } + marquee.active = false; + marquee.paused_until = None; + marquee.next_step = None; + send_ack(ack, Ok(())); + } + Cmd::Shutdown { ack } => { + send_ack(ack, Ok(())); + return Ok(true); + } + } + Ok(false) +} + +fn print_line_diff( + vfd: &mut Vfd, + marquee: &MarqueeState, + last_lines: &mut [String], + line: u8, + text: &str, +) -> Result<()> { + if line == 0 || usize::from(line) > vfd.rows() { + return Err(VfdError::InvalidLine { + line, + rows: vfd.rows(), + }); + } + if marquee.active && marquee.line == line { + return Ok(()); + } + + let next = fit_to_width(&sanitize_text(text), vfd.columns()); + let idx = (line - 1) as usize; + if last_lines[idx] == next { + return Ok(()); + } + if last_lines[idx].is_empty() { + vfd.print_line(line, &next)?; + last_lines[idx] = next; + return Ok(()); + } + for (x, text) in changed_runs(&last_lines[idx], &next) { + vfd.print_at_prepared(x, line, &text)?; + } + last_lines[idx] = next; + Ok(()) +} + +fn print_at_cached( + vfd: &mut Vfd, + marquee: &MarqueeState, + last_lines: &mut [String], + x: u8, + y: u8, + text: &str, +) -> Result<()> { + if x == 0 || y == 0 || usize::from(x) > vfd.columns() || usize::from(y) > vfd.rows() { + return Err(VfdError::InvalidCoordinate { + x, + y, + columns: vfd.columns(), + rows: vfd.rows(), + }); + } + if marquee.active && marquee.line == y { + return Ok(()); + } + + let remaining = vfd.columns() - usize::from(x) + 1; + let text: String = sanitize_text(text).chars().take(remaining).collect(); + vfd.print_at_prepared(x, y, &text)?; + replace_cached_range(&mut last_lines[(y - 1) as usize], x, &text, vfd.columns()); + Ok(()) +} + +fn render_marquee( + vfd: &mut Vfd, + marquee: &mut MarqueeState, + last_lines: &mut [String], +) -> Result<()> { + if !marquee.active { + return Ok(()); + } + let now = Instant::now(); + if let Some(until) = marquee.paused_until { + if now < until { + return Ok(()); + } + marquee.paused_until = None; + marquee.next_step = Some(now + marquee.step_interval()); + return Ok(()); + } + if marquee.next_step.is_some_and(|deadline| now < deadline) { + return Ok(()); + } + + let width = vfd.columns(); + if marquee.stream.len() < width { + marquee.rebuild_stream(width); + } + let max_off = marquee.stream.len().saturating_sub(width); + let start = marquee.offset.min(max_off); + let end = (start + width).min(marquee.stream.len()); + let frame: String = marquee.stream[start..end].iter().collect(); + + vfd.print_at_prepared(1, marquee.line, &frame)?; + if usize::from(marquee.line) <= last_lines.len() { + last_lines[(marquee.line - 1) as usize] = frame; + } + + if marquee.offset >= max_off { + marquee.offset = 0; + marquee.paused_until = Some(now + marquee.end_pause); + marquee.next_step = None; + } else { + marquee.offset += 1; + marquee.next_step = Some(now + marquee.step_interval()); + } Ok(()) } #[cfg(test)] mod tests { use super::*; + use crate::config::{DisplaySettings, TextEncoding}; + use std::io; + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }; - #[test] - fn cache_update_replaces_the_complete_fragment() { - let mut line = "Temp: 00.00C ".to_string(); - - replace_cached_range(&mut line, 7, "21.50", 20); - - assert_eq!(line, "Temp: 21.50C "); + struct FailsAfterWrites { + writes_left: usize, + failed: Arc, } - #[test] - fn cache_update_handles_unicode_and_clips_at_the_right_edge() { - let mut line = String::new(); + impl Write for FailsAfterWrites { + fn write(&mut self, buf: &[u8]) -> io::Result { + if self.writes_left == 0 { + self.failed.store(true, Ordering::SeqCst); + return Err(io::Error::other("forced write failure")); + } + self.writes_left -= 1; + Ok(buf.len()) + } - replace_cached_range(&mut line, 3, "ёжик", 5); - - assert_eq!(line, " ёжи"); - } - - #[test] - fn diff_groups_adjacent_changes_into_minimal_runs() { - assert_eq!( - changed_runs("abcd efgh", "abXY eZZh"), - vec![(3, "XY".to_string()), (7, "ZZ".to_string())] - ); - assert!(changed_runs("без перемен", "без перемен").is_empty()); + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } } #[test] fn marquee_interval_never_collapses_to_zero() { let mut marquee = MarqueeState::new(); marquee.cps = u32::MAX; - assert!(!marquee.step_interval().is_zero()); } + + #[test] + fn worker_returns_io_ack_and_dynamic_rows() { + let display = DisplaySettings::new(8, 3, TextEncoding::Ascii); + let worker = VfdWorker::from_transport(Vec::::new(), display, 2).unwrap(); + let handle = worker.handle(); + + handle.print_line(3, "abc").unwrap(); + assert!(matches!( + handle.print_line(4, "bad"), + Err(VfdError::InvalidLine { .. }) + )); + + let vfd = worker.shutdown().unwrap(); + let bytes = vfd.into_inner(); + assert_eq!(&bytes[..2], &[0x1B, 0x40]); + assert!(bytes.windows(4).any(|window| window == [0x1F, 0x24, 1, 3])); + } + + #[test] + fn worker_rejects_zero_queue_capacity() { + let display = DisplaySettings::new(8, 2, TextEncoding::Ascii); + assert!(matches!( + VfdWorker::from_transport(Vec::::new(), display, 0), + Err(VfdError::Config(ConfigError::ZeroQueueCapacity)) + )); + } + + #[test] + fn worker_print_at_validates_coordinates_before_marquee_skip() { + let display = DisplaySettings::new(5, 2, TextEncoding::Ascii); + let worker = VfdWorker::from_transport(Vec::::new(), display, 2).unwrap(); + let handle = worker.handle(); + + handle + .start_marquee(2, 10, Duration::from_millis(100)) + .unwrap(); + + assert!(matches!( + handle.print_at(0, 2, "bad"), + Err(VfdError::InvalidCoordinate { x: 0, y: 2, .. }) + )); + assert!(matches!( + handle.print_at(6, 2, "bad"), + Err(VfdError::InvalidCoordinate { x: 6, y: 2, .. }) + )); + handle.print_at(1, 2, "skipped").unwrap(); + + worker.shutdown().unwrap(); + } + + #[test] + fn worker_shutdown_prefers_startup_io_error_over_closed_queue() { + let display = DisplaySettings::new(5, 2, TextEncoding::Ascii); + let failed = Arc::new(AtomicBool::new(false)); + let transport = FailsAfterWrites { + writes_left: 1, + failed: Arc::clone(&failed), + }; + let worker = VfdWorker::from_transport(transport, display, 2).unwrap(); + + while !failed.load(Ordering::SeqCst) { + thread::yield_now(); + } + + assert!(matches!(worker.shutdown(), Err(VfdError::Io(_)))); + } } diff --git a/taskfile.yml b/taskfile.yml index ac6ab01..10d8cd8 100644 --- a/taskfile.yml +++ b/taskfile.yml @@ -5,6 +5,21 @@ vars: VFD_WIDTH: "20" tasks: + vfd:preset: + desc: Run minimal preset sync example + cmds: + - cargo run --example preset_sync -- {{.VFD_PORT}} + + vfd:manual: + desc: Run minimal manual sync configuration example + cmds: + - cargo run --example manual_sync -- {{.VFD_PORT}} + + vfd:tokio: + desc: Run Tokio worker example + cmds: + - cargo run --features tokio --example tokio_worker -- {{.VFD_PORT}} {{.VFD_WIDTH}} + vfd:clock: desc: Run VFD clock example cmds: @@ -18,7 +33,7 @@ tasks: - cargo run --example marquee -- {{.VFD_PORT}} {{.VFD_WIDTH}} {{.VFD_CPS | default "8"}} {{.VFD_END_PAUSE_MS | default "1500"}} {{.VFD_BRIGHTNESS | default "2"}} vars: VFD_CPS: "8" - VFD_END_PAUSE_MS: "1000" + VFD_END_PAUSE_MS: "1500" VFD_BRIGHTNESS: "3" vfd:brightness: