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.
This commit is contained in:
Кобелев Андрей Андреевич
2026-08-16 11:24:20 +05:00
parent efc53e6ef5
commit 244d907cc2
25 changed files with 3823 additions and 782 deletions
+33
View File
@@ -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.
Generated
+155 -15
View File
@@ -2,12 +2,6 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@@ -20,12 +14,24 @@ version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.4" version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.10.1" version = "0.10.1"
@@ -51,6 +57,28 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "io-kit-sys" name = "io-kit-sys"
version = "0.4.1" version = "0.4.1"
@@ -88,13 +116,10 @@ dependencies = [
] ]
[[package]] [[package]]
name = "m" name = "log"
version = "0.1.0" version = "0.4.33"
dependencies = [ source = "registry+https://github.com/rust-lang/crates.io-index"
"anyhow", checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
"encoding_rs",
"serialport",
]
[[package]] [[package]]
name = "mach2" name = "mach2"
@@ -105,6 +130,31 @@ dependencies = [
"libc", "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]] [[package]]
name = "nix" name = "nix"
version = "0.26.4" version = "0.26.4"
@@ -116,6 +166,24 @@ dependencies = [
"libc", "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]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.34" version = "0.3.34"
@@ -159,10 +227,20 @@ dependencies = [
"io-kit-sys", "io-kit-sys",
"libudev", "libudev",
"mach2", "mach2",
"nix", "nix 0.26.4",
"scopeguard", "scopeguard",
"unescaper", "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]] [[package]]
@@ -196,6 +274,47 @@ dependencies = [
"syn", "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]] [[package]]
name = "unescaper" name = "unescaper"
version = "0.1.10" version = "0.1.10"
@@ -211,6 +330,18 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" 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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
@@ -220,6 +351,15 @@ dependencies = [
"windows-targets", "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]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.52.6" version = "0.52.6"
+31 -3
View File
@@ -1,9 +1,37 @@
[package] [package]
name = "m" name = "escpos-vfd"
version = "0.1.0" version = "0.2.0"
edition = "2024" 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] [dependencies]
serialport = "4" serialport = "4"
anyhow = "1"
encoding_rs = "0.8" 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
+201
View File
@@ -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.
+21
View File
@@ -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.
+272 -170
View File
@@ -1,236 +1,338 @@
# VFD — управление дисплеями в Epson-совместимом режиме # escpos-vfd
Небольшая Rust-библиотека для двухстрочных VFD-дисплеев покупателя, работающих через `escpos-vfd` — Rust-библиотека для символьных VFD-дисплеев покупателя (customer display),
serial-порт в Epson/ESC-совместимом режиме. Проект ориентирован на дисплеи семейства которые работают через serial-порт и понимают Epson/ESC/POS-совместимые команды.
PD-2600/PD-2800 и похожие модели с поддержкой команд позиционирования, яркости и
таблицы символов CP866.
Библиотека подходит для часов, локальных дашбордов, кассовых приложений, уведомлений, Настройки порта и дисплея разделены: можно независимо задать скорость, геометрию,
индикаторов состояния и других проектов, где данные нужно обновлять без мерцания всей кодировку, аппаратную таблицу символов и яркость. Для проверенного 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 ```toml
[dependencies] [dependencies]
m = { path = "../vfd" } escpos-vfd = "0.2"
anyhow = "1"
``` ```
Имя 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 ```rust,no_run
use anyhow::Result; use escpos_vfd::{Preset, Vfd, VfdConfig};
use m::vfd::VfdConfig;
use m::worker::VfdWorker;
use std::time::Duration;
fn main() -> Result<()> { fn main() -> escpos_vfd::Result<()> {
let config = VfdConfig::new("/dev/cu.usbmodem101") let config = VfdConfig::preset(
.with_width(20); "/dev/cu.usbmodem101",
Preset::Epson20x2Cp866,
)?;
// Serial-порт открывается до запуска фонового потока, let mut display = Vfd::open(config)?;
// поэтому ошибка подключения вернётся из start().
let worker = VfdWorker::start(config)?;
let display = worker.handle();
display.clear(); display.clear()?;
display.set_brightness(2); display.set_brightness(2)?;
display.print_line_diff(1, "Привет!")?; display.print_line(1, "Привет!")?;
display.print_line_diff(2, "VFD готов")?; display.print_line(2, "escpos-vfd")?;
// Команды выполняются асинхронно: не завершаем программу сразу после отправки.
std::thread::sleep(Duration::from_secs(2));
Ok(()) Ok(())
} }
``` ```
`VfdWorker` последовательно выполняет команды в отдельном потоке. При удалении worker Полный пример: [examples/preset_sync.rs](examples/preset_sync.rs).
отправляет команду завершения и дожидается остановки потока. Сохраняйте `worker` в
области видимости, пока дисплей используется.
## Основной 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 ```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; use std::time::Duration;
let config = VfdConfig::new("/dev/ttyUSB0") fn main() -> escpos_vfd::Result<()> {
.with_width(20) let mut serial = SerialSettings::new("/dev/ttyUSB0", 9_600);
.with_timeout(Duration::from_millis(250)); 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 `SerialSettings::new(port, baud_rate)` использует обычные значения `8N1`, без flow
display.print_line(1, "Полная перезапись")?; control, с тайм-аутом `100 ms`. На Unix порт по умолчанию открывается эксклюзивно.
display.print_line_diff(2, "Изменились цифры: 42")?; Все эти параметры можно изменить через поля `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` хранит последний кадр каждой строки и группирует соседние изменения. Если таблица уже выбрана DIP-переключателями или устройство использует другой механизм,
Если текст не изменился, serial-команда не отправляется. Это полезно для часов и оставьте `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`; текст, выходящий за правую `print_line()` обрезает слишком длинный текст и дополняет короткий пробелами до ширины
границу, обрезается по символам без повреждения UTF-8. дисплея. Это позволяет полностью перезаписать строку без остатка предыдущего текста.
`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; use std::time::Duration;
display.set_marquee_text("Длинное сообщение для посетителя"); display.set_marquee_text("Длинный текст для бегущей строки")?;
display.start_marquee( display.start_marquee(1, 8, Duration::from_millis(1500))?;
2, // строка
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<T> = Result<T, VfdError>` и разделяет ошибки по
смыслу:
| Ошибка | Когда возникает |
| --- | --- |
| `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 ```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 marquee -- /dev/cu.usbmodem101 20 8 1500 2
cargo run --example brightness -- /dev/cu.usbmodem101 20 800 cargo run --features tokio --example tokio_worker -- /dev/cu.usbmodem101 20
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
``` ```
`update_at` использует фиксированную 20-символьную раскладку и завершится с понятной ## Проверка на реальном дисплее
ошибкой, если передать меньшую ширину.
Те же программы доступны через [Task](https://taskfile.dev/): Автоматические тесты проверяют формирование команд, кодировки, координаты, кэш строк,
worker queue, marquee и async-поведение на тестовых транспортах. Конкретное устройство
всё равно стоит проверить отдельно:
```bash 1. Выставьте правильный serial/DIP-режим.
task vfd:clock 2. Запустите `cargo run --example preset_sync -- <port>`.
task vfd:marquee 3. Проверьте очистку, строки, кириллицу и яркость.
task vfd:brightness 4. Проверьте `update_at` и `marquee`.
task vfd:position 5. При использовании Tokio запустите пример `tokio_worker`.
task vfd:update_at
task vfd:blink
```
Значения можно переопределять переменными, например: ## Разработка
```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 ```bash
cargo fmt --all -- --check cargo fmt --all -- --check
cargo clippy --all-targets --no-default-features --locked -- -D warnings
cargo clippy --all-targets --all-features --locked -- -D warnings cargo clippy --all-targets --all-features --locked -- -D warnings
cargo test --all-targets --locked cargo test --all-targets --no-default-features --locked
cargo check --examples --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-санацию, ширину строк, обновление кэша, Подробная документация публичного API: [docs.rs/escpos-vfd](https://docs.rs/escpos-vfd).
группировку diff-диапазонов, таймер marquee и функции примера часов. Для полной проверки
нужен отдельный smoke-test с физическим дисплеем и фактическим serial-портом.
## Ограничения ## Лицензия
- поддерживаются только две строки; `escpos-vfd` распространяется под двойной лицензией `MIT OR Apache-2.0`.
- скорость подключения фиксирована на `9600 baud`;
- очередь команд в worker не ограничена по размеру;
- аппаратные ошибки после запуска worker выводятся в stderr из фонового потока;
- пример `clock` использует системную команду `date` и рассчитан на Unix-подобную среду;
- библиотека не определяет serial-порт автоматически.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

+25 -16
View File
@@ -2,43 +2,52 @@
mod common; mod common;
use anyhow::Result; use std::error::Error;
use m::worker::VfdWorker; use escpos_vfd::VfdWorker;
fn main() -> Result<()> { fn main() -> Result<(), Box<dyn Error>> {
// args: // Аргументы:
// 1) port (default: /dev/cu.usbmodem101) // 1) port - serial-порт дисплея;
// 2) width (default: 20) // 2) width - ширина строки, нужна для проверки координат;
// 3) delay_ms (default: 400) // 3) delay_ms - пауза между состояниями "видно/пусто";
// 4) x (default: 1) 1-based // 4) x - колонка от единицы;
// 5) y (default: 1) 1-based line // 5) y - строка от единицы;
// 6) text (default: "BLINK") // 6) text - текст, который мигает.
let mut args = common::ExampleArgs::from_env(); let mut args = common::ExampleArgs::from_env()?;
let delay_ms: u64 = args.parse_or(400); let delay_ms: u64 = args.parse_or(400);
let x: u8 = args.parse_or(1); let x: u8 = args.parse_or(1);
let y: u8 = args.parse_or(1); let y: u8 = args.parse_or(1);
let text = args.string_or("BLINK"); let text = args.string_or("BLINK");
let text_len = text.chars().count(); let text_len = text.chars().count();
// Worker нужен, чтобы все команды шли в serial-порт последовательно. Даже в одном
// потоке пример получает те же semantics, что и приложение с несколькими producer-ами.
let worker = VfdWorker::start(args.config)?; let worker = VfdWorker::start(args.config)?;
let vfd = worker.handle(); 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 { if y != 1 {
let _ = vfd.print_at(1, y, ""); // просто чтобы “активировать” строку у некоторых дисплеев // Пустая запись в начало строки полезна на некоторых дисплеях, которые лениво
// переключают видимую строку только после позиционирования курсора.
vfd.print_at(1, y, "")?;
} }
// Стираем ровно столько символов, сколько было выведено. Так справа не остаются
// хвосты при следующем включении текста.
let blank = " ".repeat(text_len); let blank = " ".repeat(text_len);
let mut on = false; let mut on = false;
loop { loop {
// Все ошибки (`InvalidCoordinate`, I/O, закрытый worker) сразу выходят из main
// через оператор `?`, что удобно для аппаратного smoke-test.
if on { if on {
let _ = vfd.print_at(x, y, &blank); vfd.print_at(x, y, &blank)?;
} else { } else {
let _ = vfd.print_at(x, y, &text); vfd.print_at(x, y, &text)?;
} }
on = !on; on = !on;
common::sleep_ms(delay_ms); common::sleep_ms(delay_ms);
+22 -16
View File
@@ -1,36 +1,42 @@
//! Демонстрирует четыре уровня яркости и обновление строки без полного мерцания. //! Демонстрирует четыре уровня яркости и обновление строки
mod common; mod common;
use anyhow::Result; use std::error::Error;
use m::worker::VfdWorker; use escpos_vfd::VfdWorker;
fn main() -> Result<()> { fn main() -> Result<(), Box<dyn Error>> {
// args: // Аргументы:
// 1) port (default: /dev/cu.usbmodem101) // 1) port - serial-порт дисплея (по умолчанию /dev/cu.usbmodem101);
// 2) width (default: 20) // 2) width - ширина дисплея в символах (по умолчанию 20);
// 3) delay_ms (default: 800) // 3) delay_ms - пауза между уровнями яркости (по умолчанию 800).
let mut args = common::ExampleArgs::from_env(); let mut args = common::ExampleArgs::from_env()?;
let delay_ms: u64 = args.parse_or(800); let delay_ms: u64 = args.parse_or(800);
// Worker держит serial-порт в отдельном потоке. Важно сохранить `worker` в
// переменной: если он будет уничтожен, Drop остановит фоновую запись.
let worker = VfdWorker::start(args.config)?; let worker = VfdWorker::start(args.config)?;
let vfd = worker.handle(); let vfd = worker.handle();
vfd.clear(); vfd.clear()?;
// шапка один раз (не обязательно, но удобно) // Шапку пишем один раз. Дальше меняем только вторую строку через diff, чтобы
let _ = vfd.print_line_diff(1, "Яркость"); // дисплей не мерцал от полной перерисовки.
vfd.print_line_diff(1, "Яркость")?;
loop { loop {
for level in 1u8..=4u8 { 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 bar = "*".repeat(level as usize);
let line2 = format!("Уровень: {} {}", level, bar); let line2 = format!("Уровень: {} {}", level, bar);
let _ = vfd.print_line_diff(2, line2); vfd.print_line_diff(2, line2)?;
common::sleep_ms(delay_ms); common::sleep_ms(delay_ms);
} }
+30 -21
View File
@@ -2,13 +2,14 @@
mod common; mod common;
use anyhow::Result; use std::error::Error;
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use m::vfd::fit_to_width; use escpos_vfd::{VfdWorker, fit_to_width};
use m::worker::VfdWorker;
fn run_date(args: &[&str]) -> Option<String> { fn run_date(args: &[&str]) -> Option<String> {
// Используем системную `date`, чтобы пример оставался без дополнительных зависимостей.
// Если команда недоступна или вернула ошибку, ниже покажем безопасную заглушку.
let out = std::process::Command::new("date") let out = std::process::Command::new("date")
.args(args) .args(args)
.output() .output()
@@ -20,6 +21,7 @@ fn run_date(args: &[&str]) -> Option<String> {
} }
fn parse_datetime_and_weekday(value: &str) -> Option<(String, u8)> { 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 (datetime, weekday) = value.split_once('|')?;
let weekday = weekday.parse::<u8>().ok()?; let weekday = weekday.parse::<u8>().ok()?;
(1..=7) (1..=7)
@@ -28,6 +30,8 @@ fn parse_datetime_and_weekday(value: &str) -> Option<(String, u8)> {
} }
fn local_datetime_and_weekday() -> (String, u8) { fn local_datetime_and_weekday() -> (String, u8) {
// Один вызов `date` важен: дата, время и день недели приходят из одного snapshot.
// Это убирает редкие рассинхронизации на границе полуночи.
run_date(&["+%d.%m.%Y %H:%M:%S|%u"]) run_date(&["+%d.%m.%Y %H:%M:%S|%u"])
.as_deref() .as_deref()
.and_then(parse_datetime_and_weekday) .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 { fn extract_hour(datetime: &str) -> u8 {
// ожидаем "DD.MM.YYYY HH:MM:SS" // Ожидаем "DD.MM.YYYY HH:MM:SS" и берём HH как два символа после пробела.
// берём HH как 2 символа после пробела // При неожиданном формате возвращаем 12, чтобы пример продолжал работать.
datetime datetime
.split_whitespace() .split_whitespace()
.nth(1) .nth(1)
@@ -80,43 +84,48 @@ fn extract_hour(datetime: &str) -> u8 {
.unwrap_or(12) .unwrap_or(12)
} }
fn main() -> Result<()> { fn main() -> Result<(), Box<dyn Error>> {
// args: // Аргументы:
// 1) port (default: /dev/cu.usbmodem101) // 1) port - serial-порт дисплея;
// 2) width (default: 20) // 2) width - ширина строки, нужна для подгонки текста;
// 3) brightness 1..4 (default: 2) // 3) brightness - яркость из диапазона пресета 1..=4.
let mut args = common::ExampleArgs::from_env(); let mut args = common::ExampleArgs::from_env()?;
let brightness: u8 = args.parse_or(2); 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 worker = VfdWorker::start(args.config)?;
let vfd = worker.handle(); let vfd = worker.handle();
vfd.clear(); vfd.clear()?;
vfd.set_brightness(brightness); vfd.set_brightness(brightness)?;
loop { loop {
let (dt, wd) = local_datetime_and_weekday(); let (dt, wd) = local_datetime_and_weekday();
let hour = extract_hour(&dt); let hour = extract_hour(&dt);
let tod = time_of_day_ru(hour); let tod = time_of_day_ru(hour);
// 1 строка: "28.01.2006 12:03:34" // 1 строка: "28.01.2006 12:03:34". `fit_to_width` обрезает по символам и
let line1 = fit_to_width(&dt, width); // дополняет пробелами, чтобы старый текст справа не оставался на дисплее.
let line1 = fit_to_width(&dt, columns);
// 2 строка: "Понедельник сейчас день" // 2 строка: сначала пробуем полное название дня недели, если не влазит -
// но если не влазит — "Пн сейчас день" // короткое. Это показывает, как приложение может адаптироваться к ширине VFD.
let full = format!("{} сейчас {}", weekday_ru_full(wd), tod); let full = format!("{} сейчас {}", weekday_ru_full(wd), tod);
let mut line2 = full; let mut line2 = full;
if line2.chars().count() > width { if line2.chars().count() > columns {
line2 = format!("{} сейчас {}", weekday_ru_short(wd), tod); 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(1, line1)?;
vfd.print_line_diff(2, line2)?; vfd.print_line_diff(2, line2)?;
// чтобы обновлялось ровно раз в секунду (плюс/минус), можно “привязать” к UNIX time // Привязываем паузу к следующей границе секунды. Тогда часы не "уплывают" из-за
// времени, потраченного на форматирование и serial-запись.
let now = SystemTime::now() let now = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.unwrap_or_default() .unwrap_or_default()
+38 -11
View File
@@ -1,39 +1,52 @@
//! Общие утилиты командной строки для всех демонстрационных программ. //! Общие утилиты командной строки для демонстрационных программ.
//!
//! Каждый пример компилируется как отдельный binary. Этот модуль убирает повторение:
//! первый аргумент всегда serial-порт, второй - ширина дисплея, остальные параметры
//! зависят от конкретного примера.
#![allow( #![allow(
dead_code, dead_code,
reason = "модуль компилируется отдельно для каждого примера, поэтому часть API используется в соседних примерах" reason = "модуль компилируется отдельно для каждого примера, поэтому часть API используется в соседних примерах"
)] )]
use m::vfd::VfdConfig; use escpos_vfd::{Preset, VfdConfig};
use std::error::Error;
use std::str::FromStr; use std::str::FromStr;
use std::vec::IntoIter; use std::vec::IntoIter;
const DEFAULT_PORT: &str = "/dev/cu.usbmodem101"; const DEFAULT_PORT: &str = "/dev/cu.usbmodem101";
const DEFAULT_WIDTH: usize = 20; const DEFAULT_WIDTH: usize = 20;
/// Общие аргументы примера: serial-порт, ширина дисплея и оставшиеся параметры. /// Общие аргументы примера: готовая конфигурация, ширина дисплея и оставшиеся параметры.
pub struct ExampleArgs { pub struct ExampleArgs {
/// Конфигурация VFD на основе пресета текущего дисплея.
pub config: VfdConfig, pub config: VfdConfig,
pub width: usize, /// Ширина дисплея в символах.
pub columns: usize,
remaining: IntoIter<String>, remaining: IntoIter<String>,
} }
impl ExampleArgs { impl ExampleArgs {
/// Читает общие аргументы из командной строки и применяет безопасные значения по умолчанию. /// Читает общие аргументы из командной строки и применяет значения по умолчанию.
pub fn from_env() -> Self { pub fn from_env() -> Result<Self, Box<dyn Error>> {
let mut values = std::env::args().skip(1); let mut values = std::env::args().skip(1);
// Примеры должны запускаться без длинной CLI-команды на авторском стенде, но
// первый аргумент позволяет сразу проверить другой USB/COM-порт.
let port_name = values.next().unwrap_or_else(|| DEFAULT_PORT.to_string()); let port_name = values.next().unwrap_or_else(|| DEFAULT_PORT.to_string());
let width = values
// Геометрия дисплея участвует в проверке координат и обрезке строк. Меняем
// только ширину, потому что демонстрации ниже рассчитаны на две строки.
let columns = values
.next() .next()
.and_then(|value| value.parse().ok()) .and_then(|value| value.parse().ok())
.unwrap_or(DEFAULT_WIDTH); .unwrap_or(DEFAULT_WIDTH);
let config = VfdConfig::new(port_name).with_width(width); let config = preset_config(port_name, columns)?;
Self { Ok(Self {
width: config.width, columns,
config, config,
remaining: values.collect::<Vec<_>>().into_iter(), remaining: values.collect::<Vec<_>>().into_iter(),
} })
} }
/// Читает следующий аргумент нужного типа или возвращает переданное значение по умолчанию. /// Читает следующий аргумент нужного типа или возвращает переданное значение по умолчанию.
@@ -57,3 +70,17 @@ impl ExampleArgs {
pub fn sleep_ms(delay_ms: u64) { pub fn sleep_ms(delay_ms: u64) {
std::thread::sleep(std::time::Duration::from_millis(delay_ms)); std::thread::sleep(std::time::Duration::from_millis(delay_ms));
} }
/// Создаёт конфигурацию пресета текущего дисплея с переопределяемой шириной.
pub fn preset_config(
port_name: impl Into<String>,
columns: usize,
) -> Result<VfdConfig, Box<dyn Error>> {
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)
}
+49
View File
@@ -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<dyn Error>> {
// Ручной пример показывает, что библиотека не держит глобальных `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(())
}
+25 -17
View File
@@ -2,39 +2,47 @@
mod common; mod common;
use anyhow::Result; use std::error::Error;
use std::time::Duration; use std::time::Duration;
use m::worker::VfdWorker; use escpos_vfd::VfdWorker;
fn main() -> Result<()> { fn main() -> Result<(), Box<dyn Error>> {
// args: // Аргументы:
// 1) port (default: /dev/cu.usbmodem101) // 1) port - serial-порт дисплея;
// 2) width (default: 20) // 2) width - ширина строки, по ней worker строит кадры marquee;
// 3) cps (chars per second, default: 8) // 3) cps - скорость в символах в секунду;
// 4) end_pause_ms (default: 1500) // 4) end_pause_ms - пауза после полного прохода текста;
// 5) brightness 1..4 (default: 2) // 5) brightness - яркость из диапазона пресета 1..=4.
let mut args = common::ExampleArgs::from_env(); let mut args = common::ExampleArgs::from_env()?;
let cps: u32 = args.parse_or(8); let cps: u32 = args.parse_or(8);
let end_pause_ms: u64 = args.parse_or(1500); let end_pause_ms: u64 = args.parse_or(1500);
let brightness: u8 = args.parse_or(2); let brightness: u8 = args.parse_or(2);
// Marquee живёт внутри worker-потока: основной поток может спать, а worker будет
// просыпаться по собственному таймеру и отправлять следующий кадр.
let worker = VfdWorker::start(args.config)?; let worker = VfdWorker::start(args.config)?;
let vfd = worker.handle(); let vfd = worker.handle();
vfd.clear(); vfd.clear()?;
vfd.set_brightness(brightness); vfd.set_brightness(brightness)?;
let text = String::from( let text = String::from(
"Однозначно, базовые сценарии поведения пользователей призывают нас к новым свершениям, которые, в свою очередь, должны быть объявлены нарушающими общечеловеческие нормы этики и морали. С другой стороны, понимание сути ресурсосберегающих технологий не даёт нам иного выбора, кроме определения экономической целесообразности принимаемых решений. Задача организации, в особенности же укрепление и развитие внутренней структуры обеспечивает актуальность соответствующих условий активизации. Предварительные выводы неутешительны: укрепление и развитие внутренней структуры однозначно фиксирует необходимость укрепления моральных ценностей. Высокий уровень вовлечения представителей целевой аудитории является четким доказательством простого факта: повышение уровня гражданского сознания представляет собой интересный эксперимент проверки поэтапного и последовательного развития общества.", "Однозначно, базовые сценарии поведения пользователей призывают нас к новым свершениям, которые, в свою очередь, должны быть объявлены нарушающими общечеловеческие нормы этики и морали. С другой стороны, понимание сути ресурсосберегающих технологий не даёт нам иного выбора, кроме определения экономической целесообразности принимаемых решений. Задача организации, в особенности же укрепление и развитие внутренней структуры обеспечивает актуальность соответствующих условий активизации. Предварительные выводы неутешительны: укрепление и развитие внутренней структуры однозначно фиксирует необходимость укрепления моральных ценностей. Высокий уровень вовлечения представителей целевой аудитории является четким доказательством простого факта: повышение уровня гражданского сознания представляет собой интересный эксперимент проверки поэтапного и последовательного развития общества.",
); );
// “рыба” + бесконечный цикл
vfd.set_marquee_text(text);
vfd.start_marquee(2, cps, Duration::from_millis(end_pause_ms));
// можно подсветить вторую строку статикой // Длинный текст сначала сохраняется в состоянии worker-а. `start_marquee` выбирает
let _ = vfd.print_line_diff(1, "Бегущая строка"); // строку, скорость и паузу; сама анимация дальше идёт без ручного цикла кадров.
vfd.set_marquee_text(text)?;
vfd.start_marquee(2, cps, Duration::from_millis(end_pause_ms))?;
// Первая строка остаётся статикой. Вторую строку теперь лучше не обновлять обычными
// командами, пока на ней активна marquee.
vfd.print_line_diff(1, "Бегущая строка")?;
loop { loop {
// Держим процесс живым. Если `main` завершится, `worker` попадёт в Drop и
// остановит фоновую анимацию.
common::sleep_ms(3_600_000); common::sleep_ms(3_600_000);
} }
} }
+25 -21
View File
@@ -2,47 +2,51 @@
mod common; mod common;
use anyhow::Result; use std::error::Error;
use m::worker::VfdWorker; use escpos_vfd::VfdWorker;
fn main() -> Result<()> { fn main() -> Result<(), Box<dyn Error>> {
// args: // Аргументы:
// 1) port (default: /dev/cu.usbmodem101) // 1) port - serial-порт дисплея;
// 2) width (default: 20) // 2) width - ширина строки, по ней ограничиваем движение курсора;
// 3) delay_ms (default: 80) // 3) delay_ms - скорость движения символа.
let mut args = common::ExampleArgs::from_env(); let mut args = common::ExampleArgs::from_env()?;
let delay_ms: u64 = args.parse_or(80); 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 worker = VfdWorker::start(args.config)?;
let vfd = worker.handle(); let vfd = worker.handle();
vfd.clear(); vfd.clear()?;
// Статика // Статичную шапку пишем через diff-метод. Вторая строка ниже станет рабочей областью.
let _ = vfd.print_line_diff(1, "print_at demo"); vfd.print_line_diff(1, "print_at demo")?;
let _ = vfd.print_line_diff(2, "--------------------"); // будет обрезано по width // Строка автоматически обрежется по настроенной ширине дисплея.
vfd.print_line_diff(2, "--------------------")?;
// “курсор” бегает по 2-й строке // "Курсор" бегает по 2-й строке. Координаты в API начинаются с 1, не с 0.
let y = 2u8; let y = 2u8;
let mut x: u8 = 1; let mut x: u8 = 1;
let mut dir: i8 = 1; let mut dir: i8 = 1;
// чтобы не оставлять хвост — помним прошлую позицию и стираем её пробелом // Чтобы не оставлять хвост, помним прошлую позицию и стираем её пробелом.
let mut prev_x: u8 = x; let mut prev_x: u8 = x;
loop { 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; prev_x = x;
// шаг // Шаг вправо/влево с отражением от границ настроенной ширины.
if dir > 0 { if dir > 0 {
if (x as usize) >= width { if (x as usize) >= columns {
dir = -1; dir = -1;
} else { } else {
x += 1; x += 1;
+33
View File
@@ -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<dyn Error>> {
// Первый аргумент - имя 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(())
}
+34
View File
@@ -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<dyn Error>> {
// В 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(())
}
+33 -25
View File
@@ -2,12 +2,13 @@
mod common; 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 { fn pad_left(s: &str, width: usize) -> String {
// простая подгонка ширины, чтобы при уменьшении числа не оставались “хвосты” // Подгоняем ширину поля, чтобы при уменьшении числа не оставались "хвосты" от
// предыдущего значения. Например, после 1000 должно появиться " 999", а не "9990".
if s.chars().count() >= width { if s.chars().count() >= width {
s.chars().take(width).collect() s.chars().take(width).collect()
} else { } else {
@@ -15,28 +16,34 @@ fn pad_left(s: &str, width: usize) -> String {
} }
} }
fn main() -> Result<()> { fn main() -> Result<(), Box<dyn Error>> {
// args: // Аргументы:
// 1) port (default: /dev/cu.usbmodem101) // 1) port - serial-порт дисплея;
// 2) width (default: 20) // 2) width - ширина строки;
// 3) delay_ms (default: 200) // 3) delay_ms - пауза между изменениями данных.
let mut args = common::ExampleArgs::from_env(); let mut args = common::ExampleArgs::from_env()?;
let delay_ms: u64 = args.parse_or(200); let delay_ms: u64 = args.parse_or(200);
let width = args.width; let columns = args.columns;
if width < 20 {
anyhow::bail!("update_at requires a display width of at least 20 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 worker = VfdWorker::start(args.config)?;
let vfd = worker.handle(); let vfd = worker.handle();
vfd.clear(); vfd.clear()?;
// Рисуем “шаблон” один раз (как UI) // Рисуем "шаблон" один раз. Дальше меняем только числовые поля внутри шаблона.
// 12345678901234567890 // 12345678901234567890
// Temp: __.__C RPM:____ // Temp: __.__C RPM:____
// Load: ___% Uptime:____ // Load: ___% Uptime:____
let _ = vfd.print_line_diff(1, "Temp: 00.00C RPM:0000"); vfd.print_line_diff(1, "Temp: 00.00C RPM:0000")?;
let _ = vfd.print_line_diff(2, "Load: 000% Up:0000s"); vfd.print_line_diff(2, "Load: 000% Up:0000s")?;
// Координаты (1-based): // Координаты (1-based):
// "Temp: 00.00C ..." -> числа начинаются с x=7, длина 5 (00.00) // "Temp: 00.00C ..." -> числа начинаются с x=7, длина 5 (00.00)
@@ -64,7 +71,7 @@ fn main() -> Result<()> {
let mut si = 0usize; let mut si = 0usize;
loop { loop {
// чуть “шевелим” данные // Чуть "шевелим" данные, чтобы на дисплее были видны частичные обновления.
t += 0.03; t += 0.03;
if t > 29.99 { if t > 29.99 {
t = 21.50; t = 21.50;
@@ -74,21 +81,22 @@ fn main() -> Result<()> {
load = (load + 3) % 100; load = (load + 3) % 100;
uptime = uptime.wrapping_add(1); uptime = uptime.wrapping_add(1);
// Важно: обновляем только куски строки в фиксированных местах // Важно: обновляем только куски строки в фиксированных местах. Это меньше
// нагружает serial-линию и обычно выглядит спокойнее, чем полная перерисовка.
let temp_s = pad_left(&format!("{:.2}", t), temp_w); let temp_s = pad_left(&format!("{:.2}", t), temp_w);
let rpm_s = pad_left(&format!("{}", rpm), rpm_w); let rpm_s = pad_left(&format!("{}", rpm), rpm_w);
let load_s = pad_left(&format!("{}", load), load_w); let load_s = pad_left(&format!("{}", load), load_w);
let up_s = pad_left(&format!("{}", uptime % 10000), up_w); let up_s = pad_left(&format!("{}", uptime % 10000), up_w);
let _ = vfd.print_at(temp_x, 1, temp_s); vfd.print_at(temp_x, 1, temp_s)?;
let _ = vfd.print_at(rpm_x, 1, rpm_s); vfd.print_at(rpm_x, 1, rpm_s)?;
let _ = vfd.print_at(load_x, 2, load_s); vfd.print_at(load_x, 2, load_s)?;
let _ = vfd.print_at(up_x, 2, up_s); vfd.print_at(up_x, 2, up_s)?;
// “живой” индикатор справа (если ширина позволяет) // "Живой" индикатор справа показывает, что цикл продолжает выполняться.
if width >= 20 { if columns >= 20 {
let _ = vfd.print_at(20, 2, spinner[si]); vfd.print_at(20, 2, spinner[si])?;
si = (si + 1) % spinner.len(); si = (si + 1) % spinner.len();
} }
+343
View File
@@ -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<u8> {
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<u8> {
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<String> {
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<char> = 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());
}
}
+405
View File
@@ -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<String>, 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<u8>,
/// Отправлять `ESC @` при открытии.
///
/// Сброс полезен для predictable startup, но его можно выключить, если приложение
/// намеренно сохраняет состояние дисплея между открытиями.
pub reset_on_open: bool,
/// Поддерживаемый диапазон яркости для `US X n`.
///
/// `None` означает, что типизированная установка яркости запрещена и
/// [`crate::Vfd::set_brightness`] вернёт [`crate::VfdError::UnsupportedBrightness`].
pub brightness: Option<std::ops::RangeInclusive<u8>>,
/// Задержка после изменения яркости.
///
/// Некоторые дисплеи требуют короткую паузу после `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<Self, ConfigError> {
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<String>,
preset: Preset,
) -> std::result::Result<Self, ConfigError> {
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, ConfigError> {
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)
));
}
}
+184
View File
@@ -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<ConfigError> for VfdError {
fn from(value: ConfigError) -> Self {
Self::Config(value)
}
}
impl From<serialport::Error> for VfdError {
fn from(value: serialport::Error) -> Self {
Self::Serial(value)
}
}
impl From<std::io::Error> for VfdError {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
impl<T> From<mpsc::SendError<T>> for VfdError {
fn from(_: mpsc::SendError<T>) -> Self {
Self::QueueClosed
}
}
impl From<mpsc::RecvError> for VfdError {
fn from(_: mpsc::RecvError) -> Self {
Self::WorkerStopped
}
}
/// Результат операций VFD.
pub type Result<T> = std::result::Result<T, VfdError>;
+49 -3
View File
@@ -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; pub mod vfd;
/// Фоновый поток и потокобезопасный интерфейс для обновления дисплея. /// Фоновый поток и потокобезопасный sync-интерфейс.
pub mod worker; 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};
+975
View File
@@ -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<Result<()>>;
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<u8>,
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<T: AsyncWrite + Unpin = SerialStream> {
transport: T,
codec: EpsonCodec,
}
impl AsyncVfd<SerialStream> {
/// Открывает 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<Self> {
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<T: AsyncWrite + Unpin> AsyncVfd<T> {
/// Создаёт 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::<u8>::new(), display).await?;
/// vfd.print_line(1, "OK").await?;
/// # Ok(())
/// # }
/// ```
pub async fn from_transport(mut transport: T, display: DisplaySettings) -> Result<Self> {
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<Cmd>,
}
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<String>) -> 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<String>) -> 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<String>) -> 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<Vec<u8>>) -> 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<String>) -> 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<T: AsyncWrite + Unpin + Send + 'static = SerialStream> {
handle: AsyncVfdHandle,
join: Option<JoinHandle<Result<AsyncVfd<T>>>>,
}
impl AsyncVfdWorker<SerialStream> {
/// Открывает serial-порт и запускает async worker.
///
/// # Ошибки
///
/// Возвращает ошибки конфигурации, открытия serial-порта или init-записи из
/// [`AsyncVfd::open`].
pub async fn start(cfg: VfdConfig) -> Result<Self> {
cfg.validate()?;
let capacity = cfg.queue_capacity;
let vfd = AsyncVfd::open(cfg).await?;
Self::from_vfd(vfd, capacity)
}
}
impl<T: AsyncWrite + Unpin + Send + 'static> AsyncVfdWorker<T> {
/// Запускает worker поверх готового async-драйвера.
///
/// Worker сначала очищает дисплей в своей task, затем обрабатывает очередь команд.
///
/// # Ошибки
///
/// Возвращает [`VfdError::Config`], если `queue_capacity = 0`.
pub fn from_vfd(vfd: AsyncVfd<T>, queue_capacity: usize) -> Result<Self> {
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<Self> {
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<AsyncVfd<T>> {
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<T: AsyncWrite + Unpin + Send + 'static> Drop for AsyncVfdWorker<T> {
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<char>,
offset: usize,
paused_until: Option<Instant>,
next_step: Option<Instant>,
}
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<Instant> {
if !self.active {
return None;
}
self.paused_until.or(self.next_step)
}
}
async fn writer_loop<T: AsyncWrite + Unpin>(
mut vfd: AsyncVfd<T>,
mut rx: mpsc::Receiver<Cmd>,
) -> Result<AsyncVfd<T>> {
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<T: AsyncWrite + Unpin>(
cmd: Cmd,
vfd: &mut AsyncVfd<T>,
marquee: &mut MarqueeState,
last_lines: &mut [String],
) -> Result<bool> {
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<T: AsyncWrite + Unpin>(
vfd: &mut AsyncVfd<T>,
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<T: AsyncWrite + Unpin>(
vfd: &mut AsyncVfd<T>,
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<T: AsyncWrite + Unpin>(
vfd: &mut AsyncVfd<T>,
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<AtomicBool>,
}
impl AsyncWrite for AsyncFailsAfterWrites {
fn poll_write(
mut self: Pin<&mut Self>,
_cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
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<io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
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::<u8>::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::<u8>::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::<u8>::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::<u8>::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::<u8>::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);
}
}
+264 -154
View File
@@ -1,203 +1,313 @@
use anyhow::Result; //! Синхронный низкоуровневый драйвер поверх любого [`std::io::Write`].
use encoding_rs::IBM866; //!
//! `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 serialport::SerialPort;
use std::io::Write; 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; /// Тип транспорта параметризован, поэтому в тестах можно использовать `Vec<u8>`, а в
/// приложении - serial-порт. Все координаты в публичных методах задаются от единицы.
#[derive(Debug, Clone)] /// Тип не синхронизирует доступ между потоками; если один дисплей используют несколько
/// Настройки serial-подключения и геометрии VFD-дисплея. /// producer-ов, берите [`crate::VfdWorker`].
pub struct VfdConfig { pub struct Vfd<T: Write = Box<dyn SerialPort>> {
/// Имя serial-порта, например `/dev/cu.usbmodem101`. transport: T,
pub port_name: String, codec: EpsonCodec,
/// Число символов в строке; ограничивается диапазоном `1..=255`.
pub width: usize,
/// Тайм-аут операций чтения и записи serial-порта.
pub timeout: Duration,
} }
impl VfdConfig { impl Vfd<Box<dyn SerialPort>> {
/// Создаёт конфигурацию с шириной 20 символов и тайм-аутом 100 мс. /// Открывает serial-порт и инициализирует дисплей.
pub fn new(port_name: impl Into<String>) -> Self { ///
Self { /// Перед открытием выполняется полная проверка [`VfdConfig`]. После успешного
port_name: port_name.into(), /// открытия драйвер отправляет команды инициализации из [`DisplaySettings`]:
width: 20, /// опциональный `ESC @` и опциональный `ESC t n`.
timeout: Duration::from_millis(100), ///
} /// # Блокировка
} ///
/// Метод блокирует текущий поток на открытии serial-порта и записи init-команд.
/// Устанавливает ширину строки с учётом диапазона координат протокола. ///
pub fn with_width(mut self, width: usize) -> Self { /// # Ошибки
self.width = width.clamp(1, MAX_WIDTH); ///
self /// Возвращает [`VfdError::Config`] при неверной конфигурации,
} /// [`VfdError::Serial`] при ошибке открытия порта и [`VfdError::Io`] при ошибке
/// записи init-последовательности.
/// Устанавливает тайм-аут операций serial-порта.
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
}
/// Низкоуровневое соединение с дисплеем, владеющее serial-портом.
pub struct Vfd {
port: Box<dyn SerialPort>,
pub width: usize,
}
impl Vfd {
/// Открывает serial-порт и инициализирует дисплей с таблицей CP866.
pub fn open(cfg: VfdConfig) -> Result<Self> { pub fn open(cfg: VfdConfig) -> Result<Self> {
let mut port = serialport::new(cfg.port_name, FIXED_BAUD) cfg.validate()?;
.timeout(cfg.timeout) let serial = cfg.serial;
.open()?; let display = cfg.display;
// ESC @ let mut builder = serialport::new(&serial.port_name, serial.baud_rate)
port.write_all(&[0x1B, 0x40])?; .data_bits(serial.data_bits)
// ESC t 6 .parity(serial.parity)
port.write_all(&[0x1B, 0x74, TABLE_CYR])?; .stop_bits(serial.stop_bits)
.flow_control(serial.flow_control)
Ok(Self { .timeout(serial.timeout);
port, #[cfg(unix)]
width: cfg.width, {
}) builder = builder.exclusive(serial.exclusive);
}
/// Очищает обе строки дисплея стандартной командой очистки.
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 port = builder.open()?;
Self::from_transport(port, display)
}
}
impl<T: Write> Vfd<T> {
/// Создаёт драйвер поверх произвольного транспорта.
///
/// Метод сразу записывает 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::<u8>::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<Self> {
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)?; self.goto_xy(1, line)?;
let fitted = fit_to_width(&sanitize_for_cp866(text), self.width); let fitted = self.codec.fit_line(text);
self.write_cp866(&fitted) self.write_text(&fitted)
} }
/// Выводит подготовленный кадр с начала строки без предварительной очистки. /// Выводит подготовленный кадр с начала строки без предварительной очистки.
pub fn print_frame(&mut self, line: u8, frame: &str) -> std::io::Result<()> { ///
if !(1..=2).contains(&line) { /// В отличие от [`Vfd::print_line`], метод не выполняет санацию текста. Используйте
// Некорректный номер строки игнорируется так же, как в остальных методах печати. /// его для заранее подготовленных кадров бегущей строки или тестовых байтовых
return Ok(()); /// сценариев, когда содержимое уже нормализовано вызывающим кодом.
} ///
/// # Ошибки
///
/// Возвращает [`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)?; self.goto_xy(1, line)?;
let fitted = fit_to_width(frame, self.columns());
// На случай короткого или длинного кадра приводим его к ширине дисплея. self.write_text(&fitted)
let fitted = fit_to_width(frame, self.width);
self.write_cp866(&fitted)
} }
/// Печатает текст с координаты `(x, y)`, выполняя санацию и обрезку по правому краю. /// Печатает текст с координаты `(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) self.print_at_prepared(x, y, &text)
} }
/// Печатает уже подготовленный текст без повторной санации. /// Печатает уже подготовленный текст без повторной санации.
pub(crate) fn print_at_prepared(&mut self, x: u8, y: u8, text: &str) -> std::io::Result<()> { pub(crate) fn print_at_prepared(&mut self, x: u8, y: u8, text: &str) -> Result<()> {
if x == 0 || usize::from(x) > self.width || !(1..=2).contains(&y) { self.codec.validate_xy(x, y)?;
return Ok(()); let remaining = self.columns() - usize::from(x) + 1;
} let text = truncate_chars(text, remaining).to_string();
let remaining = self.width - usize::from(x) + 1;
let text = truncate_chars(text, remaining);
self.goto_xy(x, y)?; 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 /// Используйте этот escape hatch только для команд, которых нет в типизированном API,
self.port.write_all(&[0x1F, 0x24, x, y]) /// или для нестандартных таблиц символов конкретного устройства.
///
/// # Ошибки
///
/// Возвращает [`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); /// Команда кодируется как `US X n` (`0x1F 0x58 level`). После записи выполняется
self.port.write_all(&bytes) /// `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()?; /// Возвращает [`VfdError::Io`], если внутренний транспорт не смог выполнить flush.
std::thread::sleep(std::time::Duration::from_millis(2)); pub fn flush(&mut self) -> Result<()> {
self.transport.flush()?;
Ok(())
}
/// Возвращает внутренний транспорт.
///
/// Метод потребляет драйвер. Это удобно в тестах, где внутренним транспортом служит
/// `Vec<u8>`, или при передаче 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(()) Ok(())
} }
} }
/// Возвращает срез не длиннее заданного числа символов, не разрывая UTF-8. impl From<std::convert::Infallible> for VfdError {
fn truncate_chars(s: &str, max_chars: usize) -> &str { fn from(value: std::convert::Infallible) -> Self {
s.char_indices() match value {}
.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;
} }
out.extend(std::iter::repeat_n(' ', width - len));
out
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::config::{DisplaySettings, Preset, TextEncoding, VfdConfig};
#[test] #[test]
fn config_width_stays_within_protocol_coordinate_range() { fn mock_transport_gets_legacy_preset_bytes() {
assert_eq!(VfdConfig::new("test").with_width(0).width, 1); let cfg = VfdConfig::preset("test", Preset::Epson20x2Cp866).unwrap();
assert_eq!(VfdConfig::new("test").with_width(20).width, 20); let mut vfd = Vfd::from_transport(Vec::<u8>::new(), cfg.display).unwrap();
assert_eq!(VfdConfig::new("test").with_width(usize::MAX).width, 255);
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] #[test]
fn sanitizes_typographic_characters_without_changing_regular_text() { fn raw_write_bypasses_encoding() {
assert_eq!(sanitize_for_cp866("№1\t“тест”—‘да’…"), "#1 \"тест\"-'да'."); let display = DisplaySettings::new(20, 2, TextEncoding::Ascii);
assert_eq!(sanitize_for_cp866("обычный text"), "обычный text"); let mut vfd = Vfd::from_transport(Vec::<u8>::new(), display).unwrap();
vfd.write_raw(&[0x1B, b'?', 0xFF]).unwrap();
assert_eq!(vfd.into_inner(), vec![0x1B, 0x40, 0x1B, b'?', 0xFF]);
} }
#[test] #[test]
fn fits_unicode_by_characters_and_pads_short_input() { fn invalid_coordinates_and_brightness_are_errors() {
assert_eq!(fit_to_width("Привет", 4), "Прив"); let display = DisplaySettings::new(20, 3, TextEncoding::Cp866);
assert_eq!(fit_to_width("да", 4), "да "); let mut vfd = Vfd::from_transport(Vec::<u8>::new(), display).unwrap();
assert_eq!(fit_to_width("text", 0), "");
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] #[test]
fn truncates_without_splitting_utf8_characters() { fn alternate_encodings_are_selectable_manually() {
assert_eq!(truncate_chars("ёжик", 3), "ёжи"); let display = DisplaySettings::new(4, 1, TextEncoding::Windows1251);
assert_eq!(truncate_chars("ёжик", 10), "ёжик"); let mut vfd = Vfd::from_transport(Vec::<u8>::new(), display).unwrap();
assert_eq!(truncate_chars("ёжик", 0), "");
vfd.print_line(1, "т").unwrap();
assert_eq!(
vfd.into_inner(),
vec![0x1B, 0x40, 0x1F, 0x24, 1, 1, 0xF2, b' ', b' ', b' ']
);
} }
} }
+560 -309
View File
File diff suppressed because it is too large Load Diff
+16 -1
View File
@@ -5,6 +5,21 @@ vars:
VFD_WIDTH: "20" VFD_WIDTH: "20"
tasks: 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: vfd:clock:
desc: Run VFD clock example desc: Run VFD clock example
cmds: 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"}} - cargo run --example marquee -- {{.VFD_PORT}} {{.VFD_WIDTH}} {{.VFD_CPS | default "8"}} {{.VFD_END_PAUSE_MS | default "1500"}} {{.VFD_BRIGHTNESS | default "2"}}
vars: vars:
VFD_CPS: "8" VFD_CPS: "8"
VFD_END_PAUSE_MS: "1000" VFD_END_PAUSE_MS: "1500"
VFD_BRIGHTNESS: "3" VFD_BRIGHTNESS: "3"
vfd:brightness: vfd:brightness: