Initial commit
This commit is contained in:
commit
95ee08244e
162
.gitignore
vendored
Normal file
162
.gitignore
vendored
Normal file
@ -0,0 +1,162 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Yusuf Cihan
|
||||
|
||||
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.
|
290
README.md
Normal file
290
README.md
Normal file
@ -0,0 +1,290 @@
|
||||
# dymo-bluetooth
|
||||
|
||||
Use DYMO LetraTag LT-200B thermal label printer over Bluetooth in Python, without depending on its app.
|
||||
|
||||
I was not expecting to own a label printer, but after caming across to this online, I thought why I shouldn't get it for whatever reason since it was affordable. After a while, I decided to tinker with this printer so I can print anything whatever I would like instead of being limited with its mobile app, so here it is.
|
||||
|
||||

|
||||
|
||||
_The image on the printed label shown in the picture can be found [here.](https://github.com/ysfchn/dymo-bluetooth/blob/main/assets/example_image.png)_
|
||||
|
||||
<details>
|
||||
<summary>More about this printer model</summary>
|
||||
<br>
|
||||
|
||||
This printer doesn't use inks, it instead writes on the labels with thermal. And obviously, it does require a label cartridge (or called "casette"). From what I've seen, all LetraTag branded label casettes have the same same shape and dimensions, so it is easy to get another if the casette that came with the printer runs out. There are different kind of labels (paper, plastic, metalic etc.) available, and all of them are self-adhesive. The casettes don't have an electronical part, which makes the printer to be able to use casettes manufactured by someone else.
|
||||
|
||||
The label dimensions are 12 millimeter in height, however the printable area is just 32 pixels, so labels will have an very obvious white space even if all printable space is filled with black. The print resolution is 200 DPI and, as stated on the original website, it has a print speed of up to 7 millimeters per second. So unfortunately the print resolution is not perfect for everything, but still, it does its job, and in the end, I enjoyed playing around with this printer, even if it was too simple.
|
||||
|
||||
</details>
|
||||
|
||||
## Installation
|
||||
|
||||
```
|
||||
python -m pip install --upgrade https://github.com/ysfchn/dymo-bluetooth/archive/refs/heads/main.zip
|
||||
```
|
||||
|
||||
Python 3.10 or up is targeted, but 3.9 should work too. It depends on `bleak` for Bluetooth communication, and `pillow` for importing images from files. If `python-barcode` is installed, it can be used to print barcodes.
|
||||
|
||||
## Usage
|
||||
|
||||
There is a small CLI to print image files in a nearby printer.
|
||||
|
||||
```
|
||||
python -m dymo_bluetooth --help
|
||||
```
|
||||
|
||||
Or in a Python program:
|
||||
|
||||
```py
|
||||
from dymo_bluetooth import discover_printers, Canvas
|
||||
import asyncio
|
||||
|
||||
async def main():
|
||||
# Canvas class allows constructing 1-bit images in ease.
|
||||
canvas = Canvas()
|
||||
|
||||
# Pixels can be set and retrieved with methods.
|
||||
canvas.set_pixel(0, 0, True)
|
||||
|
||||
# Images needs to be stretched by at least 2 times like
|
||||
# how its mobile app does, otherwise printer will render
|
||||
# the labels too thin.
|
||||
canvas = canvas.stretch_image(2)
|
||||
|
||||
# Get a list of printers.
|
||||
printers = await discover_printers()
|
||||
|
||||
# Get the first found printer and print the
|
||||
# constructed Canvas. Returns the print status.
|
||||
await printers[0].print(canvas)
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This program is licensed under [MIT License](./LICENSE).
|
||||
|
||||
It is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See MIT license for details.
|
||||
|
||||
This program is not supported, sponsored, affiliated, approved, or endorsed in any way with DYMO. LetraTag is an trademark of DYMO. All other mentioned trademarks are the property of respective owners.
|
||||
|
||||
## Protocol
|
||||
|
||||
This printer uses Bluetooth LE (GATT) to send labels & retrieve its status. It does uses this service UUIDs:
|
||||
|
||||
| UUID | Used for |
|
||||
|:------:|:-------:|
|
||||
| `be3dd650-2b3d-42f1-99c1-f0f749dd0678` | Service |
|
||||
| `be3dd651-2b3d-42f1-99c1-f0f749dd0678` | [Print request (write -> printer)](#print-request) |
|
||||
| `be3dd652-2b3d-42f1-99c1-f0f749dd0678` | [Print reply (notify <- printer)](#result) |
|
||||
| `be3dd653-2b3d-42f1-99c1-f0f749dd0678` | Unknown |
|
||||
|
||||
`2b3d` part in the UUID may not be future-proof, but so far all produced printers of the same model seems to share the same UUID.
|
||||
|
||||
To discover the nearby printers, filter for the service UUID or/and check for the MAC address range. This printer has a MAC address in range of `58:CF:79:00:00:00` and `58:CF:79:FF:FF:FF`, which this MAC range block seems to be owned by Espressif Inc. according to Bluetooth vendor list.
|
||||
|
||||
All data structures are explained below so it can be used as a future reference.
|
||||
|
||||
### Directive
|
||||
|
||||
Directives are simply command types that found in the [payload](#payloads). A directive directly follows another directive, and so on. A payload may contain more than one directive.
|
||||
|
||||
Each directive consists of 2 bytes, first byte is always an ASCII escape character `1B` (in hexadecimal, or 27 in decimal), and second byte is the ASCII code point of the character that specifies the command type.
|
||||
|
||||
```
|
||||
COMMAND_TYPE[1] = 73 | 4D | 43 | 44 | 45 | 41 | 51
|
||||
DIRECTIVE[2] = 1B + COMMAND_TYPE[1]
|
||||
```
|
||||
|
||||
| Command | Character | = Code point (hex) | = Code point (decimal) | Notes |
|
||||
|:------:|:-------------------:|:------------------:|:------------:|:-------:|
|
||||
| [`START`](#start-command) | `s` | `0x73` | `115` | Start command. Each payload starts with this directive. |
|
||||
| [`MEDIA_TYPE`](#media_type-command) | `M` | `0x4d` | `77` | ? |
|
||||
| [`PRINT_DENSITY`](#print_density-command) | `C` | `0x43` | `67` | Seems unused. |
|
||||
| [`PRINT_DATA`](#print_data-command) | `D` | `0x44` | `68` | Defines the image that will be printed, see below for byte format. |
|
||||
| [`FORM_FEED`](#form_feed-command) | `E` | `0x45` | `69` | Follows after `PRINT_DATA`. |
|
||||
| [`STATUS`](#status-command) | `A` | `0x41` | `65` | Requests the status (?) |
|
||||
| [`END`](#end-command) | `Q` | `0x51` | `81` | End command. Each payload ends with this directive. |
|
||||
|
||||
Below is the structure of each command that follows after its directive:
|
||||
|
||||
#### `START` command
|
||||
|
||||
Each payload starts with directive. This command follows with 4 constant bytes after its directive.
|
||||
|
||||
```
|
||||
START[6] = DIRECTIVE[2] + 9A 02 00 00
|
||||
```
|
||||
|
||||
#### `MEDIA_TYPE` command
|
||||
|
||||
This command follows with 1 byte containing a number of some type (?) to be set for the printer. It doesn't really seem to be used by this printer, but this command was found in its mobile app anyway.
|
||||
|
||||
When this number has set to anything, the printer should advertise this number when searching for Bluetooth devices nearby. (not tested) So I guess it was planned to be used for some sort of casette checking & changing casette type, so printer can hold this value even when its turned off.
|
||||
|
||||
```
|
||||
MEDIA_TYPE[3] = DIRECTIVE[2] + ??
|
||||
```
|
||||
|
||||
#### `PRINT_DENSITY` command
|
||||
|
||||
Seems unused by the printer. Not sure what it does.
|
||||
|
||||
```
|
||||
PRINT_DENSITY[?] = DIRECTIVE[2] + ...
|
||||
```
|
||||
|
||||
#### `FORM_FEED` command
|
||||
|
||||
Used after [`PRINT_DATA`](#print_data-command) command. Doesn't take additional bytes other than its directive.
|
||||
|
||||
```
|
||||
FORM_FEED[2] = DIRECTIVE[2]
|
||||
```
|
||||
|
||||
#### `STATUS` command
|
||||
|
||||
Used after [`FORM_FEED`](#form_feed-command) command. Doesn't take additional bytes other than its directive.
|
||||
|
||||
```
|
||||
STATUS[2] = DIRECTIVE[2]
|
||||
```
|
||||
|
||||
#### `END` command
|
||||
|
||||
Each payload ends with directive. Doesn't take additional bytes other than its directive.
|
||||
|
||||
```
|
||||
END[2] = DIRECTIVE[2]
|
||||
```
|
||||
|
||||
#### `PRINT_DATA` command
|
||||
|
||||
This command defines the data of the image that will be printed to the label. It is followed by:
|
||||
|
||||
* Bits per pixel value; this is always equals to `1`, so in hex that would be `0x01`.
|
||||
|
||||
* Alignment; this is always equals to `2`, so in hex that would be `0x02`. This is always seems the case regardless of the content of the image, so not sure how printer will react for values other than `2`.
|
||||
|
||||
* Width of the image. This value consists of 4 bytes, in little-endian. Since the label is printed horizontally, it might be changed based on your input image, so continue reading about image format.
|
||||
|
||||
* Height of the image. This value consists of 4 bytes, in little-endian. Since labels can only contain 32 pixels from top to the bottom, simply use 32 as a height, so in hexadecimal, that would be: `20 00 00 00`
|
||||
|
||||
* Image data, see ["Image format"](#image-format).
|
||||
|
||||
```
|
||||
# Printer have fixed 32 pixel size
|
||||
HEIGHT[4] = 20 00 00 00
|
||||
|
||||
PRINT_DATA[variable] = DIRECTIVE[2] + 01 02 + WIDTH[4] + HEIGHT[4] + IMAGE_DATA[4 * WIDTH]
|
||||
```
|
||||
|
||||
### Image format
|
||||
|
||||
The printer accepts images in portrait direction, formatted as 1-bit monochrome and stored in little endian. To put it simply, it uses bits (`1` and `0`) to set each pixel, and since a byte contains 8 bits, the printer will require 4 bytes (4 * 8 = 32 pixels, which is the height of the printable area) to print a full line from top to the bottom.
|
||||
|
||||
```
|
||||
8 bits 4 bytes = 32 bits (8 * 4) = 1 printing line
|
||||
┌─────────────┐ ┌─────────┐
|
||||
│ │ │ │
|
||||
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -> 00 00 00 00 -> 1 full empty line
|
||||
binary binary binary binary hex
|
||||
|
||||
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 -> FF FF FF FF -> 1 full black line
|
||||
binary binary binary binary hex
|
||||
```
|
||||
|
||||
Since the format is in little endian, the first left-top pixel (X = 0, Y = 0) is inserted in the last byte of 4 bytes, and the biggest bit in that byte set.
|
||||
|
||||
```
|
||||
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 -> 00 00 00 80 -> 1 pixel in [x = 0, y = 0]
|
||||
binary binary binary │ binary hex
|
||||
│
|
||||
0 0 0 └─── 128
|
||||
decimal decimal decimal decimal
|
||||
```
|
||||
|
||||
As explained above, line always take up 4 bytes, so the image width can be found by dividing the byte count to 4. And since the image height doesn't vary (labels are printed horizontally in this printer, from left to right) the image height is always 32 pixels.
|
||||
|
||||
Each line is directly followed by another line (if any), so there is no additional separator between lines, and it is ordered sequantally, so the first 4 bytes in the image data will be the first line that will be printed out on the label.
|
||||
|
||||
### Payloads
|
||||
|
||||
There are 2 payloads that can be sent to the printer. Each payload starts with an [`HEADER`](#header), and it does follow with the payload data.
|
||||
|
||||
| Payload | Requires chunking? | Usage |
|
||||
|:---------:|:------------------:|:---------:|
|
||||
| [Print request](#print-request) | Yes | Sends an image to be printed out on the label. |
|
||||
| [Set media type](#set-media-type) | No | ? |
|
||||
|
||||
#### Magic value
|
||||
|
||||
Magic value consists of 2 bytes, which is `12 34` (in hexadecimal) or [18, 52] in decimal.
|
||||
|
||||
```
|
||||
MAGIC[2] = 12 34
|
||||
```
|
||||
|
||||
#### Header
|
||||
|
||||
Header consists of 9 bytes in total. First two bytes are constant, which is `FF F0`. Third and forth byte is [`MAGIC`](#magic-value), which is explained above. And it is followed by the length of the payload stored in 4 bytes (little-endian) that is going to follow after this header.
|
||||
|
||||
The ninth byte of the header is the checksum of the header, and it is calculated by simply getting sum of the all previous 8 bytes and getting the first byte of the sum.
|
||||
|
||||
```
|
||||
PARTIAL_HEADER[8] = FF F0 + MAGIC[2] + PAYLOAD_LENGTH[4]
|
||||
|
||||
CHECKSUM = SUM(PARTIAL_HEADER) & 0xFF
|
||||
|
||||
HEADER[9] = PARTIAL_HEADER[8] + CHECKSUM
|
||||
```
|
||||
|
||||
#### Print request
|
||||
|
||||
A print request payload consists of these directives; [`START`](#start-command), [`PRINT_DATA`](#print_data-command), [`FORM_FEED`](#form_feed-command), [`STATUS`](#status-command) and [`END`](#end-command) respectively. Directives follow after [`HEADER`](#header), so it would be the first.
|
||||
|
||||
```
|
||||
PRINT_REQUEST[variable] = HEADER[9] + START[6] + PRINT_DATA[variable] + FORM_FEED[2] + STATUS[2] + END[2]
|
||||
```
|
||||
|
||||
Due to both the way the printer works and the way the Bluetooth itself work, this payload needs to be broken down into chunks in each 500 bytes. Each chunk must be send separately over Bluetooth, the printer will start printing until it receives the last chunk.
|
||||
|
||||
Chunking needs to be start right after [`HEADER`](#header), so only the header is sent first (which is the 9 bytes), then the each chunk is separately sent via Bluetooth. So number of the total Bluetooth writes would be `1 + chunk count`.
|
||||
|
||||
Each chunk must contain its index before chunk data itself followed by. The chunk data can be smaller than 500, but it can only contain maximum 500 bytes, inclusive. In the end, you will have the full chunk to be sent over Bluetooth which contains the index and the data.
|
||||
|
||||
```
|
||||
CHUNK[0...501] = CHUNK_INDEX[1] + CHUNK_DATA[0...500]
|
||||
```
|
||||
|
||||
The last chunk must contain the [`MAGIC`](#magic-value) value appended end of the chunk.
|
||||
|
||||
```
|
||||
CHUNK[0...503] = CHUNK_INDEX[1] + CHUNK_DATA[0...500] + MAGIC[2]
|
||||
```
|
||||
|
||||
#### Set media type
|
||||
|
||||
A media type set payload consists of these directives; [`START`](#start-command), [`MEDIA_TYPE`](#media_type-command) and [`END`](#end-command) respectively. Directives follow after [`HEADER`](#header), so it would be the first.
|
||||
|
||||
```
|
||||
SET_MEDIA_TYPE[20] = HEADER[9] + START[6] + MEDIA_TYPE[3] + END[2]
|
||||
```
|
||||
|
||||
This payload doesn't require chunking and can be sent with single Bluetooth transaction.
|
||||
|
||||
### Result
|
||||
|
||||
Printer will notify on the [print reply UUID](#protocol), giving the status of the printing operation. First byte is always an ASCII escape character `1B` (in hexadecimal, or 27 in decimal), and second byte is the ASCII code point of the `R` character (`0x52` in hexadecimal, or 82 in decimal) that stands for the result.
|
||||
|
||||
Third byte represents the status code. The printer may be inconsistent when it comes to the status codes, see `Result` enum in the code for more details about available status codes and their (supposedly) meanings.
|
||||
|
||||
```
|
||||
RESULT[3] = 1B 52 + STATUS[1]
|
||||
```
|
||||
|
||||
## Extras
|
||||
|
||||
The printer will automatically print its MAC address if power button was pressed long.
|
BIN
assets/cover.jpg
Normal file
BIN
assets/cover.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.4 MiB |
BIN
assets/example_image.png
Normal file
BIN
assets/example_image.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.4 KiB |
38
dymo_bluetooth/__init__.py
Normal file
38
dymo_bluetooth/__init__.py
Normal file
@ -0,0 +1,38 @@
|
||||
# MIT License
|
||||
#
|
||||
# Copyright (c) 2024 ysfchn / Yusuf Cihan
|
||||
#
|
||||
# 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.
|
||||
|
||||
__all__ = [
|
||||
"discover_printers",
|
||||
"Canvas",
|
||||
"Result",
|
||||
"create_image",
|
||||
"create_code_128",
|
||||
"convert_image_to_canvas"
|
||||
]
|
||||
|
||||
from dymo_bluetooth.bluetooth import (
|
||||
discover_printers,
|
||||
create_image,
|
||||
create_code_128,
|
||||
convert_image_to_canvas
|
||||
)
|
||||
from dymo_bluetooth.printer import Canvas, Result
|
78
dymo_bluetooth/__main__.py
Normal file
78
dymo_bluetooth/__main__.py
Normal file
@ -0,0 +1,78 @@
|
||||
# MIT License
|
||||
#
|
||||
# Copyright (c) 2024 ysfchn / Yusuf Cihan
|
||||
#
|
||||
# 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.
|
||||
|
||||
from argparse import ArgumentParser, BooleanOptionalAction
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
from dymo_bluetooth.bluetooth import discover_printers, create_image
|
||||
import sys
|
||||
import asyncio
|
||||
|
||||
async def print_image(
|
||||
input_file : Path,
|
||||
max_timeout : int,
|
||||
stretch_factor : int,
|
||||
use_dither : bool
|
||||
):
|
||||
canvas = create_image(input_file, dither = use_dither)
|
||||
canvas = canvas.stretch_image(factor = stretch_factor)
|
||||
printers = await discover_printers(max_timeout)
|
||||
if not printers:
|
||||
print("Couldn't find any printers in given timeout!")
|
||||
exit(1)
|
||||
printer = printers[0]
|
||||
print(f"Starting to print on {printer._impl.address}...")
|
||||
await printer.connect()
|
||||
await printer.print(canvas)
|
||||
|
||||
|
||||
def main():
|
||||
module_name = cast(str, sys.modules[__name__].__file__).split("/")[-2]
|
||||
args = ArgumentParser(
|
||||
prog = f"python -m {module_name}",
|
||||
description = (
|
||||
"Print monochrome labels with DYMO LetraTag LT-200B label printer over "
|
||||
"Bluetooth."
|
||||
)
|
||||
)
|
||||
args.add_argument("image", help = "Image file to print. Must be 32 pixels in height.", type = Path, metavar = "IMAGE") # noqa: E501
|
||||
args.add_argument("--timeout", default = 5, type = int, help = "Maximum timeout to search for printers.") # noqa: E501
|
||||
args.add_argument("--dither", default = True, action = BooleanOptionalAction,
|
||||
help = "Use or don't use dithering when converting it to monochrome image."
|
||||
)
|
||||
args.add_argument("--factor", default = 2, type = int,
|
||||
help = "Stretch the image N times. Default is 2, otherwise printer" + \
|
||||
"will print images too thin."
|
||||
)
|
||||
parsed = args.parse_args()
|
||||
input_file = cast(Path, parsed.image).absolute()
|
||||
max_timeout = cast(int, parsed.timeout)
|
||||
stretch_factor = cast(int, parsed.factor)
|
||||
use_dither = cast(bool, parsed.dither)
|
||||
if not input_file.exists():
|
||||
print(f"Given image file doesn't exists: {input_file.as_posix()}")
|
||||
exit(1)
|
||||
asyncio.run(print_image(input_file, max_timeout, stretch_factor, use_dither))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
184
dymo_bluetooth/bluetooth.py
Normal file
184
dymo_bluetooth/bluetooth.py
Normal file
@ -0,0 +1,184 @@
|
||||
# MIT License
|
||||
#
|
||||
# Copyright (c) 2024 ysfchn / Yusuf Cihan
|
||||
#
|
||||
# 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.
|
||||
|
||||
import asyncio
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageChops
|
||||
from bleak import BleakScanner, BleakClient
|
||||
from typing import List, TYPE_CHECKING
|
||||
from dymo_bluetooth.printer import Canvas, Result, command_print
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bleak.backends.device import BLEDevice
|
||||
from bleak.backends.characteristic import BleakGATTCharacteristic
|
||||
|
||||
|
||||
SERVICE_UUID = "be3dd650-{uuid}-42f1-99c1-f0f749dd0678".format(uuid = "2b3d")
|
||||
PRINT_REQUEST_UUID = "be3dd651-{uuid}-42f1-99c1-f0f749dd0678".format(uuid = "2b3d")
|
||||
PRINT_REPLY_UUID = "be3dd652-{uuid}-42f1-99c1-f0f749dd0678".format(uuid = "2b3d")
|
||||
|
||||
# Not used in the actual vendor app.
|
||||
UNKNOWN_UUID = "be3dd653-{uuid}-42f1-99c1-f0f749dd0678".format(uuid = "2b3d")
|
||||
|
||||
|
||||
def is_espressif(mac : str):
|
||||
"""
|
||||
Returns True if given MAC address is in the range of Espressif Inc.
|
||||
"""
|
||||
start_block = 0x58_CF_79 << 24
|
||||
end_block = start_block + (1 << 24) # Exclusive
|
||||
mac_value = int(mac.replace(":", ""), base = 16)
|
||||
if (mac_value >= start_block) and (mac_value < end_block):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class Printer:
|
||||
def __init__(self, impl : "BLEDevice") -> None:
|
||||
self._impl = impl
|
||||
self._client = BleakClient(self._impl)
|
||||
|
||||
async def connect(self):
|
||||
if self._client.is_connected:
|
||||
return
|
||||
await self._client.connect()
|
||||
|
||||
async def disconnect(self):
|
||||
if not self._client.is_connected:
|
||||
return
|
||||
await self._client.disconnect()
|
||||
|
||||
async def print(self, canvas : Canvas):
|
||||
if not self._client.is_connected:
|
||||
raise Exception("Printer is not connected!")
|
||||
print_request : "BleakGATTCharacteristic" = self._client.services.get_characteristic(PRINT_REQUEST_UUID) # type: ignore # noqa: E501
|
||||
print_reply : "BleakGATTCharacteristic" = self._client.services.get_characteristic(PRINT_REPLY_UUID) # type: ignore # noqa: E501
|
||||
future : asyncio.Future[Result] = asyncio.Future()
|
||||
should_discard : bool = False
|
||||
# Printer sends two messages, first is the PRINTING, and second one is the
|
||||
# printing result. So we discard the first message.
|
||||
async def reply_get(_, data : bytearray): # noqa: E501
|
||||
nonlocal should_discard
|
||||
result = Result.from_bytes(data)
|
||||
if (not should_discard) and (result.value in [0, 1]):
|
||||
should_discard = True
|
||||
return
|
||||
# This is the second reply, which holds the actual status code.
|
||||
future.set_result(result)
|
||||
await self._client.start_notify(print_reply, reply_get)
|
||||
for chunk in command_print(canvas):
|
||||
await self._client.write_gatt_char(print_request, chunk, True)
|
||||
return await future
|
||||
|
||||
|
||||
async def discover_printers(max_timeout : int = 5) -> List[Printer]:
|
||||
"""
|
||||
Searches for printers nearby and returns a list of Printer objects. If no printer
|
||||
has found in the initial search, waits for scanning until the max timeout has been
|
||||
reached.
|
||||
"""
|
||||
printers : List[Printer] = []
|
||||
waited_total = 0
|
||||
async with BleakScanner(service_uuids = [SERVICE_UUID]) as scanner:
|
||||
while True:
|
||||
# TODO: In some cases, advetisement data may be non-null, containing
|
||||
# additional metadata about printer state but it is not implemented yet.
|
||||
for device, _ in scanner.discovered_devices_and_advertisement_data.values(): # noqa: E501
|
||||
if not is_espressif(device.address):
|
||||
continue
|
||||
# Also match the "Letratag" name just to be sure that we are looking for
|
||||
# the right device, since there are also other products made by DYMO.
|
||||
if (device.name or "") == f"Letratag {device.address.replace(':', '')}":
|
||||
printers.append(Printer(device))
|
||||
# Do we have any candidate printers? If so, return the found printers.
|
||||
# Otherwise, wait for the next scans until we found any.
|
||||
if printers:
|
||||
break
|
||||
elif waited_total >= max_timeout:
|
||||
return []
|
||||
await asyncio.sleep(0.5)
|
||||
waited_total += 0.5
|
||||
return printers
|
||||
|
||||
|
||||
def convert_image_to_canvas(
|
||||
image : Image.Image,
|
||||
dither : bool = True,
|
||||
trim : bool = False
|
||||
):
|
||||
"""
|
||||
Converts an Pillow Image to a Canvas object.
|
||||
"""
|
||||
output = image.convert("1", dither = \
|
||||
Image.Dither.FLOYDSTEINBERG if dither else Image.Dither.NONE
|
||||
)
|
||||
# If trim is enabled, discard trailing and leading blank lines.
|
||||
if trim:
|
||||
mask = Image.new("1", output.size, color = 255)
|
||||
diff = ImageChops.difference(output, mask)
|
||||
output = output.crop(diff.getbbox(alpha_only = False))
|
||||
# Shrink the image from the center if it exceeds the print height,
|
||||
# or max printable width.
|
||||
if (output.height > Canvas.HEIGHT):
|
||||
start_y = int(output.height / 2) - int(Canvas.HEIGHT / 2)
|
||||
output = output.crop(
|
||||
(0, start_y, output.width, start_y + Canvas.HEIGHT)
|
||||
)
|
||||
elif (output.height < Canvas.HEIGHT):
|
||||
raise ValueError("Image is too small, resizing not implemented.")
|
||||
if (output.width) > Canvas.MAX_WIDTH:
|
||||
raise ValueError("Image is too large, resizing not implemented.")
|
||||
# Convert image to pixel array.
|
||||
canvas = Canvas()
|
||||
for w in range(output.width):
|
||||
for h in range(output.height):
|
||||
pixel = output.getpixel((w, h, ))
|
||||
canvas.set_pixel(w, h, not pixel)
|
||||
return canvas
|
||||
|
||||
|
||||
def create_image(path : Path, dither : bool = True):
|
||||
"""
|
||||
Converts an image file in given path to Canvas.
|
||||
"""
|
||||
buffer = BytesIO()
|
||||
with path.open("rb") as op:
|
||||
buffer.write(op.read())
|
||||
buffer.seek(0)
|
||||
image = Image.open(buffer)
|
||||
return convert_image_to_canvas(image, dither)
|
||||
|
||||
|
||||
def create_code_128(text : str):
|
||||
"""
|
||||
Creates a Code 128 barcode and dumps to Canvas.
|
||||
"""
|
||||
try:
|
||||
from barcode import Code128 # type: ignore
|
||||
from barcode.writer import ImageWriter # type: ignore
|
||||
except ModuleNotFoundError:
|
||||
raise Exception("This method requires 'python-barcode' to be installed.")
|
||||
imwrite = ImageWriter()
|
||||
imwrite.dpi = 200
|
||||
code = Code128(text, writer = imwrite)
|
||||
return convert_image_to_canvas(code.render(text = ""), dither = False, trim = True)
|
392
dymo_bluetooth/printer.py
Normal file
392
dymo_bluetooth/printer.py
Normal file
@ -0,0 +1,392 @@
|
||||
# MIT License
|
||||
#
|
||||
# Copyright (c) 2024 ysfchn / Yusuf Cihan
|
||||
#
|
||||
# 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.
|
||||
|
||||
from enum import Enum
|
||||
from io import SEEK_END, BytesIO
|
||||
from typing import Union
|
||||
import math
|
||||
|
||||
BytesLike = Union[bytes, bytearray]
|
||||
|
||||
|
||||
class DirectiveCommand(Enum):
|
||||
"""
|
||||
Directives that accepted by the printer.
|
||||
"""
|
||||
START = "s"
|
||||
MEDIA_TYPE = "M"
|
||||
PRINT_DENSITY = "C" # Unused
|
||||
PRINT_DATA = "D"
|
||||
FORM_FEED = "E"
|
||||
STATUS = "A"
|
||||
END = "Q"
|
||||
|
||||
def to_bytes(self) -> bytes:
|
||||
# 27 = ASCII escape sequence
|
||||
return bytes((27, ord(self.value), ))
|
||||
|
||||
|
||||
class Result(Enum):
|
||||
|
||||
# Printing has been completed (supposedly). This may not always mean that a
|
||||
# label has been printed out from the printer. See FAILED_NO_CASETTE status
|
||||
# below for details.
|
||||
SUCCESS = 0
|
||||
|
||||
# PRINTING (or SUCCESS) is 1
|
||||
|
||||
# Print failed due to some unknown reason.
|
||||
FAILED = 2
|
||||
|
||||
# Printing has been completed but battery is low.
|
||||
SUCCESS_LOW_BATTERY = 3
|
||||
|
||||
# Print failed due to being cancelled.
|
||||
FAILED_CANCEL = 4
|
||||
|
||||
# FAILED (with a different status value) is 5
|
||||
|
||||
# Print failed due to the low batteries.
|
||||
FAILED_LOW_BATTERY = 6
|
||||
|
||||
# Failed due to casette not inserted. On my tests, printer never sends this
|
||||
# status, instead it will spin its gear even casette is not inserted, and
|
||||
# will send a SUCCESS status instead, so there is no way to check if
|
||||
# casette is actually inserted.
|
||||
FAILED_NO_CASETTE = 7
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data : BytesLike):
|
||||
if data[0] != 27:
|
||||
raise ValueError("Not a valid result value; 1st byte must be 0x1b (27)")
|
||||
if chr(data[1]) != "R":
|
||||
raise ValueError("Not a valid result value; 2nd byte must be 0x52 (82)")
|
||||
# There is a value 5, which also means printing has completed but has
|
||||
# a different status value, so we return FAILED since that's what it means.
|
||||
if data[2] == 5:
|
||||
return cls(Result.FAILED.value)
|
||||
# There is a value 1, which also means printing has completed but has
|
||||
# a different status value, so we return SUCCESS since that's what it means.
|
||||
if data[2] == 1:
|
||||
return cls(Result.SUCCESS.value)
|
||||
return cls(data[2])
|
||||
|
||||
|
||||
class Canvas:
|
||||
"""
|
||||
An implementation of 1-bit monochrome little-endian encoded 32 pixel height
|
||||
images for printing them to the label.
|
||||
|
||||
1 byte equals 8 bits, and each bit specifies if pixel should be black (= 1)
|
||||
or white (= 0). The generated image will contain a multiple of 4 bytes, equaling
|
||||
the label height in pixels (4 * 8 = 32 pixels).
|
||||
"""
|
||||
|
||||
__slots__ = ("buffer", )
|
||||
|
||||
HEIGHT = 32
|
||||
MAX_WIDTH = 8186
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.buffer = BytesIO()
|
||||
|
||||
def get_pixel(self, x : int, y : int):
|
||||
"""
|
||||
Gets the pixel in given coordinates.
|
||||
Returns True if pixel is black, otherwise False.
|
||||
"""
|
||||
if y >= Canvas.HEIGHT:
|
||||
raise ValueError(f"Canvas can't exceed {Canvas.HEIGHT} pixels in height.")
|
||||
if x >= Canvas.MAX_WIDTH:
|
||||
raise ValueError(f"Canvas can't exceed {Canvas.MAX_WIDTH} pixels in width.")
|
||||
|
||||
# Get the byte containing the pixel value of given coordinates.
|
||||
x_offset = math.ceil((x * Canvas.HEIGHT) / 8)
|
||||
y_offset = 3 - math.floor(y / 8)
|
||||
self.buffer.seek(x_offset + y_offset)
|
||||
|
||||
# Check if there is a bit value in given line.
|
||||
read = self.buffer.read(1)
|
||||
value = int.from_bytes(read or b"\x00", "little")
|
||||
is_black = bool(value & (1 << (7 - (y % 8))))
|
||||
return is_black
|
||||
|
||||
def set_pixel(self, x : int, y : int, color : bool):
|
||||
"""
|
||||
Sets a pixel to given coordinates.
|
||||
Setting color to True will paint a black color.
|
||||
"""
|
||||
if y >= Canvas.HEIGHT:
|
||||
raise ValueError(f"Canvas can't exceed {Canvas.HEIGHT} pixels in height.")
|
||||
if x >= Canvas.MAX_WIDTH:
|
||||
raise ValueError(f"Canvas can't exceed {Canvas.MAX_WIDTH} pixels in width.")
|
||||
|
||||
# Get the byte containing the pixel value of given coordinates.
|
||||
x_offset = math.ceil((x * Canvas.HEIGHT) / 8)
|
||||
y_offset = 3 - math.floor(y / 8)
|
||||
self.buffer.seek(x_offset + y_offset)
|
||||
|
||||
# Get the one of four slices in line in the given coordinates. Add the bit in
|
||||
# given location if color is black, otherwise exclude the bit to make it white.
|
||||
read = self.buffer.read(1)
|
||||
value = int.from_bytes(read or b"\x00", "little")
|
||||
if color:
|
||||
value = value | (1 << (7 - (y % 8)))
|
||||
else:
|
||||
value = value & ~(1 << (7 - (y % 8)))
|
||||
|
||||
# Change the current part of the line with modified one.
|
||||
self.buffer.seek(x_offset + y_offset)
|
||||
self.buffer.write(bytes([value]))
|
||||
|
||||
def stretch_image(self, factor : int = 2):
|
||||
"""
|
||||
Stretches image to the right in N times, and returns a new Canvas
|
||||
containing the stretched image. Factor 1 results in the same image.
|
||||
"""
|
||||
if factor < 1:
|
||||
raise ValueError("Stretch factor must be at least 1!")
|
||||
canvas = Canvas()
|
||||
for x in range(self.get_width()):
|
||||
for y in range(Canvas.HEIGHT):
|
||||
pixel = self.get_pixel(x, y)
|
||||
start_x = (x * factor)
|
||||
for i in range(factor):
|
||||
canvas.set_pixel(start_x + i, y, pixel)
|
||||
return canvas
|
||||
|
||||
def pad_image(self, width : int):
|
||||
"""
|
||||
Adds blank spacing to both sides of the image, until the image
|
||||
reaches to the given width. Returns a new Canvas containing the
|
||||
modified image. If image is already longer than given width, it
|
||||
will return the current Canvas.
|
||||
"""
|
||||
current_width = self.get_width()
|
||||
if current_width >= width:
|
||||
return self
|
||||
canvas = Canvas()
|
||||
left_padding = math.ceil(width / 2)
|
||||
for x in range(current_width):
|
||||
for y in range(Canvas.HEIGHT):
|
||||
canvas.set_pixel(left_padding + x, y, self.get_pixel(x, y))
|
||||
for i in range(Canvas.HEIGHT):
|
||||
canvas.set_pixel(current_width + left_padding, i, False)
|
||||
return canvas
|
||||
|
||||
def print(self):
|
||||
"""
|
||||
Prints the image to the console.
|
||||
"""
|
||||
for y in range(Canvas.HEIGHT):
|
||||
for x in range(self.get_width()):
|
||||
print("█" if self.get_pixel(x, y) else "░", end = "")
|
||||
print()
|
||||
|
||||
def get_width(self):
|
||||
"""
|
||||
Gets the painted width of the image.
|
||||
"""
|
||||
self.buffer.seek(0, SEEK_END)
|
||||
cur = self.buffer.tell()
|
||||
return math.ceil(cur / 4)
|
||||
|
||||
def get_size(self):
|
||||
"""
|
||||
Gets the width and height of the image in tuple.
|
||||
"""
|
||||
return (self.get_width(), Canvas.HEIGHT, )
|
||||
|
||||
def get_image(self):
|
||||
"""
|
||||
Gets the created image with added blank padding.
|
||||
"""
|
||||
self.buffer.seek(0)
|
||||
image = self.buffer.read()
|
||||
return image + (b"\x00" * (self.buffer.tell() % 4))
|
||||
|
||||
def empty(self):
|
||||
"""
|
||||
Makes all pixels in the canvas in white. Canvas size won't be changed.
|
||||
"""
|
||||
self.buffer.seek(0, SEEK_END)
|
||||
byte_count = self.buffer.tell()
|
||||
self.buffer.seek(0)
|
||||
self.buffer.truncate(0)
|
||||
self.buffer.seek(byte_count)
|
||||
self.buffer.write(b"\x00")
|
||||
|
||||
def copy(self):
|
||||
"""
|
||||
Creates a copy of this canvas.
|
||||
"""
|
||||
canvas = Canvas()
|
||||
self.buffer.seek(0)
|
||||
canvas.buffer.write(self.buffer.read())
|
||||
return canvas
|
||||
|
||||
def clear(self):
|
||||
"""
|
||||
Clears the canvas. Canvas size will be changed to 0.
|
||||
"""
|
||||
self.buffer.seek(0)
|
||||
self.buffer.truncate(0)
|
||||
|
||||
def __eq__(self, value: object, /) -> bool:
|
||||
if not isinstance(value, Canvas):
|
||||
return False
|
||||
return value.get_image() == self.get_image()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
self.buffer.seek(0, SEEK_END)
|
||||
byte_size = self.buffer.tell()
|
||||
image_size = "x".join(map(str, self.get_size()))
|
||||
return f"<{self.__class__.__name__} image={image_size} bytes={byte_size}>"
|
||||
|
||||
|
||||
class DirectiveBuilder:
|
||||
"""
|
||||
Builds directives for the printer.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def start():
|
||||
# [154, 2, 0, 0] is the "job ID".
|
||||
# Without that, printer won't print anything but a small blank label.
|
||||
# This the only "job ID" that the printer uses in start directive, it is not
|
||||
# related with some sort of queue or anything else, just a constant value.
|
||||
return bytes([*DirectiveCommand.START.to_bytes(), 154, 2, 0, 0])
|
||||
|
||||
@staticmethod
|
||||
def media_type(value : int):
|
||||
return bytes([*DirectiveCommand.MEDIA_TYPE.to_bytes(), value])
|
||||
|
||||
@staticmethod
|
||||
def form_feed():
|
||||
return bytes(DirectiveCommand.FORM_FEED.to_bytes())
|
||||
|
||||
@staticmethod
|
||||
def status():
|
||||
return bytes(DirectiveCommand.STATUS.to_bytes())
|
||||
|
||||
@staticmethod
|
||||
def end():
|
||||
return bytes(DirectiveCommand.END.to_bytes())
|
||||
|
||||
@staticmethod
|
||||
def print(
|
||||
data : BytesLike,
|
||||
image_width : int,
|
||||
bits_per_pixel : int,
|
||||
alignment : int
|
||||
):
|
||||
size = \
|
||||
image_width.to_bytes(4, "little") + \
|
||||
Canvas.HEIGHT.to_bytes(4, "little")
|
||||
return bytes([
|
||||
*DirectiveCommand.PRINT_DATA.to_bytes(),
|
||||
bits_per_pixel,
|
||||
alignment,
|
||||
*size,
|
||||
*data
|
||||
])
|
||||
|
||||
|
||||
def create_payload(data : BytesLike, is_print : bool = False):
|
||||
"""
|
||||
Creates a final payload to be sent to the printer from the input data.
|
||||
|
||||
Each iteration of this generator will yield the bytes that needs to be sent over
|
||||
the Bluetooth for each GATT write transaction.
|
||||
"""
|
||||
# Longer inputs needs to be splitted.
|
||||
CHUNK_SIZE = 500
|
||||
# Magic value.
|
||||
MAGIC = [18, 52]
|
||||
# Length of the data in 4 bytes.
|
||||
length = len(data).to_bytes(4, "little")
|
||||
# byte[9] = [255, 240, 18, 52, ...LENGTH{4}, CHECKSUM]
|
||||
header = bytearray([
|
||||
255, # Preamble
|
||||
240, # Flags
|
||||
*MAGIC,
|
||||
*length
|
||||
])
|
||||
# For checksum, we get the sum of all bytes, then get the first byte of the sum.
|
||||
checksum = sum(header) & 0xFF
|
||||
header.append(checksum)
|
||||
assert len(header) == 9, "Header must be 9 bytes"
|
||||
# Payloads other than writing doesn't require chunking,
|
||||
# so the input data can be added as-is.
|
||||
if not is_print:
|
||||
header.extend(data)
|
||||
yield header
|
||||
# However, write payloads must be chunked properly.
|
||||
else:
|
||||
# First yield the header.
|
||||
yield header
|
||||
# Split chunk in each 500 bytes.
|
||||
for index, step in enumerate(range(0, len(data), CHUNK_SIZE)):
|
||||
current_chunk = bytearray()
|
||||
chunk = data[step : step + CHUNK_SIZE]
|
||||
# TODO: Not sure what is the purpose of the this, but the original
|
||||
# vendor app skips this index, so we do the same here.
|
||||
chunk_index = index + 1 if index >= 27 else index
|
||||
current_chunk.append(chunk_index)
|
||||
current_chunk.extend(chunk)
|
||||
# If this is the last chunk, append MAGIC to the end.
|
||||
if (step + CHUNK_SIZE) >= len(data):
|
||||
current_chunk.extend(MAGIC)
|
||||
yield current_chunk
|
||||
|
||||
|
||||
def command_print(
|
||||
canvas : Canvas
|
||||
):
|
||||
"""
|
||||
Creates a print directive.
|
||||
"""
|
||||
payload = bytearray()
|
||||
payload.extend(DirectiveBuilder.start())
|
||||
payload.extend(DirectiveBuilder.print(
|
||||
canvas.get_image(),
|
||||
canvas.get_width(),
|
||||
bits_per_pixel = 1,
|
||||
alignment = 2
|
||||
))
|
||||
payload.extend(DirectiveBuilder.form_feed())
|
||||
payload.extend(DirectiveBuilder.status())
|
||||
payload.extend(DirectiveBuilder.end())
|
||||
return create_payload(payload, is_print = True)
|
||||
|
||||
|
||||
def command_casette(
|
||||
media_type : int
|
||||
):
|
||||
"""
|
||||
Creates a casette directive.
|
||||
"""
|
||||
payload = bytearray()
|
||||
payload.extend(DirectiveBuilder.start())
|
||||
payload.extend(DirectiveBuilder.media_type(media_type))
|
||||
payload.extend(DirectiveBuilder.end())
|
||||
return create_payload(payload)
|
42
pyproject.toml
Normal file
42
pyproject.toml
Normal file
@ -0,0 +1,42 @@
|
||||
[project]
|
||||
name = "dymo-bluetooth"
|
||||
version = "0.0.1"
|
||||
authors = [{ name = "ysfchn" }]
|
||||
license.file = "LICENSE"
|
||||
readme = "README.md"
|
||||
classifiers = [
|
||||
"Private :: Do Not Upload"
|
||||
]
|
||||
dependencies = [
|
||||
"bleak~=0.22.2",
|
||||
"pillow~=11.0.0"
|
||||
]
|
||||
optional-dependencies.full = ["python-barcode[images]~=0.15.1"]
|
||||
requires-python = ">=3.10,<3.12"
|
||||
|
||||
[project.urls]
|
||||
"GitHub" = "https://github.com/ysfchn/dymo-bluetooth"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["dymo_bluetooth"]
|
||||
|
||||
[tool.rye]
|
||||
dev-dependencies = [
|
||||
"ruff~=0.0.290"
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = [
|
||||
"setuptools>=61",
|
||||
"wheel"
|
||||
]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.basedpyright]
|
||||
venvPath = "."
|
||||
venv = ".venv"
|
||||
reportDeprecated = "none"
|
||||
typeCheckingMode = "standard"
|
||||
reportOptionalMemberAccess = "warning"
|
||||
reportOptionalIterable = "none"
|
||||
pythonPlatform = "All"
|
23
requirements-dev.lock
Normal file
23
requirements-dev.lock
Normal file
@ -0,0 +1,23 @@
|
||||
# generated by rye
|
||||
# use `rye lock` or `rye sync` to update this lockfile
|
||||
#
|
||||
# last locked with the following flags:
|
||||
# pre: false
|
||||
# features: []
|
||||
# all-features: false
|
||||
# with-sources: false
|
||||
# generate-hashes: false
|
||||
# universal: false
|
||||
|
||||
-e file:.
|
||||
async-timeout==4.0.3
|
||||
# via bleak
|
||||
bleak==0.22.2
|
||||
# via dymo-bluetooth
|
||||
dbus-fast==2.22.1
|
||||
# via bleak
|
||||
pillow==11.0.0
|
||||
# via dymo-bluetooth
|
||||
ruff==0.0.292
|
||||
typing-extensions==4.12.2
|
||||
# via bleak
|
22
requirements.lock
Normal file
22
requirements.lock
Normal file
@ -0,0 +1,22 @@
|
||||
# generated by rye
|
||||
# use `rye lock` or `rye sync` to update this lockfile
|
||||
#
|
||||
# last locked with the following flags:
|
||||
# pre: false
|
||||
# features: []
|
||||
# all-features: false
|
||||
# with-sources: false
|
||||
# generate-hashes: false
|
||||
# universal: false
|
||||
|
||||
-e file:.
|
||||
async-timeout==4.0.3
|
||||
# via bleak
|
||||
bleak==0.22.2
|
||||
# via dymo-bluetooth
|
||||
dbus-fast==2.22.1
|
||||
# via bleak
|
||||
pillow==11.0.0
|
||||
# via dymo-bluetooth
|
||||
typing-extensions==4.12.2
|
||||
# via bleak
|
Loading…
x
Reference in New Issue
Block a user