Compare commits
25 Commits
432a9feff9
...
stable
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9835556e6f | ||
|
|
033da0933c | ||
|
|
1336590241 | ||
|
|
78e7c92893 | ||
|
|
e59b6c1832 | ||
|
|
e343ab8b4e | ||
|
|
b9b69a4966 | ||
|
|
bdc9b3f291 | ||
|
|
8b8279878a | ||
|
|
f095e18e95 | ||
|
|
904d4ec7f7 | ||
|
|
baaa724595 | ||
|
|
8ff4203152 | ||
|
|
72c978ee75 | ||
|
|
d25f25ee52 | ||
|
|
2adfa7f7a3 | ||
|
|
96afe8dcb7 | ||
|
|
1b0b1c570d | ||
|
|
73e157cc13 | ||
|
|
18bdac03b1 | ||
|
|
173a0a9f95 | ||
|
|
01eef4eead | ||
|
|
a499446366 | ||
|
|
a6bf84d6b8 | ||
|
|
aaaf25350c |
33
.package
33
.package
@@ -1,14 +1,15 @@
|
||||
{
|
||||
title = "Inventory Manager",
|
||||
description = "Automated inventory management system for CC:Tweaked. Tracks items across networked storage, crafting turtles, furnaces, and alerts. Includes web dashboard via bridge computer.",
|
||||
repository = "gitea://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/main/",
|
||||
repository = "gitea://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/stable/",
|
||||
exclude = {
|
||||
"^web/", "^__tests__/", "^startup/",
|
||||
"%.md$", "%.json$", "^%.git", "^LICENSE$", "^node_modules/",
|
||||
"%.bak$", "^listDevicesByType%.lua$",
|
||||
},
|
||||
install = [[
|
||||
local pkgDir = fs.combine("packages", "inventory-manager")
|
||||
local cfgDir = "usr/config/inventory-manager"
|
||||
if not fs.isDir(cfgDir) then fs.makeDir(cfgDir) end
|
||||
local function ask(prompt, default)
|
||||
if default and #default > 0 then
|
||||
write(prompt .. " [" .. default .. "]: ")
|
||||
@@ -27,9 +28,10 @@
|
||||
print(" 1) Inventory Manager (main controller)")
|
||||
print(" 2) Inventory Client (display-only)")
|
||||
print(" 3) Web Bridge (HTTP forwarder)")
|
||||
print(" 4) Skip setup")
|
||||
print(" 4) Mining Turtle (cobble miner)")
|
||||
print(" 5) Skip setup")
|
||||
print("")
|
||||
write("Choice (1/2/3/4): ")
|
||||
write("Choice (1/2/3/4/5): ")
|
||||
local choice = read()
|
||||
|
||||
if choice == "1" then
|
||||
@@ -47,14 +49,14 @@
|
||||
dropperName = dropperName,
|
||||
barrelName = barrelName,
|
||||
}
|
||||
local f = fs.open(fs.combine(pkgDir, ".manager_config"), "w")
|
||||
local f = fs.open(fs.combine(cfgDir, ".manager_config"), "w")
|
||||
f.write(textutils.serialiseJSON(cfg))
|
||||
f.close()
|
||||
print("Saved manager config.")
|
||||
|
||||
if serverUrl and #serverUrl > 0 then
|
||||
local bcfg = { serverUrl = serverUrl }
|
||||
local bf = fs.open(fs.combine(pkgDir, ".webbridge_config"), "w")
|
||||
local bf = fs.open(fs.combine(cfgDir, ".webbridge_config"), "w")
|
||||
bf.write(textutils.serialiseJSON(bcfg))
|
||||
bf.close()
|
||||
print("Saved web bridge config.")
|
||||
@@ -75,7 +77,7 @@
|
||||
if dropperName and #dropperName > 0 then cfg.dropperName = dropperName end
|
||||
if barrelName and #barrelName > 0 then cfg.barrelName = barrelName end
|
||||
|
||||
local f = fs.open(fs.combine(pkgDir, ".client_config"), "w")
|
||||
local f = fs.open(fs.combine(cfgDir, ".client_config"), "w")
|
||||
f.write(textutils.serialiseJSON(cfg))
|
||||
f.close()
|
||||
print("Saved client config.")
|
||||
@@ -86,11 +88,26 @@
|
||||
local serverUrl = ask("Web server URL", "http://localhost")
|
||||
|
||||
local cfg = { serverUrl = serverUrl }
|
||||
local f = fs.open(fs.combine(pkgDir, ".webbridge_config"), "w")
|
||||
local f = fs.open(fs.combine(cfgDir, ".webbridge_config"), "w")
|
||||
f.write(textutils.serialiseJSON(cfg))
|
||||
f.close()
|
||||
print("Saved web bridge config.")
|
||||
|
||||
elseif choice == "4" then
|
||||
print("")
|
||||
print("-- Mining Turtle Configuration --")
|
||||
local mineInterval = ask("Mine interval in seconds", "0.5")
|
||||
local dumpThreshold = ask("Dump when N slots full", "14")
|
||||
|
||||
local cfg = {
|
||||
mineInterval = tonumber(mineInterval) or 0.5,
|
||||
dumpThreshold = tonumber(dumpThreshold) or 14,
|
||||
}
|
||||
local f = fs.open(fs.combine(cfgDir, ".miner_config"), "w")
|
||||
f.write(textutils.serialiseJSON(cfg))
|
||||
f.close()
|
||||
print("Saved miner config.")
|
||||
|
||||
else
|
||||
print("Skipped — edit config files manually later.")
|
||||
end
|
||||
|
||||
@@ -8,18 +8,26 @@ local peripheral = _G.peripheral
|
||||
local shell = _ENV.shell
|
||||
|
||||
local BASE = 'packages/inventory-manager'
|
||||
local CFG = 'usr/config/inventory-manager'
|
||||
|
||||
-------------------------------------------------
|
||||
-- Determine role from config files written during install
|
||||
-------------------------------------------------
|
||||
|
||||
local function cfgExists(name)
|
||||
return fs.exists(fs.combine(CFG, name))
|
||||
or fs.exists(fs.combine(BASE, name))
|
||||
end
|
||||
|
||||
local role
|
||||
if fs.exists(fs.combine(BASE, '.manager_config')) then
|
||||
if cfgExists('.manager_config') then
|
||||
role = 'manager'
|
||||
elseif fs.exists(fs.combine(BASE, '.client_config')) then
|
||||
elseif cfgExists('.client_config') then
|
||||
role = 'client'
|
||||
elseif fs.exists(fs.combine(BASE, '.webbridge_config')) then
|
||||
elseif cfgExists('.webbridge_config') then
|
||||
role = 'bridge'
|
||||
elseif cfgExists('.miner_config') then
|
||||
role = 'miner'
|
||||
elseif _G.turtle then
|
||||
role = 'turtle'
|
||||
end
|
||||
@@ -63,6 +71,7 @@ local programs = {
|
||||
client = 'inventoryClient.lua',
|
||||
bridge = 'inventoryWebBridge.lua',
|
||||
turtle = 'craftingTurtle.lua',
|
||||
miner = 'miningTurtle.lua',
|
||||
}
|
||||
|
||||
local program = fs.combine(BASE, programs[role])
|
||||
|
||||
@@ -31,7 +31,17 @@ local CRAFT_SLOTS = {1, 2, 3, 5, 6, 7, 9, 10, 11}
|
||||
local _baseDir = fs.getDir(shell.getRunningProgram())
|
||||
local function _path(rel) return fs.combine(_baseDir, rel) end
|
||||
|
||||
local TURTLE_CONFIG_FILE = _path(".turtle_config")
|
||||
-- Persistent config path (survives Opus package updates)
|
||||
local _PERSIST_DIR = "usr/config/inventory-manager"
|
||||
local function _configPath(rel)
|
||||
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
|
||||
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
|
||||
return fs.combine(_PERSIST_DIR, rel)
|
||||
end
|
||||
return _path(rel)
|
||||
end
|
||||
|
||||
local TURTLE_CONFIG_FILE = _configPath(".turtle_config")
|
||||
|
||||
local function loadConfig()
|
||||
if not fs.exists(TURTLE_CONFIG_FILE) then return end
|
||||
@@ -95,13 +105,6 @@ print("[OK] Network name: " .. selfName)
|
||||
modem.open(CRAFT_CHANNEL)
|
||||
print("[OK] Listening on channel " .. CRAFT_CHANNEL)
|
||||
|
||||
-- Wrap our own inventory peripheral for pullItems/pushItems
|
||||
local selfInv = peripheral.wrap(selfName)
|
||||
if not selfInv then
|
||||
return fatal("[ERR] Cannot wrap own peripheral: " .. selfName .. "\n Make sure the wired modem is connected.")
|
||||
end
|
||||
|
||||
print("[OK] Self-inventory peripheral ready")
|
||||
print("")
|
||||
print("Waiting for craft commands from master...")
|
||||
print("")
|
||||
@@ -111,15 +114,20 @@ print("")
|
||||
-------------------------------------------------
|
||||
|
||||
local function clearInventory(chests)
|
||||
-- Push all items from all 16 turtle slots back to chests
|
||||
-- Pull all items from turtle slots into chests (using chest.pullItems)
|
||||
local cleared = 0
|
||||
for slot = 1, 16 do
|
||||
if turtle.getItemCount(slot) > 0 then
|
||||
for _, chestName in ipairs(chests) do
|
||||
local ok, n = pcall(selfInv.pushItems, chestName, slot)
|
||||
if ok and n and n > 0 then
|
||||
cleared = cleared + n
|
||||
break
|
||||
local chest = peripheral.wrap(chestName)
|
||||
if chest and chest.pullItems then
|
||||
local ok, n = pcall(chest.pullItems, selfName, slot)
|
||||
if ok and n and n > 0 then
|
||||
cleared = cleared + n
|
||||
if turtle.getItemCount(slot) == 0 then
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -180,7 +188,13 @@ local function handleCraftCommand(message)
|
||||
print(string.format("[CRAFT] Pulling %s from %s slot %d -> turtle slot %d",
|
||||
itemName, chestName, chestSlot, turtleSlot))
|
||||
|
||||
local ok, n = pcall(selfInv.pullItems, chestName, chestSlot, count, turtleSlot)
|
||||
local chest = peripheral.wrap(chestName)
|
||||
if not chest then
|
||||
print(string.format("[CRAFT] Cannot wrap chest: %s", chestName))
|
||||
allPlaced = false
|
||||
break
|
||||
end
|
||||
local ok, n = pcall(chest.pushItems, selfName, chestSlot, count, turtleSlot)
|
||||
if ok and n and n > 0 then
|
||||
placedItems[turtleSlot] = itemName
|
||||
print(string.format("[CRAFT] Placed %s x%d in slot %d", itemName, n, turtleSlot))
|
||||
|
||||
@@ -11,7 +11,18 @@ local POLL_INTERVAL = 0.5 -- how often to check the dropper
|
||||
-------------------------------------------------
|
||||
|
||||
local _baseDir = fs.getDir(shell.getRunningProgram())
|
||||
local CONFIG_FILE = fs.combine(_baseDir, ".dropper_config")
|
||||
|
||||
-- Persistent config path: survives Opus package updates
|
||||
local _PERSIST_DIR = "usr/config/inventory-manager"
|
||||
local function _configPath(rel)
|
||||
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
|
||||
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
|
||||
return fs.combine(_PERSIST_DIR, rel)
|
||||
end
|
||||
return fs.combine(_baseDir, rel)
|
||||
end
|
||||
|
||||
local CONFIG_FILE = _configPath(".dropper_config")
|
||||
|
||||
if fs.exists(CONFIG_FILE) then
|
||||
local f = fs.open(CONFIG_FILE, "r")
|
||||
|
||||
@@ -30,6 +30,16 @@ local DROPPER_ANNOUNCE_INTERVAL = 30 -- seconds between dropper announcements
|
||||
local _baseDir = fs.getDir(shell.getRunningProgram())
|
||||
local function _path(rel) return fs.combine(_baseDir, rel) end
|
||||
|
||||
-- Persistent config path: survives Opus package updates
|
||||
local _PERSIST_DIR = "usr/config/inventory-manager"
|
||||
local function _configPath(rel)
|
||||
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
|
||||
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
|
||||
return fs.combine(_PERSIST_DIR, rel)
|
||||
end
|
||||
return _path(rel)
|
||||
end
|
||||
|
||||
-- Override dofile to load modules into our _ENV so they inherit
|
||||
-- Opus's require/package (CC:Tweaked dofile uses _G instead).
|
||||
local _ccDofile = dofile
|
||||
@@ -39,7 +49,7 @@ local function dofile(path) -- luacheck: ignore
|
||||
else error(err, 2) end
|
||||
end
|
||||
|
||||
local CLIENT_CONFIG_FILE = _path(".client_config")
|
||||
local CLIENT_CONFIG_FILE = _configPath(".client_config")
|
||||
|
||||
-------------------------------------------------
|
||||
-- Modules
|
||||
@@ -95,10 +105,14 @@ end
|
||||
-------------------------------------------------
|
||||
|
||||
local function sendToMaster(message)
|
||||
if not networkModem then return end
|
||||
if not networkModem then
|
||||
log.warn("NET", "sendToMaster: no modem, dropping %s", tostring(message.type))
|
||||
return
|
||||
end
|
||||
if not message.commandId then
|
||||
message.commandId = newCommandId()
|
||||
end
|
||||
log.info("NET", "TX -> ch %d: type=%s cmdId=%s", ORDER_CHANNEL, tostring(message.type), tostring(message.commandId))
|
||||
networkModem.transmit(ORDER_CHANNEL, CLIENT_CHANNEL, message)
|
||||
end
|
||||
|
||||
@@ -227,9 +241,12 @@ function ops.orderItem(itemName, amount)
|
||||
end
|
||||
|
||||
function ops.saveDisabledRecipes()
|
||||
-- Client doesn't persist locally; the master handles persistence.
|
||||
-- We forward the current disabledRecipes state to master via
|
||||
-- toggle/enable/disable commands (already handled by display.lua events).
|
||||
-- Sync full smelting state to master so it persists and applies.
|
||||
sendToMaster({
|
||||
type = "sync_disabled_recipes",
|
||||
disabledRecipes = state.disabledRecipes,
|
||||
smeltingPaused = state.smeltingPaused,
|
||||
})
|
||||
end
|
||||
|
||||
function ops.craftItem(recipeIdx)
|
||||
|
||||
@@ -15,6 +15,17 @@
|
||||
local _baseDir = fs.getDir(shell.getRunningProgram())
|
||||
local function _path(rel) return fs.combine(_baseDir, rel) end
|
||||
|
||||
-- Persistent config path: survives Opus package updates by storing
|
||||
-- user data in usr/config/inventory-manager/ instead of the package dir.
|
||||
local _PERSIST_DIR = "usr/config/inventory-manager"
|
||||
local function _configPath(rel)
|
||||
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
|
||||
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
|
||||
return fs.combine(_PERSIST_DIR, rel)
|
||||
end
|
||||
return _path(rel)
|
||||
end
|
||||
|
||||
-- Write crash info to a file so we can always read it
|
||||
local function _crashLog(err)
|
||||
local f = fs.open(_path(".crash.log"), "w")
|
||||
@@ -42,7 +53,7 @@ end
|
||||
local log = dofile(_path("lib/log.lua"))
|
||||
local ui = dofile(_path("lib/ui.lua"))
|
||||
local itemDB = dofile(_path("lib/itemDB.lua"))
|
||||
itemDB.init(_path(".item_names.db"))
|
||||
itemDB.init(_configPath(".item_names.db"))
|
||||
|
||||
-------------------------------------------------
|
||||
-- Load modules (factory pattern → shared context)
|
||||
@@ -137,11 +148,12 @@ local function broadcastState()
|
||||
craftTurtleOk = ctx.craftTurtleName and peripheral.isPresent(ctx.craftTurtleName),
|
||||
}
|
||||
|
||||
if state.configDirty then
|
||||
payload.smeltable = cfg.SMELTABLE
|
||||
payload.craftable = cfg.CRAFTABLE
|
||||
state.configDirty = false
|
||||
end
|
||||
-- Keep ctx in sync so display.lua can check ctx.craftTurtleOk directly
|
||||
ctx.craftTurtleOk = payload.craftTurtleOk
|
||||
|
||||
payload.smeltable = cfg.SMELTABLE
|
||||
payload.craftable = cfg.CRAFTABLE
|
||||
state.configDirty = false
|
||||
|
||||
ctx.networkModem.transmit(cfg.BROADCAST_CHANNEL, cfg.ORDER_CHANNEL, payload)
|
||||
state.lastBroadcastVersion = state.stateVersion
|
||||
@@ -183,24 +195,39 @@ local function main()
|
||||
log.warn("INIT", "No smelter monitor on %s", cfg.SMELTER_MONITOR_SIDE)
|
||||
end
|
||||
|
||||
-- Find modem for client communication
|
||||
-- Find wired modem for client/turtle communication
|
||||
for _, name in ipairs(peripheral.getNames()) do
|
||||
if peripheral.getType(name) == "modem" then
|
||||
ctx.networkModem = peripheral.wrap(name)
|
||||
ctx.networkModemName = name
|
||||
ctx.networkModem.open(cfg.ORDER_CHANNEL)
|
||||
ctx.networkModem.open(cfg.CRAFT_REPLY_CHANNEL)
|
||||
ctx.networkModem.open(cfg.SYSTEM_CHANNEL)
|
||||
break
|
||||
local m = peripheral.wrap(name)
|
||||
-- Prefer wired modem (has getNameLocal); skip wireless
|
||||
if m.isWireless and not m.isWireless() then
|
||||
ctx.networkModem = m
|
||||
ctx.networkModemName = name
|
||||
ctx.networkModem.open(cfg.ORDER_CHANNEL)
|
||||
ctx.networkModem.open(cfg.CRAFT_REPLY_CHANNEL)
|
||||
ctx.networkModem.open(cfg.SYSTEM_CHANNEL)
|
||||
break
|
||||
elseif not ctx.networkModem then
|
||||
-- Fallback: use wireless if no wired available
|
||||
ctx.networkModem = m
|
||||
ctx.networkModemName = name
|
||||
end
|
||||
end
|
||||
end
|
||||
if ctx.networkModem then
|
||||
log.info("INIT", "Network modem: %s", ctx.networkModemName)
|
||||
-- Ensure channels are open even on fallback modem
|
||||
ctx.networkModem.open(cfg.ORDER_CHANNEL)
|
||||
ctx.networkModem.open(cfg.CRAFT_REPLY_CHANNEL)
|
||||
ctx.networkModem.open(cfg.SYSTEM_CHANNEL)
|
||||
log.info("INIT", "Network modem: %s (wired=%s)", ctx.networkModemName,
|
||||
tostring(ctx.networkModem.isWireless and not ctx.networkModem.isWireless()))
|
||||
else
|
||||
log.warn("INIT", "No modem found for client sync")
|
||||
end
|
||||
|
||||
-- Detect crafting turtle on network
|
||||
-- The actual craft message goes on CRAFT_CHANNEL which only the crafting
|
||||
-- turtle listens to, so we just need any turtle name for presence checks.
|
||||
for _, name in ipairs(peripheral.getNames()) do
|
||||
if name:match("^turtle_") then
|
||||
ctx.craftTurtleName = name
|
||||
@@ -479,6 +506,22 @@ local function main()
|
||||
ops.invalidateWrapCache(name)
|
||||
ops.invalidatePeripheralCaches()
|
||||
log.info("DETACH", "%s", name)
|
||||
if name == ctx.craftTurtleName then
|
||||
ctx.craftTurtleName = nil
|
||||
log.warn("DETACH", "Crafting turtle disconnected")
|
||||
end
|
||||
end
|
||||
end
|
||||
end,
|
||||
|
||||
-- Task 11b: Peripheral attach handler (auto-detect crafting turtle)
|
||||
function()
|
||||
while true do
|
||||
local event, name = os.pullEvent("peripheral_attach")
|
||||
if name and name:match("^turtle_") and not ctx.craftTurtleName then
|
||||
ctx.craftTurtleName = name
|
||||
log.info("ATTACH", "Crafting turtle detected: %s", name)
|
||||
pcall(broadcastState)
|
||||
end
|
||||
end
|
||||
end,
|
||||
@@ -498,14 +541,23 @@ local function main()
|
||||
-- Task 13: Network order/command listener
|
||||
function()
|
||||
if not ctx.networkModem then
|
||||
log.warn("NET", "No modem — listener disabled")
|
||||
while true do sleep(3600) end
|
||||
end
|
||||
log.info("NET", "Listener started on channel %d (modem: %s)", cfg.ORDER_CHANNEL, ctx.networkModemName or "?")
|
||||
local cmdCount = 0
|
||||
while true do
|
||||
log.info("NET", "Waiting for modem_message... (handled %d so far)", cmdCount)
|
||||
local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message")
|
||||
log.info("NET", "Got modem_message: side=%s ch=%d reply=%d type=%s",
|
||||
tostring(side), channel or -1, replyChannel or -1,
|
||||
type(message) == "table" and tostring(message.type) or type(message))
|
||||
if channel == cfg.ORDER_CHANNEL and type(message) == "table" then
|
||||
if isCommandDuplicate(message.commandId) then
|
||||
log.debug("NET", "Duplicate command skipped: %s", tostring(message.commandId))
|
||||
else
|
||||
cmdCount = cmdCount + 1
|
||||
log.info("NET", "Command #%d accepted: type=%s cmdId=%s", cmdCount, tostring(message.type), tostring(message.commandId))
|
||||
recordCommandId(message.commandId)
|
||||
cleanupCommandIds()
|
||||
local handlerOk, handlerErr = pcall(function()
|
||||
@@ -665,6 +717,21 @@ local function main()
|
||||
state.needsRedraw = true
|
||||
pcall(broadcastState)
|
||||
|
||||
elseif message.type == "sync_disabled_recipes" then
|
||||
if message.disabledRecipes then
|
||||
state.disabledRecipes = message.disabledRecipes
|
||||
end
|
||||
if message.smeltingPaused ~= nil then
|
||||
state.smeltingPaused = message.smeltingPaused
|
||||
end
|
||||
ops.saveDisabledRecipes()
|
||||
log.info("NET", "Synced smelting state from client")
|
||||
state.configDirty = true
|
||||
state.bumpStateVersion()
|
||||
state.smelterNeedsRedraw = true
|
||||
state.needsRedraw = true
|
||||
pcall(broadcastState)
|
||||
|
||||
elseif message.type == "learn_crafting_recipe" and message.output and message.count and message.grid then
|
||||
cfg.recipeBook.learnCraftingRecipe(message.output, message.count, message.grid)
|
||||
cfg.refreshRecipes()
|
||||
@@ -694,13 +761,54 @@ local function main()
|
||||
state.bumpStateVersion()
|
||||
end
|
||||
pcall(broadcastState)
|
||||
|
||||
elseif message.type == "find_item" and message.items then
|
||||
-- Return chest+slot locations for the first matching item
|
||||
-- message.items = list of item names to search (in priority order)
|
||||
-- message.limit = max items to return info for (default 64)
|
||||
local limit = message.limit or 64
|
||||
local results = {}
|
||||
for _, itemName in ipairs(message.items) do
|
||||
if cache.catalogue[itemName] then
|
||||
for _, source in ipairs(cache.catalogue[itemName]) do
|
||||
local chest = ops.wrapCached(source.chest)
|
||||
if chest then
|
||||
for slot, slotItem in pairs(chest.list()) do
|
||||
if slotItem.name == itemName then
|
||||
table.insert(results, {
|
||||
chest = source.chest,
|
||||
slot = slot,
|
||||
name = itemName,
|
||||
count = slotItem.count,
|
||||
})
|
||||
if #results >= limit then break end
|
||||
end
|
||||
end
|
||||
end
|
||||
if #results >= limit then break end
|
||||
end
|
||||
end
|
||||
if #results > 0 then break end -- found fuel, stop searching
|
||||
end
|
||||
log.info("NET", "find_item: found %d source(s)", #results)
|
||||
pcall(function()
|
||||
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
|
||||
type = "find_item_result",
|
||||
commandId = message.commandId,
|
||||
results = results,
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
end) -- pcall handler
|
||||
if not handlerOk then
|
||||
log.error("NET", "Handler error: %s", tostring(handlerErr))
|
||||
else
|
||||
log.info("NET", "Command #%d handled OK", cmdCount)
|
||||
end
|
||||
end -- idempotency else
|
||||
else
|
||||
log.info("NET", "Ignored: ch=%d (want %d) or not table", channel or -1, cfg.ORDER_CHANNEL)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -24,7 +24,17 @@ local ORDER_CHANNEL = 4201
|
||||
local _baseDir = fs.getDir(shell.getRunningProgram())
|
||||
local function _path(rel) return fs.combine(_baseDir, rel) end
|
||||
|
||||
local CONFIG_FILE = _path(".webbridge_config")
|
||||
-- Persistent config path: survives Opus package updates
|
||||
local _PERSIST_DIR = "usr/config/inventory-manager"
|
||||
local function _configPath(rel)
|
||||
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
|
||||
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
|
||||
return fs.combine(_PERSIST_DIR, rel)
|
||||
end
|
||||
return _path(rel)
|
||||
end
|
||||
|
||||
local CONFIG_FILE = _configPath(".webbridge_config")
|
||||
local API_KEY = nil -- optional API key for server auth
|
||||
|
||||
local function loadConfig()
|
||||
|
||||
@@ -6,6 +6,16 @@ return function(log, _path)
|
||||
-- Fall back to CWD-relative if _path not provided (standalone use)
|
||||
if not _path then _path = function(p) return p end end
|
||||
|
||||
-- Persistent config path: survives Opus package updates
|
||||
local _PERSIST_DIR = "usr/config/inventory-manager"
|
||||
local function _configPath(rel)
|
||||
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
|
||||
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
|
||||
return fs.combine(_PERSIST_DIR, rel)
|
||||
end
|
||||
return _path(rel)
|
||||
end
|
||||
|
||||
local C = {}
|
||||
|
||||
-------------------------------------------------
|
||||
@@ -22,9 +32,9 @@ C.SMELT_RESERVE = 128
|
||||
C.DEFRAG_INTERVAL = 600
|
||||
C.COMPOST_INTERVAL = 3
|
||||
C.ALERT_INTERVAL = 15
|
||||
C.CACHE_FILE = _path(".inventory_cache")
|
||||
C.CACHE_FILE = _configPath(".inventory_cache")
|
||||
C.SMELTER_MONITOR_SIDE = "top"
|
||||
C.DISABLED_RECIPES_FILE = _path(".disabled_recipes")
|
||||
C.DISABLED_RECIPES_FILE = _configPath(".disabled_recipes")
|
||||
|
||||
-- Network
|
||||
C.BROADCAST_CHANNEL = 4200
|
||||
@@ -71,7 +81,7 @@ C.SLOT_OUTPUT = 3
|
||||
-- Config file loader
|
||||
-------------------------------------------------
|
||||
|
||||
local CONFIG_FILE = _path(".manager_config")
|
||||
local CONFIG_FILE = _configPath(".manager_config")
|
||||
|
||||
function C.loadConfig()
|
||||
if not fs.exists(CONFIG_FILE) then return end
|
||||
@@ -124,7 +134,7 @@ C.LOW_STOCK_ALERTS = dofile(_path("data/alerts.lua"))
|
||||
|
||||
-- Recipe book: merges built-in recipes + user-learned recipes
|
||||
local recipeBook = dofile(_path("lib/recipeBook.lua"))
|
||||
recipeBook.init(_path(".recipes.db"))
|
||||
recipeBook.init(_configPath(".recipes.db"))
|
||||
recipeBook.loadLegacyCrafting(dofile(_path("data/craftable.lua")))
|
||||
recipeBook.loadLegacySmelting(dofile(_path("data/smeltable.lua")))
|
||||
C.recipeBook = recipeBook
|
||||
|
||||
@@ -479,10 +479,10 @@ local function buildMainPage()
|
||||
-- Order quantity popup (full-screen overlay; starts disabled)
|
||||
orderPopup = UI.Window {
|
||||
x = 1, y = 1, ex = -1, ey = -1,
|
||||
backgroundColor = colors.black,
|
||||
backgroundColor = colors.gray,
|
||||
enable = function() end, -- toggled manually
|
||||
draw = function(self)
|
||||
self:clear(colors.black)
|
||||
self:clear(colors.gray)
|
||||
self._zones = {}
|
||||
|
||||
local dw = math.min(self.width - 2, 30)
|
||||
@@ -618,7 +618,7 @@ local function buildMainPage()
|
||||
searchQuery = searchQuery .. " "
|
||||
end
|
||||
D.refreshItemGrid()
|
||||
selfitem.total
|
||||
self.searchRow:draw()
|
||||
self.footerBar:draw()
|
||||
self:sync()
|
||||
return true
|
||||
@@ -858,6 +858,25 @@ local function buildSmelterPage()
|
||||
selectedBackgroundColor = colors.purple,
|
||||
unselectedBackgroundColor = colors.gray,
|
||||
|
||||
eventHandler = function(self, event)
|
||||
if event.type == 'tab_change' then
|
||||
local titleMap = {
|
||||
Status = 'status', Smelt = 'smelt',
|
||||
Craft = 'craft', Missing = 'missing',
|
||||
}
|
||||
if event.tab and event.tab.text then
|
||||
smelterView = titleMap[event.tab.text] or smelterView
|
||||
end
|
||||
D.refreshSmelterData()
|
||||
local page = self.parent
|
||||
if page then
|
||||
page.smelterFooter:draw()
|
||||
page.bottomBar:draw()
|
||||
end
|
||||
end
|
||||
return UI.Tabs.eventHandler(self, event)
|
||||
end,
|
||||
|
||||
-- Status tab
|
||||
statusTab = UI.Tab {
|
||||
index = 1,
|
||||
@@ -1094,17 +1113,7 @@ local function buildSmelterPage()
|
||||
},
|
||||
|
||||
eventHandler = function(self, event)
|
||||
if event.type == 'tab_change' then
|
||||
local tabMap = { 'status', 'smelt', 'craft', 'missing' }
|
||||
if event.current then
|
||||
smelterView = tabMap[event.current] or smelterView
|
||||
end
|
||||
D.refreshSmelterData()
|
||||
self.smelterFooter:draw()
|
||||
self.bottomBar:draw()
|
||||
-- fall through to default handler for tab switching
|
||||
|
||||
elseif event.type == 'enable_all' then
|
||||
if event.type == 'enable_all' then
|
||||
state.disabledRecipes = {}
|
||||
log.debug("UI", "All recipes enabled")
|
||||
ops.saveDisabledRecipes()
|
||||
@@ -1141,10 +1150,22 @@ local function buildSmelterPage()
|
||||
local recipeIdx = event.selected.idx
|
||||
local recipe = cfg.CRAFTABLE[recipeIdx]
|
||||
if recipe then
|
||||
-- Re-scan for turtle if none known
|
||||
if not ctx.craftTurtleName then
|
||||
for _, pName in ipairs(peripheral.getNames()) do
|
||||
if pName:match("^turtle_") then
|
||||
ctx.craftTurtleName = pName
|
||||
log.info("CRAFT", "Turtle found on re-scan: %s", pName)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
local turtleOk = ctx.craftTurtleOk
|
||||
or (ctx.craftTurtleName
|
||||
and peripheral.isPresent(ctx.craftTurtleName))
|
||||
if not turtleOk then
|
||||
log.warn("CRAFT", "No crafting turtle! (name=%s)",
|
||||
tostring(ctx.craftTurtleName))
|
||||
self.notification:error("No crafting turtle!")
|
||||
return true
|
||||
end
|
||||
@@ -1234,22 +1255,24 @@ function D.refreshSmelterData()
|
||||
end
|
||||
smelterPage.tabs.statusTab.grid:setValues(statusValues)
|
||||
|
||||
-- Smelt tab
|
||||
-- Smelt tab: build stock lookup from itemList (works for both manager and client)
|
||||
state.ensureItemList()
|
||||
local stockLookup = {}
|
||||
for _, item in ipairs(cache.itemList or {}) do
|
||||
stockLookup[item.name] = item.total
|
||||
end
|
||||
|
||||
local recipeList = {}
|
||||
for inputName, recipe in pairs(cfg.SMELTABLE) do
|
||||
local short = shortName(inputName)
|
||||
local resultShort = shortName(recipe.result)
|
||||
local types = ""
|
||||
if recipe.furnaceSet["minecraft:furnace"] then types = types .. "F" end
|
||||
if recipe.furnaceSet["minecraft:smoker"] then types = types .. "S" end
|
||||
if recipe.furnaceSet["minecraft:blast_furnace"] then types = types .. "B" end
|
||||
local fSet = recipe.furnaceSet or {}
|
||||
if fSet["minecraft:furnace"] then types = types .. "F" end
|
||||
if fSet["minecraft:smoker"] then types = types .. "S" end
|
||||
if fSet["minecraft:blast_furnace"] then types = types .. "B" end
|
||||
local enabled = not state.disabledRecipes[inputName]
|
||||
local inStorage = 0
|
||||
if cache.catalogue[inputName] then
|
||||
for _, s in ipairs(cache.catalogue[inputName]) do
|
||||
inStorage = inStorage + s.total
|
||||
end
|
||||
end
|
||||
local inStorage = stockLookup[inputName] or 0
|
||||
table.insert(recipeList, {
|
||||
inputName = inputName,
|
||||
inputShort = short,
|
||||
|
||||
414
miningTurtle.lua
Normal file
414
miningTurtle.lua
Normal file
@@ -0,0 +1,414 @@
|
||||
-- Mining Turtle Script
|
||||
-- Sits on top of an infinite cobblestone generator.
|
||||
-- Continuously mines the block below and pushes items into
|
||||
-- networked storage via wired modem.
|
||||
-- Requires a wired modem attached to the turtle.
|
||||
|
||||
local shell = _ENV.shell
|
||||
|
||||
local function fatal(msg)
|
||||
printError(msg)
|
||||
print("\nPress any key to exit...")
|
||||
os.pullEvent("key")
|
||||
return
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Default configuration (overridden by .miner_config)
|
||||
-------------------------------------------------
|
||||
|
||||
local MINE_INTERVAL = 0.5 -- seconds between dig attempts
|
||||
local DUMP_THRESHOLD = 14 -- dump when this many slots are occupied
|
||||
local DUMP_INTERVAL = 30 -- force dump every N seconds even if not full
|
||||
local FUEL_SLOT = 16 -- slot used for pulling/burning fuel
|
||||
local FUEL_THRESHOLD = 200 -- pull fuel when below this level
|
||||
local SYSTEM_CHANNEL = 4205 -- remote reboot channel
|
||||
local ORDER_CHANNEL = 4201 -- channel to talk to manager
|
||||
local FUEL_REQUEST_TIMEOUT = 5 -- seconds to wait for manager reply
|
||||
|
||||
-- Fuel items the turtle will look for in storage (best first)
|
||||
local FUEL_ITEMS = {
|
||||
"minecraft:coal",
|
||||
"minecraft:charcoal",
|
||||
"minecraft:coal_block",
|
||||
"minecraft:blaze_rod",
|
||||
"minecraft:dried_kelp_block",
|
||||
}
|
||||
|
||||
-------------------------------------------------
|
||||
-- Load config from file if present
|
||||
-------------------------------------------------
|
||||
|
||||
local _baseDir = fs.getDir(shell.getRunningProgram())
|
||||
local function _path(rel) return fs.combine(_baseDir, rel) end
|
||||
|
||||
-- Persistent config path (survives Opus package updates)
|
||||
local _PERSIST_DIR = "usr/config/inventory-manager"
|
||||
local function _configPath(rel)
|
||||
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
|
||||
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
|
||||
return fs.combine(_PERSIST_DIR, rel)
|
||||
end
|
||||
return _path(rel)
|
||||
end
|
||||
|
||||
local CONFIG_FILE = _configPath(".miner_config")
|
||||
|
||||
local function loadConfig()
|
||||
if not fs.exists(CONFIG_FILE) then return end
|
||||
local f = fs.open(CONFIG_FILE, "r")
|
||||
local data = f.readAll()
|
||||
f.close()
|
||||
local ok, cfg = pcall(textutils.unserialiseJSON, data)
|
||||
if not ok or not cfg then
|
||||
print("[WARN] Failed to parse " .. CONFIG_FILE)
|
||||
return
|
||||
end
|
||||
if cfg.mineInterval then MINE_INTERVAL = cfg.mineInterval end
|
||||
if cfg.dumpThreshold then DUMP_THRESHOLD = cfg.dumpThreshold end
|
||||
if cfg.dumpInterval then DUMP_INTERVAL = cfg.dumpInterval end
|
||||
if cfg.fuelSlot then FUEL_SLOT = cfg.fuelSlot end
|
||||
print("[CONFIG] Loaded from " .. CONFIG_FILE)
|
||||
end
|
||||
|
||||
loadConfig()
|
||||
|
||||
-------------------------------------------------
|
||||
-- Setup
|
||||
-------------------------------------------------
|
||||
|
||||
print("=================================")
|
||||
print(" Mining Turtle (Cobble Miner)")
|
||||
print("=================================")
|
||||
print("")
|
||||
|
||||
if not turtle then
|
||||
return fatal("[ERR] Not a turtle!")
|
||||
end
|
||||
|
||||
-- Find wired modem and get our network name
|
||||
local modem = nil
|
||||
local modemSide = nil
|
||||
local selfName = nil
|
||||
|
||||
for _, side in ipairs({"top", "bottom", "left", "right", "front", "back"}) do
|
||||
if peripheral.getType(side) == "modem" then
|
||||
local m = peripheral.wrap(side)
|
||||
if m.getNameLocal then
|
||||
local name = m.getNameLocal()
|
||||
if name then
|
||||
modem = m
|
||||
modemSide = side
|
||||
selfName = name
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if not modem or not selfName then
|
||||
return fatal("[ERR] No wired modem found!\n Attach a wired modem to the turtle\n and connect it to the network.")
|
||||
end
|
||||
|
||||
print("[OK] Modem: " .. modemSide)
|
||||
print("[OK] Network name: " .. selfName)
|
||||
print("[OK] Mine interval: " .. MINE_INTERVAL .. "s")
|
||||
print("[OK] Dump threshold: " .. DUMP_THRESHOLD .. " slots")
|
||||
print("")
|
||||
|
||||
-------------------------------------------------
|
||||
-- Find chests on the network to dump into
|
||||
-------------------------------------------------
|
||||
|
||||
local function getChests()
|
||||
local chests = {}
|
||||
for _, name in ipairs(peripheral.getNames()) do
|
||||
local ptype = peripheral.getType(name)
|
||||
if ptype == "minecraft:chest" or ptype == "minecraft:barrel" or
|
||||
ptype == "minecraft:shulker_box" then
|
||||
table.insert(chests, name)
|
||||
end
|
||||
end
|
||||
return chests
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Dump inventory into networked storage
|
||||
-- Uses pull approach: wrap the remote chest and call
|
||||
-- chest.pullItems(selfName, slot) — avoids needing
|
||||
-- to wrap the turtle's own peripheral.
|
||||
-------------------------------------------------
|
||||
|
||||
local function dumpInventory()
|
||||
local chests = getChests()
|
||||
if #chests == 0 then
|
||||
print("[DUMP] No chests on network!")
|
||||
return 0
|
||||
end
|
||||
|
||||
local totalPushed = 0
|
||||
for slot = 1, 16 do
|
||||
if slot ~= FUEL_SLOT and turtle.getItemCount(slot) > 0 then
|
||||
for _, chestName in ipairs(chests) do
|
||||
local chest = peripheral.wrap(chestName)
|
||||
if chest and chest.pullItems then
|
||||
local ok, n = pcall(chest.pullItems, selfName, slot)
|
||||
if ok and n and n > 0 then
|
||||
totalPushed = totalPushed + n
|
||||
if turtle.getItemCount(slot) == 0 then
|
||||
break -- slot empty, move to next
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Notify manager to refresh its cache so counts update immediately
|
||||
if totalPushed > 0 then
|
||||
pcall(function()
|
||||
modem.transmit(ORDER_CHANNEL, ORDER_CHANNEL, { type = "scan" })
|
||||
end)
|
||||
end
|
||||
|
||||
return totalPushed
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Count occupied inventory slots
|
||||
-------------------------------------------------
|
||||
|
||||
local function occupiedSlots()
|
||||
local count = 0
|
||||
for slot = 1, 16 do
|
||||
if slot ~= FUEL_SLOT and turtle.getItemCount(slot) > 0 then
|
||||
count = count + 1
|
||||
end
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Auto-refuel: ask manager for fuel, pull directly
|
||||
-------------------------------------------------
|
||||
|
||||
--- Try to burn whatever is already in FUEL_SLOT
|
||||
local function burnFuelSlot()
|
||||
if FUEL_SLOT <= 0 then return false end
|
||||
local prev = turtle.getSelectedSlot()
|
||||
turtle.select(FUEL_SLOT)
|
||||
if turtle.getItemCount() > 0 then
|
||||
local ok = turtle.refuel()
|
||||
turtle.select(prev)
|
||||
return ok
|
||||
end
|
||||
turtle.select(prev)
|
||||
return false
|
||||
end
|
||||
|
||||
--- Ask the inventory manager where fuel items are
|
||||
local function askManagerForFuel()
|
||||
local replyChannel = ORDER_CHANNEL + 100 + os.getComputerID()
|
||||
modem.open(replyChannel)
|
||||
|
||||
modem.transmit(ORDER_CHANNEL, replyChannel, {
|
||||
type = "find_item",
|
||||
items = FUEL_ITEMS,
|
||||
limit = 1,
|
||||
})
|
||||
|
||||
local deadline = os.clock() + FUEL_REQUEST_TIMEOUT
|
||||
local result = nil
|
||||
|
||||
while os.clock() < deadline do
|
||||
local timerId = os.startTimer(math.max(0.1, deadline - os.clock()))
|
||||
local event, p1, p2, p3, p4 = os.pullEvent()
|
||||
if event == "modem_message" and p2 == replyChannel then
|
||||
if type(p4) == "table" and p4.type == "find_item_result" then
|
||||
result = p4.results
|
||||
break
|
||||
end
|
||||
elseif event == "timer" and p1 == timerId then
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
modem.close(replyChannel)
|
||||
return result
|
||||
end
|
||||
|
||||
--- Pull fuel from a specific chest+slot (told by manager)
|
||||
local function pullFuelFromSource(source)
|
||||
if FUEL_SLOT <= 0 then return false end
|
||||
local chest = peripheral.wrap(source.chest)
|
||||
if not chest then return false end
|
||||
local ok, n = pcall(chest.pushItems, selfName, source.slot, 64, FUEL_SLOT)
|
||||
if ok and n and n > 0 then
|
||||
print(string.format("[FUEL] Pulled %s x%d from %s", source.name, n, source.chest))
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function pullFuelFromStorage()
|
||||
-- Ask manager first (it knows exactly where fuel is)
|
||||
local sources = askManagerForFuel()
|
||||
if sources and #sources > 0 then
|
||||
for _, source in ipairs(sources) do
|
||||
if pullFuelFromSource(source) then return true end
|
||||
end
|
||||
end
|
||||
|
||||
-- Fallback: scan chests directly (in case manager is offline)
|
||||
local fuelSet = {}
|
||||
for _, name in ipairs(FUEL_ITEMS) do fuelSet[name] = true end
|
||||
local chests = getChests()
|
||||
for _, chestName in ipairs(chests) do
|
||||
local chest = peripheral.wrap(chestName)
|
||||
if chest and chest.list then
|
||||
for slot, item in pairs(chest.list() or {}) do
|
||||
if fuelSet[item.name] then
|
||||
local ok, n = pcall(chest.pushItems, selfName, slot, 64, FUEL_SLOT)
|
||||
if ok and n and n > 0 then
|
||||
print(string.format("[FUEL] Pulled %s x%d (fallback)", item.name, n))
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function autoRefuel()
|
||||
if turtle.getFuelLevel() == "unlimited" then return end
|
||||
if turtle.getFuelLevel() >= FUEL_THRESHOLD then return end
|
||||
|
||||
-- First try burning whatever is already in the fuel slot
|
||||
if burnFuelSlot() then
|
||||
print("[FUEL] Burned local fuel. Level: " .. turtle.getFuelLevel())
|
||||
return
|
||||
end
|
||||
|
||||
-- Pull fresh fuel from networked storage
|
||||
if pullFuelFromStorage() then
|
||||
burnFuelSlot()
|
||||
print("[FUEL] Refueled from storage. Level: " .. turtle.getFuelLevel())
|
||||
end
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Stats
|
||||
-------------------------------------------------
|
||||
|
||||
local totalMined = 0
|
||||
local totalDumped = 0
|
||||
local startTime = os.clock()
|
||||
|
||||
local function printStats()
|
||||
local elapsed = os.clock() - startTime
|
||||
local mins = math.floor(elapsed / 60)
|
||||
local secs = math.floor(elapsed % 60)
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
print("=================================")
|
||||
print(" Mining Turtle - Running")
|
||||
print("=================================")
|
||||
print(string.format(" Mined: %d blocks", totalMined))
|
||||
print(string.format(" Dumped: %d items", totalDumped))
|
||||
print(string.format(" Uptime: %dm %ds", mins, secs))
|
||||
if turtle.getFuelLevel() ~= "unlimited" then
|
||||
print(string.format(" Fuel: %d", turtle.getFuelLevel()))
|
||||
end
|
||||
print(string.format(" Inv used: %d/16 slots", occupiedSlots()))
|
||||
print("=================================")
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Main mining loop
|
||||
-------------------------------------------------
|
||||
|
||||
local function mineLoop()
|
||||
local lastDump = os.clock()
|
||||
|
||||
while true do
|
||||
-- Auto-refuel if low
|
||||
autoRefuel()
|
||||
|
||||
-- Check fuel — keep trying to pull from storage
|
||||
if turtle.getFuelLevel() ~= "unlimited" and turtle.getFuelLevel() < 1 then
|
||||
print("[WARN] Out of fuel! Searching storage...")
|
||||
while turtle.getFuelLevel() < 1 do
|
||||
if pullFuelFromStorage() then
|
||||
burnFuelSlot()
|
||||
end
|
||||
if turtle.getFuelLevel() < 1 then
|
||||
printStats()
|
||||
sleep(10)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Mine the block below
|
||||
if turtle.detectDown() then
|
||||
local ok, err = turtle.digDown()
|
||||
if ok then
|
||||
totalMined = totalMined + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- Dump inventory if threshold reached or timer expired
|
||||
local now = os.clock()
|
||||
if occupiedSlots() >= DUMP_THRESHOLD or (now - lastDump) >= DUMP_INTERVAL then
|
||||
if occupiedSlots() > 0 then
|
||||
local pushed = dumpInventory()
|
||||
totalDumped = totalDumped + pushed
|
||||
if pushed > 0 then
|
||||
print(string.format("[DUMP] Pushed %d items to storage", pushed))
|
||||
end
|
||||
end
|
||||
lastDump = os.clock()
|
||||
end
|
||||
|
||||
-- Refresh display periodically
|
||||
printStats()
|
||||
|
||||
sleep(MINE_INTERVAL)
|
||||
end
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Reboot listener (remote reboot support)
|
||||
-------------------------------------------------
|
||||
|
||||
local function rebootListener()
|
||||
modem.open(SYSTEM_CHANNEL)
|
||||
while true do
|
||||
local _, _, channel, _, message = os.pullEvent("modem_message")
|
||||
if channel == SYSTEM_CHANNEL and type(message) == "table" and message.type == "reboot" then
|
||||
local target = message.target or "all"
|
||||
if target == "all" or target == "miner" or target == tostring(os.getComputerID()) then
|
||||
print("[SYSTEM] Reboot command received.")
|
||||
sleep(0.5)
|
||||
os.reboot()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Entry point
|
||||
-------------------------------------------------
|
||||
|
||||
print("Starting mining loop...")
|
||||
print("Press Ctrl+T to stop.")
|
||||
print("")
|
||||
sleep(1)
|
||||
|
||||
local ok, err = pcall(function()
|
||||
parallel.waitForAny(mineLoop, rebootListener)
|
||||
end)
|
||||
|
||||
if not ok then
|
||||
fatal("[ERR] Mining turtle crashed:\n" .. tostring(err))
|
||||
end
|
||||
@@ -2,8 +2,13 @@
|
||||
-- Auto-updates from git then launches inventoryClient + dropperController in parallel
|
||||
|
||||
local REPO_RAW = "https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main"
|
||||
local SETUP_FILE = ".client_setup" -- marks first-run complete
|
||||
local DROPPER_CONFIG = ".dropper_config"
|
||||
|
||||
-- Persistent config directory (survives Opus package updates)
|
||||
local PERSIST_DIR = "usr/config/inventory-manager"
|
||||
if not fs.isDir(PERSIST_DIR) then fs.makeDir(PERSIST_DIR) end
|
||||
|
||||
local SETUP_FILE = fs.combine(PERSIST_DIR, ".client_setup")
|
||||
local DROPPER_CONFIG = fs.combine(PERSIST_DIR, ".dropper_config")
|
||||
|
||||
-- Files to download (destination -> repo path)
|
||||
local FILES = {
|
||||
|
||||
58
startup/miner.lua
Normal file
58
startup/miner.lua
Normal file
@@ -0,0 +1,58 @@
|
||||
-- startup.lua for Mining Turtle
|
||||
-- Auto-updates from git then launches miningTurtle.lua
|
||||
|
||||
local REPO_RAW = "https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main"
|
||||
|
||||
local FILES = {
|
||||
["miningTurtle.lua"] = "miningTurtle.lua",
|
||||
}
|
||||
|
||||
-------------------------------------------------
|
||||
|
||||
local function download(remotePath, localPath)
|
||||
local url = REPO_RAW .. "/" .. remotePath
|
||||
local response = http.get(url)
|
||||
if response then
|
||||
local f = fs.open(localPath, "w")
|
||||
f.write(response.readAll())
|
||||
f.close()
|
||||
response.close()
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
|
||||
term.clear()
|
||||
term.setCursorPos(1, 1)
|
||||
print("==================================")
|
||||
print(" Mining Turtle - Startup")
|
||||
print(" Computer ID: " .. os.getComputerID())
|
||||
print("==================================")
|
||||
print("")
|
||||
|
||||
local updated, failed = 0, 0
|
||||
for localPath, remotePath in pairs(FILES) do
|
||||
write(" " .. localPath .. " ... ")
|
||||
if download(remotePath, localPath) then
|
||||
print("OK")
|
||||
updated = updated + 1
|
||||
else
|
||||
print("FAIL")
|
||||
failed = failed + 1
|
||||
end
|
||||
end
|
||||
|
||||
print("")
|
||||
if failed > 0 then
|
||||
print(string.format("Updated %d files, %d failed.", updated, failed))
|
||||
else
|
||||
print(string.format("All %d files up to date.", updated))
|
||||
end
|
||||
|
||||
print("")
|
||||
print("Starting miningTurtle...")
|
||||
sleep(1)
|
||||
|
||||
shell.run("miningTurtle.lua")
|
||||
@@ -1,5 +1,5 @@
|
||||
# Stage 1: Build the React app
|
||||
FROM node:18-alpine AS build
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ function App() {
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [, forceRender] = useState(0);
|
||||
|
||||
const turtleDashboardUrl = import.meta.env.VITE_TURTLE_DASHBOARD_URL || `${window.location.protocol}//${window.location.hostname}:4444`;
|
||||
const turtleDashboardUrl = import.meta.env.VITE_TURTLE_DASHBOARD_URL || 'https://turtles.spatulaa.com';
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
@@ -9,12 +9,6 @@ services:
|
||||
- API_KEY=${API_KEY:-}
|
||||
- TURTLE_SERVER_URL=${TURTLE_SERVER_URL:-}
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/api/health',r=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
start_period: 5s
|
||||
retries: 3
|
||||
|
||||
client:
|
||||
build:
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# Node.js backend
|
||||
FROM node:18-alpine
|
||||
FROM node:20-alpine
|
||||
|
||||
# Build tools needed for better-sqlite3 native compilation
|
||||
# su-exec for dropping privileges in entrypoint
|
||||
RUN apk add --no-cache python3 make g++ su-exec
|
||||
# libstdc++ is kept at runtime (needed by better-sqlite3 native addon)
|
||||
RUN apk add --no-cache python3 make g++ su-exec libstdc++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -12,6 +13,7 @@ COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# Remove build tools after install to keep image small
|
||||
# libstdc++ and su-exec are kept for runtime
|
||||
RUN apk del python3 make g++
|
||||
|
||||
COPY . .
|
||||
@@ -26,8 +28,8 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3001/api/health',r=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))"
|
||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD node -e "require('http').get('http://127.0.0.1:3001/api/health', r => { process.exit(r.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))"
|
||||
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { existsSync, mkdirSync, accessSync, constants as fsConstants } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
|
||||
const DB_PATH = process.env.DB_PATH || '/data/inventory.db';
|
||||
|
||||
console.log(`[db] Opening database at ${DB_PATH} (uid=${process.getuid()})`);
|
||||
|
||||
// Ensure the directory exists
|
||||
const dbDir = dirname(DB_PATH);
|
||||
if (!existsSync(dbDir)) {
|
||||
mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
// Verify directory is writable before opening SQLite
|
||||
try {
|
||||
accessSync(dbDir, fsConstants.W_OK);
|
||||
} catch {
|
||||
console.error(`[db] FATAL: directory ${dbDir} is not writable by uid ${process.getuid()}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let db;
|
||||
try {
|
||||
db = new Database(DB_PATH);
|
||||
} catch (err) {
|
||||
console.error(`[db] FATAL: failed to open database: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Performance pragmas
|
||||
db.pragma('journal_mode = WAL');
|
||||
@@ -19,6 +35,8 @@ db.pragma('foreign_keys = ON');
|
||||
db.pragma('cache_size = -8000'); // 8MB cache
|
||||
db.pragma('temp_store = MEMORY');
|
||||
|
||||
console.log('[db] Database ready');
|
||||
|
||||
// ========== Schema ==========
|
||||
|
||||
db.exec(`
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "[entrypoint] Starting up..."
|
||||
|
||||
# Ensure data directory exists and is writable by the node user
|
||||
mkdir -p /data
|
||||
chown node:node /data
|
||||
chown -R node:node /data
|
||||
echo "[entrypoint] /data permissions fixed"
|
||||
|
||||
# Drop privileges and exec the CMD
|
||||
echo "[entrypoint] Dropping to user 'node', running: $*"
|
||||
exec su-exec node "$@"
|
||||
|
||||
Reference in New Issue
Block a user