diff --git a/router/.luacheckrc b/router/.luacheckrc new file mode 120000 index 0000000..67d5dd5 --- /dev/null +++ b/router/.luacheckrc @@ -0,0 +1 @@ +../luacheckrc \ No newline at end of file diff --git a/router/README.rst b/router/README.rst new file mode 100644 index 0000000..3a4a156 --- /dev/null +++ b/router/README.rst @@ -0,0 +1,157 @@ +Remote Controlled Item Router +============================= + +The router script runs on a Lua Controlled Sorting Tube and allows another node +on a Digiline network to remotely control routing of items. + + +Configuration +------------- + +``unroutable`` + This string is the colour where items should be sent if they cannot be + routed properly according to the instructions received so far from the + Digiline network. Sending items to this destination indicates an error. + +``channel`` + This string is the Digiline channel on which routing instructions are + received and to which notifications and replies are sent. + + +Operational Overview +-------------------- + +The router maintains an internal routing table, keyed by item name (in +Minetest-internal form, e.g. ``default:cobble``). Unlike routing tables in, for +example, IP networks, the routing table in a remote controlled item router +contains entries that only apply to a certain number of items—for example, a +routing table entry might specify “please send seven ``default:cobble`` in the +red direction”. In a typical system, a controller will determine that a +particular number of a particular item needs to reach a particular destination, +then add routing table entries to the routers along the path to ensure the +items reach their destination. If the same item is needed in two places, +entries can be added simultaneously for both destinations; as items pass +through the system, they will go to one destination or the other, up to the +specified count in each direction, eventually delivering the proper number of +items to both destinations. + +If a stack of more than one item arrives at the router, it will be sent to some +destination that has routing table entries adding up to at least the stack +size. The router will never send more items to a destination than the total +requested, though if the table has multiple entries for the same destination, a +single stack may contribute to more than one entry; for example, if there are +two three-item requests for the same item to the same colour, a single six-item +stack could be routed to that colour, but a seven-item stack would not. Because +stacks cannot be split, an overly large stack may generate an error; for +example, if there are two three-item requests for the same item to two +different directions, a single six-item stack would generate an error and be +sent to the unroutable colour because it cannot be split into two smaller +stacks. The system injecting item stacks into the routing network is expected +to act accordingly. + + +Digiline Message Structure +-------------------------- + +All messages sent to or from the router must be table-typed messages. Messages +are divided into commands and responses: commands are sent to the router while +responses are returned from the router. All command messages contain a key +named ``command`` with a string value identifying the nature of the command. +All response messages contain a key named ``response`` with a string value +identifying the nature of the response. Other keys contain additional +information whose meaning depends on the command or response. + + +``clear`` Command +----------------- + +This command removes all routing table entries. No parameters are needed nor is +a reply generated. `Done response`_ messages are *not* sent for the removed +entries. + + +``query`` Command +----------------- + +This command causes a `query_reply response`_ to be returned. No parameters are +needed. + + +``route`` Command +----------------- + +This command adds one or more entries to the routing table. In addition to +having a ``command`` key, the message must also be an array—in other words, a +table with consecutive integer keys starting from 1. These array entries are +interpreted in order, with each entry being a routing table entry to add. + +Routing Table Entry +^^^^^^^^^^^^^^^^^^^ + +An individual routing table entry in the ``route`` command is formatted as a +table. Within the table, the following keys are understood: + +``name`` + This string is the Minetest-internal name of the item to route (e.g. + ``default:cobble``). This key is required. + +``count`` + This number is how many items the entry applies to. Once this many of the + specified item have been routed according to this entry, the entry expires + and is removed from the table. This key is required. + +``direction`` + This string is the colour indicating which direction to send the items. + This key is required. + +``id`` + This value (of any type) is returned in a `done response`_ when the entry + expires and can be used by the controller to distinguish entries. The value + is not interpreted in any way, so the controller may supply any value. This + key is optional; if not provided, the entry expires silently without a + `done response`_ being returned. + + +``done`` Response +----------------- + +This response informs the user that a routing table entry has expired. In +addition to the ``response`` key, the message also contains an ``id`` key, +which is set to the ``id`` value provided when the routing table entry was +initially added. + + +``query_reply`` Response +------------------------ + +This response reports a summary of all entries in the routing table and is only +sent in response to a `query command`_. In addition to the ``response`` key, +the message also contains an ``items`` key, whose value is a table keyed by +item name. Each item name maps to another table whose keys are the six +directional colours (all six are always present). Each colour maps to a +positive integer which is the number of items remaining to be sent in that +direction. + + +``error`` Response +------------------ + +This response reports an item stack that could not be routed properly. In +addition to the ``reponse`` key, the message contains ``name`` string and +``count`` integer keys describing the item stack as well as a ``reason`` string +key which is one of the following values: + +``unknown`` + This reason is returned if there are no routing table entries for the item + type. + +``toomany`` + This reason is returned if the item stack is larger than the sum of all the + routing table entries for the item type. + +``unsplittable`` + This reason is returned if the item stack is within the sum of all routing + table entries for the item type but larger than the sum of all routing + table entries for any single colour. + +In all cases the entire item stack is sent to the unroutable destination. diff --git a/router/router.lua b/router/router.lua new file mode 100644 index 0000000..1c65626 --- /dev/null +++ b/router/router.lua @@ -0,0 +1,248 @@ +-- Routes itemstacks according to instructions received via Digilines. + +-- Where unroutable items should be sent. +local unroutable = "yellow" + +-- The channel used to communicate with this device. +local channel = "router:1" + +-- ====== Digilines API ===== +-- +-- The router understands messages of table type. Within the table must be a +-- “command” key with string value identifying what is to be done. Valid +-- commands are as follows. +-- +-- clear +-- Removes all routing table entries immediately. +-- +-- query +-- Provokes a response message containing a summary of outstanding routing +-- requests. See the list of sent messages for details. +-- +-- route +-- Requests that certain numbers of certain types of items be sent in certain +-- directions. The message must be an array whose array elements are the +-- individual routing requests to process; there is no functional difference +-- between submitting a single “route” command with many requests versus +-- submitting the requests one at a time. +-- +-- Each request must be a table with keys “name” identifying the name of the +-- item to route, “count” indicating how many of the item should be routed, +-- “direction” identifying the direction in which the items should be sent, +-- and optionally “id” specifying an opaque value (which is not interpreted +-- by the router itself in any way) used to identify the request when +-- generating a “done” response. +-- +-- If multiple requests are submitted for the same item in different +-- directions, their priority is unspecified—items will eventually be +-- delivered in the specified numbers to the specified destinations, but they +-- may arrive in any order. +-- +-- If multiple requests are submitted for the same item in the same +-- direction, they are accumulated—their counts effectively add, and a single +-- large itemstack may satisfy multiple requests, but if the requests have +-- “id” keys, their counts are kept separate for accounting purposes so that +-- “done” replies can be generated at the proper times. +-- +-- The router may send messages of table type. Within the table will be a +-- “response” key identifying the type of information. Valid replies are as +-- follows. +-- +-- done +-- Reports that a routing table entry has been deleted because the specified +-- item count has been reached. The message contains an “id” key with the +-- identifier value provided in the routing request. Routing requests without +-- “id” keys do not generate “done” replies. +-- +-- error +-- Reports that an itemstack entered the router and could not be routed +-- properly. The message contains “name” and “count” keys identifying the +-- itemstack that could not be routed. The “reason” key identifies why and is +-- either “unknown” if no requests at all are outstanding for the item type, +-- “toomany” if the item stack is larger than the sum of counts of all +-- outstanding requests for the item type, or “unsplittable” if the item +-- stack fits within the sum of counts of all outstanding requests but not +-- within the sum of counts for a single direction (and thus the item stack +-- needs to be split into smaller stacks to be routed properly). The stack +-- has been sent to the “unroutable” destination. +-- +-- query_reply +-- In response to a “query” command, reports a summary of current routing +-- requests. The message contains an “items” key. The value of that key is a +-- table each of whose keys is an item name. The value of each such key is a +-- table each of whose keys is a direction (all six are always present). The +-- value of each such key is the number of items that have been requested to +-- be routed in that direction and have not yet passed the router. + +-- “mem” must be a table; if it is not, then initialize it. +if type(mem) ~= "table" then + mem = {} +end + +-- Handle the event. +if event.type == "item" then + -- Look up the item table for the incoming item. + local item_table = mem[event.item.name] + if item_table ~= nil then + -- There is at least one routing table entry for this item. Choose a + -- direction to send the stack. + local direction = nil + local all_directions_count = 0 + for candidate_direction, direction_data in pairs(item_table) do + all_directions_count = all_directions_count + direction_data.total_count + if event.item.count <= direction_data.total_count then + direction = candidate_direction + end + end + if direction ~= nil then + -- Routing succeeded. Update accounting information and send + -- completion notifications if possible. + local direction_table = item_table[direction] + direction_table.total_count = direction_table.total_count - event.item.count + local count_remaining = event.item.count + while count_remaining ~= 0 and direction_table.read ~= direction_table.write do + local this_count = direction_table[direction_table.read].count + local this_update = math.min(this_count, count_remaining) + this_count = this_count - this_update + count_remaining = count_remaining - this_update + if this_count == 0 then + -- This ID is now complete. Send a notification and remove + -- the entry. + local response = { + response = "done", + id = direction_table[direction_table.read].id, + } + digiline_send(channel, response) + direction_table[direction_table.read] = nil + direction_table.read = direction_table.read + 1 + else + -- This ID is not finished yet. Update the stored count. + direction_table[direction_table.read].count = this_count + end + end + + -- If all directions have counts of zero… + local all_zero = true + for _, direction_data in pairs(item_table) do + all_zero = all_zero and direction_data.total_count == 0 + end + -- … delete this item. + if all_zero then + mem[event.item.name] = nil + end + + -- Send the stack to the selected direction. + return direction + elseif all_directions_count >= event.item.count then + -- There are enough total requests to cover the item stack, but not + -- in any single direction; it would need splitting which we cannot + -- do. + local error_message = { + response = "error", + name = event.item.name, + count = event.item.count, + reason = "unsplittable", + } + digiline_send(channel, error_message) + return unroutable + else + -- There are more items than all requests for this item type. + local error_message = { + response = "error", + name = event.item.name, + count = event.item.count, + reason = "toomany", + } + digiline_send(channel, error_message) + return unroutable + end + else + -- There are no entries for this item. + local error_message = { + response = "error", + name = event.item.name, + count = event.item.count, + reason = "unknown", + } + digiline_send(channel, error_message) + return unroutable + end +elseif event.type == "digiline" and event.channel == channel then + local command = event.msg.command + if command == "route" then + -- Add the routing table entries. + for _, entry in ipairs(event.msg) do + -- Find the routing table for the item name and direction. + local item_name = entry.name + local by_name = mem[item_name] + if by_name == nil then + by_name = { + red = { + total_count = 0, + read = 1, + write = 1, + }, + blue = { + total_count = 0, + read = 1, + write = 1, + }, + yellow = { + total_count = 0, + read = 1, + write = 1, + }, + green = { + total_count = 0, + read = 1, + write = 1, + }, + black = { + total_count = 0, + read = 1, + write = 1, + }, + white = { + total_count = 0, + read = 1, + write = 1, + }, + } + mem[item_name] = by_name + end + local by_dir = by_name[entry.direction] + + -- Add the total count. + by_dir.total_count = by_dir.total_count + entry.count + + if entry.id ~= nil then + -- Stash the ID for reporting completion. + local index = by_dir.write + by_dir[index] = { + id = entry.id, + count = entry.count, + } + by_dir.write = index + 1 + end + end + elseif command == "clear" then + -- Destroy everything. + mem = {} + elseif command == "query" then + -- Construct the response, including only the total counts for each + -- item/direction and not the request ID details. + local items = {} + for item_name, item_table in pairs(mem) do + local item = {} + for direction, direction_table in pairs(item_table) do + item[direction] = direction_table.total_count + end + items[item_name] = item + end + local response = { + response = "query_reply", + items = items, + } + digiline_send(channel, response) + end +end diff --git a/router/test-router.lua b/router/test-router.lua new file mode 100644 index 0000000..a6f4741 --- /dev/null +++ b/router/test-router.lua @@ -0,0 +1,335 @@ +-- Formats a value as a string, displaying the contents of tables. +local function deep_format(x, indent) + if indent == nil then + indent = 0 + end + if type(x) == "table" then + local ret = "{\n" + for k, v in pairs(x) do + ret = ret .. string.format("%s[%q] = %s,\n", string.rep(" ", indent + 1), k, deep_format(v, indent + 1)) + end + ret = ret .. string.format("%s}", string.rep(" ", indent)) + return ret + else + return tostring(x) + end +end + +-- Compares two values for equality, recursively comparing tables by value. +-- +-- If unordered is provided and is true, and x and y are tables, they must +-- contain the same set of values but their keys are ignored. This is not +-- recursive; sub-tables must still be exactly equal. +local function deep_equal(x, y, unordered) + local tx = type(x) + local ty = type(y) + if tx ~= ty then + return false + elseif tx ~= "table" then + return x == y + else + local x_cloned = {} + for k, v in pairs(x) do + x_cloned[k] = v + end + for k, v in pairs(y) do + if unordered then + local found = false + for xk, xv in pairs(x) do + if deep_equal(v, xv) then + x_cloned[xk] = nil + found = true + break + end + end + if not found then + return false + end + else + if not deep_equal(x_cloned[k], v) then + return false + end + x_cloned[k] = nil + end + end + return next(x_cloned) == nil + end +end + +-- Mock Digiline support. +local digiline_messages = {} +function digiline_send(channel, message) + table.insert(digiline_messages, { + channel = channel, + message = message, + }) +end +local function expect_and_clear_digiline_messages(expected, unordered) + assert(deep_equal(digiline_messages, expected, unordered), + string.format("Expected digiline messages:\n%s\nGot:\n%s", deep_format(expected), deep_format(digiline_messages))) + digiline_messages = {} +end +local function expect_and_clear_digiline_message(message, channel) + return expect_and_clear_digiline_messages({{message = message, channel = channel}}) +end +local function expect_no_digiline_messages() + return expect_and_clear_digiline_messages({}) +end + +-- Load the UUT. +local uut = loadfile("router.lua") + +-- Sends a query command and expects the specified “items” table in reply. +local function check_query(items) + event = { + type = "digiline", + channel = "router:1", + msg = { + command = "query", + }, + } + uut() + expect_and_clear_digiline_message({ + response = "query_reply", + items = items, + }, "router:1") +end + +-- Sends in a stack of some number of an item. Returns the direction. Does not +-- check Digiline messages. +local function send_item(name, count) + event = { + type = "item", + pin = "blue", + itemstring = string.format("%s %d", name, count), + item = { + name = name, + count = count, + wear = 0, + }, + } + return uut() +end + +-- Sends in a stack of some number of an item. Expects it to fail with the +-- specified error reason and be routed to the unroutable output (yellow). +local function send_item_check_error(name, count, reason) + local dir = send_item(name, count) + assert(dir == "yellow", string.format("Expected yellow, got %s", dir)) + expect_and_clear_digiline_message({ + response = "error", + name = name, + count = count, + reason = reason, + }, "router:1") +end + +-- Sends the “route” command. Checks that no reply is sent back. +local function send_route(requests) + event = { + type = "digiline", + channel = "router:1", + msg = { + command = "route", + }, + } + for k, v in ipairs(requests) do + event.msg[k] = v + end + uut() + expect_no_digiline_messages() +end + +-- Send the “query” command. A query reply should come back with nothing. +check_query({}) + +-- Send the “query” command to a different channel. No reply should come back. +event = { + type = "digiline", + channel = "router:2", + msg = { + command = "query", + }, +} +uut() +expect_no_digiline_messages() + +-- Send in a cobblestone. It should be sent to yellow and an “unknown” error issued. +send_item_check_error("default:cobblestone", 1, "unknown") + +-- Request that one cobblestone be sent to red without completion reporting. +event = { + type = "digiline", + channel = "router:1", + msg = { + command = "route", + { + name = "default:cobblestone", + count = 1, + direction = "red", + }, + }, +} +uut() +expect_no_digiline_messages() + +-- Send the “query” command. A query reply should come back reporting the +-- request. +check_query({ + ["default:cobblestone"] = { + red = 1, + blue = 0, + yellow = 0, + green = 0, + black = 0, + white = 0, + }, +}) + +-- Send in a cobblestone. It should be sent to red and no messages should be +-- generated. +local actual = send_item("default:cobblestone", 1) +assert(actual == "red", string.format("Expected red, got %s", actual)) +expect_no_digiline_messages() + +-- Send the “query” command. A query reply should come back with nothing. +check_query({}) + +-- Send in a cobblestone. It should be sent to yellow and an “unknown” error issued. +send_item_check_error("default:cobblestone", 1, "unknown") + +-- Request that one cobblestone be sent to red without completion reporting. +send_route({ + { + name = "default:cobblestone", + count = 1, + direction = "red", + } +}) + +-- Send the “query” command. A query reply should come back reporting the +-- request. +check_query({ + ["default:cobblestone"] = { + red = 1, + blue = 0, + yellow = 0, + green = 0, + black = 0, + white = 0, + }, +}) + +-- Send in two cobblestone. They should be sent to yellow and a “toomany” error +-- issued. +send_item_check_error("default:cobblestone", 2, "toomany") + +-- Request that one cobblestone be sent to white without completion reporting. +send_route({ + { + name = "default:cobblestone", + count = 1, + direction = "white", + }, +}) + +-- Send the “query” command. A query reply should come back reporting the +-- request. +check_query({ + ["default:cobblestone"] = { + red = 1, + blue = 0, + yellow = 0, + green = 0, + black = 0, + white = 1, + }, +}) + +-- Send in two cobblestone. They should be sent to yellow and an “unsplittable” +-- error issued. +send_item_check_error("default:cobblestone", 2, "unsplittable") + +-- Send in two cobblestone one at a time. One should go to red and the other to +-- white, though the order is arbitrary, and no messages should be emitted. +actual = {} +table.insert(actual, send_item("default:cobblestone", 1)) +table.insert(actual, send_item("default:cobblestone", 1)) +assert(deep_equal(actual, {"red", "white"}, true), + string.format("Expected {red, white} in either order, but got {%s, %s}", actual[1], actual[2])) +expect_no_digiline_messages() + +-- Send the “query” command. There should be no items. +check_query({}) + +-- Request that one cobblestone be sent to white with a report ID, a second +-- cobblestone be sent to white with a different report ID, and two cobblestone +-- be sent to red with a single report ID. +send_route({ + { + name = "default:cobblestone", + count = 1, + direction = "white", + id = "foo", + }, + { + name = "default:cobblestone", + count = 1, + direction = "white", + id = "bar", + }, + { + name = "default:cobblestone", + count = 2, + direction = "red", + id = "baz", + }, +}) + +-- Send the “query” command. The totals in each direction should be reported. +check_query({ + ["default:cobblestone"] = { + red = 2, + blue = 0, + yellow = 0, + green = 0, + black = 0, + white = 2, + }, +}) + +-- Send in four cobblestone one at a time. Record the reports generated in each +-- case. +actual = {} +for _ = 1, 4 do + table.insert(actual, string.format("route:%s", send_item("default:cobblestone", 1))) + for _, message in ipairs(digiline_messages) do + assert(message.channel == "router:1") + assert(message.message.response == "done") + table.insert(actual, string.format("done:%s", message.message.id)) + end + digiline_messages = {} +end +-- One of these possible outcomes is expected. +local expected = { + -- WWRR + {"route:white", "done:foo", "route:white", "done:bar", "route:red", "route:red", "done:baz"}, + -- WRRW + {"route:white", "done:foo", "route:red", "route:red", "done:baz", "route:white", "done:bar"}, + -- RRWW + {"route:red", "route:red", "done:baz", "route:white", "done:foo", "route:white", "done:bar"}, + -- RWWR + {"route:red", "route:white", "done:foo", "route:white", "done:bar", "route:red", "done:baz"}, + -- WRWR + {"route:white", "done:foo", "route:red", "route:white", "done:bar", "route:red", "done:baz"}, + -- RWRW + {"route:red", "route:white", "done:foo", "route:red", "done:baz", "route:white", "done:bar"}, +} +local any_found = false +for _, v in ipairs(expected) do + any_found = any_found or deep_equal(actual, v) +end +assert(any_found, "Event ordering was not any of the legal options:\n%s", deep_format(actual)) + +-- Send the “query” command. A query reply should come back with nothing. +check_query({})