Compare commits

...

13 commits

Author SHA1 Message Date
Piotr Gaczkowski
bad2aac4d6 feat: Add basic frontend 2026-04-16 13:43:52 +02:00
Piotr Gaczkowski
2a2b5973df feat: Add Web iface with QR codes 2026-04-16 13:43:52 +02:00
Hubert Bryłkowski
3897d37f2a support more tape widths 2026-04-16 13:43:52 +02:00
Hubert Bryłkowski
6b50d02d60 expand docs 2026-04-16 13:43:52 +02:00
Hubert Bryłkowski
aad7c67d48 added support for printing simple labels 2026-04-16 13:43:52 +02:00
Hubert Bryłkowski
92bf65dc62 listed supported commands 2026-04-16 13:43:52 +02:00
Hubert Bryłkowski
c6787d8c2e starter docs 2026-04-16 13:43:50 +02:00
Hubert Bryłkowski
c721e4c909 simplified dockerfile 2026-04-16 13:42:21 +02:00
Hubert Bryłkowski
19516d50fc build on every push 2026-04-16 13:42:21 +02:00
Hubert Bryłkowski
cda1ba4f07 add action for building docker 2026-04-16 13:42:19 +02:00
Hubert Bryłkowski
27798e6e54 simple bot for getting media info 2026-04-16 13:41:44 +02:00
Hubert Bryłkowski
8c420af0af add function for getting info about installed tape 2026-04-16 13:41:44 +02:00
Hubert Bryłkowski
3865dcc402 init commit with repo structure and basic files 2026-04-16 13:41:44 +02:00
36 changed files with 9755 additions and 0 deletions

6
.dockerignore Normal file
View file

@ -0,0 +1,6 @@
.venv
.git
__pycache__
*.pyc
.ruff_cache
.mypy_cache

47
.github/workflows/build_docker.yml vendored Normal file
View file

@ -0,0 +1,47 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# GitHub recommends pinning actions to a commit SHA.
# To get a newer version, you will need to update the SHA.
# You can also reference a tag or branch, but the action may change without warning.
name: Publish Docker image
on:
push:
branches:
- main
jobs:
push_to_registries:
name: Push Docker image to multiple registries
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: ghcr.io/${{ github.repository }}
- name: Build and push Docker images
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

20
Dockerfile Normal file
View file

@ -0,0 +1,20 @@
# Build stage
FROM python:3.11 AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project
COPY . .
RUN uv sync
# Runtime stage
FROM python:3.11-slim
WORKDIR /app
COPY --from=builder /app/ /app/
ENV PATH="/app/.venv/bin:$PATH"
CMD ["fastapi", "dev", "--host", "0.0.0.0", "--port", "31337", "labeler/adapter/fastapi_srv.py" ]

12
docker-compose.yml Normal file
View file

@ -0,0 +1,12 @@
version: "3.8"
services:
bot:
build:
context: .
dockerfile: Dockerfile
environment:
- PRINTER_IP=192.168.1.93
- TELEGRAM_TOKEN=6386069775:AAFOQL7lIsDe_5njXptfOwmHWEVo_yyVAi4
command:
- python
- labeler/adapter/telegram_bot.py

0
fegen/__init__.py Normal file
View file

111
fegen/discourse.py Normal file
View file

@ -0,0 +1,111 @@
'''
Class to generate a csv file based on data fetched via Discourse REST API
'''
import os
import csv
import json
import requests
from dotenv import load_dotenv
DISCOURSE_URL = "https://kb.hs3.pl" # Database is hosted here
CATEGORY_ID = 9 # Database category ID
class DiscourseDatabase():
def __init__(self):
data = self.get_category_data()
self.category_topics_csv(data)
load_dotenv()
def get_headers(self, auth=False):
"""Get request headers, optionally with auth data."""
headers = {
"content-type": "application/json",
}
if auth:
headers["Api-Key"] = os.getenv("DISCOURSE_PAT")
headers["Api-Username"] = os.getenv("DISCOURSE_USERNAME")
return headers
def get_category_data(self) -> dict:
"""Get all topics from a Discourse category with pagination"""
url = f"{DISCOURSE_URL}/c/{CATEGORY_ID}.json"
print(f"Fetching data from {url}")
all_topics = []
page = 0
while True:
params = {"per_page": 100, "page": page}
res = requests.get(url, headers=self.get_headers(), params=params)
res.raise_for_status()
res_json = res.json()
topics = res_json["topic_list"]["topics"]
if not topics:
break
for topic in topics:
if topic["category_id"] == CATEGORY_ID:
all_topics.append(topic)
print(f"Fetched page {page}: {len(topics)} topics, {len(all_topics)} total in category")
page += 1
return {"topic_list": {"topics": all_topics}}
def get_topic_content(self, topic_id: str):
"""Get a single topic's content"""
get_url = f"{DISCOURSE_URL}/posts/{topic_id}.json"
res = requests.get(get_url, headers=self.get_headers(auth=True))
res.raise_for_status()
return res.json()
def category_topics_csv(self, category_data) -> None:
"""Save category topics to a csv file"""
columns = ["id", "title", "place", "tags"]
records = category_data["topic_list"]["topics"]
with open('zasoby.csv', 'w', encoding='UTF8') as f:
write = csv.writer(f)
write.writerow(columns)
for topic in records:
html_url = f'<a href="{DISCOURSE_URL}/t/{topic["id"]}">{topic["title"]}</a>'
place = self.get_place(topic)
write.writerow([topic["id"], html_url, place, topic["tags"]])
print(f"New zasoby.csv generated with {len(records)} records")
def get_place(self, topic):
"""Get place of a topic"""
places = ["cow-work", "garage", "lab"]
for place in places:
if place in topic["tags"]:
return f'<a href="https://kb.hs3.pl/tag/{place}">{place}</a>'
return "unknown"
def replace_string_in_post(self, topic_id: str, old_string: str, new_string: str) -> dict:
"""Replace a selected string within a topic's first post using Discourse REST API"""
# Fetch the topic to get the first post ID
topic_url = f"{DISCOURSE_URL}/t/{topic_id}.json"
topic_res = requests.get(topic_url, headers=self.get_headers(auth=True))
topic_res.raise_for_status()
topic_data = topic_res.json()
# Get the first post ID from the topic
first_post_id = topic_data["post_stream"]["posts"][0]["id"]
# Fetch the post content
post_url = f"{DISCOURSE_URL}/posts/{first_post_id}.json"
post_res = requests.get(post_url, headers=self.get_headers(auth=True))
post_res.raise_for_status()
post_data = post_res.json()
# Replace the string
updated_raw = post_data["raw"].replace(old_string, new_string)
# Update the post
payload = {"post": {"raw": updated_raw}}
res = requests.put(post_url, json=payload, headers=self.get_headers(auth=True))
res.raise_for_status()
return res.json()
if __name__ == "__main__":
disc = DiscourseDatabase()
category = disc.get_category_data()
records = category["topic_list"]["topics"]
for topic in records:
if "lab" in topic["tags"]:
disc.replace_string_in_post(topic["id"], "[Workshop](https://kb.s.hs3.pl/tag/workshop)", "[Lab](https://kb.s.hs3.pl/tag/lab)")

7030
fegen/docs/index.html Normal file

File diff suppressed because one or more lines are too long

14
fegen/docs/static/css/style.css vendored Normal file
View file

@ -0,0 +1,14 @@
footer {
padding: 12px;
margin-top: auto;
}
/* || Sidenav */
.sidenav {
width: 250px;
position: sticky;
z-index: 1;
top: 0;
overflow-x: hidden;
padding: 6px 8px 6px 16px;
}

44
fegen/main.py Normal file
View file

@ -0,0 +1,44 @@
import os, re, shutil
from jinja2 import Environment, FileSystemLoader
import pandas as pd
def add_download_button(row):
item_id = re.sub(r'<a href.*/([0-9]+)".*', "\\1", row[1])
download_button = (
f'<button id="btn_{item_id}"><i class="fa fa-download"></i> {item_id}</button>'
)
print_button = f'<a href="http://localhost:31337/print/{item_id}"><button id="prn_{item_id}"><i class="fa fa-print"></i> {item_id}</button></a>'
return row + [download_button, print_button]
def generate_dashboard():
"""Generate dashboard from zasoby.csv file"""
print("Generating HTML dashboard")
website_folder = "fegen/docs"
data = pd.read_csv("zasoby.csv")
env = Environment(loader=FileSystemLoader("fegen/template"))
print("Removing old website files")
print("Creating a new website")
shutil.copytree("fegen/template/static", f"{website_folder}/static", dirs_exist_ok=True)
template = env.get_template("_main_layout.html")
with open(f"{website_folder}/index.html", "w+", encoding="utf-8") as file:
header_row = data.columns.values.tolist() + ["label", "print"]
rows = map(
add_download_button,
data.values.tolist(),
)
html = template.render(
title="Baza Zasobów Hackerspace Trójmiasto",
t_header=header_row,
t_body=rows,
)
file.write(html)
if __name__ == "__main__":
from discourse import DiscourseDatabase
DiscourseDatabase()
generate_dashboard()
print("Done!")

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,29 @@
{% extends "_base_template.html" %} {% block body %}
<div id="site-body" class="container-fluid">
<div class="row flex-nonwrap">
<div class="sidenav">{% block sidenav %}{% endblock sidenav %}</div>
<div class="main">
<h1>Baza Zasobów Hackerspace Trójmiasto</h1>
<a href="/refresh"><button id="refresh"><i class="fa fa-arrows-rotate"></i> Refresh</button></a>
<table id="dashboardTable">
<thead>
<tr>
{% for cell in t_header %}
<td>{{cell}}</td>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in t_body %}
<tr>
{% for cell in row %}
<td>{{cell}}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock body %}

View file

@ -0,0 +1,14 @@
footer {
padding: 12px;
margin-top: auto;
}
/* || Sidenav */
.sidenav {
width: 250px;
position: sticky;
z-index: 1;
top: 0;
overflow-x: hidden;
padding: 6px 8px 6px 16px;
}

Binary file not shown.

BIN
img.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

BIN
img_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

75
labeler/adapter/cli.py Normal file
View file

@ -0,0 +1,75 @@
import os
from labeler.app.labeler import Application
from labeler.infra.e550w_printer.printer import E550W
from labeler.infra.renderer import PILRenderer
class LabelingBot:
def __init__(self, app: Application):
self.app = app
def media_info(self):
media = self.app.get_installed_media()
print(f"Installed medium: {media}")
def simple_label(self, label_text, label_length=0):
try:
label = self.app.print_label(text=label_text, length=label_length)
except Exception as e:
print(f"There was an exception: {e}")
def get_qrcode(self, label_text, label_length=0):
label = self.app.render_qrcode_preview(text=label_text, length=label_length)
return label
def print_qrcode(self, label_text, label_length=0):
try:
label = self.app.print_qrcode(text=label_text, length=label_length)
except Exception as e:
print(f"There was an exception: {e}")
# async def label_length(self):
# await update.message.reply_text(
# "Hello! Please tell me the length of the label, enter 0 for auto:"
# )
# return LABEL_LENGTH
#
# async def label_text(self, update: Update, context: CallbackContext) -> int:
# user_input = update.message.text
# context.user_data["length"] = int(user_input)
# await update.message.reply_text("Now, please tell me the text of the label:")
# return LABEL_TEXT
#
# async def simple_label(self, update: Update, context: CallbackContext) -> int:
# user_input = update.message.text
# context.user_data["label"] = user_input
# try:
# label = self.app.print_label(
# text=context.user_data["label"], length=context.user_data["length"]
# )
# except Exception as e:
# await update.message.reply_text(f"There was an exception: {e}")
# return ConversationHandler.END
#
# await update.message.reply_photo(
# label.bytes, f'Your label is: {context.user_data["label"]}'
# )
# return ConversationHandler.END
#
# async def cancel(self, update: Update, context: CallbackContext) -> int:
# await update.message.reply_text("Cancelled.")
# return ConversationHandler.END
if __name__ == "__main__":
application = Application(PILRenderer(), E550W(os.environ.get("PRINTER_IP")))
bot = LabelingBot(application)
LABEL_LENGTH, LABEL_TEXT = range(2)
bot.media_info()
label = bot.get_qrcode("512", 25)
with open("label.png", "wb") as preview:
preview.write(label.bytes)
label = bot.print_qrcode("512", 25)

View file

@ -0,0 +1,98 @@
import os
from labeler.app.labeler import Application
from labeler.infra.e550w_printer.printer import E550W
from labeler.infra.renderer import PILRenderer
from fastapi import FastAPI
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fegen import discourse, main
app = FastAPI()
app.mount("/static", StaticFiles(directory="fegen/docs/static"), name="static")
class LabelingBot:
def __init__(self, app: Application):
self.app = app
def media_info(self):
media = self.app.get_installed_media()
print(f"Installed medium: {media}")
def simple_label(self, label_text, label_length=0):
try:
label = self.app.print_label(text=label_text, length=label_length)
except Exception as e:
print(f"There was an exception: {e}")
def get_qrcode(self, label_text, label_length=0):
label = self.app.render_qrcode_preview(text=label_text, length=label_length)
return label
def print_qrcode(self, label_text, label_length=0):
try:
label = self.app.print_qrcode(text=label_text, length=label_length)
except Exception as e:
print(f"There was an exception: {e}")
# async def label_length(self):
# await update.message.reply_text(
# "Hello! Please tell me the length of the label, enter 0 for auto:"
# )
# return LABEL_LENGTH
#
# async def label_text(self, update: Update, context: CallbackContext) -> int:
# user_input = update.message.text
# context.user_data["length"] = int(user_input)
# await update.message.reply_text("Now, please tell me the text of the label:")
# return LABEL_TEXT
#
# async def simple_label(self, update: Update, context: CallbackContext) -> int:
# user_input = update.message.text
# context.user_data["label"] = user_input
# try:
# label = self.app.print_label(
# text=context.user_data["label"], length=context.user_data["length"]
# )
# except Exception as e:
# await update.message.reply_text(f"There was an exception: {e}")
# return ConversationHandler.END
#
# await update.message.reply_photo(
# label.bytes, f'Your label is: {context.user_data["label"]}'
# )
# return ConversationHandler.END
#
# async def cancel(self, update: Update, context: CallbackContext) -> int:
# await update.message.reply_text("Cancelled.")
# return ConversationHandler.END
@app.get("/", response_class=HTMLResponse)
async def root():
with open("fegen/docs/index.html", "r") as f:
index = f.read()
return index
@app.get("/print/{item_id}")
def print_item(item_id: int, q: str | None = None):
application = Application(PILRenderer(), E550W(os.environ.get("PRINTER_IP")))
bot = LabelingBot(application)
LABEL_LENGTH, LABEL_TEXT = range(2)
bot.media_info()
label = bot.get_qrcode(item_id, 25)
with open("label.png", "wb") as preview:
preview.write(label.bytes)
bot.print_qrcode(item_id, 25)
return RedirectResponse(url="/", status_code=302)
@app.get("/refresh")
def refresh():
discourse.DiscourseDatabase()
main.generate_dashboard()
return RedirectResponse(url="/", status_code=302)

View file

@ -0,0 +1,82 @@
import os
from telegram import Update
from telegram.ext import (
CommandHandler,
ApplicationBuilder,
ConversationHandler,
CallbackContext,
filters,
MessageHandler,
)
from labeler.app.labeler import Application
from labeler.infra.e550w_printer.printer import E550W
from labeler.infra.renderer import PILRenderer
class LabelingBot:
def __init__(self, app: Application):
self.app = app
async def media_info(self, update, context):
media = self.app.get_installed_media()
await update.message.reply_text(f"Installed media: {media.description}")
async def label_length(self, update, context):
await update.message.reply_text(
"Hello! Please tell me the length of the label, enter 0 for auto:"
)
return LABEL_LENGTH
async def label_text(self, update: Update, context: CallbackContext) -> int:
user_input = update.message.text
context.user_data["length"] = int(user_input)
await update.message.reply_text("Now, please tell me the text of the label:")
return LABEL_TEXT
async def simple_label(self, update: Update, context: CallbackContext) -> int:
user_input = update.message.text
context.user_data["label"] = user_input
try:
label = self.app.print_label(
text=context.user_data["label"], length=context.user_data["length"]
)
except Exception as e:
await update.message.reply_text(f"There was an exception: {e}")
return ConversationHandler.END
await update.message.reply_photo(
label.bytes, f'Your label is: {context.user_data["label"]}'
)
return ConversationHandler.END
async def cancel(self, update: Update, context: CallbackContext) -> int:
await update.message.reply_text("Cancelled.")
return ConversationHandler.END
if __name__ == "__main__":
application = Application(PILRenderer(), E550W(os.environ.get("PRINTER_IP")))
bot = LabelingBot(application)
LABEL_LENGTH, LABEL_TEXT = range(2)
conv_handler = ConversationHandler(
entry_points=[CommandHandler("simple_label", bot.label_length)],
states={
LABEL_LENGTH: [
MessageHandler(filters.Text() & ~filters.Command(), bot.label_text)
],
LABEL_TEXT: [
MessageHandler(filters.Text() & ~filters.Command(), bot.simple_label)
],
},
fallbacks=[CommandHandler("cancel", bot.cancel)],
)
app = ApplicationBuilder().token(os.environ["TELEGRAM_TOKEN"]).build()
app.add_handler(CommandHandler("media_info", bot.media_info))
app.add_handler(conv_handler)
app.run_polling()

90
labeler/app/labeler.py Normal file
View file

@ -0,0 +1,90 @@
from labeler.domain.objects import (
Label,
LabelRequest,
LabelDefinition,
MediaDefinition,
Dimension,
Image,
)
from labeler.interfaces import Renderer, Printer
class Application:
def __init__(self, renderer: Renderer, printer: Printer):
self.renderer = renderer
self.printer = printer
def render_preview(self, text: str, length: int = None) -> Label:
media = self.printer.get_installed_media()
if length != 0:
label_length = Dimension(mm=length) - 2 * media.minimal_margin_horizontal
else:
label_length = None
label_definition = LabelDefinition(
text=text,
length=label_length,
width=media.printable_width,
dpi=media.dpi,
)
self.renderer.render_label(label_definition)
def render_qrcode_preview(self, text: str, length: int = None) -> Label:
media = self.printer.get_installed_media()
if length != 0:
label_length = Dimension(mm=length) - 2 * media.minimal_margin_horizontal
else:
label_length = None
label_definition = LabelDefinition(
text=text,
length=label_length,
width=media.printable_width,
dpi=media.dpi,
)
return self.renderer.render_qrcode(label_definition)
def print_label(self, text: str, length: int = None) -> Image:
media = self.printer.get_installed_media()
if length != 0:
label_length = Dimension(mm=length) - 2 * media.minimal_margin_horizontal
else:
label_length = None
label_definition = LabelDefinition(
text=text,
length=label_length,
width=media.printable_width,
dpi=media.dpi,
)
label = self.renderer.render_label(label_definition)
self.printer.print_label(label)
return label
def print_qrcode(self, text: str, length: int = None) -> Image:
media = self.printer.get_installed_media()
if length != 0:
label_length = Dimension(mm=length) - 2 * media.minimal_margin_horizontal
else:
label_length = None
label_definition = LabelDefinition(
text=text,
length=label_length,
width=media.printable_width,
dpi=media.dpi,
)
label = self.renderer.render_qrcode(label_definition)
self.printer.print_label(label)
return label
def get_installed_media(self) -> MediaDefinition:
return self.printer.get_installed_media()

125
labeler/domain/objects.py Normal file
View file

@ -0,0 +1,125 @@
import io
from math import inf
from pydantic import BaseModel, Field
class Image(BaseModel):
bytes: bytes
width: int
height: int
@classmethod
def from_pil(cls, pil_image):
buffer = io.BytesIO()
pil_image.save(buffer, format="PNG")
buffer.seek(0)
return cls(
bytes=buffer.read(),
width=pil_image.width,
height=pil_image.height,
)
class Dimension(BaseModel):
mm: float
__EPSILON = 0.0001
@classmethod
def from_inch(cls, inch: float) -> "Dimension":
return cls(mm=inch * 25.4)
@classmethod
def from_points(cls, points: float, dpi: int) -> "Dimension":
return cls.from_inch(points / dpi)
@property
def inch(self) -> float:
return self.mm / 25.4
def in_pixels(self, dpi: int) -> int:
return int(self.inch * dpi)
def __ensure_same_type(self, other):
if type(other) != Dimension:
raise TypeError(f"Cannot use {other} to {self}")
def __add__(self, other):
self.__ensure_same_type(other)
return Dimension(mm=self.mm + other.mm)
def __sub__(self, other):
self.__ensure_same_type(other)
return Dimension(mm=self.mm - other.mm)
def __mul__(self, other):
if type(other) not in (int, float):
raise TypeError(f"Cannot multiply {self} by {other}")
return Dimension(mm=self.mm * other)
def __rmul__(self, other):
return self.__mul__(other)
def __truediv__(self, other):
if type(other) not in (int, float):
raise TypeError(f"Cannot divide {self} by {other}")
return Dimension(mm=self.mm / other)
def __eq__(self, other):
self.__ensure_same_type(other)
if self.mm == inf and other.mm == inf:
return True
return abs(self.mm - other.mm) < self.__EPSILON
def __lt__(self, other):
self.__ensure_same_type(other)
return self.mm < other.mm
def __gt__(self, other):
self.__ensure_same_type(other)
return self.mm > other.mm
class LabelRequest(BaseModel):
text: str
length: Dimension | None
class LabelDefinition(BaseModel):
text: str
length: Dimension | None = None
width: Dimension
dpi: int
@property
def pixel_width(self):
return self.width.in_pixels(self.dpi)
@property
def pixel_length(self):
return self.length.in_pixels(self.dpi)
class MediaDefinition(BaseModel):
width: Dimension
length: Dimension
minimal_margin_vertical: Dimension
minimal_margin_horizontal: Dimension
minimum_length: Dimension = Field(default_factory=lambda: Dimension(mm=5))
dpi: int
description: str
@property
def printable_width(self) -> Dimension:
return self.width - 2 * self.minimal_margin_vertical
@property
def printable_length(self) -> Dimension:
return self.length - 2 * self.minimal_margin_horizontal
class Label(BaseModel):
dpi: str
image: Image

View file

@ -0,0 +1,81 @@
"""
Values from technical reference manual, can be found in /labeler_docs/brother/technical_reference_manual.pdf
"""
WIDTH_BYTE = 10
TYPE_BYTE = 11
COLOR_BYTE = 24
TEXT_COLOR_BYTE = 25
def media_width(code):
if code == 0:
raise ValueError("NO TAPE")
elif code == 4:
return 3.5
else:
return code
def media_type(code):
media = {
0: "NO TAPE",
1: "Laminated tape",
0x11: "Heat-Shrink Tube",
0x03: "Non-laminated tape",
0xFF: "Incompatible tape",
}
return media.get(code)
def tape_color(code):
colors = {
0x01: "White",
0x02: "Other",
0x03: "Clear",
0x04: "Red",
0x05: "Blue",
0x06: "Yellow",
0x07: "Green",
0x08: "Black",
0x09: "Clear(White text)",
0x20: "Matte White",
0x21: "Matte Clear",
0x22: "Matte Silver",
0x23: "Satin Gold",
0x24: "Satin Silver",
0x30: "Blue(D)",
0x31: "Red(D)",
0x40: "Fluorescent Orange",
0x41: "Fluorescent Yellow",
0x50: "Berry Pink(S)",
0x51: "Light Gray(S)",
0x52: "Lime Green(S)",
0x60: "Yellow(F)",
0x61: "Pink(F)",
0x62: "Blue(F)",
0x70: "White(Heat-shrink Tube)",
0x90: "White(Flex. ID)",
0x91: "Yellow(Flex. ID)",
0xF0: "Cleaning",
0xF1: "Stencil",
0xFF: "Incompatible",
}
return colors.get(code)
def text_color(code):
colors = {
0x01: "White",
0x04: "Red",
0x05: "Blue",
0x08: "Black",
0x0A: "Gold",
0x62: "Blue(F)",
0xF0: "Cleaning",
0xF1: "Stencil",
0x02: "Other",
0xFF: "Incompatible",
}
return colors.get(code)

View file

@ -0,0 +1,127 @@
import io
import logging
from math import inf
from brother_ql import BrotherQLRaster, create_label
from brother_ql.backends import guess_backend, backend_factory
from brother_ql.conversion import convert
from pysnmp.entity.engine import SnmpEngine
from pysnmp.hlapi import getCmd, CommunityData, UdpTransportTarget, ContextData
from pysnmp.smi.rfc1902 import ObjectType, ObjectIdentity
from labeler.domain.objects import MediaDefinition, Dimension, Image
from labeler.infra.e550w_printer.media_definitions import (
media_width,
tape_color,
text_color,
media_type,
WIDTH_BYTE,
COLOR_BYTE,
TEXT_COLOR_BYTE,
TYPE_BYTE,
)
from labeler.interfaces import Printer
from PIL import Image as PILImage
PRINTABLE_WIDTH = {
6: Dimension.from_points(64, 360),
9: Dimension.from_points(100, 360),
12: Dimension.from_points(140, 360),
18: Dimension.from_points(224, 360),
24: Dimension.from_points(256, 360),
}
class E550W(Printer):
def __init__(self, ip_address: str):
self.ip_address = ip_address
self.snmp_port = 161
def get_installed_media(self) -> MediaDefinition:
return self.__get_printer_status()
def print_label(self, label: Image):
im = PILImage.open(io.BytesIO(label.bytes))
qlr = BrotherQLRaster("PT-E550W")
convert(
qlr,
[im],
self.__media_width_to_type(label.height),
red=False,
threshold=70,
cut=True,
rotate=90,
compress=True,
dpi_600=True,
hq=True,
)
try:
try:
selected_backend = guess_backend(f"tcp://{self.ip_address}:9100")
except ValueError:
logging.error(
"Couln't guess the backend to use from the printer string descriptor"
)
BACKEND_CLASS = backend_factory(selected_backend)["backend_class"]
be = BACKEND_CLASS(f"tcp://{self.ip_address}:9100")
be.write(qlr.data)
be.dispose()
del be
except Exception as e:
logging.exception("Exception happened: %s", e)
def __media_width_to_type(self, height: int):
metric_width = Dimension.from_points(height, 360)
for width, printable_width in PRINTABLE_WIDTH.items():
if printable_width == metric_width:
return f"pt5{width}"
raise ValueError(f"Unsupported media width: {metric_width}")
def __get_printer_status(self):
raw_snmp_data = self.__get_snmp_status().asNumbers()
width = media_width(raw_snmp_data[WIDTH_BYTE])
media_tape_color = tape_color(raw_snmp_data[COLOR_BYTE])
media_text_color = text_color(raw_snmp_data[TEXT_COLOR_BYTE])
tape_type = media_type(raw_snmp_data[TYPE_BYTE])
return MediaDefinition(
width=Dimension(mm=width),
length=Dimension(mm=inf),
minimal_margin_vertical=(Dimension(mm=width) - PRINTABLE_WIDTH[width]) / 2,
minimal_margin_horizontal=Dimension(mm=1),
dpi=360,
description=f"{tape_type} - {width}mm, {media_text_color} on {media_tape_color} background",
)
def __get_snmp_status(self):
"""
This oid was found by using wireshark, however it's also documented here:
https://support.brother.com/g/s/es/dev/en/command/faq/index.html?c=eu_ot&lang=en&navi=offall&comple=on&redirect=on
just not for the E550W model.
"""
oid = "1.3.6.1.4.1.2435.3.3.9.1.6.1.0"
error_indication, error_status, error_index, var_binds = next(
getCmd(
SnmpEngine(),
CommunityData("public", mpModel=0),
UdpTransportTarget((self.ip_address, self.snmp_port)),
ContextData(),
ObjectType(ObjectIdentity(oid)),
)
)
if error_indication:
raise Exception(error_indication)
elif error_status:
raise Exception(
"%s at %s"
% (
error_status.prettyPrint(),
error_index and var_binds[int(error_index) - 1][0] or "?",
)
)
else:
return var_binds[0][1]

169
labeler/infra/renderer.py Normal file
View file

@ -0,0 +1,169 @@
import qrcode
import textwrap
from string import ascii_letters
from PIL import ImageFont, ImageDraw, Image as PILImage
from labeler.domain.objects import Image, LabelDefinition
from labeler.interfaces import Renderer
DPI = 360.0
def points_to_pixels(point_size: float):
return int(point_size * (72 / DPI))
class PILRenderer(Renderer):
def __init__(self):
self.font_path = "/Library/Fonts/Arial Unicode.ttf"
def render_label(self, label_definition: LabelDefinition) -> Image:
if label_definition.length is None:
pil_image = self.__render_no_fixed_lenth(label_definition)
else:
pil_image = self.__render_fixed_length(label_definition)
return Image.from_pil(pil_image)
def __render_fixed_length(self, label_definition: LabelDefinition):
width = label_definition.pixel_width
length = label_definition.pixel_length
font, text = self.__get_font(
label_definition.text,
width,
length,
)
im = PILImage.new("1", (length, width), 1)
draw = ImageDraw.Draw(im)
draw.text(
(length / 2, width / 2),
text,
font=font,
fill=0,
anchor="mm",
align="center",
)
return im
def __render_no_fixed_lenth(self, label_definition: LabelDefinition):
lines_to_print = label_definition.text.count("\n") + 1
text = "\n".join([line.strip() for line in label_definition.text.split("\n")])
text_height = label_definition.pixel_width // lines_to_print
while text_height > 0:
font = ImageFont.truetype(
"/Library/Fonts/Arial Unicode.ttf",
text_height,
)
if lines_to_print > 1:
occupied_height = font.getsize_multiline(text)[1]
else:
occupied_height = font.getsize(text)[1]
if occupied_height <= label_definition.pixel_width:
break
text_height -= 1
sizes = [font.getsize(line) for line in text.split("\n")]
length = max(length for length, height in sizes)
im = PILImage.new("1", (length, label_definition.pixel_width), 1)
draw = ImageDraw.Draw(im)
draw.text(
(length / 2, label_definition.pixel_width / 2),
text,
font=font,
fill=0,
anchor="mm",
align="center",
)
return im
def render_qrcode(self, label_definition: LabelDefinition) -> Image:
width = label_definition.pixel_width
length = label_definition.pixel_length
text = label_definition.text
font = self.__get_font_for_qr()
qr = qrcode.QRCode(box_size=7)
qr.add_data(f"https://hs3.pl/db/{text}")
qr.make(fit=True)
qr_img = qr.make_image()
print(width, length)
pil_image = PILImage.new("1", (width, length), 1)
draw = ImageDraw.Draw(pil_image)
pil_image.paste(qr_img)
draw.text(
(width / 2, 232),
"HS3-DB",
font=font,
fill=0,
anchor="mm",
align="center",
)
draw.text(
(20, 232 + 34),
f"ID: {text}",
font=font,
fill=0,
align="left",
)
return Image.from_pil(pil_image.transpose(PILImage.Transpose.ROTATE_90))
def __get_font_for_qr(self):
font_size = int(360 / 9)
return ImageFont.truetype("fonts/SourceCodePro-SemiBold.ttf", font_size)
def __get_font(self, text: str, max_width: int, max_length: int):
font_size = max_width
step = max_width // 2
last_good = None
last_corrected = None
while step > 1:
fits, corrected = self.__will_font_fit(
text, self.font_path, font_size, max_width, max_length
)
if fits:
last_good = font_size
last_corrected = corrected
font_size += step
else:
font_size -= step
step //= 2
return ImageFont.truetype(self.font_path, last_good), last_corrected
def __will_font_fit(
self, text: str, font_path: str, font_size: int, max_width: int, max_length: int
):
font = ImageFont.truetype(font_path, font_size)
if "\n" in text:
text_width, text_height = font.getsize_multiline(text)
else:
text_width, text_height = font.getsize(text)
if text_height > max_width:
return False, None
if text_width <= max_length:
# Now we know that the text fits. We can stop trying
return True, text
avg_char_width = sum(font.getsize(char)[0] for char in ascii_letters) / len(
ascii_letters
)
charachters_per_line = max_length // avg_char_width
if charachters_per_line < max(len(line) for line in text.split(" ")):
return False, None
wrapped = textwrap.fill(text, charachters_per_line)
wrapped_width, wrapped_height = font.getsize_multiline(wrapped)
if wrapped_height <= max_width and wrapped_width <= max_length:
# Now we know that the text fits. We can stop trying
return True, wrapped
return False, None

23
labeler/interfaces.py Normal file
View file

@ -0,0 +1,23 @@
import abc
from labeler.domain.objects import LabelDefinition, Image, MediaDefinition
class Renderer(abc.ABC):
@abc.abstractmethod
def render_label(self, label_definition: LabelDefinition) -> Image:
pass
@abc.abstractmethod
def render_qrcode(self, label_definition: LabelDefinition) -> Image:
pass
class Printer(abc.ABC):
@abc.abstractmethod
def get_installed_media(self) -> MediaDefinition:
pass
@abc.abstractmethod
def print_label(self, label: Image):
pass

Binary file not shown.

36
lbot_readme.md Normal file
View file

@ -0,0 +1,36 @@
## labeling telegram bot
This application is build to render and print labels sent to it via telegram, as well
as provide info about printer status and other useful information.
## Supported commands
- `/media_info` - show info about currently installed media
![img.png](img.png)
- `/simple_label` - print a simple label
![img_1.png](img_1.png)
### Usage example
You need three things:
1. A telegram bot token, you can write to [@BotFather](https://t.me/BotFather) to get one
2. A compatible printer
3. docker installed on your system
```yaml
version: "3.8"
services:
bot:
build:
context: .
dockerfile: Dockerfile
environment:
- PRINTER_IP=<printer_ip>
- TELEGRAM_TOKEN=<telegram_bot_token>
command:
- python
- labeler/adapter/telegram_bot.py
```
### Supported printers
- Brother PT-E550W

37
pyproject.toml Normal file
View file

@ -0,0 +1,37 @@
[project]
name = "python-scratchpad"
version = "0.1.0"
description = ""
# authors = ["Hubert Bryłkowski <hubert@brylkowski.com>"]
readme = "readme.md"
requires-python = ">=3.11"
dependencies = [
"brother-ql @ git+https://github.com/hbrylkowski/brother_ql@4225d13d209e8e4a2c17e87a75f42809e0da8fda",
"qrcode[pil]",
# https://github.com/astral-sh/uv/issues/6384
"setuptools<81",
"jinja2>=3.1.2,<4",
"pillow>=9.5.0,<10",
"pysnmp>=4.4.12,<5",
"pyasn1==0.4.8,<0.5",
"python-telegram-bot>=20.3,<21",
"fastapi[standard]>=0.114.0",
"requests==2.32.5",
"Jinja2==3.1.6",
"pandas==2.3.3",
"python-dotenv==1.2.1",
]
[tool.poetry.group.dev.dependencies]
pytest = "^7.4.0"
black = "^23.3.0"
[tool.hatch]
metadata.allow-direct-references = true
[tool.hatch.build.targets.wheel]
packages = ["labeler"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

1
tests/conftest.py Normal file
View file

@ -0,0 +1 @@
from fixtures import * # noqa

48
tests/fixtures.py Normal file
View file

@ -0,0 +1,48 @@
import os
from typing import Callable
import pytest
from labeler.domain.objects import Dimension, MediaDefinition
@pytest.fixture
def current_dir(request) -> str:
return os.path.dirname(request.module.__file__)
@pytest.fixture
def get_test_image(current_dir) -> Callable[[str], bytes]:
def f(name):
return open(os.path.join(current_dir, "test_images", name), "rb").read()
return f
@pytest.fixture
def save_test_image(current_dir) -> Callable[[str, bytes], None]:
def f(name: str, data: bytes):
open(os.path.join(current_dir, "test_images", name), "wb").write(data)
return f
@pytest.fixture
def create_test_media() -> Callable[[int, int, int, int, int], MediaDefinition]:
def f(
width: int,
height: int,
dpi: int = 600,
margin_horizontal: int = 0,
margin_vertical: int = 0,
):
return MediaDefinition(
width=Dimension(mm=width),
length=Dimension(mm=height),
minimal_margin_horizontal=Dimension(mm=margin_horizontal),
minimal_margin_vertical=Dimension(mm=margin_vertical),
dpi=dpi,
description=f"test media {width}mm x{height}mm @ {dpi}dpi",
)
return f

View file

@ -0,0 +1,30 @@
import math
from labeler.domain.objects import Dimension, MediaDefinition
def test_dimension():
dimension = Dimension(mm=25.4)
assert dimension.mm == 25.4
assert dimension.inch == 1.0
assert dimension.in_pixels(dpi=300) == 300
assert dimension + Dimension(mm=10) == Dimension(mm=35.4)
assert dimension * 2 == Dimension(mm=50.8)
assert dimension / 2 == Dimension(mm=12.7)
def test_infinite_media():
media = MediaDefinition(
width=Dimension(mm=12),
length=Dimension(mm=math.inf),
minimal_margin_vertical=Dimension(mm=1),
minimal_margin_horizontal=Dimension(mm=2),
dpi=300,
description="test media",
)
assert media.printable_length == Dimension(mm=math.inf)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,50 @@
from labeler.domain.objects import LabelDefinition, Dimension
from labeler.infra.renderer import PILRenderer
def test_simple_label(get_test_image):
renderer = PILRenderer()
expected_label = get_test_image("simple_label_test.png")
definition = LabelDefinition(
text="dolphin", length=Dimension(mm=40), width=Dimension(mm=10), dpi=600
)
label = renderer.render_label(definition)
assert label.bytes == expected_label
def test_multiline_label(get_test_image):
label_text = "dolphin\nis\nawesome"
expected_label = get_test_image("multiline_label_test.png")
renderer = PILRenderer()
definition = LabelDefinition(
text=label_text, length=Dimension(mm=40), width=Dimension(mm=10), dpi=600
)
label = renderer.render_label(definition)
assert label.bytes == expected_label
def test_simple_label_no_fixed_width(get_test_image):
renderer = PILRenderer()
expected_label = get_test_image("no_fixed_width.png")
definition = LabelDefinition(text="dolphin", width=Dimension(mm=10), dpi=600)
label = renderer.render_label(definition)
assert label.bytes == expected_label
def test_multiline_label_no_fixed_width(get_test_image):
renderer = PILRenderer()
expected_label = get_test_image("multiline_label_no_fixed_width.png")
definition = LabelDefinition(
text="dolphin\nis\nawesome", width=Dimension(mm=10), dpi=600
)
label = renderer.render_label(definition)
assert label.bytes == expected_label

1206
uv.lock generated Normal file

File diff suppressed because it is too large Load diff