From 244d907cc21fa04b2f7c3cbd023dace2b24e1b15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=B1=D0=B5=D0=BB=D0=B5=D0=B2=20=D0=90=D0=BD?= =?UTF-8?q?=D0=B4=D1=80=D0=B5=D0=B9=20=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B5?= =?UTF-8?q?=D0=B2=D0=B8=D1=87?= Date: Sun, 16 Aug 2026 11:24:20 +0500 Subject: [PATCH] Rename crate to escpos-vfd and add tokio support Rename crate to escpos-vfd and add tokio support Introduce a configurable crate-ready API with presets, typed errors, optional Tokio async support, and expanded documentation. --- CHANGELOG.md | 33 ++ Cargo.lock | 170 ++++++- Cargo.toml | 34 +- LICENSE-APACHE | 201 ++++++++ LICENSE-MIT | 21 + README.md | 442 +++++++++++------- docs/display.jpg | Bin 0 -> 160767 bytes examples/blink.rs | 41 +- examples/brightness.rs | 38 +- examples/clock.rs | 51 +- examples/common/mod.rs | 49 +- examples/manual_sync.rs | 49 ++ examples/marquee.rs | 42 +- examples/position.rs | 46 +- examples/preset_sync.rs | 33 ++ examples/tokio_worker.rs | 34 ++ examples/update_at.rs | 58 ++- src/codec.rs | 343 ++++++++++++++ src/config.rs | 405 ++++++++++++++++ src/error.rs | 184 ++++++++ src/lib.rs | 52 ++- src/tokio.rs | 975 +++++++++++++++++++++++++++++++++++++++ src/vfd.rs | 418 ++++++++++------- src/worker.rs | 869 +++++++++++++++++++++------------- taskfile.yml | 17 +- 25 files changed, 3823 insertions(+), 782 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 LICENSE-APACHE create mode 100644 LICENSE-MIT create mode 100644 docs/display.jpg create mode 100644 examples/manual_sync.rs create mode 100644 examples/preset_sync.rs create mode 100644 examples/tokio_worker.rs create mode 100644 src/codec.rs create mode 100644 src/config.rs create mode 100644 src/error.rs create mode 100644 src/tokio.rs 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 0000000000000000000000000000000000000000..8662af964d000dfac071eeeabf467254cefd0a80 GIT binary patch literal 160767 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

0eJ2diB3Kb=*9-==PR}wq8qYO%B9=4T`TEj}$Di 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%0A?JKFhbe*hHwKU&AR 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 literal 0 HcmV?d00001 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: