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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
@@ -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`.
|
||||||
|
|
||||||
## Возможности
|

|
||||||
|
|
||||||
- вывод текста в первую или вторую строку;
|
## Установка
|
||||||
- печать с координаты `(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-порт автоматически.
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
+25
-16
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+260
-150
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Очищает обе строки дисплея стандартной командой очистки.
|
let port = builder.open()?;
|
||||||
pub fn clear(&mut self) -> std::io::Result<()> {
|
Self::from_transport(port, display)
|
||||||
self.port.write_all(&[0x0C])
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Полностью перезаписывает первую или вторую строку текстом фиксированной ширины.
|
impl<T: Write> Vfd<T> {
|
||||||
pub fn print_line(&mut self, line: u8, text: &str) -> std::io::Result<()> {
|
/// Создаёт драйвер поверх произвольного транспорта.
|
||||||
if !(1..=2).contains(&line) {
|
///
|
||||||
return Ok(());
|
/// Метод сразу записывает 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);
|
||||||
}
|
}
|
||||||
/// Устанавливает яркость; значение ограничивается диапазоном `1..=4`.
|
Ok(())
|
||||||
pub fn set_brightness(&mut self, n: u8) -> Result<()> {
|
}
|
||||||
let n = n.clamp(1, 4);
|
|
||||||
let cmd = [0x1F, 0x58, n]; // US 'X' n
|
/// Завершает буферизированные записи транспорта.
|
||||||
self.port.write_all(&cmd)?;
|
///
|
||||||
self.port.flush()?;
|
/// # Ошибки
|
||||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
///
|
||||||
|
/// Возвращает [`VfdError::Io`], если внутренний транспорт не смог выполнить flush.
|
||||||
|
pub fn flush(&mut self) -> Result<()> {
|
||||||
|
self.transport.flush()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Возвращает внутренний транспорт.
|
||||||
|
///
|
||||||
|
/// Метод потребляет драйвер. Это удобно в тестах, где внутренним транспортом служит
|
||||||
|
/// `Vec<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' ']
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+507
-256
@@ -1,149 +1,268 @@
|
|||||||
use crate::vfd::{Vfd, VfdConfig, sanitize_for_cp866};
|
//! Синхронный worker для последовательной записи команд из bounded queue.
|
||||||
use anyhow::Result;
|
//!
|
||||||
use std::sync::{
|
//! Worker владеет [`crate::Vfd`] в отдельном потоке. Методы [`VfdHandle`] блокируются,
|
||||||
Arc,
|
//! пока команда не будет записана в транспорт или пока worker не вернёт ошибку.
|
||||||
atomic::{AtomicBool, Ordering},
|
//! Очередь команд ограничена `queue_capacity`, поэтому быстрые producer-ы получают
|
||||||
mpsc::{self, Receiver, Sender},
|
//! backpressure вместо неограниченного роста памяти.
|
||||||
};
|
|
||||||
|
use crate::codec::{changed_runs, fit_to_width, replace_cached_range, sanitize_text};
|
||||||
|
use crate::config::{DisplaySettings, VfdConfig};
|
||||||
|
use crate::error::{ConfigError, Result, VfdError};
|
||||||
|
use crate::vfd::Vfd;
|
||||||
|
use serialport::SerialPort;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::sync::mpsc::{self, Receiver, SyncSender};
|
||||||
use std::thread::{self, JoinHandle};
|
use std::thread::{self, JoinHandle};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
type Ack = mpsc::SyncSender<Result<()>>;
|
||||||
/// Команда, передаваемая фоновому потоку дисплея.
|
|
||||||
pub enum Cmd {
|
#[derive(Debug)]
|
||||||
Clear,
|
enum Cmd {
|
||||||
|
Clear {
|
||||||
|
ack: Ack,
|
||||||
|
},
|
||||||
PrintLine {
|
PrintLine {
|
||||||
line: u8,
|
line: u8,
|
||||||
text: String,
|
text: String,
|
||||||
|
ack: Ack,
|
||||||
},
|
},
|
||||||
|
|
||||||
PrintLineDiff {
|
PrintLineDiff {
|
||||||
line: u8,
|
line: u8,
|
||||||
text: String,
|
text: String,
|
||||||
|
ack: Ack,
|
||||||
},
|
},
|
||||||
|
|
||||||
PrintAt {
|
PrintAt {
|
||||||
x: u8,
|
x: u8,
|
||||||
y: u8,
|
y: u8,
|
||||||
text: String,
|
text: String,
|
||||||
|
ack: Ack,
|
||||||
|
},
|
||||||
|
WriteRaw {
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
ack: Ack,
|
||||||
},
|
},
|
||||||
|
|
||||||
// marquee control
|
|
||||||
SetMarqueeText {
|
SetMarqueeText {
|
||||||
text: String,
|
text: String,
|
||||||
|
ack: Ack,
|
||||||
},
|
},
|
||||||
|
|
||||||
StartMarquee {
|
StartMarquee {
|
||||||
line: u8,
|
line: u8,
|
||||||
cps: u32,
|
cps: u32,
|
||||||
end_pause: Duration,
|
end_pause: Duration,
|
||||||
|
ack: Ack,
|
||||||
|
},
|
||||||
|
StopMarquee {
|
||||||
|
ack: Ack,
|
||||||
},
|
},
|
||||||
|
|
||||||
StopMarquee,
|
|
||||||
|
|
||||||
SetBrightness {
|
SetBrightness {
|
||||||
level: u8, // 1..4
|
level: u8,
|
||||||
|
ack: Ack,
|
||||||
|
},
|
||||||
|
Shutdown {
|
||||||
|
ack: Ack,
|
||||||
},
|
},
|
||||||
|
|
||||||
// stop worker
|
|
||||||
Shutdown,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
/// Клонируемый потокобезопасный интерфейс отправки команд дисплею.
|
/// Клонируемый потокобезопасный интерфейс отправки команд дисплею.
|
||||||
|
///
|
||||||
|
/// Каждый вызов ставит команду в bounded queue и ждёт подтверждения от worker. Если
|
||||||
|
/// очередь заполнена, отправитель ждёт свободное место вместо бесконтрольного роста памяти.
|
||||||
|
/// Клон handle можно передавать в другие потоки, но порядок команд гарантируется только
|
||||||
|
/// порядком их фактического попадания в общую очередь.
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct VfdHandle {
|
pub struct VfdHandle {
|
||||||
tx: Sender<Cmd>,
|
tx: SyncSender<Cmd>,
|
||||||
stop: Arc<AtomicBool>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VfdHandle {
|
impl VfdHandle {
|
||||||
/// Ставит в очередь очистку дисплея.
|
/// Ставит в очередь очистку дисплея и ждёт выполнения I/O.
|
||||||
pub fn clear(&self) {
|
///
|
||||||
let _ = self.tx.send(Cmd::Clear);
|
/// # Ошибки
|
||||||
|
///
|
||||||
|
/// Возвращает [`VfdError::QueueClosed`], если worker уже остановлен,
|
||||||
|
/// [`VfdError::WorkerStopped`], если worker завершился до подтверждения, или
|
||||||
|
/// ошибку команды, например [`VfdError::Io`].
|
||||||
|
pub fn clear(&self) -> Result<()> {
|
||||||
|
self.call(|ack| Cmd::Clear { ack })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ставит в очередь изменение яркости.
|
/// Изменяет яркость и ждёт выполнения I/O.
|
||||||
pub fn set_brightness(&self, level: u8) {
|
///
|
||||||
let _ = self.tx.send(Cmd::SetBrightness { level });
|
/// Метод возвращается после записи `US X n`, `flush` и задержки
|
||||||
|
/// `brightness_settle` внутри worker-потока.
|
||||||
|
///
|
||||||
|
/// # Ошибки
|
||||||
|
///
|
||||||
|
/// Возвращает [`VfdError::UnsupportedBrightness`], ошибки очереди или I/O.
|
||||||
|
pub fn set_brightness(&self, level: u8) -> Result<()> {
|
||||||
|
self.call(|ack| Cmd::SetBrightness { level, ack })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ставит в очередь полную перезапись строки.
|
/// Полностью перезаписывает строку.
|
||||||
pub fn print_line(&self, line: u8, text: impl Into<String>) -> anyhow::Result<()> {
|
///
|
||||||
self.tx.send(Cmd::PrintLine {
|
/// Строка санитизируется, обрезается и дополняется пробелами до ширины дисплея.
|
||||||
line,
|
///
|
||||||
text: text.into(),
|
/// # Ошибки
|
||||||
})?;
|
///
|
||||||
Ok(())
|
/// Возвращает [`VfdError::InvalidLine`], ошибки очереди или I/O.
|
||||||
|
pub fn print_line(&self, line: u8, text: impl Into<String>) -> Result<()> {
|
||||||
|
let text = text.into();
|
||||||
|
self.call(|ack| Cmd::PrintLine { line, text, ack })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Обновляет только изменившиеся диапазоны строки и уменьшает число serial-команд.
|
/// Обновляет только изменившиеся диапазоны строки.
|
||||||
pub fn print_line_diff(&self, line: u8, text: impl Into<String>) -> anyhow::Result<()> {
|
///
|
||||||
self.tx.send(Cmd::PrintLineDiff {
|
/// Worker хранит кэш последнего содержимого каждой строки. Если строка уже содержит
|
||||||
line,
|
/// тот же текст, команда не пишет в устройство. Если изменилась часть строки,
|
||||||
text: text.into(),
|
/// отправляются только минимальные смежные диапазоны через позиционирование.
|
||||||
})?;
|
///
|
||||||
Ok(())
|
/// Когда на той же строке активна бегущая строка, команда подтверждается без записи,
|
||||||
|
/// чтобы не ломать текущую анимацию.
|
||||||
|
///
|
||||||
|
/// # Ошибки
|
||||||
|
///
|
||||||
|
/// Возвращает [`VfdError::InvalidLine`], ошибки очереди или I/O.
|
||||||
|
pub fn print_line_diff(&self, line: u8, text: impl Into<String>) -> Result<()> {
|
||||||
|
let text = text.into();
|
||||||
|
self.call(|ack| Cmd::PrintLineDiff { line, text, ack })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ставит в очередь печать текста с координаты `(x, y)`.
|
/// Печатает текст с координаты `(x, y)`.
|
||||||
pub fn print_at(&self, x: u8, y: u8, text: impl Into<String>) -> anyhow::Result<()> {
|
///
|
||||||
self.tx.send(Cmd::PrintAt {
|
/// Координаты задаются от единицы. Worker обновляет кэш строки, поэтому следующие
|
||||||
x,
|
/// [`VfdHandle::print_line_diff`] учитывают частичную запись.
|
||||||
y,
|
///
|
||||||
text: text.into(),
|
/// # Ошибки
|
||||||
})?;
|
///
|
||||||
Ok(())
|
/// Возвращает [`VfdError::InvalidCoordinate`], ошибки очереди или I/O.
|
||||||
|
pub 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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Заменяет текст, используемый бегущей строкой.
|
/// Записывает байты напрямую.
|
||||||
pub fn set_marquee_text(&self, text: impl Into<String>) {
|
///
|
||||||
let _ = self.tx.send(Cmd::SetMarqueeText { text: text.into() });
|
/// Байты не кодируются и не отражаются в строковом кэше worker. После raw-команд,
|
||||||
|
/// которые меняют видимый текст, лучше выполнить обычный `print_line` или `clear`,
|
||||||
|
/// чтобы синхронизировать кэш с дисплеем.
|
||||||
|
///
|
||||||
|
/// # Ошибки
|
||||||
|
///
|
||||||
|
/// Возвращает ошибки очереди или [`VfdError::Io`].
|
||||||
|
pub fn write_raw(&self, bytes: impl Into<Vec<u8>>) -> Result<()> {
|
||||||
|
let bytes = bytes.into();
|
||||||
|
self.call(|ack| Cmd::WriteRaw { bytes, ack })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Запускает бегущую строку на выбранной линии с заданной скоростью и паузой.
|
/// Заменяет текст бегущей строки.
|
||||||
pub fn start_marquee(&self, line: u8, cps: u32, end_pause: Duration) {
|
///
|
||||||
let _ = self.tx.send(Cmd::StartMarquee {
|
/// Если marquee уже активна, поток символов перестраивается сразу. Метод сам ничего
|
||||||
|
/// не пишет в дисплей; следующий кадр будет записан по таймеру worker.
|
||||||
|
///
|
||||||
|
/// # Ошибки
|
||||||
|
///
|
||||||
|
/// Возвращает ошибки очереди, если worker закрыт или остановлен.
|
||||||
|
pub fn set_marquee_text(&self, text: impl Into<String>) -> Result<()> {
|
||||||
|
let text = text.into();
|
||||||
|
self.call(|ack| Cmd::SetMarqueeText { text, ack })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Запускает бегущую строку на выбранной линии.
|
||||||
|
///
|
||||||
|
/// `cps` - скорость в символах в секунду. Значение `0` безопасно приводится к `1`,
|
||||||
|
/// чтобы таймер не схлопнулся в нулевой интервал. `end_pause` - пауза после полного
|
||||||
|
/// прохода текста перед следующим циклом.
|
||||||
|
///
|
||||||
|
/// # Ошибки
|
||||||
|
///
|
||||||
|
/// Возвращает [`VfdError::InvalidLine`], если строка вне дисплея, или ошибки очереди.
|
||||||
|
pub fn start_marquee(&self, line: u8, cps: u32, end_pause: Duration) -> Result<()> {
|
||||||
|
self.call(|ack| Cmd::StartMarquee {
|
||||||
line,
|
line,
|
||||||
cps,
|
cps,
|
||||||
end_pause,
|
end_pause,
|
||||||
});
|
ack,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Останавливает активную бегущую строку.
|
/// Останавливает активную бегущую строку.
|
||||||
pub fn stop_marquee(&self) {
|
///
|
||||||
let _ = self.tx.send(Cmd::StopMarquee);
|
/// Метод останавливает таймер marquee и сбрасывает кэш строки, на которой она была
|
||||||
|
/// активна. Видимый текст не очищается автоматически.
|
||||||
|
///
|
||||||
|
/// # Ошибки
|
||||||
|
///
|
||||||
|
/// Возвращает ошибки очереди, если worker закрыт или остановлен.
|
||||||
|
pub fn stop_marquee(&self) -> Result<()> {
|
||||||
|
self.call(|ack| Cmd::StopMarquee { ack })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Сигнализирует фоновому потоку о завершении работы.
|
/// Завершает worker после ранее принятых команд.
|
||||||
pub fn shutdown(&self) {
|
///
|
||||||
self.stop.store(true, Ordering::Relaxed);
|
/// Handle-метод только отправляет команду shutdown и ждёт подтверждения. Чтобы
|
||||||
let _ = self.tx.send(Cmd::Shutdown);
|
/// дождаться завершения потока и вернуть внутренний [`crate::Vfd`], используйте
|
||||||
|
/// [`VfdWorker::shutdown`].
|
||||||
|
///
|
||||||
|
/// # Ошибки
|
||||||
|
///
|
||||||
|
/// Возвращает ошибки очереди, если worker уже недоступен.
|
||||||
|
pub fn shutdown(&self) -> Result<()> {
|
||||||
|
self.call(|ack| Cmd::Shutdown { ack })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call(&self, build: impl FnOnce(Ack) -> Cmd) -> Result<()> {
|
||||||
|
let (ack_tx, ack_rx) = mpsc::sync_channel(1);
|
||||||
|
self.tx.send(build(ack_tx))?;
|
||||||
|
ack_rx.recv()?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Владеет фоновым потоком записи и дожидается его завершения при удалении.
|
/// Владеет фоновым потоком записи.
|
||||||
pub struct VfdWorker {
|
///
|
||||||
|
/// Предпочитайте явный [`VfdWorker::shutdown`], чтобы дождаться завершения потока и
|
||||||
|
/// вернуть внутренний драйвер. `Drop` предназначен только для аварийного закрытия
|
||||||
|
/// забытых worker-ов и игнорирует ошибку завершения.
|
||||||
|
pub struct VfdWorker<T: Write + Send + 'static = Box<dyn SerialPort>> {
|
||||||
handle: VfdHandle,
|
handle: VfdHandle,
|
||||||
join: Option<JoinHandle<()>>,
|
join: Option<JoinHandle<Result<Vfd<T>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VfdWorker {
|
impl VfdWorker<Box<dyn SerialPort>> {
|
||||||
/// Открывает дисплей и запускает поток последовательной обработки команд.
|
/// Открывает дисплей и запускает поток последовательной обработки команд.
|
||||||
|
///
|
||||||
|
/// # Блокировка
|
||||||
|
///
|
||||||
|
/// Метод блокирует текущий поток на открытии serial-порта и init-записи, затем
|
||||||
|
/// создаёт отдельный поток writer-а.
|
||||||
|
///
|
||||||
|
/// # Ошибки
|
||||||
|
///
|
||||||
|
/// Возвращает ошибки конфигурации, serial open или init I/O из [`crate::Vfd::open`].
|
||||||
pub fn start(cfg: VfdConfig) -> Result<Self> {
|
pub fn start(cfg: VfdConfig) -> Result<Self> {
|
||||||
|
cfg.validate()?;
|
||||||
|
let capacity = cfg.queue_capacity;
|
||||||
let vfd = Vfd::open(cfg)?;
|
let vfd = Vfd::open(cfg)?;
|
||||||
let (tx, rx) = mpsc::channel::<Cmd>();
|
Self::from_vfd(vfd, capacity)
|
||||||
let stop = Arc::new(AtomicBool::new(false));
|
|
||||||
|
|
||||||
let handle = VfdHandle {
|
|
||||||
tx: tx.clone(),
|
|
||||||
stop: stop.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let join = thread::spawn(move || {
|
|
||||||
if let Err(e) = writer_loop(vfd, rx, stop) {
|
|
||||||
eprintln!("[vfd] writer loop error: {e:#}");
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
impl<T: Write + Send + 'static> VfdWorker<T> {
|
||||||
|
/// Запускает worker поверх уже созданного драйвера.
|
||||||
|
///
|
||||||
|
/// Worker сначала очищает дисплей, затем начинает принимать команды. Ошибка первой
|
||||||
|
/// очистки завершит поток и вернётся ожидающему `shutdown`.
|
||||||
|
///
|
||||||
|
/// # Ошибки
|
||||||
|
///
|
||||||
|
/// Возвращает [`VfdError::Config`], если `queue_capacity = 0`.
|
||||||
|
pub fn from_vfd(vfd: Vfd<T>, queue_capacity: usize) -> Result<Self> {
|
||||||
|
if queue_capacity == 0 {
|
||||||
|
return Err(ConfigError::ZeroQueueCapacity.into());
|
||||||
|
}
|
||||||
|
let (tx, rx) = mpsc::sync_channel::<Cmd>(queue_capacity);
|
||||||
|
let handle = VfdHandle { tx };
|
||||||
|
let join = thread::spawn(move || writer_loop(vfd, rx));
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
handle,
|
handle,
|
||||||
@@ -151,17 +270,62 @@ impl VfdWorker {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Запускает worker поверх произвольного транспорта.
|
||||||
|
///
|
||||||
|
/// Метод удобен для тестов и интеграций с собственным транспортом. Init-команды
|
||||||
|
/// записываются до запуска worker-потока.
|
||||||
|
///
|
||||||
|
/// # Ошибки
|
||||||
|
///
|
||||||
|
/// Возвращает ошибки [`crate::Vfd::from_transport`] или
|
||||||
|
/// [`VfdError::Config`] при `queue_capacity = 0`.
|
||||||
|
pub fn from_transport(
|
||||||
|
transport: T,
|
||||||
|
display: DisplaySettings,
|
||||||
|
queue_capacity: usize,
|
||||||
|
) -> Result<Self> {
|
||||||
|
if queue_capacity == 0 {
|
||||||
|
return Err(ConfigError::ZeroQueueCapacity.into());
|
||||||
|
}
|
||||||
|
let vfd = Vfd::from_transport(transport, display)?;
|
||||||
|
Self::from_vfd(vfd, queue_capacity)
|
||||||
|
}
|
||||||
|
|
||||||
/// Возвращает новый клон интерфейса отправки команд.
|
/// Возвращает новый клон интерфейса отправки команд.
|
||||||
|
///
|
||||||
|
/// Клон можно хранить отдельно от [`VfdWorker`]. Сам worker всё равно должен жить
|
||||||
|
/// дольше handle-ов, иначе команды начнут возвращать ошибки очереди.
|
||||||
pub fn handle(&self) -> VfdHandle {
|
pub fn handle(&self) -> VfdHandle {
|
||||||
self.handle.clone()
|
self.handle.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Корректно останавливает worker и возвращает драйвер с транспортом.
|
||||||
|
///
|
||||||
|
/// Shutdown-команда обрабатывается после ранее принятых команд. После этого очередь
|
||||||
|
/// больше не используется, а join result возвращает накопленную ошибку worker-а.
|
||||||
|
///
|
||||||
|
/// # Ошибки
|
||||||
|
///
|
||||||
|
/// Возвращает ошибку отправки shutdown, последнюю ошибку worker-а или
|
||||||
|
/// [`VfdError::WorkerPanicked`], если поток завершился panic-ом.
|
||||||
|
pub fn shutdown(mut self) -> Result<Vfd<T>> {
|
||||||
|
let shutdown_result = self.handle.shutdown();
|
||||||
|
let worker_result = self
|
||||||
|
.join
|
||||||
|
.take()
|
||||||
|
.expect("join handle exists")
|
||||||
|
.join()
|
||||||
|
.map_err(|_| VfdError::WorkerPanicked)?;
|
||||||
|
|
||||||
|
let vfd = worker_result?;
|
||||||
|
shutdown_result?;
|
||||||
|
Ok(vfd)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for VfdWorker {
|
impl<T: Write + Send + 'static> Drop for VfdWorker<T> {
|
||||||
/// Корректно останавливает фоновый поток при выходе владельца из области видимости.
|
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
// Сначала посылаем сигнал остановки, затем дожидаемся завершения потока.
|
let _ = self.handle.shutdown();
|
||||||
self.handle.shutdown();
|
|
||||||
if let Some(j) = self.join.take() {
|
if let Some(j) = self.join.take() {
|
||||||
let _ = j.join();
|
let _ = j.join();
|
||||||
}
|
}
|
||||||
@@ -175,16 +339,13 @@ struct MarqueeState {
|
|||||||
cps: u32,
|
cps: u32,
|
||||||
end_pause: Duration,
|
end_pause: Duration,
|
||||||
text: String,
|
text: String,
|
||||||
|
|
||||||
// runtime
|
|
||||||
stream: Vec<char>,
|
stream: Vec<char>,
|
||||||
offset: usize,
|
offset: usize,
|
||||||
last_step: Instant,
|
|
||||||
paused_until: Option<Instant>,
|
paused_until: Option<Instant>,
|
||||||
|
next_step: Option<Instant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MarqueeState {
|
impl MarqueeState {
|
||||||
/// Создаёт неактивное состояние бегущей строки со значениями по умолчанию.
|
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
active: false,
|
active: false,
|
||||||
@@ -194,14 +355,13 @@ impl MarqueeState {
|
|||||||
text: String::new(),
|
text: String::new(),
|
||||||
stream: Vec::new(),
|
stream: Vec::new(),
|
||||||
offset: 0,
|
offset: 0,
|
||||||
last_step: Instant::now(),
|
|
||||||
paused_until: None,
|
paused_until: None,
|
||||||
|
next_step: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Перестраивает поток символов с пустыми полями до и после текста.
|
|
||||||
fn rebuild_stream(&mut self, width: usize) {
|
fn rebuild_stream(&mut self, width: usize) {
|
||||||
let text = sanitize_for_cp866(&self.text);
|
let text = sanitize_text(&self.text);
|
||||||
self.stream.clear();
|
self.stream.clear();
|
||||||
self.stream.reserve(width * 2 + text.chars().count());
|
self.stream.reserve(width * 2 + text.chars().count());
|
||||||
self.stream.extend(std::iter::repeat_n(' ', width));
|
self.stream.extend(std::iter::repeat_n(' ', width));
|
||||||
@@ -209,259 +369,350 @@ impl MarqueeState {
|
|||||||
self.stream.extend(std::iter::repeat_n(' ', width));
|
self.stream.extend(std::iter::repeat_n(' ', width));
|
||||||
self.offset = 0;
|
self.offset = 0;
|
||||||
self.paused_until = None;
|
self.paused_until = None;
|
||||||
self.last_step = Instant::now();
|
self.next_step = Some(Instant::now() + self.step_interval());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Рассчитывает ненулевой интервал между сдвигами из скорости в символах в секунду.
|
|
||||||
fn step_interval(&self) -> Duration {
|
fn step_interval(&self) -> Duration {
|
||||||
let cps = u64::from(self.cps.max(1));
|
let cps = u64::from(self.cps.max(1));
|
||||||
Duration::from_nanos((1_000_000_000 / 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)
|
||||||
/// Группирует соседние изменившиеся символы в минимальное число диапазонов записи.
|
|
||||||
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 {
|
fn send_ack(ack: Ack, result: Result<()>) {
|
||||||
runs.push((start, run_text));
|
let _ = ack.send(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
runs
|
fn writer_loop<T: Write + Send + 'static>(mut vfd: Vfd<T>, rx: Receiver<Cmd>) -> Result<Vfd<T>> {
|
||||||
}
|
vfd.clear()?;
|
||||||
|
|
||||||
/// Обновляет фрагмент кэшированной строки с учётом Unicode и правой границы.
|
|
||||||
fn replace_cached_range(line: &mut String, x: u8, text: &str, width: usize) {
|
|
||||||
if x == 0 || usize::from(x) > width || text.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut chars: Vec<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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Последовательно обрабатывает команды и по таймеру формирует кадры бегущей строки.
|
|
||||||
fn writer_loop(mut vfd: Vfd, rx: Receiver<Cmd>, stop: Arc<AtomicBool>) -> Result<()> {
|
|
||||||
// optional initial clear:
|
|
||||||
let _ = vfd.clear();
|
|
||||||
|
|
||||||
|
let rows = vfd.rows();
|
||||||
let mut marquee = MarqueeState::new();
|
let mut marquee = MarqueeState::new();
|
||||||
let mut last_lines = [String::new(), String::new()]; // line 1..2, fixed-width
|
let mut last_lines = vec![String::new(); rows];
|
||||||
let width = vfd.width;
|
|
||||||
|
|
||||||
let normalize_line =
|
|
||||||
|text: &str| -> String { crate::vfd::fit_to_width(&sanitize_for_cp866(text), width) };
|
|
||||||
|
|
||||||
let tick = Duration::from_millis(20); // internal scheduler tick
|
|
||||||
|
|
||||||
while !stop.load(Ordering::Relaxed) {
|
|
||||||
// 1) Drain commands (non-blocking)
|
|
||||||
loop {
|
loop {
|
||||||
match rx.try_recv() {
|
let timeout = marquee
|
||||||
Ok(cmd) => match cmd {
|
.next_deadline()
|
||||||
Cmd::Clear => {
|
.map(|deadline| deadline.saturating_duration_since(Instant::now()));
|
||||||
let _ = vfd.clear();
|
|
||||||
last_lines = [String::new(), String::new()];
|
|
||||||
}
|
|
||||||
Cmd::SetBrightness { level } => {
|
|
||||||
let _ = vfd.set_brightness(level);
|
|
||||||
}
|
|
||||||
Cmd::PrintLine { line, text } => {
|
|
||||||
let _ = vfd.print_line(line, &text);
|
|
||||||
|
|
||||||
if (1..=2).contains(&line) {
|
let command = match timeout {
|
||||||
let idx = (line - 1) as usize;
|
Some(delay) => match rx.recv_timeout(delay) {
|
||||||
last_lines[idx] = normalize_line(&text);
|
Ok(cmd) => Some(cmd),
|
||||||
|
Err(mpsc::RecvTimeoutError::Timeout) => None,
|
||||||
|
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||||
|
},
|
||||||
|
None => match rx.recv() {
|
||||||
|
Ok(cmd) => Some(cmd),
|
||||||
|
Err(_) => break,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(cmd) = command {
|
||||||
|
if handle_command(cmd, &mut vfd, &mut marquee, &mut last_lines)? {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
render_marquee(&mut vfd, &mut marquee, &mut last_lines)?;
|
||||||
}
|
}
|
||||||
Cmd::PrintLineDiff { line, text } => {
|
|
||||||
// конфликт с marquee
|
|
||||||
if !(1..=2).contains(&line) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if marquee.active && marquee.line == line {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let next = normalize_line(&text);
|
Ok(vfd)
|
||||||
let idx = (line - 1) as usize;
|
|
||||||
|
|
||||||
// если строка не поменялась — ничего не делаем
|
|
||||||
if last_lines[idx] == next {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// первый кадр (или после clear) — лучше один раз вывести целиком
|
fn handle_command<T: Write>(
|
||||||
if last_lines[idx].is_empty() {
|
cmd: Cmd,
|
||||||
let _ = vfd.print_line(line, &next);
|
vfd: &mut Vfd<T>,
|
||||||
last_lines[idx] = next;
|
marquee: &mut MarqueeState,
|
||||||
continue;
|
last_lines: &mut [String],
|
||||||
|
) -> Result<bool> {
|
||||||
|
let width = vfd.columns();
|
||||||
|
let rows = vfd.rows();
|
||||||
|
match cmd {
|
||||||
|
Cmd::Clear { ack } => {
|
||||||
|
let result = vfd.clear();
|
||||||
|
if result.is_ok() {
|
||||||
|
last_lines.fill(String::new());
|
||||||
}
|
}
|
||||||
|
send_ack(ack, result);
|
||||||
for (x, text) in changed_runs(&last_lines[idx], &next) {
|
|
||||||
let _ = vfd.print_at_prepared(x, line, &text);
|
|
||||||
}
|
}
|
||||||
|
Cmd::SetBrightness { level, ack } => {
|
||||||
last_lines[idx] = next;
|
send_ack(ack, vfd.set_brightness(level));
|
||||||
}
|
}
|
||||||
Cmd::PrintAt { x, y, text } => {
|
Cmd::PrintLine { line, text, ack } => {
|
||||||
// базовая валидация координат
|
let result = vfd.print_line(line, &text);
|
||||||
if x == 0 || usize::from(x) > width {
|
if result.is_ok() {
|
||||||
continue;
|
last_lines[(line - 1) as usize] = fit_to_width(&sanitize_text(&text), width);
|
||||||
}
|
}
|
||||||
|
send_ack(ack, result);
|
||||||
if !(1..=2).contains(&y) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
Cmd::PrintLineDiff { line, text, ack } => {
|
||||||
// если marquee активен и пишет в эту строку — игнорируем, иначе будет “драка”
|
let result = print_line_diff(vfd, marquee, last_lines, line, &text);
|
||||||
if marquee.active && marquee.line == y {
|
send_ack(ack, result);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
Cmd::PrintAt { x, y, text, ack } => {
|
||||||
let remaining = width - usize::from(x) + 1;
|
let result = print_at_cached(vfd, marquee, last_lines, x, y, &text);
|
||||||
let text: String =
|
send_ack(ack, result);
|
||||||
sanitize_for_cp866(&text).chars().take(remaining).collect();
|
|
||||||
let _ = vfd.print_at_prepared(x, y, &text);
|
|
||||||
|
|
||||||
let idx = (y - 1) as usize;
|
|
||||||
replace_cached_range(&mut last_lines[idx], x, &text, width);
|
|
||||||
}
|
}
|
||||||
Cmd::SetMarqueeText { text } => {
|
Cmd::WriteRaw { bytes, ack } => {
|
||||||
|
send_ack(ack, vfd.write_raw(&bytes));
|
||||||
|
}
|
||||||
|
Cmd::SetMarqueeText { text, ack } => {
|
||||||
marquee.text = text;
|
marquee.text = text;
|
||||||
if marquee.active {
|
if marquee.active {
|
||||||
marquee.rebuild_stream(width);
|
marquee.rebuild_stream(width);
|
||||||
}
|
}
|
||||||
|
send_ack(ack, Ok(()));
|
||||||
}
|
}
|
||||||
Cmd::StartMarquee {
|
Cmd::StartMarquee {
|
||||||
line,
|
line,
|
||||||
cps,
|
cps,
|
||||||
end_pause,
|
end_pause,
|
||||||
|
ack,
|
||||||
} => {
|
} => {
|
||||||
let line = if (1..=2).contains(&line) { line } else { 1 };
|
let result = if line == 0 || usize::from(line) > rows {
|
||||||
|
Err(VfdError::InvalidLine { line, rows })
|
||||||
|
} else {
|
||||||
last_lines[(line - 1) as usize].clear();
|
last_lines[(line - 1) as usize].clear();
|
||||||
marquee.active = true;
|
marquee.active = true;
|
||||||
marquee.line = line;
|
marquee.line = line;
|
||||||
marquee.cps = cps.max(1);
|
marquee.cps = cps.max(1);
|
||||||
marquee.end_pause = end_pause;
|
marquee.end_pause = end_pause;
|
||||||
marquee.rebuild_stream(width);
|
marquee.rebuild_stream(width);
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
send_ack(ack, result);
|
||||||
}
|
}
|
||||||
Cmd::StopMarquee => {
|
Cmd::StopMarquee { ack } => {
|
||||||
if (1..=2).contains(&marquee.line) {
|
if marquee.active && usize::from(marquee.line) <= last_lines.len() {
|
||||||
last_lines[(marquee.line - 1) as usize].clear();
|
last_lines[(marquee.line - 1) as usize].clear();
|
||||||
}
|
}
|
||||||
marquee.active = false;
|
marquee.active = false;
|
||||||
marquee.paused_until = None;
|
marquee.paused_until = None;
|
||||||
|
marquee.next_step = None;
|
||||||
|
send_ack(ack, Ok(()));
|
||||||
}
|
}
|
||||||
Cmd::Shutdown => {
|
Cmd::Shutdown { ack } => {
|
||||||
stop.store(true, Ordering::Relaxed);
|
send_ack(ack, Ok(()));
|
||||||
}
|
return Ok(true);
|
||||||
},
|
|
||||||
Err(std::sync::mpsc::TryRecvError::Empty) => break,
|
|
||||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
|
||||||
// All senders dropped => exit thread cleanly
|
|
||||||
stop.store(true, Ordering::Relaxed);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Render marquee if active
|
fn print_line_diff<T: Write>(
|
||||||
if marquee.active {
|
vfd: &mut Vfd<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)?;
|
||||||
|
last_lines[idx] = next;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
for (x, text) in changed_runs(&last_lines[idx], &next) {
|
||||||
|
vfd.print_at_prepared(x, line, &text)?;
|
||||||
|
}
|
||||||
|
last_lines[idx] = next;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_at_cached<T: Write>(
|
||||||
|
vfd: &mut Vfd<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)?;
|
||||||
|
replace_cached_range(&mut last_lines[(y - 1) as usize], x, &text, vfd.columns());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_marquee<T: Write>(
|
||||||
|
vfd: &mut Vfd<T>,
|
||||||
|
marquee: &mut MarqueeState,
|
||||||
|
last_lines: &mut [String],
|
||||||
|
) -> Result<()> {
|
||||||
|
if !marquee.active {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
|
|
||||||
if let Some(until) = marquee.paused_until {
|
if let Some(until) = marquee.paused_until {
|
||||||
if now >= until {
|
if now < until {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
marquee.paused_until = None;
|
marquee.paused_until = None;
|
||||||
marquee.last_step = now;
|
marquee.next_step = Some(now + marquee.step_interval());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if marquee.next_step.is_some_and(|deadline| now < deadline) {
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
} else if now.duration_since(marquee.last_step) >= marquee.step_interval() {
|
|
||||||
marquee.last_step = now;
|
|
||||||
|
|
||||||
if marquee.stream.len() >= width {
|
|
||||||
let max_off = marquee.stream.len() - width;
|
|
||||||
|
|
||||||
|
let 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 start = marquee.offset.min(max_off);
|
||||||
let end = (start + width).min(marquee.stream.len());
|
let end = (start + width).min(marquee.stream.len());
|
||||||
|
|
||||||
let frame: String = marquee.stream[start..end].iter().collect();
|
let frame: String = marquee.stream[start..end].iter().collect();
|
||||||
let _ = vfd.print_at_prepared(1, marquee.line, &frame);
|
|
||||||
|
|
||||||
if (1..=2).contains(&marquee.line) {
|
vfd.print_at_prepared(1, marquee.line, &frame)?;
|
||||||
|
if usize::from(marquee.line) <= last_lines.len() {
|
||||||
last_lines[(marquee.line - 1) as usize] = frame;
|
last_lines[(marquee.line - 1) as usize] = frame;
|
||||||
}
|
}
|
||||||
|
|
||||||
if marquee.offset >= max_off {
|
if marquee.offset >= max_off {
|
||||||
marquee.offset = 0;
|
marquee.offset = 0;
|
||||||
marquee.paused_until = Some(now + marquee.end_pause);
|
marquee.paused_until = Some(now + marquee.end_pause);
|
||||||
|
marquee.next_step = None;
|
||||||
} else {
|
} else {
|
||||||
marquee.offset += 1;
|
marquee.offset += 1;
|
||||||
|
marquee.next_step = Some(now + marquee.step_interval());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
thread::sleep(tick);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::config::{DisplaySettings, TextEncoding};
|
||||||
|
use std::io;
|
||||||
|
use std::sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
};
|
||||||
|
|
||||||
#[test]
|
struct FailsAfterWrites {
|
||||||
fn cache_update_replaces_the_complete_fragment() {
|
writes_left: usize,
|
||||||
let mut line = "Temp: 00.00C ".to_string();
|
failed: Arc<AtomicBool>,
|
||||||
|
|
||||||
replace_cached_range(&mut line, 7, "21.50", 20);
|
|
||||||
|
|
||||||
assert_eq!(line, "Temp: 21.50C ");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
impl Write for FailsAfterWrites {
|
||||||
fn cache_update_handles_unicode_and_clips_at_the_right_edge() {
|
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||||
let mut line = String::new();
|
if self.writes_left == 0 {
|
||||||
|
self.failed.store(true, Ordering::SeqCst);
|
||||||
replace_cached_range(&mut line, 3, "ёжик", 5);
|
return Err(io::Error::other("forced write failure"));
|
||||||
|
}
|
||||||
assert_eq!(line, " ёжи");
|
self.writes_left -= 1;
|
||||||
|
Ok(buf.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
fn flush(&mut self) -> io::Result<()> {
|
||||||
fn diff_groups_adjacent_changes_into_minimal_runs() {
|
Ok(())
|
||||||
assert_eq!(
|
}
|
||||||
changed_runs("abcd efgh", "abXY eZZh"),
|
|
||||||
vec![(3, "XY".to_string()), (7, "ZZ".to_string())]
|
|
||||||
);
|
|
||||||
assert!(changed_runs("без перемен", "без перемен").is_empty());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn marquee_interval_never_collapses_to_zero() {
|
fn marquee_interval_never_collapses_to_zero() {
|
||||||
let mut marquee = MarqueeState::new();
|
let mut marquee = MarqueeState::new();
|
||||||
marquee.cps = u32::MAX;
|
marquee.cps = u32::MAX;
|
||||||
|
|
||||||
assert!(!marquee.step_interval().is_zero());
|
assert!(!marquee.step_interval().is_zero());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_returns_io_ack_and_dynamic_rows() {
|
||||||
|
let display = DisplaySettings::new(8, 3, TextEncoding::Ascii);
|
||||||
|
let worker = VfdWorker::from_transport(Vec::<u8>::new(), display, 2).unwrap();
|
||||||
|
let handle = worker.handle();
|
||||||
|
|
||||||
|
handle.print_line(3, "abc").unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
handle.print_line(4, "bad"),
|
||||||
|
Err(VfdError::InvalidLine { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
let vfd = worker.shutdown().unwrap();
|
||||||
|
let bytes = vfd.into_inner();
|
||||||
|
assert_eq!(&bytes[..2], &[0x1B, 0x40]);
|
||||||
|
assert!(bytes.windows(4).any(|window| window == [0x1F, 0x24, 1, 3]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_rejects_zero_queue_capacity() {
|
||||||
|
let display = DisplaySettings::new(8, 2, TextEncoding::Ascii);
|
||||||
|
assert!(matches!(
|
||||||
|
VfdWorker::from_transport(Vec::<u8>::new(), display, 0),
|
||||||
|
Err(VfdError::Config(ConfigError::ZeroQueueCapacity))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_print_at_validates_coordinates_before_marquee_skip() {
|
||||||
|
let display = DisplaySettings::new(5, 2, TextEncoding::Ascii);
|
||||||
|
let worker = VfdWorker::from_transport(Vec::<u8>::new(), display, 2).unwrap();
|
||||||
|
let handle = worker.handle();
|
||||||
|
|
||||||
|
handle
|
||||||
|
.start_marquee(2, 10, Duration::from_millis(100))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
handle.print_at(0, 2, "bad"),
|
||||||
|
Err(VfdError::InvalidCoordinate { x: 0, y: 2, .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
handle.print_at(6, 2, "bad"),
|
||||||
|
Err(VfdError::InvalidCoordinate { x: 6, y: 2, .. })
|
||||||
|
));
|
||||||
|
handle.print_at(1, 2, "skipped").unwrap();
|
||||||
|
|
||||||
|
worker.shutdown().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_shutdown_prefers_startup_io_error_over_closed_queue() {
|
||||||
|
let display = DisplaySettings::new(5, 2, TextEncoding::Ascii);
|
||||||
|
let failed = Arc::new(AtomicBool::new(false));
|
||||||
|
let transport = FailsAfterWrites {
|
||||||
|
writes_left: 1,
|
||||||
|
failed: Arc::clone(&failed),
|
||||||
|
};
|
||||||
|
let worker = VfdWorker::from_transport(transport, display, 2).unwrap();
|
||||||
|
|
||||||
|
while !failed.load(Ordering::SeqCst) {
|
||||||
|
thread::yield_now();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(matches!(worker.shutdown(), Err(VfdError::Io(_))));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-1
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user