Fix text/image alignment, add dithering, extend Classic BT to Windows
This commit is contained in:
@@ -1,21 +1,25 @@
|
|||||||
"""Fichero D11s thermal label printer - BLE interface."""
|
"""Fichero D11s thermal label printer - BLE + Classic Bluetooth interface."""
|
||||||
|
|
||||||
from fichero.printer import (
|
from fichero.printer import (
|
||||||
|
RFCOMM_CHANNEL,
|
||||||
PrinterClient,
|
PrinterClient,
|
||||||
PrinterError,
|
PrinterError,
|
||||||
PrinterNotFound,
|
PrinterNotFound,
|
||||||
PrinterNotReady,
|
PrinterNotReady,
|
||||||
PrinterStatus,
|
PrinterStatus,
|
||||||
PrinterTimeout,
|
PrinterTimeout,
|
||||||
|
RFCOMMClient,
|
||||||
connect,
|
connect,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"RFCOMM_CHANNEL",
|
||||||
"PrinterClient",
|
"PrinterClient",
|
||||||
"PrinterError",
|
"PrinterError",
|
||||||
"PrinterNotFound",
|
"PrinterNotFound",
|
||||||
"PrinterNotReady",
|
"PrinterNotReady",
|
||||||
"PrinterStatus",
|
"PrinterStatus",
|
||||||
"PrinterTimeout",
|
"PrinterTimeout",
|
||||||
|
"RFCOMMClient",
|
||||||
"connect",
|
"connect",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ from fichero.printer import (
|
|||||||
connect,
|
connect,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
DOTS_PER_MM = 8 # 203 DPI
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_label_height(args: argparse.Namespace) -> int:
|
||||||
|
"""Return label height in pixels from --label-length (mm) or --label-height (px)."""
|
||||||
|
if args.label_length is not None:
|
||||||
|
return args.label_length * DOTS_PER_MM
|
||||||
|
return args.label_height
|
||||||
|
|
||||||
|
|
||||||
async def do_print(
|
async def do_print(
|
||||||
pc: PrinterClient,
|
pc: PrinterClient,
|
||||||
@@ -28,17 +37,15 @@ async def do_print(
|
|||||||
density: int = 1,
|
density: int = 1,
|
||||||
paper: int = PAPER_GAP,
|
paper: int = PAPER_GAP,
|
||||||
copies: int = 1,
|
copies: int = 1,
|
||||||
|
dither: bool = True,
|
||||||
|
max_rows: int = 240,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
img = prepare_image(img)
|
img = prepare_image(img, max_rows=max_rows, dither=dither)
|
||||||
rows = img.height
|
rows = img.height
|
||||||
raster = image_to_raster(img)
|
raster = image_to_raster(img)
|
||||||
|
|
||||||
print(f" Image: {img.width}x{rows}, {len(raster)} bytes, {copies} copies")
|
print(f" Image: {img.width}x{rows}, {len(raster)} bytes, {copies} copies")
|
||||||
|
|
||||||
status = await pc.get_status()
|
|
||||||
if not status.ok:
|
|
||||||
raise PrinterNotReady(f"Printer not ready: {status}")
|
|
||||||
|
|
||||||
await pc.set_density(density)
|
await pc.set_density(density)
|
||||||
await asyncio.sleep(DELAY_AFTER_DENSITY)
|
await asyncio.sleep(DELAY_AFTER_DENSITY)
|
||||||
|
|
||||||
@@ -46,6 +53,11 @@ async def do_print(
|
|||||||
if copies > 1:
|
if copies > 1:
|
||||||
print(f" Copy {copy_num + 1}/{copies}...")
|
print(f" Copy {copy_num + 1}/{copies}...")
|
||||||
|
|
||||||
|
# Check status before each copy (matches decompiled app behaviour)
|
||||||
|
status = await pc.get_status()
|
||||||
|
if not status.ok:
|
||||||
|
raise PrinterNotReady(f"Printer not ready: {status}")
|
||||||
|
|
||||||
# AiYin print sequence (from decompiled APK)
|
# AiYin print sequence (from decompiled APK)
|
||||||
await pc.set_paper_type(paper)
|
await pc.set_paper_type(paper)
|
||||||
await asyncio.sleep(DELAY_COMMAND_GAP)
|
await asyncio.sleep(DELAY_COMMAND_GAP)
|
||||||
@@ -72,7 +84,7 @@ async def do_print(
|
|||||||
|
|
||||||
|
|
||||||
async def cmd_info(args: argparse.Namespace) -> None:
|
async def cmd_info(args: argparse.Namespace) -> None:
|
||||||
async with connect(args.address) as pc:
|
async with connect(args.address, classic=args.classic, channel=args.channel) as pc:
|
||||||
info = await pc.get_info()
|
info = await pc.get_info()
|
||||||
for k, v in info.items():
|
for k, v in info.items():
|
||||||
print(f" {k}: {v}")
|
print(f" {k}: {v}")
|
||||||
@@ -84,7 +96,7 @@ async def cmd_info(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def cmd_status(args: argparse.Namespace) -> None:
|
async def cmd_status(args: argparse.Namespace) -> None:
|
||||||
async with connect(args.address) as pc:
|
async with connect(args.address, classic=args.classic, channel=args.channel) as pc:
|
||||||
status = await pc.get_status()
|
status = await pc.get_status()
|
||||||
print(f" Status: {status}")
|
print(f" Status: {status}")
|
||||||
print(f" Raw: 0x{status.raw:02X} ({status.raw:08b})")
|
print(f" Raw: 0x{status.raw:02X} ({status.raw:08b})")
|
||||||
@@ -95,23 +107,28 @@ async def cmd_status(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
async def cmd_text(args: argparse.Namespace) -> None:
|
async def cmd_text(args: argparse.Namespace) -> None:
|
||||||
text = " ".join(args.text)
|
text = " ".join(args.text)
|
||||||
img = text_to_image(text, font_size=args.font_size, label_height=args.label_height)
|
label_h = _resolve_label_height(args)
|
||||||
async with connect(args.address) as pc:
|
img = text_to_image(text, font_size=args.font_size, label_height=label_h)
|
||||||
|
async with connect(args.address, classic=args.classic, channel=args.channel) as pc:
|
||||||
print(f'Printing "{text}"...')
|
print(f'Printing "{text}"...')
|
||||||
ok = await do_print(pc, img, args.density, copies=args.copies)
|
ok = await do_print(pc, img, args.density, paper=args.paper,
|
||||||
|
copies=args.copies, dither=False, max_rows=label_h)
|
||||||
print("Done." if ok else "FAILED.")
|
print("Done." if ok else "FAILED.")
|
||||||
|
|
||||||
|
|
||||||
async def cmd_image(args: argparse.Namespace) -> None:
|
async def cmd_image(args: argparse.Namespace) -> None:
|
||||||
img = Image.open(args.path)
|
img = Image.open(args.path)
|
||||||
async with connect(args.address) as pc:
|
label_h = _resolve_label_height(args)
|
||||||
|
async with connect(args.address, classic=args.classic, channel=args.channel) as pc:
|
||||||
print(f"Printing {args.path}...")
|
print(f"Printing {args.path}...")
|
||||||
ok = await do_print(pc, img, args.density, copies=args.copies)
|
ok = await do_print(pc, img, args.density, paper=args.paper,
|
||||||
|
copies=args.copies, dither=not args.no_dither,
|
||||||
|
max_rows=label_h)
|
||||||
print("Done." if ok else "FAILED.")
|
print("Done." if ok else "FAILED.")
|
||||||
|
|
||||||
|
|
||||||
async def cmd_set(args: argparse.Namespace) -> None:
|
async def cmd_set(args: argparse.Namespace) -> None:
|
||||||
async with connect(args.address) as pc:
|
async with connect(args.address, classic=args.classic, channel=args.channel) as pc:
|
||||||
if args.setting == "density":
|
if args.setting == "density":
|
||||||
val = int(args.value)
|
val = int(args.value)
|
||||||
if not 0 <= val <= 2:
|
if not 0 <= val <= 2:
|
||||||
@@ -143,10 +160,39 @@ async def cmd_set(args: argparse.Namespace) -> None:
|
|||||||
print(f" Set paper={args.value}: {'OK' if ok else 'FAILED'}")
|
print(f" Set paper={args.value}: {'OK' if ok else 'FAILED'}")
|
||||||
|
|
||||||
|
|
||||||
|
def _add_paper_arg(parser: argparse.ArgumentParser) -> None:
|
||||||
|
"""Add --paper argument to a subparser."""
|
||||||
|
parser.add_argument(
|
||||||
|
"--paper", type=str, default="gap",
|
||||||
|
help="Paper type: gap (default), black, continuous",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_paper(value: str) -> int:
|
||||||
|
"""Convert paper string/int to protocol value."""
|
||||||
|
types = {"gap": 0, "black": 1, "continuous": 2}
|
||||||
|
if value in types:
|
||||||
|
return types[value]
|
||||||
|
try:
|
||||||
|
val = int(value)
|
||||||
|
if 0 <= val <= 2:
|
||||||
|
return val
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
print(f" WARNING: unknown paper type '{value}', using gap")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(description="Fichero D11s Label Printer")
|
parser = argparse.ArgumentParser(description="Fichero D11s Label Printer")
|
||||||
parser.add_argument("--address", default=os.environ.get("FICHERO_ADDR"),
|
parser.add_argument("--address", default=os.environ.get("FICHERO_ADDR"),
|
||||||
help="BLE address (skip scanning, or set FICHERO_ADDR)")
|
help="BLE address (skip scanning, or set FICHERO_ADDR)")
|
||||||
|
parser.add_argument("--classic", action="store_true",
|
||||||
|
default=os.environ.get("FICHERO_TRANSPORT", "").lower() == "classic",
|
||||||
|
help="Use Classic Bluetooth (RFCOMM) instead of BLE (Linux only, "
|
||||||
|
"or set FICHERO_TRANSPORT=classic)")
|
||||||
|
parser.add_argument("--channel", type=int, default=1,
|
||||||
|
help="RFCOMM channel (default: 1, only used with --classic)")
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
p_info = sub.add_parser("info", help="Show device info")
|
p_info = sub.add_parser("info", help="Show device info")
|
||||||
@@ -161,8 +207,11 @@ def main() -> None:
|
|||||||
help="Print density: 0=light, 1=medium, 2=thick")
|
help="Print density: 0=light, 1=medium, 2=thick")
|
||||||
p_text.add_argument("--copies", type=int, default=1, help="Number of copies")
|
p_text.add_argument("--copies", type=int, default=1, help="Number of copies")
|
||||||
p_text.add_argument("--font-size", type=int, default=30, help="Font size in points")
|
p_text.add_argument("--font-size", type=int, default=30, help="Font size in points")
|
||||||
|
p_text.add_argument("--label-length", type=int, default=None,
|
||||||
|
help="Label length in mm (default: 30mm)")
|
||||||
p_text.add_argument("--label-height", type=int, default=240,
|
p_text.add_argument("--label-height", type=int, default=240,
|
||||||
help="Label height in pixels (default: 240)")
|
help="Label height in pixels (default: 240, prefer --label-length)")
|
||||||
|
_add_paper_arg(p_text)
|
||||||
p_text.set_defaults(func=cmd_text)
|
p_text.set_defaults(func=cmd_text)
|
||||||
|
|
||||||
p_image = sub.add_parser("image", help="Print image file")
|
p_image = sub.add_parser("image", help="Print image file")
|
||||||
@@ -170,6 +219,13 @@ def main() -> None:
|
|||||||
p_image.add_argument("--density", type=int, default=2, choices=[0, 1, 2],
|
p_image.add_argument("--density", type=int, default=2, choices=[0, 1, 2],
|
||||||
help="Print density: 0=light, 1=medium, 2=thick")
|
help="Print density: 0=light, 1=medium, 2=thick")
|
||||||
p_image.add_argument("--copies", type=int, default=1, help="Number of copies")
|
p_image.add_argument("--copies", type=int, default=1, help="Number of copies")
|
||||||
|
p_image.add_argument("--no-dither", action="store_true",
|
||||||
|
help="Disable Floyd-Steinberg dithering (use simple threshold)")
|
||||||
|
p_image.add_argument("--label-length", type=int, default=None,
|
||||||
|
help="Label length in mm (default: 30mm)")
|
||||||
|
p_image.add_argument("--label-height", type=int, default=240,
|
||||||
|
help="Max image height in pixels (default: 240, prefer --label-length)")
|
||||||
|
_add_paper_arg(p_image)
|
||||||
p_image.set_defaults(func=cmd_image)
|
p_image.set_defaults(func=cmd_image)
|
||||||
|
|
||||||
p_set = sub.add_parser("set", help="Change printer settings")
|
p_set = sub.add_parser("set", help="Change printer settings")
|
||||||
@@ -179,6 +235,11 @@ def main() -> None:
|
|||||||
p_set.set_defaults(func=cmd_set)
|
p_set.set_defaults(func=cmd_set)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Resolve --paper string to int for print commands
|
||||||
|
if hasattr(args, "paper") and isinstance(args.paper, str):
|
||||||
|
args.paper = _parse_paper(args.paper)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
asyncio.run(args.func(args))
|
asyncio.run(args.func(args))
|
||||||
except PrinterError as e:
|
except PrinterError as e:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
||||||
|
|
||||||
from fichero.printer import PRINTHEAD_PX
|
from fichero.printer import PRINTHEAD_PX
|
||||||
@@ -9,16 +10,60 @@ from fichero.printer import PRINTHEAD_PX
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def prepare_image(img: Image.Image, max_rows: int = 240) -> Image.Image:
|
def floyd_steinberg_dither(img: Image.Image) -> Image.Image:
|
||||||
"""Convert any image to 96px wide, 1-bit, black on white."""
|
"""Floyd-Steinberg error-diffusion dithering to 1-bit.
|
||||||
|
|
||||||
|
Same algorithm as PrinterImageProcessor.ditherFloydSteinberg() in the
|
||||||
|
decompiled Fichero APK: distributes quantisation error to neighbouring
|
||||||
|
pixels with weights 7/16, 3/16, 5/16, 1/16.
|
||||||
|
"""
|
||||||
|
arr = np.array(img, dtype=np.float32)
|
||||||
|
h, w = arr.shape
|
||||||
|
|
||||||
|
for y in range(h):
|
||||||
|
for x in range(w):
|
||||||
|
old = arr[y, x]
|
||||||
|
new = 0.0 if old < 128 else 255.0
|
||||||
|
arr[y, x] = new
|
||||||
|
err = old - new
|
||||||
|
if x + 1 < w:
|
||||||
|
arr[y, x + 1] += err * 7 / 16
|
||||||
|
if y + 1 < h:
|
||||||
|
if x - 1 >= 0:
|
||||||
|
arr[y + 1, x - 1] += err * 3 / 16
|
||||||
|
arr[y + 1, x] += err * 5 / 16
|
||||||
|
if x + 1 < w:
|
||||||
|
arr[y + 1, x + 1] += err * 1 / 16
|
||||||
|
|
||||||
|
arr = np.clip(arr, 0, 255).astype(np.uint8)
|
||||||
|
return Image.fromarray(arr, mode="L")
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_image(
|
||||||
|
img: Image.Image, max_rows: int = 240, dither: bool = True
|
||||||
|
) -> Image.Image:
|
||||||
|
"""Convert any image to 96px wide, 1-bit, black on white.
|
||||||
|
|
||||||
|
When *dither* is True (default), uses Floyd-Steinberg error diffusion
|
||||||
|
for better quality on photos and gradients. Set False for crisp text.
|
||||||
|
"""
|
||||||
img = img.convert("L")
|
img = img.convert("L")
|
||||||
w, h = img.size
|
w, h = img.size
|
||||||
new_h = int(h * (PRINTHEAD_PX / w))
|
new_h = int(h * (PRINTHEAD_PX / w))
|
||||||
|
img = img.resize((PRINTHEAD_PX, new_h), Image.LANCZOS)
|
||||||
|
|
||||||
if new_h > max_rows:
|
if new_h > max_rows:
|
||||||
log.warning("Image height %dpx exceeds max %dpx, cropping bottom", new_h, max_rows)
|
log.warning("Image height %dpx exceeds max %dpx, cropping bottom", new_h, max_rows)
|
||||||
new_h = max_rows
|
img = img.crop((0, 0, PRINTHEAD_PX, max_rows))
|
||||||
img = img.resize((PRINTHEAD_PX, new_h), Image.LANCZOS)
|
|
||||||
img = ImageOps.autocontrast(img, cutoff=1)
|
img = ImageOps.autocontrast(img, cutoff=1)
|
||||||
|
|
||||||
|
if dither:
|
||||||
|
img = floyd_steinberg_dither(img)
|
||||||
|
|
||||||
|
# Pack to 1-bit. PIL mode "1" tobytes() uses 0-bit=black, 1-bit=white,
|
||||||
|
# but the printer wants 1-bit=black. Mapping dark->1 via point() inverts
|
||||||
|
# the PIL convention so the final packed bits match what the printer needs.
|
||||||
img = img.point(lambda x: 1 if x < 128 else 0, "1")
|
img = img.point(lambda x: 1 if x < 128 else 0, "1")
|
||||||
return img
|
return img
|
||||||
|
|
||||||
@@ -44,8 +89,8 @@ def text_to_image(text: str, font_size: int = 30, label_height: int = 240) -> Im
|
|||||||
|
|
||||||
bbox = draw.textbbox((0, 0), text, font=font)
|
bbox = draw.textbbox((0, 0), text, font=font)
|
||||||
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||||
x = (canvas_w - tw) // 2
|
x = (canvas_w - tw) // 2 - bbox[0]
|
||||||
y = (canvas_h - th) // 2
|
y = (canvas_h - th) // 2 - bbox[1]
|
||||||
draw.text((x, y), text, fill=0, font=font)
|
draw.text((x, y), text, fill=0, font=font)
|
||||||
|
|
||||||
img = img.rotate(90, expand=True)
|
img = img.rotate(90, expand=True)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Fichero / D11s thermal label printer - BLE interface.
|
Fichero / D11s thermal label printer - BLE + Classic Bluetooth interface.
|
||||||
|
|
||||||
Protocol reverse-engineered from decompiled Fichero APK (com.lj.fichero).
|
Protocol reverse-engineered from decompiled Fichero APK (com.lj.fichero).
|
||||||
Device class: AiYinNormalDevice (LuckPrinter SDK)
|
Device class: AiYinNormalDevice (LuckPrinter SDK)
|
||||||
@@ -7,11 +7,22 @@ Device class: AiYinNormalDevice (LuckPrinter SDK)
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import sys
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from bleak import BleakClient, BleakGATTCharacteristic, BleakScanner
|
from bleak import BleakClient, BleakGATTCharacteristic, BleakScanner
|
||||||
|
|
||||||
|
# --- RFCOMM (Classic Bluetooth) support - Linux + Windows (Python 3.9+) ---
|
||||||
|
|
||||||
|
_RFCOMM_AVAILABLE = False
|
||||||
|
if sys.platform in ("linux", "win32"):
|
||||||
|
import socket as _socket
|
||||||
|
|
||||||
|
_RFCOMM_AVAILABLE = hasattr(_socket, "AF_BLUETOOTH")
|
||||||
|
|
||||||
|
RFCOMM_CHANNEL = 1
|
||||||
|
|
||||||
# --- BLE identifiers ---
|
# --- BLE identifiers ---
|
||||||
|
|
||||||
PRINTER_NAME_PREFIXES = ("FICHERO", "D11s_")
|
PRINTER_NAME_PREFIXES = ("FICHERO", "D11s_")
|
||||||
@@ -24,7 +35,8 @@ NOTIFY_UUID = "00002af0-0000-1000-8000-00805f9b34fb"
|
|||||||
|
|
||||||
PRINTHEAD_PX = 96
|
PRINTHEAD_PX = 96
|
||||||
BYTES_PER_ROW = PRINTHEAD_PX // 8 # 12
|
BYTES_PER_ROW = PRINTHEAD_PX // 8 # 12
|
||||||
CHUNK_SIZE = 200
|
CHUNK_SIZE_BLE = 200 # BLE MTU-limited
|
||||||
|
CHUNK_SIZE_CLASSIC = 16384 # from decompiled app (C1703d.java), stream-based
|
||||||
|
|
||||||
# --- Paper types for 10 FF 84 nn ---
|
# --- Paper types for 10 FF 84 nn ---
|
||||||
|
|
||||||
@@ -111,6 +123,81 @@ class PrinterStatus:
|
|||||||
return not (self.cover_open or self.no_paper or self.overheated)
|
return not (self.cover_open or self.no_paper or self.overheated)
|
||||||
|
|
||||||
|
|
||||||
|
# --- RFCOMM client (duck-types the BleakClient interface) ---
|
||||||
|
|
||||||
|
|
||||||
|
class RFCOMMClient:
|
||||||
|
"""Classic Bluetooth (RFCOMM) transport. Linux + Windows (Python 3.9+).
|
||||||
|
|
||||||
|
Implements the same async context manager + write_gatt_char/start_notify
|
||||||
|
interface that PrinterClient expects from BleakClient. Zero dependencies
|
||||||
|
beyond stdlib.
|
||||||
|
"""
|
||||||
|
|
||||||
|
is_classic = True # transport marker for PrinterClient chunk sizing
|
||||||
|
|
||||||
|
def __init__(self, address: str, channel: int = RFCOMM_CHANNEL):
|
||||||
|
self._address = address
|
||||||
|
self._channel = channel
|
||||||
|
self._sock: "_socket.socket | None" = None
|
||||||
|
self._reader_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "RFCOMMClient":
|
||||||
|
if not _RFCOMM_AVAILABLE:
|
||||||
|
raise PrinterError(
|
||||||
|
"RFCOMM transport requires socket.AF_BLUETOOTH "
|
||||||
|
"(Linux with BlueZ, or Windows with Python 3.9+). "
|
||||||
|
"Not available on this platform."
|
||||||
|
)
|
||||||
|
import socket as _socket
|
||||||
|
|
||||||
|
sock = _socket.socket(
|
||||||
|
_socket.AF_BLUETOOTH, _socket.SOCK_STREAM, _socket.BTPROTO_RFCOMM
|
||||||
|
)
|
||||||
|
sock.setblocking(False)
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
loop.sock_connect(sock, (self._address, self._channel)),
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
sock.close()
|
||||||
|
raise
|
||||||
|
self._sock = sock
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc) -> None:
|
||||||
|
if self._reader_task is not None:
|
||||||
|
self._reader_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._reader_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._reader_task = None
|
||||||
|
if self._sock is not None:
|
||||||
|
self._sock.close()
|
||||||
|
self._sock = None
|
||||||
|
|
||||||
|
async def write_gatt_char(self, _uuid: str, data: bytes, response: bool = False) -> None:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
await loop.sock_sendall(self._sock, data)
|
||||||
|
|
||||||
|
async def start_notify(self, _uuid: str, callback) -> None:
|
||||||
|
self._reader_task = asyncio.create_task(self._reader_loop(callback))
|
||||||
|
|
||||||
|
async def _reader_loop(self, callback) -> None:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
data = await loop.sock_recv(self._sock, 1024)
|
||||||
|
except (OSError, asyncio.CancelledError):
|
||||||
|
return
|
||||||
|
if not data:
|
||||||
|
return
|
||||||
|
callback(None, bytearray(data))
|
||||||
|
|
||||||
|
|
||||||
# --- Client ---
|
# --- Client ---
|
||||||
|
|
||||||
|
|
||||||
@@ -120,6 +207,7 @@ class PrinterClient:
|
|||||||
self._buf = bytearray()
|
self._buf = bytearray()
|
||||||
self._event = asyncio.Event()
|
self._event = asyncio.Event()
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
self._is_classic = getattr(client, "is_classic", False)
|
||||||
|
|
||||||
def _on_notify(self, _char: BleakGATTCharacteristic, data: bytearray) -> None:
|
def _on_notify(self, _char: BleakGATTCharacteristic, data: bytearray) -> None:
|
||||||
self._buf.extend(data)
|
self._buf.extend(data)
|
||||||
@@ -142,11 +230,16 @@ class PrinterClient:
|
|||||||
raise PrinterTimeout(f"No response within {timeout}s")
|
raise PrinterTimeout(f"No response within {timeout}s")
|
||||||
return bytes(self._buf)
|
return bytes(self._buf)
|
||||||
|
|
||||||
async def send_chunked(self, data: bytes, chunk_size: int = CHUNK_SIZE) -> None:
|
async def send_chunked(self, data: bytes, chunk_size: int | None = None) -> None:
|
||||||
|
if chunk_size is None:
|
||||||
|
chunk_size = CHUNK_SIZE_CLASSIC if self._is_classic else CHUNK_SIZE_BLE
|
||||||
|
delay = 0 if self._is_classic else DELAY_CHUNK_GAP
|
||||||
|
async with self._lock:
|
||||||
for i in range(0, len(data), chunk_size):
|
for i in range(0, len(data), chunk_size):
|
||||||
chunk = data[i : i + chunk_size]
|
chunk = data[i : i + chunk_size]
|
||||||
await self.client.write_gatt_char(WRITE_UUID, chunk, response=False)
|
await self.client.write_gatt_char(WRITE_UUID, chunk, response=False)
|
||||||
await asyncio.sleep(DELAY_CHUNK_GAP)
|
if delay:
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
# --- Info commands (all tested and confirmed on D11s fw 2.4.6) ---
|
# --- Info commands (all tested and confirmed on D11s fw 2.4.6) ---
|
||||||
|
|
||||||
@@ -266,8 +359,20 @@ class PrinterClient:
|
|||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def connect(address: str | None = None) -> AsyncGenerator[PrinterClient, None]:
|
async def connect(
|
||||||
|
address: str | None = None,
|
||||||
|
classic: bool = False,
|
||||||
|
channel: int = RFCOMM_CHANNEL,
|
||||||
|
) -> AsyncGenerator[PrinterClient, None]:
|
||||||
"""Discover printer, connect, and yield a ready PrinterClient."""
|
"""Discover printer, connect, and yield a ready PrinterClient."""
|
||||||
|
if classic:
|
||||||
|
if not address:
|
||||||
|
raise PrinterError("--address is required for Classic Bluetooth (no scanning)")
|
||||||
|
async with RFCOMMClient(address, channel) as client:
|
||||||
|
pc = PrinterClient(client)
|
||||||
|
await pc.start()
|
||||||
|
yield pc
|
||||||
|
else:
|
||||||
addr = address or await find_printer()
|
addr = address or await find_printer()
|
||||||
async with BleakClient(addr) as client:
|
async with BleakClient(addr) as client:
|
||||||
pc = PrinterClient(client)
|
pc = PrinterClient(client)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ description = "Fichero D11s thermal label printer - BLE CLI tool"
|
|||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bleak",
|
"bleak",
|
||||||
|
"numpy",
|
||||||
"pillow",
|
"pillow",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
281
tests/test_rfcomm.py
Normal file
281
tests/test_rfcomm.py
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
"""Tests for the RFCOMM (Classic Bluetooth) transport layer."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from fichero.printer import (
|
||||||
|
RFCOMM_CHANNEL,
|
||||||
|
PrinterClient,
|
||||||
|
PrinterError,
|
||||||
|
RFCOMMClient,
|
||||||
|
connect,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- RFCOMMClient unit tests ---
|
||||||
|
|
||||||
|
|
||||||
|
class TestRFCOMMClientInit:
|
||||||
|
def test_defaults(self):
|
||||||
|
c = RFCOMMClient("AA:BB:CC:DD:EE:FF")
|
||||||
|
assert c._address == "AA:BB:CC:DD:EE:FF"
|
||||||
|
assert c._channel == RFCOMM_CHANNEL
|
||||||
|
assert c._sock is None
|
||||||
|
assert c._reader_task is None
|
||||||
|
|
||||||
|
def test_custom_channel(self):
|
||||||
|
c = RFCOMMClient("AA:BB:CC:DD:EE:FF", channel=3)
|
||||||
|
assert c._channel == 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestRFCOMMClientPlatformGuard:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_raises_on_unavailable_platform(self):
|
||||||
|
with patch("fichero.printer._RFCOMM_AVAILABLE", False):
|
||||||
|
client = RFCOMMClient("AA:BB:CC:DD:EE:FF")
|
||||||
|
with pytest.raises(PrinterError, match="requires socket.AF_BLUETOOTH"):
|
||||||
|
async with client:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestRFCOMMClientConnect:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connect_and_close(self):
|
||||||
|
mock_sock = MagicMock()
|
||||||
|
mock_sock.close = MagicMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("fichero.printer._RFCOMM_AVAILABLE", True),
|
||||||
|
patch("fichero.printer.RFCOMMClient.__aenter__") as mock_enter,
|
||||||
|
patch("fichero.printer.RFCOMMClient.__aexit__") as mock_exit,
|
||||||
|
):
|
||||||
|
client = RFCOMMClient("AA:BB:CC:DD:EE:FF")
|
||||||
|
mock_enter.return_value = client
|
||||||
|
mock_exit.return_value = None
|
||||||
|
client._sock = mock_sock
|
||||||
|
|
||||||
|
async with client:
|
||||||
|
assert client._sock is mock_sock
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_socket_closed_on_connect_failure(self):
|
||||||
|
"""If sock_connect fails, the socket must be closed."""
|
||||||
|
mock_sock = MagicMock()
|
||||||
|
mock_sock.setblocking = MagicMock()
|
||||||
|
mock_sock.close = MagicMock()
|
||||||
|
|
||||||
|
mock_socket_mod = MagicMock()
|
||||||
|
mock_socket_mod.AF_BLUETOOTH = 31
|
||||||
|
mock_socket_mod.SOCK_STREAM = 1
|
||||||
|
mock_socket_mod.BTPROTO_RFCOMM = 3
|
||||||
|
mock_socket_mod.socket.return_value = mock_sock
|
||||||
|
|
||||||
|
async def fail_connect(sock, addr):
|
||||||
|
raise ConnectionRefusedError("refused")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("fichero.printer._RFCOMM_AVAILABLE", True),
|
||||||
|
patch.dict("sys.modules", {"socket": mock_socket_mod}),
|
||||||
|
):
|
||||||
|
client = RFCOMMClient("AA:BB:CC:DD:EE:FF")
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
with patch.object(loop, "sock_connect", fail_connect):
|
||||||
|
with pytest.raises(ConnectionRefusedError):
|
||||||
|
await client.__aenter__()
|
||||||
|
mock_sock.close.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestRFCOMMClientIO:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_write_gatt_char_sends_data(self):
|
||||||
|
client = RFCOMMClient("AA:BB:CC:DD:EE:FF")
|
||||||
|
client._sock = MagicMock()
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
with patch.object(loop, "sock_sendall", new_callable=AsyncMock) as mock_send:
|
||||||
|
await client.write_gatt_char("ignored-uuid", b"\x10\xff\x40")
|
||||||
|
mock_send.assert_called_once_with(client._sock, b"\x10\xff\x40")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_write_gatt_char_ignores_uuid_and_response(self):
|
||||||
|
client = RFCOMMClient("AA:BB:CC:DD:EE:FF")
|
||||||
|
client._sock = MagicMock()
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
with patch.object(loop, "sock_sendall", new_callable=AsyncMock) as mock_send:
|
||||||
|
await client.write_gatt_char("any-uuid", b"\xAB", response=True)
|
||||||
|
mock_send.assert_called_once_with(client._sock, b"\xAB")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_notify_launches_reader(self):
|
||||||
|
client = RFCOMMClient("AA:BB:CC:DD:EE:FF")
|
||||||
|
client._sock = MagicMock()
|
||||||
|
|
||||||
|
callback = MagicMock()
|
||||||
|
|
||||||
|
# Mock sock_recv to return data once then empty (EOF)
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
async def mock_recv(sock, size):
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
if call_count == 1:
|
||||||
|
return b"\x01\x02"
|
||||||
|
return b""
|
||||||
|
|
||||||
|
with patch.object(loop, "sock_recv", mock_recv):
|
||||||
|
await client.start_notify("ignored-uuid", callback)
|
||||||
|
assert client._reader_task is not None
|
||||||
|
# Let the reader loop run
|
||||||
|
await client._reader_task
|
||||||
|
|
||||||
|
callback.assert_called_once_with(None, bytearray(b"\x01\x02"))
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reader_loop_handles_oserror(self):
|
||||||
|
client = RFCOMMClient("AA:BB:CC:DD:EE:FF")
|
||||||
|
client._sock = MagicMock()
|
||||||
|
callback = MagicMock()
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
async def mock_recv(sock, size):
|
||||||
|
raise OSError("socket closed")
|
||||||
|
|
||||||
|
with patch.object(loop, "sock_recv", mock_recv):
|
||||||
|
await client.start_notify("uuid", callback)
|
||||||
|
await client._reader_task
|
||||||
|
|
||||||
|
callback.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestRFCOMMClientExit:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_exit_cancels_reader_and_closes_socket(self):
|
||||||
|
client = RFCOMMClient("AA:BB:CC:DD:EE:FF")
|
||||||
|
mock_sock = MagicMock()
|
||||||
|
client._sock = mock_sock
|
||||||
|
|
||||||
|
# Create a long-running task to cancel
|
||||||
|
async def hang_forever():
|
||||||
|
await asyncio.sleep(999)
|
||||||
|
|
||||||
|
client._reader_task = asyncio.create_task(hang_forever())
|
||||||
|
|
||||||
|
await client.__aexit__(None, None, None)
|
||||||
|
|
||||||
|
assert client._sock is None
|
||||||
|
assert client._reader_task is None
|
||||||
|
mock_sock.close.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_exit_no_reader_no_socket(self):
|
||||||
|
"""Exit is safe even if never connected."""
|
||||||
|
client = RFCOMMClient("AA:BB:CC:DD:EE:FF")
|
||||||
|
await client.__aexit__(None, None, None)
|
||||||
|
assert client._sock is None
|
||||||
|
assert client._reader_task is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- connect() integration tests ---
|
||||||
|
|
||||||
|
|
||||||
|
class TestConnectClassic:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_classic_requires_address(self):
|
||||||
|
with pytest.raises(PrinterError, match="--address is required"):
|
||||||
|
async with connect(classic=True):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_classic_uses_rfcomm_client(self):
|
||||||
|
mock_rfcomm = AsyncMock()
|
||||||
|
mock_rfcomm.__aenter__ = AsyncMock(return_value=mock_rfcomm)
|
||||||
|
mock_rfcomm.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_rfcomm.start_notify = AsyncMock()
|
||||||
|
|
||||||
|
with patch("fichero.printer.RFCOMMClient", return_value=mock_rfcomm) as mock_cls:
|
||||||
|
async with connect("AA:BB:CC:DD:EE:FF", classic=True, channel=3) as pc:
|
||||||
|
assert isinstance(pc, PrinterClient)
|
||||||
|
mock_cls.assert_called_once_with("AA:BB:CC:DD:EE:FF", 3)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ble_path_unchanged(self):
|
||||||
|
"""classic=False still uses BleakClient."""
|
||||||
|
mock_bleak = AsyncMock()
|
||||||
|
mock_bleak.__aenter__ = AsyncMock(return_value=mock_bleak)
|
||||||
|
mock_bleak.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_bleak.start_notify = AsyncMock()
|
||||||
|
|
||||||
|
with patch("fichero.printer.BleakClient", return_value=mock_bleak) as mock_cls:
|
||||||
|
async with connect("AA:BB:CC:DD:EE:FF", classic=False) as pc:
|
||||||
|
assert isinstance(pc, PrinterClient)
|
||||||
|
mock_cls.assert_called_once_with("AA:BB:CC:DD:EE:FF")
|
||||||
|
|
||||||
|
|
||||||
|
# --- CLI arg parsing tests ---
|
||||||
|
|
||||||
|
|
||||||
|
class TestCLIArgs:
|
||||||
|
def test_classic_flag_default_false(self):
|
||||||
|
from fichero.cli import main
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
with patch("argparse.ArgumentParser.parse_args") as mock_parse:
|
||||||
|
mock_parse.return_value = argparse.Namespace(
|
||||||
|
address=None, classic=False, channel=1,
|
||||||
|
command="status", func=AsyncMock(),
|
||||||
|
)
|
||||||
|
# Just verify the parser accepts --classic
|
||||||
|
from fichero.cli import main as cli_main
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--classic", action="store_true", default=False)
|
||||||
|
parser.add_argument("--channel", type=int, default=1)
|
||||||
|
args = parser.parse_args([])
|
||||||
|
assert args.classic is False
|
||||||
|
assert args.channel == 1
|
||||||
|
|
||||||
|
def test_classic_flag_set(self):
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--classic", action="store_true", default=False)
|
||||||
|
parser.add_argument("--channel", type=int, default=1)
|
||||||
|
args = parser.parse_args(["--classic", "--channel", "5"])
|
||||||
|
assert args.classic is True
|
||||||
|
assert args.channel == 5
|
||||||
|
|
||||||
|
def test_env_var_transport(self):
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
with patch.dict("os.environ", {"FICHERO_TRANSPORT": "classic"}):
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
import os
|
||||||
|
parser.add_argument(
|
||||||
|
"--classic", action="store_true",
|
||||||
|
default=os.environ.get("FICHERO_TRANSPORT", "").lower() == "classic",
|
||||||
|
)
|
||||||
|
args = parser.parse_args([])
|
||||||
|
assert args.classic is True
|
||||||
|
|
||||||
|
|
||||||
|
# --- Exports ---
|
||||||
|
|
||||||
|
|
||||||
|
class TestExports:
|
||||||
|
def test_rfcomm_client_exported(self):
|
||||||
|
from fichero import RFCOMMClient as RC
|
||||||
|
assert RC is RFCOMMClient
|
||||||
|
|
||||||
|
def test_rfcomm_channel_exported(self):
|
||||||
|
from fichero import RFCOMM_CHANNEL as CH
|
||||||
|
assert CH == 1
|
||||||
|
|
||||||
|
def test_all_contains_new_symbols(self):
|
||||||
|
import fichero
|
||||||
|
assert "RFCOMMClient" in fichero.__all__
|
||||||
|
assert "RFCOMM_CHANNEL" in fichero.__all__
|
||||||
154
uv.lock
generated
154
uv.lock
generated
@@ -1,6 +1,10 @@
|
|||||||
version = 1
|
version = 1
|
||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
resolution-markers = [
|
||||||
|
"python_full_version >= '3.11'",
|
||||||
|
"python_full_version < '3.11'",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-timeout"
|
name = "async-timeout"
|
||||||
@@ -76,15 +80,165 @@ version = "0.1.0"
|
|||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "bleak" },
|
{ name = "bleak" },
|
||||||
|
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||||
|
{ name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||||
{ name = "pillow" },
|
{ name = "pillow" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "bleak" },
|
{ name = "bleak" },
|
||||||
|
{ name = "numpy" },
|
||||||
{ name = "pillow" },
|
{ name = "pillow" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "numpy"
|
||||||
|
version = "2.2.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
resolution-markers = [
|
||||||
|
"python_full_version < '3.11'",
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "numpy"
|
||||||
|
version = "2.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
resolution-markers = [
|
||||||
|
"python_full_version >= '3.11'",
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/44/71852273146957899753e69986246d6a176061ea183407e95418c2aa4d9a/numpy-2.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7e88598032542bd49af7c4747541422884219056c268823ef6e5e89851c8825", size = 16955478, upload-time = "2026-01-31T23:10:25.623Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/41/5d17d4058bd0cd96bcbd4d9ff0fb2e21f52702aab9a72e4a594efa18692f/numpy-2.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7edc794af8b36ca37ef5fcb5e0d128c7e0595c7b96a2318d1badb6fcd8ee86b1", size = 14965467, upload-time = "2026-01-31T23:10:28.186Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/48/fb1ce8136c19452ed15f033f8aee91d5defe515094e330ce368a0647846f/numpy-2.4.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:6e9f61981ace1360e42737e2bae58b27bf28a1b27e781721047d84bd754d32e7", size = 5475172, upload-time = "2026-01-31T23:10:30.848Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/a9/3feb49f17bbd1300dd2570432961f5c8a4ffeff1db6f02c7273bd020a4c9/numpy-2.4.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cb7bbb88aa74908950d979eeaa24dbdf1a865e3c7e45ff0121d8f70387b55f73", size = 6805145, upload-time = "2026-01-31T23:10:32.352Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/39/fdf35cbd6d6e2fcad42fcf85ac04a85a0d0fbfbf34b30721c98d602fd70a/numpy-2.4.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f069069931240b3fc703f1e23df63443dbd6390614c8c44a87d96cd0ec81eb1", size = 15966084, upload-time = "2026-01-31T23:10:34.502Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/46/6fa4ea94f1ddf969b2ee941290cca6f1bfac92b53c76ae5f44afe17ceb69/numpy-2.4.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c02ef4401a506fb60b411467ad501e1429a3487abca4664871d9ae0b46c8ba32", size = 16899477, upload-time = "2026-01-31T23:10:37.075Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/a1/2a424e162b1a14a5bd860a464ab4e07513916a64ab1683fae262f735ccd2/numpy-2.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2653de5c24910e49c2b106499803124dde62a5a1fe0eedeaecf4309a5f639390", size = 17323429, upload-time = "2026-01-31T23:10:39.704Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/a2/73014149ff250628df72c58204822ac01d768697913881aacf839ff78680/numpy-2.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1ae241bbfc6ae276f94a170b14785e561cb5e7f626b6688cf076af4110887413", size = 18635109, upload-time = "2026-01-31T23:10:41.924Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/0c/73e8be2f1accd56df74abc1c5e18527822067dced5ec0861b5bb882c2ce0/numpy-2.4.2-cp311-cp311-win32.whl", hash = "sha256:df1b10187212b198dd45fa943d8985a3c8cf854aed4923796e0e019e113a1bda", size = 6237915, upload-time = "2026-01-31T23:10:45.26Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/ae/e0265e0163cf127c24c3969d29f1c4c64551a1e375d95a13d32eab25d364/numpy-2.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:b9c618d56a29c9cb1c4da979e9899be7578d2e0b3c24d52079c166324c9e8695", size = 12607972, upload-time = "2026-01-31T23:10:47.021Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/a5/c43029af9b8014d6ea157f192652c50042e8911f4300f8f6ed3336bf437f/numpy-2.4.2-cp311-cp311-win_arm64.whl", hash = "sha256:47c5a6ed21d9452b10227e5e8a0e1c22979811cad7dcc19d8e3e2fb8fa03f1a3", size = 10485763, upload-time = "2026-01-31T23:10:50.087Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820, upload-time = "2026-01-31T23:10:59.429Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067, upload-time = "2026-01-31T23:11:01.291Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782, upload-time = "2026-01-31T23:11:03.669Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128, upload-time = "2026-01-31T23:11:05.913Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324, upload-time = "2026-01-31T23:11:08.248Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/f8/50e14d36d915ef64d8f8bc4a087fc8264d82c785eda6711f80ab7e620335/numpy-2.4.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:89f7268c009bc492f506abd6f5265defa7cb3f7487dc21d357c3d290add45082", size = 16833179, upload-time = "2026-01-31T23:12:53.5Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/17/809b5cad63812058a8189e91a1e2d55a5a18fd04611dbad244e8aeae465c/numpy-2.4.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6dee3bb76aa4009d5a912180bf5b2de012532998d094acee25d9cb8dee3e44a", size = 14889755, upload-time = "2026-01-31T23:12:55.933Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/ea/181b9bcf7627fc8371720316c24db888dcb9829b1c0270abf3d288b2e29b/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:cd2bd2bbed13e213d6b55dc1d035a4f91748a7d3edc9480c13898b0353708920", size = 5399500, upload-time = "2026-01-31T23:12:58.671Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/9f/413adf3fc955541ff5536b78fcf0754680b3c6d95103230252a2c9408d23/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:cf28c0c1d4c4bf00f509fa7eb02c58d7caf221b50b467bcb0d9bbf1584d5c821", size = 6714252, upload-time = "2026-01-31T23:13:00.518Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/da/643aad274e29ccbdf42ecd94dafe524b81c87bcb56b83872d54827f10543/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e04ae107ac591763a47398bb45b568fc38f02dbc4aa44c063f67a131f99346cb", size = 15797142, upload-time = "2026-01-31T23:13:02.219Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/27/965b8525e9cb5dc16481b30a1b3c21e50c7ebf6e9dbd48d0c4d0d5089c7e/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:602f65afdef699cda27ec0b9224ae5dc43e328f4c24c689deaf77133dbee74d0", size = 16727979, upload-time = "2026-01-31T23:13:04.62Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/e5/b7d20451657664b07986c2f6e3be564433f5dcaf3482d68eaecd79afaf03/numpy-2.4.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be71bf1edb48ebbbf7f6337b5bfd2f895d1902f6335a5830b20141fc126ffba0", size = 12502577, upload-time = "2026-01-31T23:13:07.08Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pillow"
|
name = "pillow"
|
||||||
version = "12.1.1"
|
version = "12.1.1"
|
||||||
|
|||||||
@@ -310,6 +310,9 @@
|
|||||||
<div>Drop an image here, or click to browse</div>
|
<div>Drop an image here, or click to browse</div>
|
||||||
<input type="file" id="fileInput" accept="image/*">
|
<input type="file" id="fileInput" accept="image/*">
|
||||||
</div>
|
</div>
|
||||||
|
<label style="font-size:13px;color:var(--text2);display:flex;align-items:center;gap:6px">
|
||||||
|
<input type="checkbox" id="imgDither" checked> Floyd-Steinberg dithering
|
||||||
|
</label>
|
||||||
<div class="preview-wrap" id="imgPreviewWrap" style="display:none">
|
<div class="preview-wrap" id="imgPreviewWrap" style="display:none">
|
||||||
<canvas id="imgPreview"></canvas>
|
<canvas id="imgPreview"></canvas>
|
||||||
</div>
|
</div>
|
||||||
@@ -393,6 +396,7 @@ let notifyBuf = [];
|
|||||||
let notifyResolve = null;
|
let notifyResolve = null;
|
||||||
let connected = false;
|
let connected = false;
|
||||||
let uploadedCanvas = null; // prepared 1-bit canvas from image upload
|
let uploadedCanvas = null; // prepared 1-bit canvas from image upload
|
||||||
|
let lastUploadedImg = null; // original Image element for re-processing
|
||||||
|
|
||||||
// ---- DOM refs ----
|
// ---- DOM refs ----
|
||||||
const $ = id => document.getElementById(id);
|
const $ = id => document.getElementById(id);
|
||||||
@@ -654,9 +658,11 @@ function textToCanvas(text, fontSize) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function prepareImageCanvas(img) {
|
function prepareImageCanvas(img) {
|
||||||
// Resize to 96px wide, proportional height
|
// Resize to 96px wide, proportional height, cap to label length
|
||||||
const scale = PRINTHEAD_PX / img.width;
|
const scale = PRINTHEAD_PX / img.width;
|
||||||
const newH = Math.round(img.height * scale);
|
const maxH = getLabelHeight();
|
||||||
|
let newH = Math.round(img.height * scale);
|
||||||
|
if (newH > maxH) newH = maxH;
|
||||||
|
|
||||||
const c = document.createElement('canvas');
|
const c = document.createElement('canvas');
|
||||||
c.width = PRINTHEAD_PX;
|
c.width = PRINTHEAD_PX;
|
||||||
@@ -664,7 +670,8 @@ function prepareImageCanvas(img) {
|
|||||||
const ctx = c.getContext('2d');
|
const ctx = c.getContext('2d');
|
||||||
ctx.drawImage(img, 0, 0, PRINTHEAD_PX, newH);
|
ctx.drawImage(img, 0, 0, PRINTHEAD_PX, newH);
|
||||||
|
|
||||||
return threshold(c);
|
const useDither = $('imgDither').checked;
|
||||||
|
return useDither ? floydSteinbergDither(c) : threshold(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
function threshold(canvas) {
|
function threshold(canvas) {
|
||||||
@@ -685,6 +692,52 @@ function threshold(canvas) {
|
|||||||
return canvas;
|
return canvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function floydSteinbergDither(canvas) {
|
||||||
|
// Floyd-Steinberg error-diffusion dithering.
|
||||||
|
// Same algorithm as PrinterImageProcessor.ditherFloydSteinberg() in the
|
||||||
|
// decompiled Fichero APK (7/16, 3/16, 5/16, 1/16 error distribution).
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const data = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
const px = data.data;
|
||||||
|
const w = canvas.width;
|
||||||
|
const h = canvas.height;
|
||||||
|
|
||||||
|
// Build grayscale float buffer
|
||||||
|
const gray = new Float32Array(w * h);
|
||||||
|
for (let i = 0; i < w * h; i++) {
|
||||||
|
const j = i * 4;
|
||||||
|
gray[i] = 0.299 * px[j] + 0.587 * px[j + 1] + 0.114 * px[j + 2];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dither
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const idx = y * w + x;
|
||||||
|
const old = gray[idx];
|
||||||
|
const val = old < 128 ? 0 : 255;
|
||||||
|
gray[idx] = val;
|
||||||
|
const err = old - val;
|
||||||
|
if (x + 1 < w) gray[idx + 1] += err * 7 / 16;
|
||||||
|
if (y + 1 < h) {
|
||||||
|
if (x - 1 >= 0) gray[idx + w - 1] += err * 3 / 16;
|
||||||
|
gray[idx + w] += err * 5 / 16;
|
||||||
|
if (x + 1 < w) gray[idx + w + 1] += err * 1 / 16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back
|
||||||
|
for (let i = 0; i < w * h; i++) {
|
||||||
|
const v = gray[i] < 128 ? 0 : 255;
|
||||||
|
const j = i * 4;
|
||||||
|
px[j] = px[j + 1] = px[j + 2] = v;
|
||||||
|
px[j + 3] = 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.putImageData(data, 0, 0);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
function canvasToRaster(canvas) {
|
function canvasToRaster(canvas) {
|
||||||
// Pack pixels into MSB-first bytes. 1 = black, 0 = white.
|
// Pack pixels into MSB-first bytes. 1 = black, 0 = white.
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
@@ -834,10 +887,8 @@ function handleFile(file) {
|
|||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
uploadedCanvas = prepareImageCanvas(img);
|
lastUploadedImg = img;
|
||||||
showPreview(uploadedCanvas, 'imgPreview');
|
reprocessImage();
|
||||||
$('imgPreviewWrap').style.display = '';
|
|
||||||
$('imgPrintBtn').disabled = !connected;
|
|
||||||
log(`Image loaded: ${img.width}x${img.height} -> ${uploadedCanvas.width}x${uploadedCanvas.height}`);
|
log(`Image loaded: ${img.width}x${img.height} -> ${uploadedCanvas.width}x${uploadedCanvas.height}`);
|
||||||
};
|
};
|
||||||
img.src = reader.result;
|
img.src = reader.result;
|
||||||
@@ -845,6 +896,14 @@ function handleFile(file) {
|
|||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reprocessImage() {
|
||||||
|
if (!lastUploadedImg) return;
|
||||||
|
uploadedCanvas = prepareImageCanvas(lastUploadedImg);
|
||||||
|
showPreview(uploadedCanvas, 'imgPreview');
|
||||||
|
$('imgPreviewWrap').style.display = '';
|
||||||
|
$('imgPrintBtn').disabled = !connected;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Event listeners ----
|
// ---- Event listeners ----
|
||||||
|
|
||||||
$('textInput').addEventListener('input', updateTextPreview);
|
$('textInput').addEventListener('input', updateTextPreview);
|
||||||
@@ -856,6 +915,10 @@ $('fileInput').addEventListener('change', e => {
|
|||||||
if (e.target.files[0]) handleFile(e.target.files[0]);
|
if (e.target.files[0]) handleFile(e.target.files[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Re-process image when dither toggle or label length changes
|
||||||
|
$('imgDither').addEventListener('change', () => { if (lastUploadedImg) reprocessImage(); });
|
||||||
|
$('labelLength').addEventListener('change', () => { if (lastUploadedImg) reprocessImage(); });
|
||||||
|
|
||||||
// Dropzone click -> trigger file input
|
// Dropzone click -> trigger file input
|
||||||
$('dropzone').addEventListener('click', () => $('fileInput').click());
|
$('dropzone').addEventListener('click', () => $('fileInput').click());
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user