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 |
- A bin with a pending put shows
put, with theskuandquantityof its oldest pending put; else the latest put; elsecomplete. So without confirmation, the bin of the scan that completed an order keeps showingputuntil that operator's next scan, while the order's other bins already showcomplete. - Every operator (the
operatorof the scans) keeps their own latest put lit, so several operators can work one wall. Undoing a scan turns its light off. - With confirmed puts, only pending puts light up as
put: confirming the put turns it off (or tocomplete, when it was the order's last).
The state belongs to the layout, across all its waves. Read it any time, whatever the driver:
curl -s "$SORTER_URL/api/layouts/PUTWALL-A/lights"[
{ "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#
- After every change, the install compares the new state with what it sent last and sends only the bins that changed,
offincluded. - On start, and every
SORTER_LIGHTS_RESYNC_MS, it sends the whole state again: every lit bin, andofffor bins that may still be lit (on start, for every bin of the layout). After a failed send, the next send of that layout is a whole state too, also when it was already waiting (a change made while the failed send was under way), so nothing the failed send carried is lost. - So your gateway or modules must set the state they receive (
putwith this quantity,complete,off), not toggle, and may receive the same state several times. - Failed sends are logged as warnings, once per outage, and never fail a scan.
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:
{
"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
<prefix>/<layoutId>/<address>/setwith the payload
{ "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
<prefix>/<layoutId>/<address>/pressnot 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.
- Command queue (install -> bridge): the light states.
- Event queue (bridge -> install): button presses, requests for the whole state, and the bridge's heartbeat.
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:
{
"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:
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 |
- Empty (the default) allows no bridge at all. A layout whose
bridgenames a queue outside the list is refused when it is saved (422 bridge_not_allowed, whateverSORTER_LIGHTSis), and the answer names only the layout's own field, never the list. - Every entry names at least the account, namespace or project. An entry with only that (
gcp-pubsub:acme-wcs-prod) allows all its queues, and never a longer name (acme-wcs-prod2). - A prefix is a plain prefix:
.../wms-site-1also allowswms-site-10-commands. End it with a separator (wms-site-1-), or give the whole queue name. - A layout saved before the list was narrowed is skipped: one warning in the log, no lights, no presses, and nothing is
received from or done to its queues (no client is created for them).
GET /api/layouts/{layoutId}/bridgeshows it asblocked. Fix the list or the layout.
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:
{"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 |
deltaafter every change: the new state of each light that changed (absolute, not a toggle). A change of more than 1000 lights is split into several deltas.desiredwith every lit light of the layout (lights not listed are off): on start, everySORTER_BRIDGE_FULL_STATE_MS(defaultSORTER_LIGHTS_RESYNC_MS, 60 000), whenever the bridge asks for it, and after a failed send. It is also sent when nothing is lit, so it doubles as the bridge's heartbeat.seqincreases with every message of a layout: the bridge ignores a light's state older than the one it has, so redelivered or reordered messages are harmless. It ismax(previous + 1, time in ms x 1000), so it keeps increasing across restarts without being stored (as long as the clock doesn't go back).- A message is at most 256 KiB (the SQS and Service Bus standard limit): about 5000 lit bins in one layout. A layout with
more lights lit at once fails its
desiredwith an error in the log. - FIFO SQS queues get the layout id as message group and
<layout>:<seq>as deduplication id; Service Bus messages get<layout>:<seq>as message id. Ordered delivery is not needed. - When a layout's bridge changes or is removed (or the layout is deleted), its old bridge gets an empty
desired, so its lights go off.
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 |
- Duplicates: the install remembers the ids of the last 10 000 presses and ignores a press it has seen (the bridge's outbox and the queues deliver at least once). The press guard still applies.
- Late presses: a press that arrives more than
SORTER_BRIDGE_PRESS_MAX_AGE_MS(default 60 000) after it happened (the event'sat) is ignored with a warning: after an outage, the bridge delivers what it stored, and an old press could confirm the bin's next put. Keep the bridge's and the install's clocks in sync (NTP), or set0to accept any age. - Licensing: like MQTT presses, presses are ignored with a warning while the install's license blocks changes.
- Dead letters: what can never be handled (not JSON, not
v: 1, an unknown type, ascopethat is not a layout of this queue, a press without an address, over 64 KiB) goes to the provider's dead-letter path: Service Bus dead-letters it; SQS leaves it to the queue's redrive policy and Pub/Sub to the subscription's dead-letter topic, so configure a redrive policy / dead-letter topic on the event queue. A press the install can't apply now (the store is down) is retried after 5 s, doubling up to 60 s.
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#
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.
{"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:
{ "id": "A-01-01", "x": 0, "y": 30, "width": 40, "height": 30, "depth": 40, "lightAddress": "ctrl1/017" }- 1 to 64 characters of
A-Z a-z 0-9 . _ : / -, unique in the layout. - An address may not be another bin's id (that is the other bin's address when it has none).
- The setup editor has a Light address column (empty means the bin id). In the CSV import, set it afterwards in the editor or in the JSON.