Sorter 2.0.0

Put-to-light

With put-to-light, every bin has a light (and usually a button) that shows the operator where to put: the bin lights up after a scan, often with the quantity on a small display, and the operator presses its button when the unit is in. The install drives the lights of any vendor through one of two neutral interfaces, an HTTP gateway or an MQTT broker, or a wcs-bridge on the warehouse network over cloud queues, and has a simulator for trying it out without hardware.

The lights are an addition, never a dependency: a scan never waits for them and never fails because of them. If the hardware is offline, scanning goes on, and the lights catch up when it is back.

Choose a driver#

SORTER_LIGHTS
none (default) No hardware. The light state can still be read (below)
http POST the changes to your light gateway: HTTP gateway
mqtt Publish the changes to an MQTT broker, one topic per bin: MQTT
queue Send the changes to a wcs-bridge on another network over cloud queues (AWS SQS, Azure Service Bus, Google Cloud Pub/Sub), per layout: Cloud queues
simulator No hardware: the wall drawing shows the lights and its bins work as buttons: Simulator

SORTER_LIGHTS_RESYNC_MS (default 60000, 1000 to 86400000) is how often the install sends the whole state again (below). The other settings are per driver.

The light state#

The install doesn't send "light on" and "light off" commands that your hardware has to keep track of. It works out, from the state of the waves, how every bin's light should look, and sends what changed. Each bin is in one of three states:

State When Shown as (your choice)
put Put here: the bin of a pending put (waves with confirmed puts), or, without confirmation, the bins of each operator's latest scan, until that operator's next scan in the wave Lit, often blinking, with quantity on the display
complete The order in this bin is complete: pack it out Lit in another colour, steady
off Anything else (free bins, orders still being filled, released orders, closed waves) Dark

The state belongs to the layout, across all its waves. Read it any time, whatever the driver:

bash
curl -s "$SORTER_URL/api/layouts/PUTWALL-A/lights"
json
[
  { "binId": "A-01-01", "wallId": "A", "address": "A-01-01", "state": "put", "waveId": "WAVE-CURL-2", "orderId": "ORD-2001", "quantity": 1, "sku": "MUG" }
]

Bins that are not listed are off. address is the bin's light address.

How the install keeps the lights right#

With several instances, each one sends the changes it makes, and every instance resends the whole state periodically, so the hardware ends up with the same state whichever instance it heard last.

HTTP gateway#

For a light controller with an HTTP interface, or a small service of yours that talks to the vendor's controller.

Variable
SORTER_LIGHTS_URL Required: the gateway's http:// or https:// URL. No user name or password in the URL
SORTER_LIGHTS_TOKEN Optional: sent as Authorization: Bearer <token> (1 to 4096 visible characters)

The install sends a POST with Content-Type: application/json per layout and change:

json
{
  "layoutId": "PUTWALL-A",
  "changes": [
    { "address": "A-01-01", "binId": "A-01-01", "wallId": "A", "state": "put", "quantity": 2, "sku": "MUG" },
    { "address": "A-01-02", "binId": "A-01-02", "wallId": "A", "state": "off" }
  ]
}
Field
layoutId The layout the bins belong to
changes[] Bins whose light must change: address, binId, wallId, state (put, complete or off), and for put the quantity and sku to show
all Only in a resync: the layout's whole light state, as in GET /api/layouts/{layoutId}/lights. A gateway that prefers to replace its state can use it; changes then holds every lit bin and the bins to turn off

Answer 2xx within 2 seconds. Any other answer, a timeout or a redirect (not followed) counts as failed, and the layout is sent as a whole state with the next change and at the next resync.

When an operator presses a bin's button, the gateway calls the press route of the install.

MQTT#

For lights that speak MQTT, or a gateway that bridges MQTT to the vendor's bus.

Variable
SORTER_LIGHTS_MQTT_URL Required: mqtt://broker:1883, or mqtts://broker:8883 for TLS. A user name and password may be in the URL
SORTER_LIGHTS_MQTT_USERNAME, SORTER_LIGHTS_MQTT_PASSWORD The broker login, if it isn't in the URL (these win)
SORTER_LIGHTS_MQTT_CA_FILE A PEM file with the CA certificate of your broker (private CA). Needs an mqtts:// URL. It replaces the system's CAs for the broker connection, so a broker with a public certificate needs no CA file
SORTER_LIGHTS_MQTT_PREFIX The first topic levels, default sorter (letters, digits, ., _, -, levels separated by /)

The install publishes each bin's state retained, with QoS 1, to

text
<prefix>/<layoutId>/<address>/set

with the payload

json
{ "state": "put", "quantity": 2, "sku": "MUG", "binId": "A-01-01" }

state is put, complete or off (off and complete without quantity and sku). Because the messages are retained, a module that restarts or reconnects gets its bin's current state from the broker at once. In a topic, the layout id and the address keep letters, digits and . _ : -; any other character is percent-encoded (UTF-8), so a light address A/01 is the single topic level A%2F01.

Button presses: the install subscribes to <prefix>/+/+/press (QoS 1). A module (or gateway) publishes any payload (up to 1 KiB; it is not read) to

text
<prefix>/<layoutId>/<address>/press

not retained: retained press messages are ignored, so a stale press can't confirm a put when the install reconnects. A press is handled exactly like the press route; a press with nothing to do is logged and ignored. While the install has no valid license, MQTT presses are ignored (with a warning in the log), just as the press route answers 402.

The install connects with a random client id and a clean session, reconnects every 5 seconds when the broker is gone, and logs the connection state (never the password). On every reconnect it sends the whole state of every layout (a broker that restarted without persistence has lost the retained states), so changes made while the broker was offline arrive once it is back.

Restrict who may press. Any client that can publish to <prefix>/<layoutId>/<address>/press confirms puts and releases orders: the install can't tell a module from any other MQTT client. Configure the broker's ACLs so only your light modules or gateways may publish to <prefix>/+/+/press, only the install may publish to <prefix>/+/+/set, and nobody else may write under the prefix (for example Mosquitto acl_file with a user per gateway, topic write sorter/+/+/press; EMQX or HiveMQ authorization rules). Give the install its own broker user with exactly that: subscribe <prefix>/+/+/press, publish <prefix>/+/+/set.

Cloud queues (wcs-bridge)#

When the install runs in the cloud (or in another network) and the light controllers are on the warehouse floor, a wcs-bridge on the warehouse network drives the controllers, and the two talk over two cloud queues per site. Nothing connects into the warehouse: the bridge only polls and sends outbound, and so does the install.

Set SORTER_LIGHTS=queue, list the queues layouts may use in SORTER_BRIDGE_ALLOWED_QUEUES (below), and give each layout that has a bridge its queues in bridge:

json
{
  "id": "PUTWALL-A", "unit": "mm", "weightUnit": "kg", "walls": [ ... ],
  "bridge": {
    "commandQueue": { "provider": "aws-sqs", "queueUrl": "https://sqs.eu-central-1.amazonaws.com/123456789012/site1-commands" },
    "eventQueue": { "provider": "aws-sqs", "queueUrl": "https://sqs.eu-central-1.amazonaws.com/123456789012/site1-events" }
  }
}

Azure Service Bus: { "provider": "azure-servicebus", "namespace": "acme-wcs", "queue": "site1-commands" } (or a topic; the event queue may be a topic with its subscription). Google Cloud Pub/Sub: { "provider": "gcp-pubsub", "project": "acme-wcs-prod", "topic": "site1-commands" } and { ..., "subscription": "site1-events-sorter" }. Layouts without a bridge have no lights with this driver (their light state can still be read). Several layouts can name the same queues (one bridge for several walls): each message carries the layout id.

Allowed queues#

SORTER_BRIDGE_ALLOWED_QUEUES is a comma-separated list of queue prefixes, one provider each:

bash
SORTER_BRIDGE_ALLOWED_QUEUES=aws-sqs:https://sqs.us-east-1.amazonaws.com/123456789012/wms-site-,azure-servicebus:acme-wcs/site1-,gcp-pubsub:acme-wcs-prod/site1-
Entry Matches
aws-sqs:https://sqs.<region>.amazonaws.com/<account>/<name prefix> Queue URLs starting with it. Queue names are case-sensitive
azure-servicebus:<namespace>/<entity prefix> Queues and topics (<topic>/subscriptions/<subscription> for the event side) of that namespace starting with the prefix. Not case-sensitive, like Service Bus
gcp-pubsub:<project>/<topic or subscription prefix> Topics and subscriptions of that project starting with it

Security: the API has no authentication, so the allowlist is the only thing stopping an API caller from pointing Sorter's credentials at other queues (another tenant's, or unrelated ones, which the install would then read and delete from, or send to). Keep it as narrow as your queue naming allows, and give the install's credentials rights on exactly those queues too.

Credentials#

Layouts hold queue addresses only, never credentials. The install uses its own, from each provider's default chain:

Provider Credentials The install needs
AWS SQS The default AWS chain: AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY, AWS_PROFILE, a container or instance role. The region is the queue URL's sqs:SendMessage on the command queue; sqs:ReceiveMessage, sqs:DeleteMessage, sqs:ChangeMessageVisibility on the event queue
Azure Service Bus SORTER_AZURE_SERVICEBUS_CONNECTION_STRING (for the namespace the layouts name), else DefaultAzureCredential (AZURE_CLIENT_ID / AZURE_TENANT_ID / AZURE_CLIENT_SECRET, workload or managed identity) Send on the command queue or topic, Listen on the event queue or subscription (Azure Service Bus Data Sender / Receiver)
Google Cloud Pub/Sub Application Default Credentials (GOOGLE_APPLICATION_CREDENTIALS, workload identity, the metadata server) pubsub.topics.publish on the command topic, pubsub.subscriptions.consume on the event subscription (Pub/Sub Publisher / Subscriber)

Give the bridge the opposite rights (receive commands, send events), and nothing else: the queue permissions are what keeps one site's bridge from another site's lights. Anyone who can send to an event queue can press its buttons.

What the install sends#

JSON, one message per change, in the wcs-bridge upstream protocol (v: 1). The scope is the layout id, the key of each light its light address:

json
{"v":1,"type":"delta","scope":"PUTWALL-A","seq":1792656000000000,"lights":{"A-01-01":{"state":"put","quantity":2},"A-01-02":null}}
Sorter wcs-bridge
put with quantity { "state": "put", "quantity": 2 } (the SKU is not sent: the module shows the quantity)
complete { "state": "complete" }
off null in a delta; not listed in a desired

Sends never block or fail a scan. A send that fails (or takes more than 10 s) is logged once per outage, and the next send of the layout is a whole desired.

What the install receives#

From the event queue, one message at a time:

Message The install
event with a light.press (scope, address, data.button) Handles it like POST /api/lights/press: confirms or releases. Only the confirm button (or none) counts; plus, minus and function presses are ignored
resync (scope) Sends that layout's desired at once
hello, status, error Recorded: Check a bridge
other events (job and device status) Ignored

Check a bridge#

GET /api/layouts/{layoutId}/bridge shows whether the bridge is blocked by the allowlist, the queues, the bridge's last hello (id, version, license, devices, scopes), when it was last heard from, its last status and error, and this instance's counters (reference). /v1/info shows lights.driver: "queue". The bridge sends hello every 5 minutes, so a lastSeen older than that means the bridge (or its queue) is down.

Several instances and the event queue#

Every instance sends: each sends the changes it makes, and every instance sends the whole state periodically, so the bridge ends up with the latest state (a delta from one instance can briefly be overtaken by another's; the next desired heals it). Receiving from the event queue on several instances also works (the queues give each message to one of them), but the duplicate check is per instance: a press delivered again to another instance is only stopped by the press guard. Simplest: let one instance receive and set SORTER_BRIDGE_CONSUME=0 on the others. Status and counters are per instance.

Button presses#

bash
curl -s -X POST "$SORTER_URL/api/lights/press" -H 'Content-Type: application/json' \
     -d '{ "layoutId": "PUTWALL-A", "address": "A-01-01" }'

POST /api/lights/press with the layout and the light's address (its light address, else the bin id). The bin decides what the press means:

The bin The press Answer
Has a pending put (confirmed puts) Confirms the bin's oldest pending put, like a scan of the bin label { "action": "confirmed", "result": ConfirmResult }
Holds a complete order Releases the order: packed out, all its bins are free { "action": "released", "orderIds": [...], "bins": [...] }
Just confirmed by a press, or its order completed, less than SORTER_PRESS_RELEASE_GUARD_MS ago Nothing changes: the press repeats the one before (a double press, a bouncing button, a redelivered MQTT message) 200 { "action": "ignored", "reason": "duplicate" } (a press confirmed a put in this bin) or "too_soon" (the order just completed)
Anything else (a free bin, an order still being filled, an unknown address) Nothing changes 409 not_pending

So one button does both jobs of a put-to-light wall: press after putting (with confirmed puts), and press after emptying the bin at pack-out. A complete order lights all its bins complete; pressing any of them releases the whole order.

Because the same button confirms and releases, a press right after another one of the bin is ignored for SORTER_PRESS_RELEASE_GUARD_MS (default 3000, 0 to 600000; 0 turns the guard off): the last put's press must not also pack out the order, and a press the broker delivers twice must not confirm the next put. An ignored press is not an error (200); press again after the pause. An invalid value falls back to the default.

json
{"action":"released","orderIds":["ORD-2001"],"bins":[{"id":"A-01-01","wallId":"A"}]}

The press route is a change like any other: it needs a license (402 otherwise), and it refuses requests from browser pages on other origins (Network and security); a gateway calling from a server sends no Origin and is not affected. It works with every SORTER_LIGHTS setting, so an HTTP gateway can report presses while the install drives the lights over MQTT, or the other way round. MQTT presses and wcs-bridge presses go to the same logic.

The simulator#

SORTER_LIGHTS=simulator needs no hardware. On the admin page (/), the Station tab has a Light simulator switch that shows every wall of the station's layout with its lights: put bins pulse with the quantity, complete bins are steady, and clicking a lit bin presses its button. Scan at the station, click the bin, and watch the put being confirmed or the order being released.

The same in your own pages: <sorter-wall lights simulate-buttons> polls the light state and shows it; with simulate-buttons a click (or Enter or Space) on a lit bin presses its button. Without simulate-buttons it only shows the lights, which is useful next to real hardware too, for example on a supervisor's screen. The light state and the press route are available with every driver, so the simulator works next to real lights.

Light addresses#

A bin's light is addressed by the bin's lightAddress in the layout, or by the bin id when it has none. Give bins an address when your light modules are numbered differently from the bin labels:

json
{ "id": "A-01-01", "x": 0, "y": 30, "width": 40, "height": 30, "depth": 40, "lightAddress": "ctrl1/017" }