Files
reHDDPrinter/layouter.py

415 lines
12 KiB
Python
Raw Normal View History

2022-11-16 21:37:01 +01:00
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
2025-06-08 20:21:42 +02:00
"""
Author: Hendrik Schutter, localhorst@mosad.xyz
Date of creation: 2022/11/16
Date of last modification: 2025/06/08
2022-11-16 21:37:01 +01:00
"""
import re
2022-11-22 20:10:45 +01:00
import dataclasses
2022-11-16 21:37:01 +01:00
import glob
2025-06-08 20:21:42 +02:00
import datetime
2022-11-22 20:10:45 +01:00
import json
2025-06-08 20:21:42 +02:00
import logging
2022-11-22 20:10:45 +01:00
import qrcode
2025-06-08 20:21:42 +02:00
from PIL import Image, ImageFont, ImageDraw
# Constants
FONT_PATH = "/usr/share/fonts"
DEFAULT_FONT_REGULAR = "DejaVuSans.ttf"
DEFAULT_FONT_BOLD = "DejaVuSans-Bold.ttf"
OUTPUT_WIDTH = 696 # px
2025-06-22 12:54:48 +02:00
OUTPUT_HEIGHT = 190 # px
TEXT_X_OFFSET = 190 # px
QR_CODE_SIZE = 179 # px
2025-06-08 20:21:42 +02:00
# Configure logging
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)
2025-06-22 13:32:56 +02:00
2022-11-22 20:10:45 +01:00
@dataclasses.dataclass
2022-11-16 21:37:01 +01:00
class DriveData:
drive_index: int
2025-06-08 20:21:42 +02:00
drive_state: str
2025-12-05 21:21:18 +01:00
drive_connection_type: str
2025-06-08 20:21:42 +02:00
modelfamily: str
modelname: str
capacity: int
2022-11-16 21:37:01 +01:00
serialnumber: str
2025-06-08 20:21:42 +02:00
power_on_hours: int
2022-11-16 21:37:01 +01:00
power_cycle: int
smart_error_count: int
2025-06-08 20:21:42 +02:00
shred_timestamp: int
shred_duration: int
2025-06-22 13:32:56 +02:00
@dataclasses.dataclass
class DriveDataJson:
state: str
2025-12-05 21:41:52 +01:00
contype: str
2025-06-22 13:32:56 +02:00
fam: str
name: str
cap: int
sn: str
poh: int
pc: int
err: int
time: int
dur: int
2022-11-22 20:10:45 +01:00
@dataclasses.dataclass
2022-11-16 21:37:01 +01:00
class DriveDataPrintable:
2025-12-05 21:21:18 +01:00
connectiontype: str
2025-06-08 20:21:42 +02:00
modelfamily: str
modelname: str
capacity: str
serialnumber: str
power_on_hours: str
power_cycle: str
smart_error_count: str
shred_timestamp: str
shred_duration: str
2022-11-16 21:37:01 +01:00
2022-11-22 20:10:45 +01:00
@dataclasses.dataclass
class ReHddInfo:
2025-06-22 15:08:54 +02:00
link: str
version: str
2022-11-22 20:10:45 +01:00
2025-06-08 20:21:42 +02:00
2022-11-22 20:10:45 +01:00
@dataclasses.dataclass
2025-06-22 13:32:56 +02:00
class QrDataJson:
drive: DriveDataJson
2022-11-22 20:10:45 +01:00
rehdd: ReHddInfo
2022-11-16 21:37:01 +01:00
2025-06-08 20:21:42 +02:00
def find_font_path(font_name):
"""Finds the full path of the specified font."""
try:
files = glob.glob(f"{FONT_PATH}/**/{font_name}", recursive=True)
return files[0] if files else None
except Exception as e:
logging.error(f"Error locating font {font_name}: {e}")
return None
def human_readable_capacity(size, decimal_places=0, base=1000):
"""Converts bytes to a human-readable string."""
units = (
["B", "KB", "MB", "GB", "TB", "PB"]
if base == 1000
else ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]
)
for unit in units:
if size < base or unit == units[-1]:
return f"{size:.{decimal_places}f} {unit}"
size /= base
def human_readable_power_on_hours(hours):
"""Converts hours to human-readable string in hours, days, and years."""
return (
str(hours)
+ "h or "
+ str(int(hours / 24))
+ "d or "
+ str("{:.2f}".format(float(hours / 24 / 365)))
+ "y"
)
def cut_string(max_length, data, direction="end"):
"""Trims a string to the maximum length, adding ellipses if necessary."""
if len(data) > max_length:
return (
f"{data[:max_length-4]} ..."
if direction == "end"
else f"... {data[-(max_length-4):]}"
)
return data
2022-11-16 21:37:01 +01:00
def format_to_printable(drive):
2022-11-22 20:54:11 +01:00
return DriveDataPrintable(
2025-12-05 21:21:18 +01:00
drive.drive_connection_type,
2025-06-08 20:21:42 +02:00
cut_string(20, re.sub(r"[^a-zA-Z0-9. ]", "", drive.modelfamily), "end"),
cut_string(20, re.sub(r"[^a-zA-Z0-9. ]", "", drive.modelname), "end"),
cut_string(20, human_readable_capacity(drive.capacity), "end"),
2025-06-22 12:43:13 +02:00
cut_string(20, re.sub(r"[^a-zA-Z0-9.-_]", "", drive.serialnumber), "start"),
2025-06-08 20:21:42 +02:00
cut_string(30, human_readable_power_on_hours(drive.power_on_hours), "end"),
cut_string(10, str(drive.power_cycle), "end"),
cut_string(10, str(drive.smart_error_count), "end"),
cut_string(
30,
datetime.datetime.fromtimestamp(
drive.shred_timestamp, datetime.UTC
).strftime("%Y-%m-%d %H:%M:%S"),
"end",
),
cut_string(30, str(datetime.timedelta(seconds=drive.shred_duration)), "end"),
)
def draw_text(drawable, printable_data, font_regular, font_bold, font_bold_bigger):
2025-06-22 12:43:13 +02:00
"""Draws formatted text with Cycles and Errors on one row."""
2025-12-05 21:41:52 +01:00
y_start = 4
2025-06-22 12:43:13 +02:00
line_height = 26
label_x = TEXT_X_OFFSET
value_offset = 115
right_field_spacing = 200 # Horizontal gap between Cycles and Errors
2025-06-22 12:56:40 +02:00
x_capacity = 520
2025-06-22 12:54:48 +02:00
y_capacity = 142
2025-12-05 21:41:52 +01:00
x_connection_type = 600
y_connection_type = y_start
y_spacing_connection_type = 25
2025-06-22 12:43:13 +02:00
# Serial Number
drawable.text((label_x, y_start), "Serial:", fill=0, font=font_bold)
drawable.text(
(label_x + value_offset, y_start),
printable_data.serialnumber,
fill=0,
font=font_bold,
)
y1 = y_start + line_height
y2 = y1 + line_height
y3 = y2 + line_height
y4 = y3 + line_height
y5 = y4 + line_height
y6 = y5 + line_height
2025-06-08 20:21:42 +02:00
2025-06-22 12:43:13 +02:00
# Left-Aligned Fields (One per row)
drawable.text((label_x, y1), "Family:", fill=0, font=font_bold)
2025-06-08 20:21:42 +02:00
drawable.text(
2025-06-22 12:43:13 +02:00
(label_x + value_offset, y1),
printable_data.modelfamily,
fill=0,
font=font_regular,
2025-06-08 20:21:42 +02:00
)
2025-06-22 12:43:13 +02:00
drawable.text((label_x, y2), "Model:", fill=0, font=font_bold)
drawable.text(
(label_x + value_offset, y2),
printable_data.modelname,
fill=0,
font=font_regular,
)
drawable.text((label_x, y3), "Hours:", fill=0, font=font_bold)
drawable.text(
(label_x + value_offset, y3),
printable_data.power_on_hours,
fill=0,
font=font_regular,
)
# Cycles and Errors on the same line
drawable.text((label_x, y4), "Cycles:", fill=0, font=font_bold)
drawable.text(
(label_x + value_offset, y4),
printable_data.power_cycle,
fill=0,
font=font_regular,
)
2025-06-08 20:21:42 +02:00
drawable.text(
2025-06-22 12:43:13 +02:00
(label_x + right_field_spacing, y4), "Errors:", fill=0, font=font_bold
)
drawable.text(
(label_x + right_field_spacing + value_offset, y4),
printable_data.smart_error_count,
fill=0,
font=font_regular,
)
# Continue remaining fields
drawable.text((label_x, y5), "Shred on:", fill=0, font=font_bold)
drawable.text(
(label_x + value_offset, y5),
printable_data.shred_timestamp,
fill=0,
font=font_regular,
)
drawable.text((label_x, y6), "Duration:", fill=0, font=font_bold)
drawable.text(
(label_x + value_offset, y6),
printable_data.shred_duration,
fill=0,
font=font_regular,
)
# Capacity at the bottom
drawable.text(
(x_capacity, y_capacity),
2025-06-08 20:21:42 +02:00
printable_data.capacity,
2025-06-22 12:43:13 +02:00
fill=0,
2025-06-08 20:21:42 +02:00
font=font_bold_bigger,
)
2022-11-20 21:13:44 +01:00
2025-12-05 21:41:52 +01:00
if (printable_data.connectiontype == "sata"):
drawable.text(
(x_connection_type, y_connection_type),
"⬤ SATA",
fill=0,
font=font_regular,
)
drawable.text(
(x_connection_type, y_connection_type+y_spacing_connection_type),
"◯ NVME",
fill=0,
font=font_regular,
)
elif (printable_data.connectiontype == "nvme"):
drawable.text(
(x_connection_type, y_connection_type),
"◯ SATA",
fill=0,
font=font_regular,
)
drawable.text(
(x_connection_type, y_connection_type+y_spacing_connection_type),
"⬤ NVME",
fill=0,
font=font_regular,
)
else:
drawable.text(
(x_connection_type, y_connection_type),
"◯ SATA",
fill=0,
font=font_regular,
)
drawable.text(
(x_connection_type, y_connection_type+y_spacing_connection_type),
"◯ NVME",
fill=0,
font=font_regular,
)
2022-11-20 21:13:44 +01:00
2022-11-22 20:10:45 +01:00
def draw_qr_code(image, data):
2025-06-08 20:21:42 +02:00
"""
Draws a QR code on the provided image at a specific location.
Parameters:
image (Image.Image): The target image.
data (str): The data to encode in the QR code.
"""
# Generate the QR code
2022-11-22 20:10:45 +01:00
qr_img = qrcode.make(data)
2025-06-08 20:21:42 +02:00
qr_img = qr_img.convert("1") # Ensure QR code is in binary (black/white)
2022-11-20 21:13:44 +01:00
2025-06-08 20:21:42 +02:00
# Remove white border
bbox = qr_img.getbbox() # Get the bounding box of the non-white area
qr_img = qr_img.crop(bbox)
2022-11-16 21:37:01 +01:00
2025-06-08 20:21:42 +02:00
# Resize to desired size
qr_img = qr_img.resize((QR_CODE_SIZE, QR_CODE_SIZE), Image.Resampling.LANCZOS)
2022-11-16 21:37:01 +01:00
2025-06-08 20:21:42 +02:00
# Paste the QR code onto the image
region = (5, 5, 5 + QR_CODE_SIZE, 5 + QR_CODE_SIZE)
image.paste(qr_img, box=region)
def draw_outline(drawable, margin, width, output_width, output_height):
"""
Draws a rectangular outline on the drawable canvas.
Parameters:
drawable (ImageDraw.Draw): The drawable canvas.
margin (int): The margin from the edges of the image.
width (int): The width of the outline.
output_width (int): The total width of the image.
output_height (int): The total height of the image.
"""
# Define the four corners of the rectangle, adjusting for line width
top_left = (margin, margin)
top_right = (output_width - margin - width, margin)
bottom_left = (margin, output_height - margin - width)
bottom_right = (output_width - margin - width, output_height - margin - width)
# Draw the outline lines with adjusted coordinates
lines = [
(top_left, top_right), # Top edge
(top_left, bottom_left), # Left edge
(top_right, bottom_right), # Right edge
(bottom_left, bottom_right), # Bottom edge
]
for line in lines:
drawable.line(line, fill=0, width=width)
2022-11-16 21:37:01 +01:00
2025-06-08 20:21:42 +02:00
def generate_image(drive, rehdd_info, output_file):
"""Generates an image containing drive data and a QR code."""
2022-11-23 18:38:27 +01:00
try:
2025-06-22 13:32:56 +02:00
drive_json = DriveDataJson(
state=drive.drive_state,
2025-12-05 21:41:52 +01:00
contype=drive.drive_connection_type,
2025-06-22 13:32:56 +02:00
fam=drive.modelfamily,
name=drive.modelname,
cap=drive.capacity,
sn=drive.serialnumber,
poh=drive.power_on_hours,
pc=drive.power_cycle,
err=drive.smart_error_count,
time=int(drive.shred_timestamp),
dur=drive.shred_duration,
)
qr_data = json.dumps(dataclasses.asdict(QrDataJson(drive_json, rehdd_info)))
2022-11-23 18:38:27 +01:00
printable_data = format_to_printable(drive)
2025-06-08 20:21:42 +02:00
except Exception as e:
logging.error(f"Error preparing data: {e}")
2022-11-23 18:38:27 +01:00
return
2022-11-22 20:10:45 +01:00
2025-06-08 20:21:42 +02:00
output_image = Image.new("1", (OUTPUT_WIDTH, OUTPUT_HEIGHT), "white")
2022-11-16 21:37:01 +01:00
draw = ImageDraw.Draw(output_image)
2025-06-22 12:56:40 +02:00
font_regular = ImageFont.truetype(find_font_path(DEFAULT_FONT_REGULAR), 20)
font_bold = ImageFont.truetype(find_font_path(DEFAULT_FONT_BOLD), 20)
2025-06-22 12:54:48 +02:00
font_bold_bigger = ImageFont.truetype(find_font_path(DEFAULT_FONT_BOLD), 42)
2022-11-16 21:37:01 +01:00
2025-06-22 13:32:56 +02:00
draw_outline(draw, 0, 3, OUTPUT_WIDTH + 1, OUTPUT_HEIGHT + 1)
2025-06-08 20:21:42 +02:00
draw_text(draw, printable_data, font_regular, font_bold, font_bold_bigger)
draw_qr_code(output_image, qr_data)
2022-11-23 18:38:27 +01:00
2025-06-08 20:21:42 +02:00
try:
output_image.save(output_file)
logging.info(f"Image saved to {output_file}")
except Exception as e:
logging.error(f"Error saving image: {e}")
2022-11-23 18:38:27 +01:00
2025-06-08 20:21:42 +02:00
def main():
rehdd_info = ReHddInfo("https://git.mosad.xyz/localhorst/reHDD", "bV0.2.2")
2022-11-23 18:38:27 +01:00
temp_drive = DriveData(
2025-06-08 20:21:42 +02:00
drive_index=0,
2025-12-05 21:21:18 +01:00
drive_connection_type="sata",
2025-06-08 20:21:42 +02:00
drive_state="shredded",
modelfamily='Toshiba 2.5" HDD MK..65GSSX',
modelname="TOSHIBA MK3265GSDX",
2025-06-22 12:56:40 +02:00
capacity=343597383000,
2025-06-08 20:21:42 +02:00
serialnumber="YG6742U56UDRL123456789ABCDEFGJKL",
power_on_hours=7074,
power_cycle=4792,
smart_error_count=1,
shred_timestamp=datetime.datetime.now(datetime.timezone.utc).timestamp(),
shred_duration=81718,
)
2022-11-23 18:38:27 +01:00
generate_image(temp_drive, rehdd_info, "output.png")
2022-11-16 21:37:01 +01:00
2025-06-22 12:43:13 +02:00
2022-11-16 21:37:01 +01:00
if __name__ == "__main__":
2025-06-08 20:21:42 +02:00
main()