local Adapter = require('milo.miniAdapter') local class = require('opus.class') local Config = require('opus.config') local Event = require('opus.event') local itemDB = require('core.itemDB') local Util = require('opus.util') local device = _G.device local os = _G.os local parallel = _G.parallel local SCAN_CHUNK_SIZE = 16 local Storage = class() local function loadOld(storage) storage.nodes = Config.load('milo', { }) -- TODO: remove - temporary if storage.nodes.remoteDefaults then storage.nodes.nodes = storage.nodes.remoteDefaults storage.nodes.remoteDefaults = nil end -- TODO: remove - temporary if storage.nodes.nodes then local categories = { input = 'custom', trashcan = 'custom', machine = 'machine', brewingStand = 'machine', activity = 'display', jobs = 'display', ignore = 'ignore', hidden = 'ignore', manipulator = 'custom', storage = 'storage', } for _, node in pairs(storage.nodes.nodes) do if node.lock and type(node.lock) == 'string' then node.lock = { [ node.lock ] = true, } end if not node.category then node.category = categories[node.mtype] if not node.category then Util.print(node) error('invalid node') end end end storage.nodes = storage.nodes.nodes end end function Storage:init() local defaults = { dirty = true, activity = { }, storageOnline = true, } Util.merge(self, defaults) self.nodes = Config.load('storage', { }) if Util.empty(self.nodes) then -- TODO: temporary loadOld(self) end Event.on({ 'device_attach', 'device_detach' }, function(e, dev) _G._syslog('%s: %s', e, tostring(dev)) self:initStorage() -- this can yield - so we might miss events end) Event.onInterval(60, function() self:showStorage() end) end function Storage:showStorage() local t = { } local ignores = { ignore = true, hidden = true, } for k,v in pairs(self.nodes) do local online = v.adapter and v.adapter.online if not online and not ignores[v.mtype] then table.insert(t, k) end end if #t > 0 then _G._syslog('Adapter:') for _, k in pairs(t) do _G._syslog(' offline: ' .. k) end _G._syslog('') end end function Storage:isOnline() return self.storageOnline end function Storage:initStorage() local online = true -- unknown why this is not working below for _,v in pairs(device) do v.transferLocations = nil end for k,v in pairs(self.nodes) do if v.mtype ~= 'hidden' then if v.adapter then v.adapter.online = not not device[k] if v.adapter.online then Util.merge(v.adapter, device[k]) end elseif device[k] and device[k].list and device[k].size and device[k].pullItems then if v.adapterType then v.adapter = require(v.adapterType)({ side = k }) else v.adapter = Adapter({ side = k }) end v.adapter.online = true v.adapter.dirty = true if v.adapter.isOn and not v.adapter.isOn() then -- turtle v.adapter.turnOn() end elseif device[k] then v.adapter = device[k] v.adapter.online = true end if v.adapter then -- force a new getTransferLocations() as the list may have changed v.adapter.transferLocations = nil end if v.mtype == 'storage' then online = online and not not (v.adapter and v.adapter.online) end end end if online ~= self.storageOnline then self.storageOnline = online -- TODO: if online, then list items os.queueEvent(self.storageOnline and 'storage_online' or 'storage_offline', online) _G._syslog('Storage: %s', self.storageOnline and 'online' or 'offline') end self:listItems() end function Storage:saveConfiguration() local t = { } for k,v in pairs(self.nodes) do t[k] = v.adapter v.adapter = nil end Config.update('storage', self.nodes) for k,v in pairs(t) do self.nodes[k].adapter = v end self:initStorage() end function Storage:getSingleNode(mtype) local node = Util.find(self.nodes, 'mtype', mtype) if node and node.adapter and node.adapter.online then return node end end function Storage:filterNodes(mtype, filter) local iter = { } for _, v in pairs(self.nodes) do if v.mtype == mtype then if not filter or filter(v) then table.insert(iter, v) end end end local i = 0 return function() i = i + 1 return iter[i] end end function Storage:filterActive(mtype, filter) return self:filterNodes(mtype, function(v) if v.adapter and v.adapter.online then return not filter and true or filter(v) end end) end function Storage:onlineAdapters() local iter = { } for _, v in pairs(self.nodes) do if v.adapter and v.adapter.online and v.mtype == 'storage' then table.insert(iter, v) end end table.sort(iter, function(a, b) if not a.priority then return false elseif not b.priority then return true end return a.priority > b.priority end) local i = 0 return function() i = i + 1 local a = iter[i] if a then return a, a.adapter end end end function Storage:setDirty() self.dirty = true end function Storage:refresh(throttle) self.dirty = true _G._syslog('STORAGE: Forcing full refresh') for _, adapter in self:onlineAdapters() do adapter.dirty = true end return self:listItems(throttle) end -- provide a consolidated list of items function Storage:listItems(throttle) if not self.dirty then return self.cache end local timer = Util.timer() local cache = { } throttle = throttle or Util.throttle() local t = { } for _, adapter in self:onlineAdapters() do if adapter.dirty then table.insert(t, function() adapter:listItems(throttle) if not adapter.__size then adapter.__size = adapter.size() end adapter.dirty = false end) end end if #t > 0 then local chunk = {} -- Split the work into chunks to avoid spamming too many coroutines. for i = 1, #t do table.insert(chunk, t[i]) -- When we reach the chunk limit, execute them and start a new chunk if #chunk >= SCAN_CHUNK_SIZE then parallel.waitForAll(table.unpack(chunk)) chunk = {} end end if #chunk > 0 then parallel.waitForAll(table.unpack(chunk)) end end for _, adapter in self:onlineAdapters() do local rcache = adapter.cache or { } for key,v in pairs(rcache) do local entry = cache[key] if not entry then entry = Util.shallowCopy(v) entry.count = v.count entry.key = key cache[key] = entry else entry.count = entry.count + v.count end throttle() end end itemDB:flush() _G._syslog('STORAGE: refresh ' .. #t .. ' inventories in ' .. Util.round(timer(), 2)) self.dirty = false self.cache = cache return self.cache end -- provide a raw list of all the items in all storage chests -- it might be beneficial to move this to the adapter class at some point and cache the raw item list in this class at some point function Storage:listItemsRaw(throttle) local res = {} throttle = throttle or Util.throttle() for _, v in pairs(self.nodes) do if v.category == "storage" then local chest = device[v.name] local items = chest.list() for slot, item in pairs(items) do items[slot] = itemDB:get(item, function() return chest.getItemMeta(slot) end) end res[v.name] = items throttle() end end return res end -- provide a list of items and which chests provide them function Storage:listProviders(throttle) local res = {} local rawItems = self:listItemsRaw(throttle) for chest, items in pairs(rawItems) do for slot, item in pairs(items) do local key = table.concat({item.name, item.damage, item.nbtHash}, ":") if not res[key] then res[key] = {} end table.insert(res[key], {item = item, device = device[chest], lockedToThis = (self.nodes[chest].lock or {})[key] or false, slot = slot}) end end return res end -- defrags the storage system function Storage:defrag(throttle) local items = self:listProviders(throttle) local slotsSaved = 0 -- This will make sure the table is sorted in the following order: -- Unlocked stacks with less than maxCount items | Locked stacks with less than maxCount items | stacks with more than maxCount items -- This way the locked stacks will be filled first local function sortFunction(a, b) local preferenceA, preferenceB preferenceA = (a.item.count == a.item.maxCount and 3) or (a.lockedToThis and 2) or 1 preferenceB = (b.item.count == b.item.maxCount and 3) or (b.lockedToThis and 2) or 1 if preferenceA < preferenceB then return true elseif preferenceB > preferenceA then return false else return a.item.count < b.item.count end end for _, providers in pairs(items) do table.sort(providers, sortFunction) -- We're done when we either compressed the stacks so far, that there's only one left (#providers == 1) -- Or when we've compressed so far, that the there's only one stack which has a lower count than the maxCount -- Because of the sorting, we know that this will be the stack in providers[1], so we check if providers[2] is at the maxCount while #providers > 1 and providers[2].item.count ~= providers[2].item.maxCount do local from = providers[1] local to -- We're pushing to the highest stack which is still below the maxCount, this way as many slots as possible will be filled -- This loop is guarenteed to assign a value to "to", as the only cases where it wouldn't (#providers == 1 or no provider with less than maxCount) -- are ruled out by the condition of the outer while loop for i = 2, #providers do -- Give preference to locked chests if not (to and to.lockedToThis or false) and providers[i].lockedToThis then to = providers[i] elseif ((to and to.lockedToThis or false) == providers[i].lockedToThis) and providers[i].item.count < providers[i].item.maxCount then to = providers[i] elseif providers[i].item.count == providers[i].item.maxCount then -- As this slot is already at maxCount, all the remaining ones will also be due to sorting -- If any of the remaining providers is locked that doesn't matter. We wouldn't have been able to push there anyways break end end local toMove = math.min(to.item.maxCount - to.item.count, from.item.count) local s, m = pcall(function() from.device.pushItems(to.device.name, from.slot, toMove, to.slot) end) if not s and m then _G._syslog(m) end if s then to.item.count = to.item.count + toMove from.item.count = from.item.count - toMove else -- Do not try to send to the target again after it failed for i = 2, #providers do if to == providers[i] then table.remove(providers, i) break end end end if from.item.count <= 0 then table.remove(providers, 1) slotsSaved = slotsSaved + 1 end table.sort(providers, sortFunction) end end return slotsSaved end function Storage:updateCache(adapter, item, count) if not adapter.cache then adapter.dirty = true self.dirty = true return end local key = item.key or table.concat({ item.name, item.damage, item.nbtHash }, ':') local entry = adapter.cache[key] if not entry then if count < 0 then _G._syslog('STORAGE: update cache - count < 0', 4) else entry = Util.shallowCopy(item) entry.count = count entry.key = key adapter.cache[key] = entry end else entry.count = entry.count + count if entry.count <= 0 then adapter.cache[key] = nil end end if not entry then _G._syslog('STORAGE: item missing details') adapter.dirty = true self.dirty = true else local sentry = self.cache[key] if sentry then sentry.count = sentry.count + count if sentry.count <= 0 then self.cache[key] = nil end elseif count > 0 then sentry = Util.shallowCopy(entry) sentry.count = count self.cache[key] = sentry else self.dirty = true end end end function Storage:_sn(name) local node = self.nodes[name] return node and node.displayName or name end local function isValidTransfer(adapter, target) -- lazily cache transfer locations if not adapter.transferLocations then adapter.transferLocations = adapter.getTransferLocations() end for _,v in pairs(adapter.transferLocations) do if v == target then return true end end end local function rawExport(source, target, item, qty, slot) local total = 0 local transfer if isValidTransfer(source, target.name) then transfer = function(key, amount) return source.pushItems(target.name, key, amount, slot) end else --if isValidTransfer(target, source.name) then transfer = function(key, amount) return target.pullItems(source.name, key, amount, slot) end end --[[ -- TODO: mass storage will require a transfer chest (or something) elseif isValidTransfer(source, 'minecraft:chest_0') then transfer = function(key, amount) local a = source.pushItems('minecraft:chest_0', key, amount, 1) return target.pullItems('minecraft:chest_0', 1, amount, slot) end else ... end ]] local s, m = pcall(function() local stacks = source.list() for key,stack in Util.rpairs(stacks) do if stack.name == item.name and stack.damage == item.damage and stack.nbtHash == item.nbtHash then local amount = math.min(qty, stack.count) if amount > 0 then amount = transfer(key, amount, slot) if amount > 0 then source.lastUpdate = os.clock() target.lastUpdate = os.clock() else -- break -- this should work ?? is cache out of sync ? end end qty = qty - amount total = total + amount if qty <= 0 then break end end end end) if not s and m then _G._syslog(m) end return total, m end function Storage:export(target, slot, count, item) local timer = Util.timer() local total = 0 local key = item.key or table.concat({ item.name, item.damage, item.nbtHash }, ':') local function provide(adapter, pcount) -- update cache before export to allow for simultaneous calls self:updateCache(adapter, item, -pcount) local amount = rawExport(adapter, target.adapter, item, pcount, slot) if amount ~= pcount then -- this *should* only happen if cache is out of sync -- or... the target is full self:updateCache(adapter, item, pcount - amount) end if amount > 0 then _G._syslog('EXT: %s(%d): %s -> %s%s in %s', item.displayName or item.name, amount, self:_sn(adapter.name), self:_sn(target.name), slot and string.format('[%d]', slot) or '[*]', Util.round(timer(), 2)) end count = count - amount total = total + amount return amount end -- request from adapters with this item for _, adapter in self:onlineAdapters() do local cache = adapter.cache and adapter.cache[key] if cache then local request = math.min(count, cache.count) local amount = provide(adapter, request) -- couldn't provide the amount that was requested -- either the target must be full - or the cache is invalid if amount ~= request then break end if count <= 0 then return total end end end if slot then -- ignore warning when exporting to all slots _G._syslog('STORAGE warning: %s(%d): %s%s %s failed to export', item.displayName or item.name, count, self:_sn(target.name), slot and string.format('[%d]', slot) or '[*]', key) end -- TODO: If there are misses when a slot is specified than something is wrong... -- The caller should confirm the quantity beforehand -- If no slot and full amount is not exported, then no need to check rest of adapters -- ... so should not reach here -- but... there is the case where exporting to all slots of the target -- this is valid return total end local function rawInsert(source, target, slot, qty) local count = 0 local s, m = pcall(function() if isValidTransfer(source, target.name) then --_syslog('pull %s %s %d %d', source.name, target.name, slot, qty) count = source.pullItems(target.name, slot, qty) else --_syslog('push %s %s', target.name, source.name) count = target.pushItems(source.name, slot, qty) end end) if not s and m then _G._syslog(m) end if count > 0 then source.lastUpdate = os.clock() target.lastUpdate = os.clock() end return count end function Storage:import(source, slot, count, item) if not source then error('Storage:import: source is required') end if not slot then error('Storage:import: slot is required') end local timer = Util.timer() local total = 0 local key = item.key or table.concat({ item.name, item.damage, item.nbtHash }, ':') if not self.cache then self:listItems() end local entry = itemDB:get(key) if not entry then if item.displayName then -- this item already has metadata entry = itemDB:add(item) else -- get the metadata from the device and add to db entry = itemDB:add(source.adapter.getItemMeta(slot)) end itemDB:flush() end item = entry local function insert(adapter) local amount = rawInsert(adapter, source.adapter, slot, count) if amount > 0 then self:updateCache(adapter, item, amount) _G._syslog('INS: %s(%d): %s[%d] -> %s in %s', item.displayName or item.name, amount, self:_sn(source.name), slot, self:_sn(adapter.name), Util.round(timer(), 2)) -- record that we have imported this item into storage during this cycle self.activity[key] = (self.activity[key] or 0) + amount end count = count - amount total = total + amount end -- find a chest locked with this item local doVoid for node in self:onlineAdapters() do if node.lock and node.lock[key] then insert(node.adapter, item) if count > 0 and node.void then doVoid = true end end if count <= 0 then return total end end if doVoid then return total + self:trash(source, slot, count, item) end -- is this item in some chest if self.cache[key] then for node, adapter in self:onlineAdapters() do if count <= 0 then return total end if not node.lock and adapter.cache and adapter.cache[key] then insert(adapter) end end end -- high to low priority for node in self:onlineAdapters() do if count <= 0 then break end if not node.lock then insert(node.adapter) end end if count ~= 0 then _G._syslog('STORAGE warning: %s(%d): %s -> INSERT failed', item.displayName or item.name, count, self:_sn(source.name)) end return total end -- When importing items into a locked chest, trash any remaining items if full -- TODO: use all available trashcans function Storage:trash(source, slot, count, item) local timer = Util.timer() local target = Util.find(self.nodes, 'mtype', 'trashcan') local amount = 0 if target and target.adapter and target.adapter.online then local s, m = pcall(function() if isValidTransfer(source.adapter, target.name) then amount = source.adapter.pushItems(target.name, slot, count) else amount = target.adapter.pullItems(source.name, slot, count) end _G._syslog('TRA: %s(%d): %s%s -> %s in %s', item.displayName or item.name, amount, self:_sn(source.name), slot and string.format('[%d]', slot) or '[*]', self:_sn(target.name), Util.round(timer(), 2)) end) if not s and m then _G._syslog(m) end end if amount ~= count then _G._syslog('STORAGE warning: %s(%d): %s -> TRASH failed', item.displayName or item.name, count - amount, self:_sn(source.name)) end return amount end return Storage