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.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@@ -20,12 +14,24 @@ version = "2.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -51,6 +57,28 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "escpos-vfd"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"serialport",
|
||||
"tokio",
|
||||
"tokio-serial",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
|
||||
|
||||
[[package]]
|
||||
name = "io-kit-sys"
|
||||
version = "0.4.1"
|
||||
@@ -88,13 +116,10 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "m"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"encoding_rs",
|
||||
"serialport",
|
||||
]
|
||||
name = "log"
|
||||
version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
@@ -105,6 +130,31 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio-serial"
|
||||
version = "5.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d4ba3f20276f21b7cad3f1b54c97489cf096a3894fd627cc6951cb3abdd4c60"
|
||||
dependencies = [
|
||||
"log",
|
||||
"mio",
|
||||
"nix 0.31.3",
|
||||
"serialport",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.26.4"
|
||||
@@ -116,6 +166,24 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.34"
|
||||
@@ -159,10 +227,20 @@ dependencies = [
|
||||
"io-kit-sys",
|
||||
"libudev",
|
||||
"mach2",
|
||||
"nix",
|
||||
"nix 0.26.4",
|
||||
"scopeguard",
|
||||
"unescaper",
|
||||
"windows-sys",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -196,6 +274,47 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-serial"
|
||||
version = "5.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd00f5f8b1e01c3e5afccd9e42ed80c2ad2df6d007877f29f8592c62e69cd116"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"log",
|
||||
"mio-serial",
|
||||
"serialport",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unescaper"
|
||||
version = "0.1.10"
|
||||
@@ -211,6 +330,18 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
@@ -220,6 +351,15 @@ dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
|
||||
+31
-3
@@ -1,9 +1,37 @@
|
||||
[package]
|
||||
name = "m"
|
||||
version = "0.1.0"
|
||||
name = "escpos-vfd"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
description = "ESC/POS-compatible VFD customer display driver with sync and optional Tokio APIs"
|
||||
license = "MIT OR Apache-2.0"
|
||||
readme = "README.md"
|
||||
repository = "https://git.belvedersky.ru/belvedersky/vfd.git"
|
||||
documentation = "https://docs.rs/escpos-vfd"
|
||||
categories = ["hardware-support", "api-bindings"]
|
||||
keywords = ["vfd", "escpos", "serial", "customer-display", "tokio"]
|
||||
publish = ["crates-io"]
|
||||
exclude = [
|
||||
"/docs/*.pdf",
|
||||
"/taskfile.yml",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
serialport = "4"
|
||||
anyhow = "1"
|
||||
encoding_rs = "0.8"
|
||||
tokio = { version = "1", optional = true, default-features = false, features = ["io-util", "macros", "rt", "sync", "time"] }
|
||||
tokio-serial = { version = "5.5.0", optional = true, default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", default-features = false, features = ["io-util", "macros", "rt", "sync", "test-util", "time"] }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
tokio = ["dep:tokio", "dep:tokio-serial"]
|
||||
|
||||
[[example]]
|
||||
name = "tokio_worker"
|
||||
required-features = ["tokio"]
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
|
||||
+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-дисплеев покупателя, работающих через
|
||||
serial-порт в Epson/ESC-совместимом режиме. Проект ориентирован на дисплеи семейства
|
||||
PD-2600/PD-2800 и похожие модели с поддержкой команд позиционирования, яркости и
|
||||
таблицы символов CP866.
|
||||
`escpos-vfd` — Rust-библиотека для символьных VFD-дисплеев покупателя (customer display),
|
||||
которые работают через serial-порт и понимают Epson/ESC/POS-совместимые команды.
|
||||
|
||||
Библиотека подходит для часов, локальных дашбордов, кассовых приложений, уведомлений,
|
||||
индикаторов состояния и других проектов, где данные нужно обновлять без мерцания всей
|
||||
строки.
|
||||
Настройки порта и дисплея разделены: можно независимо задать скорость, геометрию,
|
||||
кодировку, аппаратную таблицу символов и яркость. Для проверенного 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
|
||||
[dependencies]
|
||||
m = { path = "../vfd" }
|
||||
anyhow = "1"
|
||||
escpos-vfd = "0.2"
|
||||
```
|
||||
|
||||
Имя crate сейчас — `m`, поэтому импорт начинается с `m::`.
|
||||
Tokio API подключается отдельным feature:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
escpos-vfd = { version = "0.2", features = ["tokio"] }
|
||||
```
|
||||
|
||||
Без feature `tokio` async-зависимости не подключаются.
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
Для 20×2 дисплея с `9600 8N1`, CP866 и таблицей `ESC t 6` достаточно готового пресета:
|
||||
|
||||
```rust,no_run
|
||||
use anyhow::Result;
|
||||
use m::vfd::VfdConfig;
|
||||
use m::worker::VfdWorker;
|
||||
use std::time::Duration;
|
||||
use escpos_vfd::{Preset, Vfd, VfdConfig};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let config = VfdConfig::new("/dev/cu.usbmodem101")
|
||||
.with_width(20);
|
||||
fn main() -> escpos_vfd::Result<()> {
|
||||
let config = VfdConfig::preset(
|
||||
"/dev/cu.usbmodem101",
|
||||
Preset::Epson20x2Cp866,
|
||||
)?;
|
||||
|
||||
// Serial-порт открывается до запуска фонового потока,
|
||||
// поэтому ошибка подключения вернётся из start().
|
||||
let worker = VfdWorker::start(config)?;
|
||||
let display = worker.handle();
|
||||
let mut display = Vfd::open(config)?;
|
||||
|
||||
display.clear();
|
||||
display.set_brightness(2);
|
||||
display.print_line_diff(1, "Привет!")?;
|
||||
display.print_line_diff(2, "VFD готов")?;
|
||||
|
||||
// Команды выполняются асинхронно: не завершаем программу сразу после отправки.
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
display.clear()?;
|
||||
display.set_brightness(2)?;
|
||||
display.print_line(1, "Привет!")?;
|
||||
display.print_line(2, "escpos-vfd")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
`VfdWorker` последовательно выполняет команды в отдельном потоке. При удалении worker
|
||||
отправляет команду завершения и дожидается остановки потока. Сохраняйте `worker` в
|
||||
области видимости, пока дисплей используется.
|
||||
Полный пример: [examples/preset_sync.rs](examples/preset_sync.rs).
|
||||
|
||||
## Основной API
|
||||
## Что выбрать
|
||||
|
||||
### Конфигурация
|
||||
| Задача | API |
|
||||
| --- | --- |
|
||||
| Запустить проверенный 20×2 CP866 дисплей | `VfdConfig::preset(...)` |
|
||||
| Настроить другой дисплей | `SerialSettings` + `DisplaySettings` |
|
||||
| Писать напрямую из текущего потока | `Vfd` |
|
||||
| Отправлять команды из нескольких потоков | `VfdWorker` + `VfdHandle` |
|
||||
| Писать напрямую из Tokio task | `tokio::AsyncVfd` |
|
||||
| Отправлять команды из нескольких Tokio tasks | `tokio::AsyncVfdWorker` |
|
||||
| Отправить нестандартную команду | `write_raw(...)` |
|
||||
|
||||
## Настройка другого дисплея
|
||||
|
||||
Пресет не обязателен. Параметры serial-порта и дисплея можно задать вручную:
|
||||
|
||||
```rust,no_run
|
||||
use m::vfd::VfdConfig;
|
||||
use escpos_vfd::{DisplaySettings, SerialSettings, TextEncoding, Vfd, VfdConfig};
|
||||
use serialport::{DataBits, FlowControl, Parity, StopBits};
|
||||
use std::time::Duration;
|
||||
|
||||
let config = VfdConfig::new("/dev/ttyUSB0")
|
||||
.with_width(20)
|
||||
.with_timeout(Duration::from_millis(250));
|
||||
fn main() -> escpos_vfd::Result<()> {
|
||||
let mut serial = SerialSettings::new("/dev/ttyUSB0", 9_600);
|
||||
serial.data_bits = DataBits::Eight;
|
||||
serial.parity = Parity::None;
|
||||
serial.stop_bits = StopBits::One;
|
||||
serial.flow_control = FlowControl::None;
|
||||
serial.timeout = Duration::from_millis(100);
|
||||
|
||||
let mut display = DisplaySettings::new(20, 2, TextEncoding::Cp866);
|
||||
display.code_table = Some(6);
|
||||
display.brightness = Some(1..=4);
|
||||
|
||||
let config = VfdConfig::new(serial, display)?;
|
||||
let mut vfd = Vfd::open(config)?;
|
||||
|
||||
vfd.print_line(1, "Ручной режим")?;
|
||||
vfd.print_at(1, 2, "20x2, CP866")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
Ширина автоматически ограничивается диапазоном `1..=255`, потому что координаты
|
||||
протокола передаются одним байтом.
|
||||
Полный пример: [examples/manual_sync.rs](examples/manual_sync.rs).
|
||||
|
||||
### Полная и дифференциальная запись
|
||||
### Serial-порт
|
||||
|
||||
```rust,ignore
|
||||
display.print_line(1, "Полная перезапись")?;
|
||||
display.print_line_diff(2, "Изменились цифры: 42")?;
|
||||
`SerialSettings::new(port, baud_rate)` использует обычные значения `8N1`, без flow
|
||||
control, с тайм-аутом `100 ms`. На Unix порт по умолчанию открывается эксклюзивно.
|
||||
Все эти параметры можно изменить через поля `SerialSettings`.
|
||||
|
||||
### Дисплей
|
||||
|
||||
`DisplaySettings::new(columns, rows, encoding)` задаёт геометрию и текстовую кодировку.
|
||||
Размеры должны быть в диапазоне `1..=255`.
|
||||
|
||||
Дополнительно можно настроить:
|
||||
|
||||
- `code_table` — аппаратную таблицу символов через `ESC t n`;
|
||||
- `reset_on_open` — отправку `ESC @` при открытии;
|
||||
- `brightness` — допустимый диапазон яркости;
|
||||
- `brightness_settle` — паузу после изменения яркости.
|
||||
|
||||
`VfdConfig` также содержит `queue_capacity` для worker API. По умолчанию очередь вмещает
|
||||
32 команды; значение `0` запрещено.
|
||||
|
||||
## Кодировка и таблица символов
|
||||
|
||||
Кодировка текста и таблица символов самого дисплея — разные настройки.
|
||||
|
||||
`TextEncoding` определяет, как Rust-строка превращается в байты:
|
||||
|
||||
| Значение | Назначение |
|
||||
| --- | --- |
|
||||
| `Cp866` | CP866 / IBM866 |
|
||||
| `Windows1251` | Windows-1251 / CP1251 |
|
||||
| `Ascii` | только ASCII, остальные символы заменяются на `?` |
|
||||
| `Utf8` | UTF-8 без перекодирования |
|
||||
|
||||
`code_table` отвечает за команду `ESC t n`. Например, конкретному CP866-дисплею могут
|
||||
одновременно понадобиться:
|
||||
|
||||
```rust
|
||||
let mut display = DisplaySettings::new(20, 2, TextEncoding::Cp866);
|
||||
display.code_table = Some(6);
|
||||
```
|
||||
|
||||
`print_line_diff` хранит последний кадр каждой строки и группирует соседние изменения.
|
||||
Если текст не изменился, serial-команда не отправляется. Это полезно для часов и
|
||||
дашбордов, которые обновляются часто.
|
||||
Если таблица уже выбрана DIP-переключателями или устройство использует другой механизм,
|
||||
оставьте `code_table = None`.
|
||||
|
||||
### Печать по координатам
|
||||
Перед обычным выводом библиотека заменяет некоторые типографские символы на более
|
||||
безопасные для символьного VFD варианты: например, длинное тире на `-`, `№` на `#`, а
|
||||
табуляцию на пробел.
|
||||
|
||||
```rust,ignore
|
||||
display.print_at(7, 1, "21.50")?;
|
||||
display.print_at(16, 1, "1200")?;
|
||||
## Вывод текста
|
||||
|
||||
Все публичные координаты начинаются с **1**:
|
||||
|
||||
```rust
|
||||
vfd.print_line(1, "Первая строка")?;
|
||||
vfd.print_at(6, 2, "текст")?;
|
||||
```
|
||||
|
||||
Координаты начинаются с единицы. Допустимы строки `1` и `2`; текст, выходящий за правую
|
||||
границу, обрезается по символам без повреждения UTF-8.
|
||||
`print_line()` обрезает слишком длинный текст и дополняет короткий пробелами до ширины
|
||||
дисплея. Это позволяет полностью перезаписать строку без остатка предыдущего текста.
|
||||
|
||||
`print_at()` пишет с указанной позиции и обрезает текст по правому краю. Автоматического
|
||||
переноса на следующую строку нет.
|
||||
|
||||
Неверные координаты возвращают `VfdError::InvalidLine` или
|
||||
`VfdError::InvalidCoordinate`.
|
||||
|
||||
## Worker и частичные обновления
|
||||
|
||||
`VfdWorker` владеет дисплеем в отдельном потоке, а `VfdHandle` можно клонировать и
|
||||
передавать между потоками.
|
||||
|
||||
Очередь ограничена по размеру: если она заполнена, отправитель ждёт свободное место вместо
|
||||
неограниченного накопления команд. Вызов метода handle завершается после фактического
|
||||
выполнения команды или ошибки I/O.
|
||||
|
||||
```rust,no_run
|
||||
use escpos_vfd::{Preset, VfdConfig, VfdWorker};
|
||||
|
||||
fn main() -> escpos_vfd::Result<()> {
|
||||
let config = VfdConfig::preset(
|
||||
"/dev/cu.usbmodem101",
|
||||
Preset::Epson20x2Cp866,
|
||||
)?;
|
||||
|
||||
let worker = VfdWorker::start(config)?;
|
||||
let display = worker.handle();
|
||||
|
||||
display.print_line_diff(1, "Temp: 21.5 C")?;
|
||||
display.print_line_diff(2, "Humidity: 42%")?;
|
||||
|
||||
worker.shutdown()?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
`print_line_diff()` хранит кэш последнего содержимого строк. Если текст не изменился,
|
||||
запись не выполняется; если изменился только фрагмент, worker отправляет только изменённые
|
||||
смежные диапазоны. Это удобно для часов, статусов и других часто обновляемых значений.
|
||||
|
||||
Для нормального завершения используйте `VfdWorker::shutdown()`.
|
||||
|
||||
### Бегущая строка
|
||||
|
||||
```rust,ignore
|
||||
Worker также умеет обновлять marquee по таймеру:
|
||||
|
||||
```rust
|
||||
use std::time::Duration;
|
||||
|
||||
display.set_marquee_text("Длинное сообщение для посетителя");
|
||||
display.start_marquee(
|
||||
2, // строка
|
||||
8, // символов в секунду
|
||||
Duration::from_millis(1500), // пауза после полного прохода
|
||||
);
|
||||
display.set_marquee_text("Длинный текст для бегущей строки")?;
|
||||
display.start_marquee(1, 8, Duration::from_millis(1500))?;
|
||||
|
||||
// При необходимости:
|
||||
display.stop_marquee();
|
||||
// ...
|
||||
|
||||
display.stop_marquee()?;
|
||||
```
|
||||
|
||||
Обычные команды записи в строку, занятую marquee, игнорируются, чтобы два источника
|
||||
не перезаписывали друг друга.
|
||||
`cps` задаёт скорость в символах в секунду, `end_pause` — паузу после полного прохода.
|
||||
`stop_marquee()` останавливает анимацию, но не очищает уже отображённый текст.
|
||||
|
||||
## Tokio
|
||||
|
||||
Feature `tokio` добавляет два варианта API:
|
||||
|
||||
- `AsyncVfd` — прямой драйвер поверх `AsyncWrite`;
|
||||
- `AsyncVfdWorker` — одна задача-писатель, bounded queue и клонируемый
|
||||
`AsyncVfdHandle`.
|
||||
|
||||
```rust,no_run
|
||||
use escpos_vfd::tokio::AsyncVfdWorker;
|
||||
use escpos_vfd::{Preset, VfdConfig};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> escpos_vfd::Result<()> {
|
||||
let config = VfdConfig::preset(
|
||||
"/dev/cu.usbmodem101",
|
||||
Preset::Epson20x2Cp866,
|
||||
)?;
|
||||
|
||||
let worker = AsyncVfdWorker::start(config).await?;
|
||||
let display = worker.handle();
|
||||
|
||||
display.print_line_diff(1, "Tokio worker").await?;
|
||||
display.print_line_diff(2, "async I/O").await?;
|
||||
|
||||
worker.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
После попадания команды в очередь отмена ожидающего future не отменяет уже поставленную
|
||||
запись в устройство. Для корректного завершения используйте
|
||||
`AsyncVfdWorker::shutdown().await`.
|
||||
|
||||
## Низкоуровневая запись
|
||||
|
||||
Если нужной команды нет в типизированном API, байты можно отправить напрямую:
|
||||
|
||||
```rust
|
||||
vfd.write_raw(&[0x1b, 0x40])?;
|
||||
```
|
||||
|
||||
`write_raw()` не кодирует и не интерпретирует данные. В worker API такая запись также не
|
||||
обновляет строковый кэш, поэтому после raw-команд, меняющих текст на экране, лучше
|
||||
выполнить `clear()` или `print_line()`.
|
||||
|
||||
Для нестандартных транспортов и диагностики доступен публичный
|
||||
`escpos_vfd::codec::EpsonCodec`, который формирует байты команд без открытия serial-порта.
|
||||
|
||||
## Ошибки
|
||||
|
||||
Библиотека использует `escpos_vfd::Result<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
|
||||
cargo run --example clock -- /dev/cu.usbmodem101 20 2
|
||||
cargo run --example preset_sync -- /dev/cu.usbmodem101
|
||||
cargo run --example marquee -- /dev/cu.usbmodem101 20 8 1500 2
|
||||
cargo run --example brightness -- /dev/cu.usbmodem101 20 800
|
||||
cargo run --example position -- /dev/cu.usbmodem101 20 80
|
||||
cargo run --example update_at -- /dev/cu.usbmodem101 20 200
|
||||
cargo run --example blink -- /dev/cu.usbmodem101 20 400 1 2 BLINK
|
||||
cargo run --features tokio --example tokio_worker -- /dev/cu.usbmodem101 20
|
||||
```
|
||||
|
||||
`update_at` использует фиксированную 20-символьную раскладку и завершится с понятной
|
||||
ошибкой, если передать меньшую ширину.
|
||||
## Проверка на реальном дисплее
|
||||
|
||||
Те же программы доступны через [Task](https://taskfile.dev/):
|
||||
Автоматические тесты проверяют формирование команд, кодировки, координаты, кэш строк,
|
||||
worker queue, marquee и async-поведение на тестовых транспортах. Конкретное устройство
|
||||
всё равно стоит проверить отдельно:
|
||||
|
||||
```bash
|
||||
task vfd:clock
|
||||
task vfd:marquee
|
||||
task vfd:brightness
|
||||
task vfd:position
|
||||
task vfd:update_at
|
||||
task vfd:blink
|
||||
```
|
||||
1. Выставьте правильный serial/DIP-режим.
|
||||
2. Запустите `cargo run --example preset_sync -- <port>`.
|
||||
3. Проверьте очистку, строки, кириллицу и яркость.
|
||||
4. Проверьте `update_at` и `marquee`.
|
||||
5. При использовании Tokio запустите пример `tokio_worker`.
|
||||
|
||||
Значения можно переопределять переменными, например:
|
||||
|
||||
```bash
|
||||
task vfd:marquee VFD_PORT=/dev/ttyUSB0 VFD_WIDTH=20 VFD_CPS=10
|
||||
```
|
||||
|
||||
## Кодировка текста
|
||||
|
||||
Перед записью библиотека заменяет несколько типографских символов на совместимые
|
||||
аналоги:
|
||||
|
||||
- `…` → `.`;
|
||||
- `—` и `–` → `-`;
|
||||
- `№` → `#`;
|
||||
- типографские кавычки → обычные кавычки;
|
||||
- табуляция → пробел.
|
||||
|
||||
Остальной текст кодируется с помощью `encoding_rs::IBM866`.
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```text
|
||||
src/vfd.rs низкоуровневые команды и CP866
|
||||
src/worker.rs очередь команд, diff и marquee
|
||||
examples/common/mod.rs общие CLI-утилиты примеров
|
||||
examples/*.rs демонстрационные программы
|
||||
docs/ руководства для дисплеев
|
||||
taskfile.yml команды запуска примеров
|
||||
```
|
||||
|
||||
## Разработка и проверка
|
||||
## Разработка
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --all-targets --no-default-features --locked -- -D warnings
|
||||
cargo clippy --all-targets --all-features --locked -- -D warnings
|
||||
cargo test --all-targets --locked
|
||||
cargo check --examples --locked
|
||||
cargo test --all-targets --no-default-features --locked
|
||||
cargo test --all-targets --all-features --locked
|
||||
RUSTDOCFLAGS='-D warnings -D missing_docs -D rustdoc::broken_intra_doc_links' \
|
||||
cargo doc --all-features --no-deps --locked
|
||||
```
|
||||
|
||||
Автоматические тесты проверяют Unicode, CP866-санацию, ширину строк, обновление кэша,
|
||||
группировку diff-диапазонов, таймер marquee и функции примера часов. Для полной проверки
|
||||
нужен отдельный smoke-test с физическим дисплеем и фактическим serial-портом.
|
||||
Подробная документация публичного API: [docs.rs/escpos-vfd](https://docs.rs/escpos-vfd).
|
||||
|
||||
## Ограничения
|
||||
## Лицензия
|
||||
|
||||
- поддерживаются только две строки;
|
||||
- скорость подключения фиксирована на `9600 baud`;
|
||||
- очередь команд в worker не ограничена по размеру;
|
||||
- аппаратные ошибки после запуска worker выводятся в stderr из фонового потока;
|
||||
- пример `clock` использует системную команду `date` и рассчитан на Unix-подобную среду;
|
||||
- библиотека не определяет serial-порт автоматически.
|
||||
`escpos-vfd` распространяется под двойной лицензией `MIT OR Apache-2.0`.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
+25
-16
@@ -2,43 +2,52 @@
|
||||
|
||||
mod common;
|
||||
|
||||
use anyhow::Result;
|
||||
use std::error::Error;
|
||||
|
||||
use m::worker::VfdWorker;
|
||||
use escpos_vfd::VfdWorker;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
// args:
|
||||
// 1) port (default: /dev/cu.usbmodem101)
|
||||
// 2) width (default: 20)
|
||||
// 3) delay_ms (default: 400)
|
||||
// 4) x (default: 1) 1-based
|
||||
// 5) y (default: 1) 1-based line
|
||||
// 6) text (default: "BLINK")
|
||||
let mut args = common::ExampleArgs::from_env();
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
// Аргументы:
|
||||
// 1) port - serial-порт дисплея;
|
||||
// 2) width - ширина строки, нужна для проверки координат;
|
||||
// 3) delay_ms - пауза между состояниями "видно/пусто";
|
||||
// 4) x - колонка от единицы;
|
||||
// 5) y - строка от единицы;
|
||||
// 6) text - текст, который мигает.
|
||||
let mut args = common::ExampleArgs::from_env()?;
|
||||
let delay_ms: u64 = args.parse_or(400);
|
||||
let x: u8 = args.parse_or(1);
|
||||
let y: u8 = args.parse_or(1);
|
||||
let text = args.string_or("BLINK");
|
||||
let text_len = text.chars().count();
|
||||
|
||||
// Worker нужен, чтобы все команды шли в serial-порт последовательно. Даже в одном
|
||||
// потоке пример получает те же semantics, что и приложение с несколькими producer-ами.
|
||||
let worker = VfdWorker::start(args.config)?;
|
||||
let vfd = worker.handle();
|
||||
|
||||
vfd.clear();
|
||||
vfd.clear()?;
|
||||
|
||||
let _ = vfd.print_line_diff(1, "blink demo (print_at)");
|
||||
// Первая строка остаётся статичной, мигает только выбранная область.
|
||||
vfd.print_line_diff(1, "blink demo (print_at)")?;
|
||||
if y != 1 {
|
||||
let _ = vfd.print_at(1, y, ""); // просто чтобы “активировать” строку у некоторых дисплеев
|
||||
// Пустая запись в начало строки полезна на некоторых дисплеях, которые лениво
|
||||
// переключают видимую строку только после позиционирования курсора.
|
||||
vfd.print_at(1, y, "")?;
|
||||
}
|
||||
|
||||
// Стираем ровно столько символов, сколько было выведено. Так справа не остаются
|
||||
// хвосты при следующем включении текста.
|
||||
let blank = " ".repeat(text_len);
|
||||
|
||||
let mut on = false;
|
||||
loop {
|
||||
// Все ошибки (`InvalidCoordinate`, I/O, закрытый worker) сразу выходят из main
|
||||
// через оператор `?`, что удобно для аппаратного smoke-test.
|
||||
if on {
|
||||
let _ = vfd.print_at(x, y, &blank);
|
||||
vfd.print_at(x, y, &blank)?;
|
||||
} else {
|
||||
let _ = vfd.print_at(x, y, &text);
|
||||
vfd.print_at(x, y, &text)?;
|
||||
}
|
||||
on = !on;
|
||||
common::sleep_ms(delay_ms);
|
||||
|
||||
+22
-16
@@ -1,36 +1,42 @@
|
||||
//! Демонстрирует четыре уровня яркости и обновление строки без полного мерцания.
|
||||
//! Демонстрирует четыре уровня яркости и обновление строки
|
||||
|
||||
mod common;
|
||||
|
||||
use anyhow::Result;
|
||||
use std::error::Error;
|
||||
|
||||
use m::worker::VfdWorker;
|
||||
use escpos_vfd::VfdWorker;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
// args:
|
||||
// 1) port (default: /dev/cu.usbmodem101)
|
||||
// 2) width (default: 20)
|
||||
// 3) delay_ms (default: 800)
|
||||
let mut args = common::ExampleArgs::from_env();
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
// Аргументы:
|
||||
// 1) port - serial-порт дисплея (по умолчанию /dev/cu.usbmodem101);
|
||||
// 2) width - ширина дисплея в символах (по умолчанию 20);
|
||||
// 3) delay_ms - пауза между уровнями яркости (по умолчанию 800).
|
||||
let mut args = common::ExampleArgs::from_env()?;
|
||||
let delay_ms: u64 = args.parse_or(800);
|
||||
|
||||
// Worker держит serial-порт в отдельном потоке. Важно сохранить `worker` в
|
||||
// переменной: если он будет уничтожен, Drop остановит фоновую запись.
|
||||
let worker = VfdWorker::start(args.config)?;
|
||||
let vfd = worker.handle();
|
||||
|
||||
vfd.clear();
|
||||
vfd.clear()?;
|
||||
|
||||
// шапка один раз (не обязательно, но удобно)
|
||||
let _ = vfd.print_line_diff(1, "Яркость");
|
||||
// Шапку пишем один раз. Дальше меняем только вторую строку через diff, чтобы
|
||||
// дисплей не мерцал от полной перерисовки.
|
||||
vfd.print_line_diff(1, "Яркость")?;
|
||||
|
||||
loop {
|
||||
for level in 1u8..=4u8 {
|
||||
vfd.set_brightness(level);
|
||||
// Пресет текущего дисплея объявляет поддерживаемый диапазон яркости 1..=4.
|
||||
// Если уровень вне диапазона, метод вернёт typed error.
|
||||
vfd.set_brightness(level)?;
|
||||
|
||||
// обновляем строку медленно, через print_line_diff
|
||||
// можно сделать “индикатор” уровня (*****)
|
||||
// Индикатор обновляется с той же паузой, чтобы глазами было видно, какой
|
||||
// уровень сейчас активен.
|
||||
let bar = "*".repeat(level as usize);
|
||||
let line2 = format!("Уровень: {} {}", level, bar);
|
||||
|
||||
let _ = vfd.print_line_diff(2, line2);
|
||||
vfd.print_line_diff(2, line2)?;
|
||||
|
||||
common::sleep_ms(delay_ms);
|
||||
}
|
||||
|
||||
+30
-21
@@ -2,13 +2,14 @@
|
||||
|
||||
mod common;
|
||||
|
||||
use anyhow::Result;
|
||||
use std::error::Error;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use m::vfd::fit_to_width;
|
||||
use m::worker::VfdWorker;
|
||||
use escpos_vfd::{VfdWorker, fit_to_width};
|
||||
|
||||
fn run_date(args: &[&str]) -> Option<String> {
|
||||
// Используем системную `date`, чтобы пример оставался без дополнительных зависимостей.
|
||||
// Если команда недоступна или вернула ошибку, ниже покажем безопасную заглушку.
|
||||
let out = std::process::Command::new("date")
|
||||
.args(args)
|
||||
.output()
|
||||
@@ -20,6 +21,7 @@ fn run_date(args: &[&str]) -> Option<String> {
|
||||
}
|
||||
|
||||
fn parse_datetime_and_weekday(value: &str) -> Option<(String, u8)> {
|
||||
// Формат одной строки: "DD.MM.YYYY HH:MM:SS|N", где N - номер дня недели 1..=7.
|
||||
let (datetime, weekday) = value.split_once('|')?;
|
||||
let weekday = weekday.parse::<u8>().ok()?;
|
||||
(1..=7)
|
||||
@@ -28,6 +30,8 @@ fn parse_datetime_and_weekday(value: &str) -> Option<(String, u8)> {
|
||||
}
|
||||
|
||||
fn local_datetime_and_weekday() -> (String, u8) {
|
||||
// Один вызов `date` важен: дата, время и день недели приходят из одного snapshot.
|
||||
// Это убирает редкие рассинхронизации на границе полуночи.
|
||||
run_date(&["+%d.%m.%Y %H:%M:%S|%u"])
|
||||
.as_deref()
|
||||
.and_then(parse_datetime_and_weekday)
|
||||
@@ -70,8 +74,8 @@ fn time_of_day_ru(hour: u8) -> &'static str {
|
||||
}
|
||||
|
||||
fn extract_hour(datetime: &str) -> u8 {
|
||||
// ожидаем "DD.MM.YYYY HH:MM:SS"
|
||||
// берём HH как 2 символа после пробела
|
||||
// Ожидаем "DD.MM.YYYY HH:MM:SS" и берём HH как два символа после пробела.
|
||||
// При неожиданном формате возвращаем 12, чтобы пример продолжал работать.
|
||||
datetime
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
@@ -80,43 +84,48 @@ fn extract_hour(datetime: &str) -> u8 {
|
||||
.unwrap_or(12)
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
// args:
|
||||
// 1) port (default: /dev/cu.usbmodem101)
|
||||
// 2) width (default: 20)
|
||||
// 3) brightness 1..4 (default: 2)
|
||||
let mut args = common::ExampleArgs::from_env();
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
// Аргументы:
|
||||
// 1) port - serial-порт дисплея;
|
||||
// 2) width - ширина строки, нужна для подгонки текста;
|
||||
// 3) brightness - яркость из диапазона пресета 1..=4.
|
||||
let mut args = common::ExampleArgs::from_env()?;
|
||||
let brightness: u8 = args.parse_or(2);
|
||||
let width = args.width;
|
||||
let columns = args.columns;
|
||||
|
||||
// Worker остаётся жить до конца процесса. Handle можно клонировать, но сам worker
|
||||
// владеет serial-портом и фоновым потоком записи.
|
||||
let worker = VfdWorker::start(args.config)?;
|
||||
let vfd = worker.handle();
|
||||
|
||||
vfd.clear();
|
||||
vfd.set_brightness(brightness);
|
||||
vfd.clear()?;
|
||||
vfd.set_brightness(brightness)?;
|
||||
|
||||
loop {
|
||||
let (dt, wd) = local_datetime_and_weekday();
|
||||
let hour = extract_hour(&dt);
|
||||
let tod = time_of_day_ru(hour);
|
||||
|
||||
// 1 строка: "28.01.2006 12:03:34"
|
||||
let line1 = fit_to_width(&dt, width);
|
||||
// 1 строка: "28.01.2006 12:03:34". `fit_to_width` обрезает по символам и
|
||||
// дополняет пробелами, чтобы старый текст справа не оставался на дисплее.
|
||||
let line1 = fit_to_width(&dt, columns);
|
||||
|
||||
// 2 строка: "Понедельник сейчас день"
|
||||
// но если не влазит — "Пн сейчас день"
|
||||
// 2 строка: сначала пробуем полное название дня недели, если не влазит -
|
||||
// короткое. Это показывает, как приложение может адаптироваться к ширине VFD.
|
||||
let full = format!("{} сейчас {}", weekday_ru_full(wd), tod);
|
||||
let mut line2 = full;
|
||||
|
||||
if line2.chars().count() > width {
|
||||
if line2.chars().count() > columns {
|
||||
line2 = format!("{} сейчас {}", weekday_ru_short(wd), tod);
|
||||
}
|
||||
|
||||
line2 = fit_to_width(&line2, width);
|
||||
line2 = fit_to_width(&line2, columns);
|
||||
|
||||
vfd.print_line_diff(1, line1)?;
|
||||
vfd.print_line_diff(2, line2)?;
|
||||
|
||||
// чтобы обновлялось ровно раз в секунду (плюс/минус), можно “привязать” к UNIX time
|
||||
// Привязываем паузу к следующей границе секунды. Тогда часы не "уплывают" из-за
|
||||
// времени, потраченного на форматирование и serial-запись.
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
|
||||
+38
-11
@@ -1,39 +1,52 @@
|
||||
//! Общие утилиты командной строки для всех демонстрационных программ.
|
||||
//! Общие утилиты командной строки для демонстрационных программ.
|
||||
//!
|
||||
//! Каждый пример компилируется как отдельный binary. Этот модуль убирает повторение:
|
||||
//! первый аргумент всегда serial-порт, второй - ширина дисплея, остальные параметры
|
||||
//! зависят от конкретного примера.
|
||||
#![allow(
|
||||
dead_code,
|
||||
reason = "модуль компилируется отдельно для каждого примера, поэтому часть API используется в соседних примерах"
|
||||
)]
|
||||
|
||||
use m::vfd::VfdConfig;
|
||||
use escpos_vfd::{Preset, VfdConfig};
|
||||
use std::error::Error;
|
||||
use std::str::FromStr;
|
||||
use std::vec::IntoIter;
|
||||
|
||||
const DEFAULT_PORT: &str = "/dev/cu.usbmodem101";
|
||||
const DEFAULT_WIDTH: usize = 20;
|
||||
|
||||
/// Общие аргументы примера: serial-порт, ширина дисплея и оставшиеся параметры.
|
||||
/// Общие аргументы примера: готовая конфигурация, ширина дисплея и оставшиеся параметры.
|
||||
pub struct ExampleArgs {
|
||||
/// Конфигурация VFD на основе пресета текущего дисплея.
|
||||
pub config: VfdConfig,
|
||||
pub width: usize,
|
||||
/// Ширина дисплея в символах.
|
||||
pub columns: usize,
|
||||
remaining: IntoIter<String>,
|
||||
}
|
||||
|
||||
impl ExampleArgs {
|
||||
/// Читает общие аргументы из командной строки и применяет безопасные значения по умолчанию.
|
||||
pub fn from_env() -> Self {
|
||||
/// Читает общие аргументы из командной строки и применяет значения по умолчанию.
|
||||
pub fn from_env() -> Result<Self, Box<dyn Error>> {
|
||||
let mut values = std::env::args().skip(1);
|
||||
|
||||
// Примеры должны запускаться без длинной CLI-команды на авторском стенде, но
|
||||
// первый аргумент позволяет сразу проверить другой USB/COM-порт.
|
||||
let port_name = values.next().unwrap_or_else(|| DEFAULT_PORT.to_string());
|
||||
let width = values
|
||||
|
||||
// Геометрия дисплея участвует в проверке координат и обрезке строк. Меняем
|
||||
// только ширину, потому что демонстрации ниже рассчитаны на две строки.
|
||||
let columns = values
|
||||
.next()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(DEFAULT_WIDTH);
|
||||
let config = VfdConfig::new(port_name).with_width(width);
|
||||
let config = preset_config(port_name, columns)?;
|
||||
|
||||
Self {
|
||||
width: config.width,
|
||||
Ok(Self {
|
||||
columns,
|
||||
config,
|
||||
remaining: values.collect::<Vec<_>>().into_iter(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Читает следующий аргумент нужного типа или возвращает переданное значение по умолчанию.
|
||||
@@ -57,3 +70,17 @@ impl ExampleArgs {
|
||||
pub fn sleep_ms(delay_ms: u64) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
|
||||
}
|
||||
|
||||
/// Создаёт конфигурацию пресета текущего дисплея с переопределяемой шириной.
|
||||
pub fn preset_config(
|
||||
port_name: impl Into<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;
|
||||
|
||||
use anyhow::Result;
|
||||
use std::error::Error;
|
||||
use std::time::Duration;
|
||||
|
||||
use m::worker::VfdWorker;
|
||||
use escpos_vfd::VfdWorker;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
// args:
|
||||
// 1) port (default: /dev/cu.usbmodem101)
|
||||
// 2) width (default: 20)
|
||||
// 3) cps (chars per second, default: 8)
|
||||
// 4) end_pause_ms (default: 1500)
|
||||
// 5) brightness 1..4 (default: 2)
|
||||
let mut args = common::ExampleArgs::from_env();
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
// Аргументы:
|
||||
// 1) port - serial-порт дисплея;
|
||||
// 2) width - ширина строки, по ней worker строит кадры marquee;
|
||||
// 3) cps - скорость в символах в секунду;
|
||||
// 4) end_pause_ms - пауза после полного прохода текста;
|
||||
// 5) brightness - яркость из диапазона пресета 1..=4.
|
||||
let mut args = common::ExampleArgs::from_env()?;
|
||||
let cps: u32 = args.parse_or(8);
|
||||
let end_pause_ms: u64 = args.parse_or(1500);
|
||||
let brightness: u8 = args.parse_or(2);
|
||||
|
||||
// Marquee живёт внутри worker-потока: основной поток может спать, а worker будет
|
||||
// просыпаться по собственному таймеру и отправлять следующий кадр.
|
||||
let worker = VfdWorker::start(args.config)?;
|
||||
let vfd = worker.handle();
|
||||
|
||||
vfd.clear();
|
||||
vfd.set_brightness(brightness);
|
||||
vfd.clear()?;
|
||||
vfd.set_brightness(brightness)?;
|
||||
|
||||
let text = String::from(
|
||||
"Однозначно, базовые сценарии поведения пользователей призывают нас к новым свершениям, которые, в свою очередь, должны быть объявлены нарушающими общечеловеческие нормы этики и морали. С другой стороны, понимание сути ресурсосберегающих технологий не даёт нам иного выбора, кроме определения экономической целесообразности принимаемых решений. Задача организации, в особенности же укрепление и развитие внутренней структуры обеспечивает актуальность соответствующих условий активизации. Предварительные выводы неутешительны: укрепление и развитие внутренней структуры однозначно фиксирует необходимость укрепления моральных ценностей. Высокий уровень вовлечения представителей целевой аудитории является четким доказательством простого факта: повышение уровня гражданского сознания представляет собой интересный эксперимент проверки поэтапного и последовательного развития общества.",
|
||||
);
|
||||
// “рыба” + бесконечный цикл
|
||||
vfd.set_marquee_text(text);
|
||||
vfd.start_marquee(2, cps, Duration::from_millis(end_pause_ms));
|
||||
|
||||
// можно подсветить вторую строку статикой
|
||||
let _ = vfd.print_line_diff(1, "Бегущая строка");
|
||||
// Длинный текст сначала сохраняется в состоянии worker-а. `start_marquee` выбирает
|
||||
// строку, скорость и паузу; сама анимация дальше идёт без ручного цикла кадров.
|
||||
vfd.set_marquee_text(text)?;
|
||||
vfd.start_marquee(2, cps, Duration::from_millis(end_pause_ms))?;
|
||||
|
||||
// Первая строка остаётся статикой. Вторую строку теперь лучше не обновлять обычными
|
||||
// командами, пока на ней активна marquee.
|
||||
vfd.print_line_diff(1, "Бегущая строка")?;
|
||||
|
||||
loop {
|
||||
// Держим процесс живым. Если `main` завершится, `worker` попадёт в Drop и
|
||||
// остановит фоновую анимацию.
|
||||
common::sleep_ms(3_600_000);
|
||||
}
|
||||
}
|
||||
|
||||
+25
-21
@@ -2,47 +2,51 @@
|
||||
|
||||
mod common;
|
||||
|
||||
use anyhow::Result;
|
||||
use std::error::Error;
|
||||
|
||||
use m::worker::VfdWorker;
|
||||
use escpos_vfd::VfdWorker;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
// args:
|
||||
// 1) port (default: /dev/cu.usbmodem101)
|
||||
// 2) width (default: 20)
|
||||
// 3) delay_ms (default: 80)
|
||||
let mut args = common::ExampleArgs::from_env();
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
// Аргументы:
|
||||
// 1) port - serial-порт дисплея;
|
||||
// 2) width - ширина строки, по ней ограничиваем движение курсора;
|
||||
// 3) delay_ms - скорость движения символа.
|
||||
let mut args = common::ExampleArgs::from_env()?;
|
||||
let delay_ms: u64 = args.parse_or(80);
|
||||
let width = args.width;
|
||||
let columns = args.columns;
|
||||
|
||||
// Worker сериализует `print_at`: сначала стираем старую позицию, затем рисуем новую.
|
||||
let worker = VfdWorker::start(args.config)?;
|
||||
let vfd = worker.handle();
|
||||
|
||||
vfd.clear();
|
||||
vfd.clear()?;
|
||||
|
||||
// Статика
|
||||
let _ = vfd.print_line_diff(1, "print_at demo");
|
||||
let _ = vfd.print_line_diff(2, "--------------------"); // будет обрезано по width
|
||||
// Статичную шапку пишем через diff-метод. Вторая строка ниже станет рабочей областью.
|
||||
vfd.print_line_diff(1, "print_at demo")?;
|
||||
// Строка автоматически обрежется по настроенной ширине дисплея.
|
||||
vfd.print_line_diff(2, "--------------------")?;
|
||||
|
||||
// “курсор” бегает по 2-й строке
|
||||
// "Курсор" бегает по 2-й строке. Координаты в API начинаются с 1, не с 0.
|
||||
let y = 2u8;
|
||||
let mut x: u8 = 1;
|
||||
let mut dir: i8 = 1;
|
||||
|
||||
// чтобы не оставлять хвост — помним прошлую позицию и стираем её пробелом
|
||||
// Чтобы не оставлять хвост, помним прошлую позицию и стираем её пробелом.
|
||||
let mut prev_x: u8 = x;
|
||||
|
||||
loop {
|
||||
// стереть прошлую позицию
|
||||
let _ = vfd.print_at(prev_x, y, " ");
|
||||
// Стереть прошлую позицию.
|
||||
vfd.print_at(prev_x, y, " ")?;
|
||||
|
||||
// нарисовать новую
|
||||
let _ = vfd.print_at(x, y, "█"); // можно заменить на "*" если надо
|
||||
// Нарисовать новую. Если дисплей плохо показывает этот символ в выбранной
|
||||
// таблице, замените его на ASCII "*".
|
||||
vfd.print_at(x, y, "█")?;
|
||||
|
||||
prev_x = x;
|
||||
|
||||
// шаг
|
||||
// Шаг вправо/влево с отражением от границ настроенной ширины.
|
||||
if dir > 0 {
|
||||
if (x as usize) >= width {
|
||||
if (x as usize) >= columns {
|
||||
dir = -1;
|
||||
} else {
|
||||
x += 1;
|
||||
|
||||
@@ -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;
|
||||
|
||||
use anyhow::Result;
|
||||
use std::error::Error;
|
||||
|
||||
use m::worker::VfdWorker;
|
||||
use escpos_vfd::VfdWorker;
|
||||
|
||||
fn pad_left(s: &str, width: usize) -> String {
|
||||
// простая подгонка ширины, чтобы при уменьшении числа не оставались “хвосты”
|
||||
// Подгоняем ширину поля, чтобы при уменьшении числа не оставались "хвосты" от
|
||||
// предыдущего значения. Например, после 1000 должно появиться " 999", а не "9990".
|
||||
if s.chars().count() >= width {
|
||||
s.chars().take(width).collect()
|
||||
} else {
|
||||
@@ -15,28 +16,34 @@ fn pad_left(s: &str, width: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
// args:
|
||||
// 1) port (default: /dev/cu.usbmodem101)
|
||||
// 2) width (default: 20)
|
||||
// 3) delay_ms (default: 200)
|
||||
let mut args = common::ExampleArgs::from_env();
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
// Аргументы:
|
||||
// 1) port - serial-порт дисплея;
|
||||
// 2) width - ширина строки;
|
||||
// 3) delay_ms - пауза между изменениями данных.
|
||||
let mut args = common::ExampleArgs::from_env()?;
|
||||
let delay_ms: u64 = args.parse_or(200);
|
||||
let width = args.width;
|
||||
if width < 20 {
|
||||
anyhow::bail!("update_at requires a display width of at least 20 columns");
|
||||
let columns = args.columns;
|
||||
|
||||
// Координаты ниже подобраны под 20 символов. Для более узких дисплеев лучше
|
||||
// сделать другой layout, поэтому пример явно сообщает об ограничении.
|
||||
if columns < 20 {
|
||||
return Err("update_at requires a display width of at least 20 columns".into());
|
||||
}
|
||||
|
||||
// Worker сохраняет кэш строк. Благодаря этому `print_at` и `print_line_diff`
|
||||
// согласованно обновляют только изменившиеся области.
|
||||
let worker = VfdWorker::start(args.config)?;
|
||||
let vfd = worker.handle();
|
||||
|
||||
vfd.clear();
|
||||
vfd.clear()?;
|
||||
|
||||
// Рисуем “шаблон” один раз (как UI)
|
||||
// Рисуем "шаблон" один раз. Дальше меняем только числовые поля внутри шаблона.
|
||||
// 12345678901234567890
|
||||
// Temp: __.__C RPM:____
|
||||
// Load: ___% Uptime:____
|
||||
let _ = vfd.print_line_diff(1, "Temp: 00.00C RPM:0000");
|
||||
let _ = vfd.print_line_diff(2, "Load: 000% Up:0000s");
|
||||
vfd.print_line_diff(1, "Temp: 00.00C RPM:0000")?;
|
||||
vfd.print_line_diff(2, "Load: 000% Up:0000s")?;
|
||||
|
||||
// Координаты (1-based):
|
||||
// "Temp: 00.00C ..." -> числа начинаются с x=7, длина 5 (00.00)
|
||||
@@ -64,7 +71,7 @@ fn main() -> Result<()> {
|
||||
let mut si = 0usize;
|
||||
|
||||
loop {
|
||||
// чуть “шевелим” данные
|
||||
// Чуть "шевелим" данные, чтобы на дисплее были видны частичные обновления.
|
||||
t += 0.03;
|
||||
if t > 29.99 {
|
||||
t = 21.50;
|
||||
@@ -74,21 +81,22 @@ fn main() -> Result<()> {
|
||||
load = (load + 3) % 100;
|
||||
uptime = uptime.wrapping_add(1);
|
||||
|
||||
// Важно: обновляем только куски строки в фиксированных местах
|
||||
// Важно: обновляем только куски строки в фиксированных местах. Это меньше
|
||||
// нагружает serial-линию и обычно выглядит спокойнее, чем полная перерисовка.
|
||||
let temp_s = pad_left(&format!("{:.2}", t), temp_w);
|
||||
let rpm_s = pad_left(&format!("{}", rpm), rpm_w);
|
||||
let load_s = pad_left(&format!("{}", load), load_w);
|
||||
let up_s = pad_left(&format!("{}", uptime % 10000), up_w);
|
||||
|
||||
let _ = vfd.print_at(temp_x, 1, temp_s);
|
||||
let _ = vfd.print_at(rpm_x, 1, rpm_s);
|
||||
vfd.print_at(temp_x, 1, temp_s)?;
|
||||
vfd.print_at(rpm_x, 1, rpm_s)?;
|
||||
|
||||
let _ = vfd.print_at(load_x, 2, load_s);
|
||||
let _ = vfd.print_at(up_x, 2, up_s);
|
||||
vfd.print_at(load_x, 2, load_s)?;
|
||||
vfd.print_at(up_x, 2, up_s)?;
|
||||
|
||||
// “живой” индикатор справа (если ширина позволяет)
|
||||
if width >= 20 {
|
||||
let _ = vfd.print_at(20, 2, spinner[si]);
|
||||
// "Живой" индикатор справа показывает, что цикл продолжает выполняться.
|
||||
if columns >= 20 {
|
||||
vfd.print_at(20, 2, spinner[si])?;
|
||||
si = (si + 1) % spinner.len();
|
||||
}
|
||||
|
||||
|
||||
+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;
|
||||
/// Фоновый поток и потокобезопасный интерфейс для обновления дисплея.
|
||||
/// Фоновый поток и потокобезопасный sync-интерфейс.
|
||||
pub mod worker;
|
||||
|
||||
/// Tokio API поверх настоящего `AsyncWrite`.
|
||||
#[cfg(feature = "tokio")]
|
||||
pub mod tokio;
|
||||
|
||||
pub use codec::{fit_to_width, sanitize_for_cp866, sanitize_text};
|
||||
pub use config::{DisplaySettings, Preset, SerialSettings, TextEncoding, VfdConfig};
|
||||
pub use error::{ConfigError, Result, VfdError};
|
||||
pub use vfd::Vfd;
|
||||
pub use worker::{VfdHandle, VfdWorker};
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+264
-154
@@ -1,203 +1,313 @@
|
||||
use anyhow::Result;
|
||||
use encoding_rs::IBM866;
|
||||
//! Синхронный низкоуровневый драйвер поверх любого [`std::io::Write`].
|
||||
//!
|
||||
//! `Vfd` полезен, когда приложение само управляет потоками и хочет немедленно выполнять
|
||||
//! команды на конкретном транспорте. Каждый метод выполняет запись до возврата из
|
||||
//! функции. Для фоновой сериализации команд из разных частей приложения используйте
|
||||
//! [`crate::VfdWorker`] или `escpos_vfd::tokio::AsyncVfdWorker`.
|
||||
|
||||
use crate::codec::{EpsonCodec, fit_to_width, sanitize_text, truncate_chars};
|
||||
use crate::config::{DisplaySettings, VfdConfig};
|
||||
use crate::error::{Result, VfdError};
|
||||
use serialport::SerialPort;
|
||||
use std::io::Write;
|
||||
use std::time::Duration;
|
||||
|
||||
const TABLE_CYR: u8 = 6; // PD-2600: your Cyrillic table (ESC t 6)
|
||||
const FIXED_BAUD: u32 = 9600;
|
||||
const MAX_WIDTH: usize = u8::MAX as usize;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// Настройки serial-подключения и геометрии VFD-дисплея.
|
||||
pub struct VfdConfig {
|
||||
/// Имя serial-порта, например `/dev/cu.usbmodem101`.
|
||||
pub port_name: String,
|
||||
/// Число символов в строке; ограничивается диапазоном `1..=255`.
|
||||
pub width: usize,
|
||||
/// Тайм-аут операций чтения и записи serial-порта.
|
||||
pub timeout: Duration,
|
||||
/// Низкоуровневое соединение с дисплеем, владеющее транспортом записи.
|
||||
///
|
||||
/// Тип транспорта параметризован, поэтому в тестах можно использовать `Vec<u8>`, а в
|
||||
/// приложении - serial-порт. Все координаты в публичных методах задаются от единицы.
|
||||
/// Тип не синхронизирует доступ между потоками; если один дисплей используют несколько
|
||||
/// producer-ов, берите [`crate::VfdWorker`].
|
||||
pub struct Vfd<T: Write = Box<dyn SerialPort>> {
|
||||
transport: T,
|
||||
codec: EpsonCodec,
|
||||
}
|
||||
|
||||
impl VfdConfig {
|
||||
/// Создаёт конфигурацию с шириной 20 символов и тайм-аутом 100 мс.
|
||||
pub fn new(port_name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
port_name: port_name.into(),
|
||||
width: 20,
|
||||
timeout: Duration::from_millis(100),
|
||||
}
|
||||
}
|
||||
|
||||
/// Устанавливает ширину строки с учётом диапазона координат протокола.
|
||||
pub fn with_width(mut self, width: usize) -> Self {
|
||||
self.width = width.clamp(1, MAX_WIDTH);
|
||||
self
|
||||
}
|
||||
|
||||
/// Устанавливает тайм-аут операций serial-порта.
|
||||
pub fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.timeout = timeout;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Низкоуровневое соединение с дисплеем, владеющее serial-портом.
|
||||
pub struct Vfd {
|
||||
port: Box<dyn SerialPort>,
|
||||
pub width: usize,
|
||||
}
|
||||
|
||||
impl Vfd {
|
||||
/// Открывает serial-порт и инициализирует дисплей с таблицей CP866.
|
||||
impl Vfd<Box<dyn SerialPort>> {
|
||||
/// Открывает serial-порт и инициализирует дисплей.
|
||||
///
|
||||
/// Перед открытием выполняется полная проверка [`VfdConfig`]. После успешного
|
||||
/// открытия драйвер отправляет команды инициализации из [`DisplaySettings`]:
|
||||
/// опциональный `ESC @` и опциональный `ESC t n`.
|
||||
///
|
||||
/// # Блокировка
|
||||
///
|
||||
/// Метод блокирует текущий поток на открытии serial-порта и записи init-команд.
|
||||
///
|
||||
/// # Ошибки
|
||||
///
|
||||
/// Возвращает [`VfdError::Config`] при неверной конфигурации,
|
||||
/// [`VfdError::Serial`] при ошибке открытия порта и [`VfdError::Io`] при ошибке
|
||||
/// записи init-последовательности.
|
||||
pub fn open(cfg: VfdConfig) -> Result<Self> {
|
||||
let mut port = serialport::new(cfg.port_name, FIXED_BAUD)
|
||||
.timeout(cfg.timeout)
|
||||
.open()?;
|
||||
cfg.validate()?;
|
||||
let serial = cfg.serial;
|
||||
let display = cfg.display;
|
||||
|
||||
// ESC @
|
||||
port.write_all(&[0x1B, 0x40])?;
|
||||
// ESC t 6
|
||||
port.write_all(&[0x1B, 0x74, TABLE_CYR])?;
|
||||
|
||||
Ok(Self {
|
||||
port,
|
||||
width: cfg.width,
|
||||
})
|
||||
}
|
||||
|
||||
/// Очищает обе строки дисплея стандартной командой очистки.
|
||||
pub fn clear(&mut self) -> std::io::Result<()> {
|
||||
self.port.write_all(&[0x0C])
|
||||
}
|
||||
|
||||
/// Полностью перезаписывает первую или вторую строку текстом фиксированной ширины.
|
||||
pub fn print_line(&mut self, line: u8, text: &str) -> std::io::Result<()> {
|
||||
if !(1..=2).contains(&line) {
|
||||
return Ok(());
|
||||
let mut builder = serialport::new(&serial.port_name, serial.baud_rate)
|
||||
.data_bits(serial.data_bits)
|
||||
.parity(serial.parity)
|
||||
.stop_bits(serial.stop_bits)
|
||||
.flow_control(serial.flow_control)
|
||||
.timeout(serial.timeout);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
builder = builder.exclusive(serial.exclusive);
|
||||
}
|
||||
// Кадр фиксированной ширины сам перезаписывает остаток предыдущей строки.
|
||||
|
||||
let port = builder.open()?;
|
||||
Self::from_transport(port, display)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Write> Vfd<T> {
|
||||
/// Создаёт драйвер поверх произвольного транспорта.
|
||||
///
|
||||
/// Метод сразу записывает init-последовательность в переданный транспорт. Это удобно
|
||||
/// для mock transport в тестах и для нестандартных serial-обёрток.
|
||||
///
|
||||
/// # Ошибки
|
||||
///
|
||||
/// Возвращает [`VfdError::Config`], если настройки дисплея неверны, или
|
||||
/// [`VfdError::Io`], если транспорт не принял init-байты.
|
||||
///
|
||||
/// # Примеры
|
||||
///
|
||||
/// ```
|
||||
/// use escpos_vfd::{DisplaySettings, TextEncoding, Vfd};
|
||||
///
|
||||
/// let display = DisplaySettings::new(4, 1, TextEncoding::Ascii);
|
||||
/// let mut vfd = Vfd::from_transport(Vec::<u8>::new(), display)?;
|
||||
/// vfd.print_line(1, "OK")?;
|
||||
/// let bytes = vfd.into_inner();
|
||||
/// assert!(bytes.starts_with(&[0x1B, 0x40]));
|
||||
/// # Ok::<(), escpos_vfd::VfdError>(())
|
||||
/// ```
|
||||
pub fn from_transport(mut transport: T, display: DisplaySettings) -> Result<Self> {
|
||||
display.validate()?;
|
||||
let codec = EpsonCodec::new(display);
|
||||
let init = codec.init();
|
||||
if !init.is_empty() {
|
||||
transport.write_all(&init)?;
|
||||
}
|
||||
Ok(Self { transport, codec })
|
||||
}
|
||||
|
||||
/// Возвращает геометрию и настройки дисплея.
|
||||
pub fn display(&self) -> &DisplaySettings {
|
||||
self.codec.display()
|
||||
}
|
||||
|
||||
/// Количество символов в строке.
|
||||
pub fn columns(&self) -> usize {
|
||||
self.display().columns
|
||||
}
|
||||
|
||||
/// Количество строк дисплея.
|
||||
pub fn rows(&self) -> usize {
|
||||
self.display().rows
|
||||
}
|
||||
|
||||
/// Очищает дисплей стандартной командой очистки.
|
||||
///
|
||||
/// Метод отправляет байт `0x0C`. Он не обновляет внешние кэши приложения и не
|
||||
/// вызывает `flush`; при необходимости вызовите [`Vfd::flush`] явно.
|
||||
///
|
||||
/// # Ошибки
|
||||
///
|
||||
/// Возвращает [`VfdError::Io`] при ошибке записи.
|
||||
pub fn clear(&mut self) -> Result<()> {
|
||||
self.transport.write_all(&self.codec.clear())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Полностью перезаписывает строку текстом фиксированной ширины.
|
||||
///
|
||||
/// Текст санитизируется, обрезается по числу символов и дополняется пробелами до
|
||||
/// ширины дисплея, чтобы удалить остаток предыдущего содержимого строки.
|
||||
///
|
||||
/// # Ошибки
|
||||
///
|
||||
/// Возвращает [`VfdError::InvalidLine`], если `line` вне диапазона `1..=rows`, или
|
||||
/// [`VfdError::Io`] при ошибке записи.
|
||||
pub fn print_line(&mut self, line: u8, text: &str) -> Result<()> {
|
||||
self.codec.validate_line(line)?;
|
||||
self.goto_xy(1, line)?;
|
||||
let fitted = fit_to_width(&sanitize_for_cp866(text), self.width);
|
||||
self.write_cp866(&fitted)
|
||||
let fitted = self.codec.fit_line(text);
|
||||
self.write_text(&fitted)
|
||||
}
|
||||
|
||||
/// Выводит подготовленный кадр с начала строки без предварительной очистки.
|
||||
pub fn print_frame(&mut self, line: u8, frame: &str) -> std::io::Result<()> {
|
||||
if !(1..=2).contains(&line) {
|
||||
// Некорректный номер строки игнорируется так же, как в остальных методах печати.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
///
|
||||
/// В отличие от [`Vfd::print_line`], метод не выполняет санацию текста. Используйте
|
||||
/// его для заранее подготовленных кадров бегущей строки или тестовых байтовых
|
||||
/// сценариев, когда содержимое уже нормализовано вызывающим кодом.
|
||||
///
|
||||
/// # Ошибки
|
||||
///
|
||||
/// Возвращает [`VfdError::InvalidLine`] или [`VfdError::Io`].
|
||||
pub fn print_frame(&mut self, line: u8, frame: &str) -> Result<()> {
|
||||
self.codec.validate_line(line)?;
|
||||
self.goto_xy(1, line)?;
|
||||
|
||||
// На случай короткого или длинного кадра приводим его к ширине дисплея.
|
||||
let fitted = fit_to_width(frame, self.width);
|
||||
self.write_cp866(&fitted)
|
||||
let fitted = fit_to_width(frame, self.columns());
|
||||
self.write_text(&fitted)
|
||||
}
|
||||
|
||||
/// Печатает текст с координаты `(x, y)`, выполняя санацию и обрезку по правому краю.
|
||||
pub fn print_at(&mut self, x: u8, y: u8, text: &str) -> std::io::Result<()> {
|
||||
let text = sanitize_for_cp866(text);
|
||||
///
|
||||
/// Координаты задаются от единицы. Если текст длиннее оставшегося места в строке,
|
||||
/// лишние символы отбрасываются, а следующая строка не затрагивается.
|
||||
///
|
||||
/// # Ошибки
|
||||
///
|
||||
/// Возвращает [`VfdError::InvalidCoordinate`] или [`VfdError::Io`].
|
||||
pub fn print_at(&mut self, x: u8, y: u8, text: &str) -> Result<()> {
|
||||
let text = sanitize_text(text);
|
||||
self.print_at_prepared(x, y, &text)
|
||||
}
|
||||
|
||||
/// Печатает уже подготовленный текст без повторной санации.
|
||||
pub(crate) fn print_at_prepared(&mut self, x: u8, y: u8, text: &str) -> std::io::Result<()> {
|
||||
if x == 0 || usize::from(x) > self.width || !(1..=2).contains(&y) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let remaining = self.width - usize::from(x) + 1;
|
||||
let text = truncate_chars(text, remaining);
|
||||
pub(crate) fn print_at_prepared(&mut self, x: u8, y: u8, text: &str) -> Result<()> {
|
||||
self.codec.validate_xy(x, y)?;
|
||||
let remaining = self.columns() - usize::from(x) + 1;
|
||||
let text = truncate_chars(text, remaining).to_string();
|
||||
self.goto_xy(x, y)?;
|
||||
self.write_cp866(text)
|
||||
self.write_text(&text)
|
||||
}
|
||||
|
||||
/// Перемещает аппаратный курсор в координаты дисплея, заданные от единицы.
|
||||
fn goto_xy(&mut self, x: u8, y: u8) -> std::io::Result<()> {
|
||||
// US $ x y
|
||||
self.port.write_all(&[0x1F, 0x24, x, y])
|
||||
/// Записывает байты напрямую без кодировки и проверки содержимого.
|
||||
///
|
||||
/// Используйте этот escape hatch только для команд, которых нет в типизированном API,
|
||||
/// или для нестандартных таблиц символов конкретного устройства.
|
||||
///
|
||||
/// # Ошибки
|
||||
///
|
||||
/// Возвращает [`VfdError::Io`] при ошибке записи.
|
||||
pub fn write_raw(&mut self, bytes: &[u8]) -> Result<()> {
|
||||
self.transport.write_all(bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Кодирует строку в CP866 и целиком записывает байты в serial-порт.
|
||||
fn write_cp866(&mut self, s: &str) -> std::io::Result<()> {
|
||||
let (bytes, _, _) = IBM866.encode(s);
|
||||
self.port.write_all(&bytes)
|
||||
/// Устанавливает яркость.
|
||||
///
|
||||
/// Команда кодируется как `US X n` (`0x1F 0x58 level`). После записи выполняется
|
||||
/// `flush`, затем sync sleep на `display.brightness_settle`, если задержка не нулевая.
|
||||
///
|
||||
/// # Ошибки
|
||||
///
|
||||
/// Возвращает [`VfdError::UnsupportedBrightness`], если уровень вне настроенного
|
||||
/// диапазона или яркость отключена, и [`VfdError::Io`] при ошибке записи/flush.
|
||||
pub fn set_brightness(&mut self, level: u8) -> Result<()> {
|
||||
let cmd = self.codec.brightness(level)?;
|
||||
self.transport.write_all(&cmd)?;
|
||||
self.transport.flush()?;
|
||||
let settle = self.display().brightness_settle;
|
||||
if !settle.is_zero() {
|
||||
std::thread::sleep(settle);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Устанавливает яркость; значение ограничивается диапазоном `1..=4`.
|
||||
pub fn set_brightness(&mut self, n: u8) -> Result<()> {
|
||||
let n = n.clamp(1, 4);
|
||||
let cmd = [0x1F, 0x58, n]; // US 'X' n
|
||||
self.port.write_all(&cmd)?;
|
||||
self.port.flush()?;
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
|
||||
/// Завершает буферизированные записи транспорта.
|
||||
///
|
||||
/// # Ошибки
|
||||
///
|
||||
/// Возвращает [`VfdError::Io`], если внутренний транспорт не смог выполнить flush.
|
||||
pub fn flush(&mut self) -> Result<()> {
|
||||
self.transport.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Возвращает внутренний транспорт.
|
||||
///
|
||||
/// Метод потребляет драйвер. Это удобно в тестах, где внутренним транспортом служит
|
||||
/// `Vec<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(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Возвращает срез не длиннее заданного числа символов, не разрывая UTF-8.
|
||||
fn truncate_chars(s: &str, max_chars: usize) -> &str {
|
||||
s.char_indices()
|
||||
.nth(max_chars)
|
||||
.map_or(s, |(byte_index, _)| &s[..byte_index])
|
||||
}
|
||||
|
||||
/// Заменяет типографские символы на безопасные аналоги, представимые в CP866.
|
||||
pub fn sanitize_for_cp866(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| match c {
|
||||
'…' => '.', //
|
||||
'—' | '–' => '-', //
|
||||
'№' => '#', //
|
||||
'\t' => ' ',
|
||||
'“' | '”' => '"',
|
||||
'‘' | '’' => '\'',
|
||||
_ => c,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Обрезает строку по числу символов и дополняет пробелами до заданной ширины.
|
||||
pub fn fit_to_width(s: &str, width: usize) -> String {
|
||||
let mut out = String::with_capacity(width);
|
||||
let mut len = 0;
|
||||
for ch in s.chars().take(width) {
|
||||
out.push(ch);
|
||||
len += 1;
|
||||
impl From<std::convert::Infallible> for VfdError {
|
||||
fn from(value: std::convert::Infallible) -> Self {
|
||||
match value {}
|
||||
}
|
||||
out.extend(std::iter::repeat_n(' ', width - len));
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{DisplaySettings, Preset, TextEncoding, VfdConfig};
|
||||
|
||||
#[test]
|
||||
fn config_width_stays_within_protocol_coordinate_range() {
|
||||
assert_eq!(VfdConfig::new("test").with_width(0).width, 1);
|
||||
assert_eq!(VfdConfig::new("test").with_width(20).width, 20);
|
||||
assert_eq!(VfdConfig::new("test").with_width(usize::MAX).width, 255);
|
||||
fn mock_transport_gets_legacy_preset_bytes() {
|
||||
let cfg = VfdConfig::preset("test", Preset::Epson20x2Cp866).unwrap();
|
||||
let mut vfd = Vfd::from_transport(Vec::<u8>::new(), cfg.display).unwrap();
|
||||
|
||||
vfd.clear().unwrap();
|
||||
vfd.print_line(1, "Привет").unwrap();
|
||||
vfd.print_at(20, 2, "X!").unwrap();
|
||||
vfd.set_brightness(4).unwrap();
|
||||
|
||||
let bytes = vfd.into_inner();
|
||||
let mut expected = vec![0x1B, 0x40, 0x1B, 0x74, 6, 0x0C];
|
||||
expected.extend_from_slice(&[0x1F, 0x24, 1, 1]);
|
||||
expected.extend_from_slice(&encoding_rs::IBM866.encode("Привет ").0);
|
||||
expected.extend_from_slice(&[0x1F, 0x24, 20, 2, b'X']);
|
||||
expected.extend_from_slice(&[0x1F, 0x58, 4]);
|
||||
|
||||
assert_eq!(bytes, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitizes_typographic_characters_without_changing_regular_text() {
|
||||
assert_eq!(sanitize_for_cp866("№1\t“тест”—‘да’…"), "#1 \"тест\"-'да'.");
|
||||
assert_eq!(sanitize_for_cp866("обычный text"), "обычный text");
|
||||
fn raw_write_bypasses_encoding() {
|
||||
let display = DisplaySettings::new(20, 2, TextEncoding::Ascii);
|
||||
let mut vfd = Vfd::from_transport(Vec::<u8>::new(), display).unwrap();
|
||||
|
||||
vfd.write_raw(&[0x1B, b'?', 0xFF]).unwrap();
|
||||
|
||||
assert_eq!(vfd.into_inner(), vec![0x1B, 0x40, 0x1B, b'?', 0xFF]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fits_unicode_by_characters_and_pads_short_input() {
|
||||
assert_eq!(fit_to_width("Привет", 4), "Прив");
|
||||
assert_eq!(fit_to_width("да", 4), "да ");
|
||||
assert_eq!(fit_to_width("text", 0), "");
|
||||
fn invalid_coordinates_and_brightness_are_errors() {
|
||||
let display = DisplaySettings::new(20, 3, TextEncoding::Cp866);
|
||||
let mut vfd = Vfd::from_transport(Vec::<u8>::new(), display).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
vfd.print_line(4, "bad"),
|
||||
Err(VfdError::InvalidLine { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
vfd.print_at(21, 1, "bad"),
|
||||
Err(VfdError::InvalidCoordinate { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
vfd.set_brightness(5),
|
||||
Err(VfdError::UnsupportedBrightness { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_without_splitting_utf8_characters() {
|
||||
assert_eq!(truncate_chars("ёжик", 3), "ёжи");
|
||||
assert_eq!(truncate_chars("ёжик", 10), "ёжик");
|
||||
assert_eq!(truncate_chars("ёжик", 0), "");
|
||||
fn alternate_encodings_are_selectable_manually() {
|
||||
let display = DisplaySettings::new(4, 1, TextEncoding::Windows1251);
|
||||
let mut vfd = Vfd::from_transport(Vec::<u8>::new(), display).unwrap();
|
||||
|
||||
vfd.print_line(1, "т").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
vfd.into_inner(),
|
||||
vec![0x1B, 0x40, 0x1F, 0x24, 1, 1, 0xF2, b' ', b' ', b' ']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+560
-309
File diff suppressed because it is too large
Load Diff
+16
-1
@@ -5,6 +5,21 @@ vars:
|
||||
VFD_WIDTH: "20"
|
||||
|
||||
tasks:
|
||||
vfd:preset:
|
||||
desc: Run minimal preset sync example
|
||||
cmds:
|
||||
- cargo run --example preset_sync -- {{.VFD_PORT}}
|
||||
|
||||
vfd:manual:
|
||||
desc: Run minimal manual sync configuration example
|
||||
cmds:
|
||||
- cargo run --example manual_sync -- {{.VFD_PORT}}
|
||||
|
||||
vfd:tokio:
|
||||
desc: Run Tokio worker example
|
||||
cmds:
|
||||
- cargo run --features tokio --example tokio_worker -- {{.VFD_PORT}} {{.VFD_WIDTH}}
|
||||
|
||||
vfd:clock:
|
||||
desc: Run VFD clock example
|
||||
cmds:
|
||||
@@ -18,7 +33,7 @@ tasks:
|
||||
- cargo run --example marquee -- {{.VFD_PORT}} {{.VFD_WIDTH}} {{.VFD_CPS | default "8"}} {{.VFD_END_PAUSE_MS | default "1500"}} {{.VFD_BRIGHTNESS | default "2"}}
|
||||
vars:
|
||||
VFD_CPS: "8"
|
||||
VFD_END_PAUSE_MS: "1000"
|
||||
VFD_END_PAUSE_MS: "1500"
|
||||
VFD_BRIGHTNESS: "3"
|
||||
|
||||
vfd:brightness:
|
||||
|
||||
Reference in New Issue
Block a user