Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
An InvenTree **machine driver** that dispatches label print jobs to a
[labelfab](https://github.com/sengine-cloud/labelfab) agent over MQTT. Click *Print*
in InvenTree; a job lands on the broker; the agent renders and prints it on a Phomemo
D30.
D30. A second plugin in the same package resolves the printed QR codes back to their
InvenTree items when scanned.

## Why a machine driver, not a `LabelPrintingMixin`

Expand Down Expand Up @@ -118,6 +119,10 @@ next refresh picks up the truth from the retained topic.
- Default path sends `{preset, vars}` — the producer stays dumb and layout ownership
stays in the agent. Extractors map a `StockItem` / `Part` / `StockLocation` to a
short scannable code plus title/sub text.
- The title leads with the item's InvenTree short barcode and IPN (`INV-SI1 · A1533`),
with the name underneath. The short barcode can be typed into InvenTree's scanner as
is, so a label stays usable when its QR does not scan. The prefix is read from the
builtin barcode plugin's `SHORT_BARCODE_PREFIX`, so it follows the instance.
- `server_render` (a per-machine setting or a per-print option) is the escape hatch:
render the InvenTree label template to PNG here and send it as a `raw_png` element.
One awkward label becomes a checkbox, not a code change.
Expand All @@ -143,12 +148,31 @@ the Django-Q worker (that is where `print_labels` runs):
Per-printer settings (Printer ID, tape width/kind, default preset, server render)
live on the machine in the InvenTree admin.

## Scanning labels back

The QR on a label encodes the agent's `render.qr_base_url` plus a bare code, for example
`https://sngn.top/i/SI1`. A phone opens that as a link, but InvenTree's own scanner only
knows its native short format (`INV-SI1`) and answers "No match found".

**LabelfabShortlink** (`labelfab-shortlink`) closes that gap. It strips the configured
base, rebuilds `INV-SI1`, and hands it to InvenTree's builtin barcode plugin, so model
codes, permissions and the response are all InvenTree's own. Nothing has to be linked
per item.

1. **Admin → Plugins**: activate **LabelfabShortlink**.
2. Set **Short link base** to the same value as the agent's `render.qr_base_url`.
Scheme and case are ignored when matching, so labels printed as `SNGN.TOP/I/PA39`
and as `https://sngn.top/i/PA39` both resolve. Empty disables the plugin.

It depends on the builtin **InvenTreeBarcode** plugin being active, which it is by
default.

## Development

```bash
python -m venv .venv && . .venv/bin/activate
pip install -e '.[dev]'
pytest # covers the pure dispatch + extractor logic
pytest # covers the pure dispatch, extractor and short-link logic
ruff check inventree_label_dispatch tests
```

Expand Down
61 changes: 61 additions & 0 deletions inventree_label_dispatch/barcode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Barcode plugin that resolves the short links printed on labelfab labels.

A plugin class of its own, beside the machine driver, so it can be enabled separately.
It only translates: ``https://sngn.top/i/SI1`` becomes ``INV-SI1`` and goes to
InvenTree's builtin barcode plugin, so model codes, permissions and the response shape
all stay InvenTree's. Without it, InvenTree answers "No match found" for every label
this package prints, and linking barcodes item by item does not scale.
"""

from __future__ import annotations

from plugin import InvenTreePlugin
from plugin.mixins import BarcodeMixin, SettingsMixin
from plugin.registry import registry

from . import shortlink
from .extractors import DEFAULT_PREFIX
from .version import __version__

#: InvenTree's builtin barcode plugin. It owns the short format and its prefix.
BUILTIN = "inventreebarcode"


def short_prefix() -> str:
"""The instance's short barcode prefix (``INV-`` unless someone changed it)."""
builtin = registry.get_plugin(BUILTIN)
if builtin is None:
return DEFAULT_PREFIX
return builtin.get_setting("SHORT_BARCODE_PREFIX") or DEFAULT_PREFIX


class LabelfabShortlinkPlugin(BarcodeMixin, SettingsMixin, InvenTreePlugin):
NAME = "LabelfabShortlink"
SLUG = "labelfab-shortlink"
TITLE = "labelfab short links"
DESCRIPTION = "Scan the short-link QR codes on labelfab labels, e.g. https://sngn.top/i/SI1"
AUTHOR = "sengine"
VERSION = __version__
WEBSITE = "https://github.com/sengine-cloud/inventree-label-dispatch"
MIN_VERSION = "1.4.0"

SETTINGS = {
"SHORTLINK_BASE": {
"name": "Short link base",
"description": (
"The same value as the agent's render.qr_base_url, e.g. "
"https://sngn.top/i/. Leave empty to disable."
),
"default": "",
},
}

def scan(self, barcode_data, user, **kwargs):
parsed = shortlink.parse(barcode_data, self.get_setting("SHORTLINK_BASE"))
if parsed is None:
return None
builtin = registry.get_plugin(BUILTIN)
if builtin is None:
return None
code, pk = parsed
return builtin.scan(f"{short_prefix()}{code}{pk}", user, **kwargs)
6 changes: 5 additions & 1 deletion inventree_label_dispatch/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,13 +192,17 @@ def print_labels(self, machine, label, items, **kwargs):
tape_kind = machine.get_setting("TAPE_KIND", "D") or "continuous"
length = options.get("label_length_mm")

# Read once per batch: the prefix is an InvenTree setting, not per item.
from .barcode import short_prefix

prefix = short_prefix()
labels = []
for item in items:
if server_render:
png = self._png(label, item)
spec = {"elements": [{"type": "raw_png", "data_b64": png, "fit": "contain"}]}
else:
spec = {"preset": preset, "vars": extract(item)}
spec = {"preset": preset, "vars": extract(item, prefix)}
labels.append(build_label(spec, copies=copies, length_mm=length))

job = build_job(
Expand Down
65 changes: 48 additions & 17 deletions inventree_label_dispatch/extractors.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,42 +3,73 @@
Duck-typed on purpose: each extractor reads attributes, so a plain stand-in object
exercises it in a test without a Django database. The agent owns layout; these only
decide *what text* a label carries, keyed by a short scannable code.

Every label leads with the item's InvenTree short barcode (``INV-SI1``) and IPN, so
the text can be typed into InvenTree's scanner when the QR is unreadable. The name
goes underneath as context. ``code`` stays the bare ``SI1``: it is what the QR
encodes, behind the agent's short-link base, and every character there costs module
size on 12mm tape.
"""

from __future__ import annotations

from decimal import Decimal, InvalidOperation

#: InvenTree's default ``SHORT_BARCODE_PREFIX``. The driver passes the instance's real
#: setting; this only covers callers that have no InvenTree to ask.
DEFAULT_PREFIX = "INV-"


def _s(value) -> str:
return "" if value is None else str(value)


def extract_stock_item(item) -> dict:
def _qty(value) -> str:
"""``1.00000`` -> ``1`` and ``250.00000`` -> ``250``; ``1.5`` stays ``1.5``."""
try:
return format(Decimal(str(value)).normalize(), "f")
except (InvalidOperation, ValueError):
return _s(value)


def _headline(short_code: str, ipn) -> str:
ipn = _s(ipn)
return f"{short_code} · {ipn}" if ipn else short_code


def extract_stock_item(item, prefix: str = DEFAULT_PREFIX) -> dict:
part = getattr(item, "part", None)
location = getattr(item, "location", None)
sub = _s(getattr(part, "name", "") or getattr(part, "full_name", ""))
qty = getattr(item, "quantity", None)
sub = _s(getattr(location, "name", "")) if location else ""
if qty is not None:
sub = f"{sub} · {qty}".strip(" ·")
# A serialised item is exactly one by definition, and a count of one says nothing,
# so the quantity only earns space on bulk stock.
if qty is not None and not getattr(item, "serial", None) and _qty(qty) != "1":
sub = f"{sub} · ×{_qty(qty)}".strip(" ·")
code = f"SI{item.pk}"
return {
"code": f"SI{item.pk}",
"title": _s(getattr(part, "full_name", None) or getattr(part, "name", "")),
"code": code,
"title": _headline(f"{prefix}{code}", getattr(part, "IPN", None)),
"sub": sub,
}


def extract_part(item) -> dict:
def extract_part(item, prefix: str = DEFAULT_PREFIX) -> dict:
code = f"PA{item.pk}"
return {
"code": f"PA{item.pk}",
"title": _s(getattr(item, "full_name", None) or getattr(item, "name", "")),
"sub": _s(getattr(item, "IPN", "") or getattr(item, "description", "")),
"code": code,
"title": _headline(f"{prefix}{code}", getattr(item, "IPN", None)),
# ``name``, not ``full_name``: InvenTree builds full_name as "IPN | name", and
# the IPN is already in the headline.
"sub": _s(getattr(item, "name", "") or getattr(item, "full_name", "")),
}


def extract_stock_location(item) -> dict:
def extract_stock_location(item, prefix: str = DEFAULT_PREFIX) -> dict:
code = f"SL{item.pk}"
return {
"code": f"SL{item.pk}",
"title": _s(getattr(item, "name", "")),
"sub": _s(getattr(item, "pathstring", "")),
"code": code,
"title": f"{prefix}{code}",
"sub": _s(getattr(item, "name", "")),
}


Expand All @@ -50,11 +81,11 @@ def extract_stock_location(item) -> dict:
}


def extract(item) -> dict:
def extract(item, prefix: str = DEFAULT_PREFIX) -> dict:
"""Best-effort extraction, dispatched on the item's model class name."""
fn = _BY_MODEL.get(type(item).__name__)
if fn is not None:
return fn(item)
return fn(item, prefix)
# Unknown model: fall back to the primary key, so a label still prints something
# scannable rather than the driver erroring on an unmapped type.
return {"code": f"PK{getattr(item, 'pk', '?')}", "title": _s(getattr(item, 'name', '')), "sub": ""}
41 changes: 41 additions & 0 deletions inventree_label_dispatch/shortlink.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Map the short links printed in label QR codes back to InvenTree's short barcodes.

The agent prints ``<qr_base_url><code>``, for example ``https://sngn.top/i/SI1``, so a
phone camera gets a URL it can open. InvenTree's own scanner only resolves its native
short format, ``INV-SI1``. Parsing lives here with no InvenTree imports so it can be
tested on its own; the plugin in ``barcode`` does the lookup.
"""

from __future__ import annotations

import re

_SCHEME = re.compile(r"^[a-z][a-z0-9+.-]*://", re.IGNORECASE)
#: Two-character model code, then the primary key. InvenTree's codes are uppercase
#: letters (PA, SI, SL, ...); an optional trailing slash tolerates hand-typed links.
_CODE = re.compile(r"([0-9A-Z]{2})(\d+)/?")


def _bare(url: str) -> str:
"""Drop the scheme and lowercase, so ``HTTPS://SNGN.TOP/I/`` equals ``sngn.top/i/``."""
return _SCHEME.sub("", url.strip()).lower()


def parse(data, base: str) -> tuple[str, int] | None:
"""Return ``(model_code, pk)`` when ``data`` is a short link under ``base``.

Scheme and case are ignored on both sides: the same label may carry the bare
uppercase form (``SNGN.TOP/I/PA39``, which keeps the QR in alphanumeric mode) or a
full URL (``https://sngn.top/i/PA39``, which phones open directly). An empty
``base`` matches nothing, so the plugin stays inert until it is configured.
"""
if not base or not isinstance(data, str):
return None
prefix = _bare(base)
bare = _bare(data)
if not bare.startswith(prefix):
return None
match = _CODE.fullmatch(bare.removeprefix(prefix).lstrip("/").upper())
if match is None:
return None
return match.group(1), int(match.group(2))
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "inventree-label-dispatch"
description = "InvenTree machine driver that dispatches labels to a labelfab print agent"
description = "InvenTree machine driver that dispatches labels to a labelfab print agent, and scans them back"
readme = "README.md"
requires-python = ">=3.9"
license = { text = "MIT" }
Expand All @@ -23,6 +23,7 @@ dev = ["pytest>=8", "ruff>=0.4"]
# deliberately not dependencies here.
[project.entry-points."inventree_plugins"]
LabelfabDispatch = "inventree_label_dispatch.plugin:LabelfabDispatchPlugin"
LabelfabShortlink = "inventree_label_dispatch.barcode:LabelfabShortlinkPlugin"

[project.urls]
Homepage = "https://github.com/sengine-cloud/inventree-label-dispatch"
Expand Down
52 changes: 40 additions & 12 deletions tests/test_extractors.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@

from __future__ import annotations

from decimal import Decimal

from inventree_label_dispatch.extractors import extract


class StockItem:
def __init__(self, pk, part, location, quantity):
self.pk, self.part, self.location, self.quantity = pk, part, location, quantity
def __init__(self, pk, part, location=None, quantity=None, serial=None):
self.pk, self.part, self.location = pk, part, location
self.quantity, self.serial = quantity, serial


class Part:
Expand All @@ -33,22 +36,47 @@ def __init__(self, pk, name):
self.pk, self.name = pk, name


def test_stock_item():
item = StockItem(4821, Part(1, full_name="M3x8 hex bolt"), StockLocation(2, "BIN-A4", ""), 250)
out = extract(item)
assert out["code"] == "SI4821"
assert out["title"] == "M3x8 hex bolt"
assert "BIN-A4" in out["sub"] and "250" in out["sub"]
PHONE = Part(39, full_name="A1533 | iPhone 5s", name="iPhone 5s", IPN="A1533")


def test_serialised_stock_item_leads_with_short_code_and_ipn():
out = extract(StockItem(1, PHONE, quantity=Decimal("1.00000"), serial="358814056238878"))
assert out == {"code": "SI1", "title": "INV-SI1 · A1533", "sub": "iPhone 5s"}


def test_bulk_stock_shows_quantity_without_trailing_zeros():
bolt = Part(7, name="M3x8 hex bolt", IPN="M3-8")
out = extract(StockItem(4821, bolt, StockLocation(2, "BIN-A4", ""), Decimal("250.00000")))
assert out["title"] == "INV-SI4821 · M3-8"
assert out["sub"] == "M3x8 hex bolt · ×250"


def test_a_count_of_one_is_not_printed():
out = extract(StockItem(5, Part(7, name="Bracket"), quantity=Decimal("1.00000")))
assert out["sub"] == "Bracket"


def test_fractional_quantity_keeps_its_fraction():
out = extract(StockItem(6, Part(8, name="Solder"), quantity=Decimal("1.50000")))
assert out["sub"] == "Solder · ×1.5"


def test_part_uses_name_not_full_name():
# full_name is "IPN | name"; the IPN is already in the headline.
assert extract(PHONE) == {"code": "PA39", "title": "INV-PA39 · A1533", "sub": "iPhone 5s"}


def test_part_without_ipn_is_just_the_short_code():
assert extract(Part(12, name="Widget"))["title"] == "INV-PA12"


def test_part():
out = extract(Part(12, full_name="Widget", IPN="WID-001"))
assert out == {"code": "PA12", "title": "Widget", "sub": "WID-001"}
def test_prefix_follows_the_instance_setting():
assert extract(PHONE, "ACME-")["title"] == "ACME-PA39 · A1533"


def test_stock_location():
out = extract(StockLocation(3, "Shelf 3", "Warehouse/Shelf 3"))
assert out == {"code": "SL3", "title": "Shelf 3", "sub": "Warehouse/Shelf 3"}
assert out == {"code": "SL3", "title": "INV-SL3", "sub": "Shelf 3"}


def test_unknown_model_falls_back_to_pk():
Expand Down
Loading
Loading