Compare commits
49 Commits
76772140aa
...
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 | ||
|
|
432a9feff9 | ||
|
|
ee76a61240 | ||
|
|
85228b134b | ||
|
|
d97167b21c | ||
|
|
69e24e7d79 | ||
|
|
02248ccc38 | ||
|
|
e67fded321 | ||
|
|
621902b3e8 | ||
|
|
380289d484 | ||
|
|
d67a2fde88 | ||
|
|
215652d47c | ||
|
|
b49574f39b | ||
|
|
d4a9441b54 | ||
|
|
5518161adf | ||
|
|
4c329bbfb3 | ||
|
|
381951bd91 | ||
|
|
fdc3c36cd7 | ||
|
|
64b3e4b069 | ||
|
|
c4acc2159e | ||
|
|
a0740b81f5 | ||
|
|
82d74a01b5 | ||
|
|
bb139b4afd | ||
|
|
40e6eab42d | ||
|
|
d9b7bd32b7 |
34
.package
34
.package
@@ -1,13 +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 .. "]: ")
|
||||
@@ -26,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
|
||||
@@ -46,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.")
|
||||
@@ -74,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.")
|
||||
@@ -85,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])
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
-- Pulls ingredients from chests, crafts, and pushes results back.
|
||||
-- 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 .turtle_config)
|
||||
-------------------------------------------------
|
||||
@@ -22,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
|
||||
@@ -52,9 +71,7 @@ print("")
|
||||
|
||||
-- Verify this is a crafting turtle
|
||||
if not turtle or not turtle.craft then
|
||||
print("[ERR] No turtle.craft() available!")
|
||||
print(" This must be a Crafting Turtle.")
|
||||
return
|
||||
return fatal("[ERR] No turtle.craft() available!\n This must be a Crafting Turtle.")
|
||||
end
|
||||
|
||||
-- Find wired modem and get our network name
|
||||
@@ -78,10 +95,7 @@ for _, side in ipairs({"top", "bottom", "left", "right", "front", "back"}) do
|
||||
end
|
||||
|
||||
if not modem or not selfName then
|
||||
print("[ERR] No wired modem found!")
|
||||
print(" Attach a wired modem to the turtle")
|
||||
print(" and connect it to the network.")
|
||||
return
|
||||
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)
|
||||
@@ -91,15 +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
|
||||
print("[ERR] Cannot wrap own peripheral: " .. selfName)
|
||||
print(" Make sure the wired modem is connected.")
|
||||
return
|
||||
end
|
||||
|
||||
print("[OK] Self-inventory peripheral ready")
|
||||
print("")
|
||||
print("Waiting for craft commands from master...")
|
||||
print("")
|
||||
@@ -109,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
|
||||
@@ -178,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))
|
||||
@@ -266,20 +282,26 @@ end
|
||||
-- Main loop: listen for modem commands
|
||||
-------------------------------------------------
|
||||
|
||||
while true do
|
||||
local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message")
|
||||
local ok, err = pcall(function()
|
||||
while true do
|
||||
local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message")
|
||||
|
||||
if channel == CRAFT_CHANNEL and type(message) == "table" then
|
||||
if message.type == "craft_request" then
|
||||
local result = handleCraftCommand(message)
|
||||
-- Send result back to master
|
||||
modem.transmit(CRAFT_REPLY_CHANNEL, CRAFT_CHANNEL, result)
|
||||
elseif message.type == "ping" then
|
||||
-- Health check from master
|
||||
modem.transmit(CRAFT_REPLY_CHANNEL, CRAFT_CHANNEL, {
|
||||
type = "pong",
|
||||
name = selfName,
|
||||
})
|
||||
if channel == CRAFT_CHANNEL and type(message) == "table" then
|
||||
if message.type == "craft_request" then
|
||||
local result = handleCraftCommand(message)
|
||||
-- Send result back to master
|
||||
modem.transmit(CRAFT_REPLY_CHANNEL, CRAFT_CHANNEL, result)
|
||||
elseif message.type == "ping" then
|
||||
-- Health check from master
|
||||
modem.transmit(CRAFT_REPLY_CHANNEL, CRAFT_CHANNEL, {
|
||||
type = "pong",
|
||||
name = selfName,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
if not ok then
|
||||
fatal("[ERR] Crafting turtle crashed:\n" .. tostring(err))
|
||||
end
|
||||
|
||||
@@ -1,10 +1,41 @@
|
||||
-- Dropper Controller: Runs on Computer 1 (next to dropper_0)
|
||||
-- Watches the dropper and pulses redstone until it's empty.
|
||||
-- Dropper Controller
|
||||
-- Watches a dropper peripheral and pulses redstone until it's empty.
|
||||
|
||||
local REDSTONE_SIDE = "back" -- side facing dropper_0
|
||||
local DROPPER_SIDE = "back" -- side where the dropper peripheral is
|
||||
local REDSTONE_SIDE = "back" -- side facing dropper (for redstone output)
|
||||
local PULSE_TIME = 0.3 -- redstone pulse duration in seconds
|
||||
local POLL_INTERVAL = 0.5 -- how often to check the dropper
|
||||
local DROPPER_SIDE = "back" -- side where the dropper is (as peripheral)
|
||||
|
||||
-------------------------------------------------
|
||||
-- Load config from file if present
|
||||
-------------------------------------------------
|
||||
|
||||
local _baseDir = fs.getDir(shell.getRunningProgram())
|
||||
|
||||
-- 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")
|
||||
local raw = f.readAll()
|
||||
f.close()
|
||||
local ok, cfg = pcall(textutils.unserialiseJSON, raw)
|
||||
if ok and cfg then
|
||||
if cfg.dropperSide then DROPPER_SIDE = cfg.dropperSide end
|
||||
if cfg.redstoneSide then REDSTONE_SIDE = cfg.redstoneSide end
|
||||
if cfg.pulseTime then PULSE_TIME = cfg.pulseTime end
|
||||
if cfg.pollInterval then POLL_INTERVAL = cfg.pollInterval end
|
||||
end
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Helpers
|
||||
|
||||
19
etc/apps.db
19
etc/apps.db
@@ -1,11 +1,11 @@
|
||||
{
|
||||
[ "im_inventory_manager" ] = {
|
||||
title = "Inventory Manager",
|
||||
title = "Inv Manager",
|
||||
category = "Inventory",
|
||||
run = "inventoryManager.lua",
|
||||
},
|
||||
[ "im_inventory_client" ] = {
|
||||
title = "Inventory Display",
|
||||
title = "Inv Display",
|
||||
category = "Inventory",
|
||||
run = "inventoryClient.lua",
|
||||
},
|
||||
@@ -13,21 +13,16 @@
|
||||
title = "Crafting Turtle",
|
||||
category = "Inventory",
|
||||
run = "craftingTurtle.lua",
|
||||
requires = { turtle = true },
|
||||
},
|
||||
[ "im_dropper_controller" ] = {
|
||||
title = "Dropper Controller",
|
||||
category = "Inventory",
|
||||
run = "dropperController.lua",
|
||||
requires = "turtle",
|
||||
},
|
||||
[ "im_web_bridge" ] = {
|
||||
title = "Inventory Web Bridge",
|
||||
title = "Inv Web Bridge",
|
||||
category = "Inventory",
|
||||
run = "inventoryWebBridge.lua",
|
||||
},
|
||||
[ "im_list_devices" ] = {
|
||||
title = "List Devices",
|
||||
[ "im_dropper" ] = {
|
||||
title = "Dropper Ctrl",
|
||||
category = "Inventory",
|
||||
run = "listDevicesByType.lua",
|
||||
run = "dropperController.lua",
|
||||
},
|
||||
}
|
||||
|
||||
1403
inventoryClient.lua
1403
inventoryClient.lua
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,37 @@
|
||||
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")
|
||||
if f then
|
||||
f.write(tostring(err) .. "\n" .. (debug and debug.traceback and debug.traceback() or ""))
|
||||
f.close()
|
||||
end
|
||||
end
|
||||
|
||||
local ok, err = xpcall(function()
|
||||
|
||||
-- Override dofile to load modules into our _ENV so they inherit
|
||||
-- Opus's require/package (CC:Tweaked dofile uses _G instead).
|
||||
local _ccDofile = dofile
|
||||
local function dofile(path) -- luacheck: ignore
|
||||
local fn, err = loadfile(path, nil, _ENV)
|
||||
if fn then return fn()
|
||||
else error(err, 2) end
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- Structured logging & shared UI helpers
|
||||
-------------------------------------------------
|
||||
@@ -22,7 +53,7 @@ local function _path(rel) return fs.combine(_baseDir, rel) 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)
|
||||
@@ -117,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
|
||||
@@ -163,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
|
||||
@@ -291,6 +338,11 @@ local function main()
|
||||
end
|
||||
|
||||
ops.refreshCache(drawBoot)
|
||||
|
||||
-- Destroy boot window so it doesn't intercept future monitor writes
|
||||
buf.setVisible(true)
|
||||
buf.clear()
|
||||
buf = nil
|
||||
else
|
||||
ops.refreshCache()
|
||||
end
|
||||
@@ -392,7 +444,8 @@ local function main()
|
||||
while true do
|
||||
if state.needsRedraw then
|
||||
state.needsRedraw = false
|
||||
pcall(display.drawDashboard)
|
||||
local dok, derr = pcall(display.drawDashboard)
|
||||
if not dok then log.error("DRAW", "Dashboard: %s", tostring(derr)) end
|
||||
end
|
||||
if state.statusTimer > 0 then
|
||||
state.statusTimer = state.statusTimer - 0.1
|
||||
@@ -414,7 +467,8 @@ local function main()
|
||||
while true do
|
||||
if state.smelterNeedsRedraw then
|
||||
state.smelterNeedsRedraw = false
|
||||
pcall(display.drawSmelterDashboard)
|
||||
local sok, serr = pcall(display.drawSmelterDashboard)
|
||||
if not sok then log.error("DRAW", "Smelter: %s", tostring(serr)) end
|
||||
end
|
||||
sleep(0.1)
|
||||
end
|
||||
@@ -452,13 +506,31 @@ 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,
|
||||
|
||||
-- Task 12: Supply chest (builder / manifest-based stocking)
|
||||
function()
|
||||
if cfg.SUPPLY_CHEST == "" or #cfg.SUPPLY_MANIFEST == 0 then return end
|
||||
if cfg.SUPPLY_CHEST == "" or #cfg.SUPPLY_MANIFEST == 0 then
|
||||
while true do sleep(3600) end
|
||||
end
|
||||
log.info("SUPPLY", "Stocking %s with %d item types", cfg.SUPPLY_CHEST, #cfg.SUPPLY_MANIFEST)
|
||||
while true do
|
||||
pcall(ops.supplyChest)
|
||||
@@ -468,13 +540,24 @@ local function main()
|
||||
|
||||
-- Task 13: Network order/command listener
|
||||
function()
|
||||
if not ctx.networkModem then return end
|
||||
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()
|
||||
@@ -634,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()
|
||||
@@ -663,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
|
||||
@@ -677,3 +816,15 @@ local function main()
|
||||
end
|
||||
|
||||
main()
|
||||
|
||||
end, function(e) return tostring(e) .. "\n" .. debug.traceback() end)
|
||||
|
||||
if not ok then
|
||||
_crashLog(err)
|
||||
printError(tostring(err))
|
||||
print("")
|
||||
print("Crash log saved to: " .. _path(".crash.log"))
|
||||
print("")
|
||||
print("Press any key to exit...")
|
||||
os.pullEvent("char")
|
||||
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
|
||||
|
||||
@@ -40,6 +40,10 @@ local smelterPage = nil
|
||||
local selectedAmount = 1
|
||||
local amountOptions = {1, 4, 8, 16, 32, 64}
|
||||
local searchQuery = ""
|
||||
local showKeyboard = false
|
||||
local orderPopupItem = nil
|
||||
local orderPopupShort = nil
|
||||
local orderPopupQty = 1
|
||||
local smelterView = "status"
|
||||
|
||||
-------------------------------------------------
|
||||
@@ -263,17 +267,46 @@ local function buildMainPage()
|
||||
searchRow = UI.Window {
|
||||
x = 1, y = 6, ex = -1, height = 1,
|
||||
backgroundColor = colors.black,
|
||||
},
|
||||
|
||||
searchEntry = UI.TextEntry {
|
||||
x = 3, y = 6,
|
||||
ex = '45%',
|
||||
shadowText = "search...",
|
||||
backgroundColor = colors.black,
|
||||
backgroundFocusColor = colors.gray,
|
||||
textColor = colors.white,
|
||||
shadowTextColor = colors.gray,
|
||||
limit = 30,
|
||||
draw = function(self)
|
||||
self:clear(colors.black)
|
||||
-- Keyboard toggle button
|
||||
local kbLabel = showKeyboard and " X " or " ? "
|
||||
local kbBg = showKeyboard and colors.red or colors.purple
|
||||
self:write(1, 1, kbLabel, kbBg, colors.white)
|
||||
-- Search query display
|
||||
local fieldW = math.floor(self.width * 0.4)
|
||||
if fieldW < 10 then fieldW = 10 end
|
||||
local queryDisplay = searchQuery
|
||||
if showKeyboard then
|
||||
queryDisplay = queryDisplay .. "|"
|
||||
elseif queryDisplay == "" then
|
||||
queryDisplay = "search..."
|
||||
end
|
||||
local displayText = queryDisplay:sub(1, fieldW)
|
||||
displayText = displayText .. string.rep("_", math.max(0, fieldW - #displayText))
|
||||
local tc = (searchQuery == "" and not showKeyboard) and colors.gray or colors.white
|
||||
self:write(5, 1, displayText, colors.black, tc)
|
||||
end,
|
||||
eventHandler = function(self, event)
|
||||
if event.type == 'mouse_click' then
|
||||
showKeyboard = not showKeyboard
|
||||
local page = self.parent
|
||||
if showKeyboard then
|
||||
UI.Window.enable(page.keyboard)
|
||||
page.keyboard:raise()
|
||||
page.keyboard:draw()
|
||||
else
|
||||
page.keyboard:disable()
|
||||
page.alertBar:draw()
|
||||
page.footerBar:draw()
|
||||
page.bottomBar:draw()
|
||||
end
|
||||
self:draw()
|
||||
page:sync()
|
||||
return true
|
||||
end
|
||||
return UI.Window.eventHandler(self, event)
|
||||
end,
|
||||
},
|
||||
|
||||
refreshBtn = UI.Button {
|
||||
@@ -383,15 +416,229 @@ local function buildMainPage()
|
||||
end,
|
||||
},
|
||||
|
||||
-- On-screen keyboard overlay (bottom 3 rows; starts disabled)
|
||||
keyboard = UI.Window {
|
||||
x = 1, ex = -1, ey = -1, height = 3,
|
||||
backgroundColor = colors.black,
|
||||
enable = function() end, -- prevent auto-enable; toggled manually
|
||||
draw = function(self)
|
||||
self:clear(colors.black)
|
||||
local kbDefs = {
|
||||
{ keys = {"Q","W","E","R","T","Y","U","I","O","P"}, specials = {{ label = " Bksp ", bg = colors.red, action = "kb_bksp" }} },
|
||||
{ keys = {"A","S","D","F","G","H","J","K","L"}, specials = {{ label = " Done ", bg = colors.green, action = "kb_done" }} },
|
||||
{ keys = {"Z","X","C","V","B","N","M"}, specials = {
|
||||
{ label = " Space ", bg = colors.lightGray, action = "kb_space" },
|
||||
{ label = " Clr ", bg = colors.orange, action = "kb_clear" },
|
||||
}},
|
||||
}
|
||||
self._zones = {}
|
||||
local keyW = 3
|
||||
local keyGap = 1
|
||||
for rowIdx, def in ipairs(kbDefs) do
|
||||
local y = rowIdx
|
||||
local keysW = #def.keys * keyW + math.max(0, #def.keys - 1) * keyGap
|
||||
local specialsW = 0
|
||||
for _, sp in ipairs(def.specials) do
|
||||
specialsW = specialsW + keyGap + #sp.label
|
||||
end
|
||||
local rowW = keysW + specialsW
|
||||
local x = math.floor((self.width - rowW) / 2) + 1
|
||||
-- Draw letter keys
|
||||
for ki, key in ipairs(def.keys) do
|
||||
self:write(x, y, " " .. key .. " ", colors.gray, colors.white)
|
||||
table.insert(self._zones, { x1 = x, y1 = y, x2 = x + keyW - 1, y2 = y, action = "kb_key", data = key:lower() })
|
||||
x = x + keyW
|
||||
if ki < #def.keys then x = x + keyGap end
|
||||
end
|
||||
-- Draw special keys
|
||||
for _, sp in ipairs(def.specials) do
|
||||
x = x + keyGap
|
||||
self:write(x, y, sp.label, sp.bg, colors.white)
|
||||
table.insert(self._zones, { x1 = x, y1 = y, x2 = x + #sp.label - 1, y2 = y, action = sp.action })
|
||||
x = x + #sp.label
|
||||
end
|
||||
end
|
||||
end,
|
||||
eventHandler = function(self, event)
|
||||
if event.type == 'mouse_click' then
|
||||
if self._zones then
|
||||
for _, zone in ipairs(self._zones) do
|
||||
if event.x >= zone.x1 and event.x <= zone.x2
|
||||
and event.y >= zone.y1 and event.y <= zone.y2 then
|
||||
self:emit({ type = zone.action, data = zone.data, element = self })
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
return true -- consume click even if no zone hit
|
||||
end
|
||||
return UI.Window.eventHandler(self, event)
|
||||
end,
|
||||
},
|
||||
|
||||
-- Order quantity popup (full-screen overlay; starts disabled)
|
||||
orderPopup = UI.Window {
|
||||
x = 1, y = 1, ex = -1, ey = -1,
|
||||
backgroundColor = colors.gray,
|
||||
enable = function() end, -- toggled manually
|
||||
draw = function(self)
|
||||
self:clear(colors.gray)
|
||||
self._zones = {}
|
||||
|
||||
local dw = math.min(self.width - 2, 30)
|
||||
local dh = 8
|
||||
local dx = math.floor((self.width - dw) / 2) + 1
|
||||
local dy = math.floor((self.height - dh) / 2) + 1
|
||||
|
||||
-- Title row (blue background)
|
||||
local title = "Order: " .. (orderPopupShort or "?")
|
||||
if #title > dw - 2 then title = title:sub(1, dw - 2) end
|
||||
self:write(dx, dy, string.rep(" ", dw), colors.blue)
|
||||
local titleX = dx + math.floor((dw - #title) / 2)
|
||||
self:write(titleX, dy, title, colors.blue, colors.white)
|
||||
|
||||
-- Dialog body rows (gray background)
|
||||
for row = 1, dh - 1 do
|
||||
self:write(dx, dy + row, string.rep(" ", dw), colors.gray)
|
||||
end
|
||||
|
||||
-- Quantity display (row 2)
|
||||
local qtyStr = string.format("Quantity: %d", orderPopupQty)
|
||||
local qtyX = dx + math.floor((dw - #qtyStr) / 2)
|
||||
self:write(qtyX, dy + 2, qtyStr, colors.gray, colors.white)
|
||||
|
||||
-- Increment buttons (row 4): [-8] [-1] [+1] [+8]
|
||||
local incBtns = {
|
||||
{ label = " -8 ", delta = -8, bg = colors.red },
|
||||
{ label = " -1 ", delta = -1, bg = colors.red },
|
||||
{ label = " +1 ", delta = 1, bg = colors.green },
|
||||
{ label = " +8 ", delta = 8, bg = colors.green },
|
||||
}
|
||||
local totalIncW = 0
|
||||
for _, b in ipairs(incBtns) do totalIncW = totalIncW + #b.label end
|
||||
totalIncW = totalIncW + (#incBtns - 1) * 2
|
||||
local incX = dx + math.floor((dw - totalIncW) / 2)
|
||||
local incRow = dy + 4
|
||||
for _, b in ipairs(incBtns) do
|
||||
self:write(incX, incRow, b.label, b.bg, colors.white)
|
||||
table.insert(self._zones, {
|
||||
x1 = incX, y1 = incRow,
|
||||
x2 = incX + #b.label - 1, y2 = incRow,
|
||||
action = "order_delta", data = b.delta,
|
||||
})
|
||||
incX = incX + #b.label + 2
|
||||
end
|
||||
|
||||
-- Preset buttons (row 5): [1] [4] [8] [16] [32] [64]
|
||||
local presets = {1, 4, 8, 16, 32, 64}
|
||||
local totalPreW = 0
|
||||
for _, p in ipairs(presets) do totalPreW = totalPreW + #tostring(p) + 2 end
|
||||
totalPreW = totalPreW + (#presets - 1)
|
||||
local preX = dx + math.floor((dw - totalPreW) / 2)
|
||||
local preRow = dy + 5
|
||||
for _, p in ipairs(presets) do
|
||||
local label = " " .. tostring(p) .. " "
|
||||
local bg = (p == orderPopupQty) and colors.cyan or colors.lightGray
|
||||
local fg = (p == orderPopupQty) and colors.white or colors.black
|
||||
self:write(preX, preRow, label, bg, fg)
|
||||
table.insert(self._zones, {
|
||||
x1 = preX, y1 = preRow,
|
||||
x2 = preX + #label - 1, y2 = preRow,
|
||||
action = "order_set", data = p,
|
||||
})
|
||||
preX = preX + #label + 1
|
||||
end
|
||||
|
||||
-- Action buttons (row 7): [Cancel] [Order]
|
||||
local cancelLabel = " Cancel "
|
||||
local orderLabel = " Order "
|
||||
local actRow = dy + 7
|
||||
local cancelX = dx + math.floor(dw / 4) - math.floor(#cancelLabel / 2)
|
||||
local orderX = dx + math.floor(3 * dw / 4) - math.floor(#orderLabel / 2)
|
||||
self:write(cancelX, actRow, cancelLabel, colors.red, colors.white)
|
||||
table.insert(self._zones, {
|
||||
x1 = cancelX, y1 = actRow,
|
||||
x2 = cancelX + #cancelLabel - 1, y2 = actRow,
|
||||
action = "order_cancel",
|
||||
})
|
||||
self:write(orderX, actRow, orderLabel, colors.lime, colors.white)
|
||||
table.insert(self._zones, {
|
||||
x1 = orderX, y1 = actRow,
|
||||
x2 = orderX + #orderLabel - 1, y2 = actRow,
|
||||
action = "order_confirm",
|
||||
})
|
||||
end,
|
||||
eventHandler = function(self, event)
|
||||
if event.type == 'mouse_click' then
|
||||
if self._zones then
|
||||
for _, zone in ipairs(self._zones) do
|
||||
if event.x >= zone.x1 and event.x <= zone.x2
|
||||
and event.y >= zone.y1 and event.y <= zone.y2 then
|
||||
self:emit({ type = zone.action, data = zone.data, element = self })
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
-- Click outside dialog dismisses popup
|
||||
self:emit({ type = 'order_cancel', element = self })
|
||||
return true
|
||||
end
|
||||
return UI.Window.eventHandler(self, event)
|
||||
end,
|
||||
},
|
||||
|
||||
-- Notification overlay
|
||||
notification = UI.Notification {
|
||||
anchor = 'bottom',
|
||||
},
|
||||
|
||||
eventHandler = function(self, event)
|
||||
if event.type == 'text_change' then
|
||||
searchQuery = event.text or ""
|
||||
if event.type == 'kb_key' then
|
||||
if #searchQuery < 30 then
|
||||
searchQuery = searchQuery .. event.data
|
||||
end
|
||||
D.refreshItemGrid()
|
||||
self.searchRow:draw()
|
||||
self.footerBar:draw()
|
||||
self:sync()
|
||||
return true
|
||||
|
||||
elseif event.type == 'kb_bksp' then
|
||||
if #searchQuery > 0 then
|
||||
searchQuery = searchQuery:sub(1, -2)
|
||||
end
|
||||
D.refreshItemGrid()
|
||||
self.searchRow:draw()
|
||||
self.footerBar:draw()
|
||||
self:sync()
|
||||
return true
|
||||
|
||||
elseif event.type == 'kb_space' then
|
||||
if #searchQuery < 30 then
|
||||
searchQuery = searchQuery .. " "
|
||||
end
|
||||
D.refreshItemGrid()
|
||||
self.searchRow:draw()
|
||||
self.footerBar:draw()
|
||||
self:sync()
|
||||
return true
|
||||
|
||||
elseif event.type == 'kb_done' then
|
||||
showKeyboard = false
|
||||
self.keyboard:disable()
|
||||
self.searchRow:draw()
|
||||
self.alertBar:draw()
|
||||
self.footerBar:draw()
|
||||
self.bottomBar:draw()
|
||||
self:sync()
|
||||
return true
|
||||
|
||||
elseif event.type == 'kb_clear' then
|
||||
searchQuery = ""
|
||||
showKeyboard = false
|
||||
self.keyboard:disable()
|
||||
D.refreshItemGrid()
|
||||
self.searchRow:draw()
|
||||
self.alertBar:draw()
|
||||
self.footerBar:draw()
|
||||
self.bottomBar:draw()
|
||||
@@ -401,14 +648,55 @@ local function buildMainPage()
|
||||
elseif event.type == 'grid_select' then
|
||||
local row = event.selected
|
||||
if row and row.name then
|
||||
local short = shortName(row.name)
|
||||
state.statusMessage = string.format("Ordering %s x%d...", short, selectedAmount)
|
||||
-- Hide keyboard if showing
|
||||
if showKeyboard then
|
||||
showKeyboard = false
|
||||
self.keyboard:disable()
|
||||
end
|
||||
-- Show order popup
|
||||
orderPopupItem = row.name
|
||||
orderPopupShort = shortName(row.name)
|
||||
orderPopupQty = selectedAmount
|
||||
UI.Window.enable(self.orderPopup)
|
||||
self.orderPopup:raise()
|
||||
self.orderPopup:draw()
|
||||
self:sync()
|
||||
end
|
||||
return true
|
||||
|
||||
elseif event.type == 'order_delta' then
|
||||
orderPopupQty = math.max(1, math.min(999, orderPopupQty + event.data))
|
||||
self.orderPopup:draw()
|
||||
self:sync()
|
||||
return true
|
||||
|
||||
elseif event.type == 'order_set' then
|
||||
orderPopupQty = event.data
|
||||
self.orderPopup:draw()
|
||||
self:sync()
|
||||
return true
|
||||
|
||||
elseif event.type == 'order_confirm' then
|
||||
if orderPopupItem then
|
||||
local short = shortName(orderPopupItem)
|
||||
state.statusMessage = string.format("Ordering %s x%d...", short, orderPopupQty)
|
||||
state.statusColor = colors.cyan
|
||||
state.statusTimer = 10
|
||||
activity.dispensing = true
|
||||
state.needsRedraw = true
|
||||
ops.orderItem(row.name, selectedAmount)
|
||||
ops.orderItem(orderPopupItem, orderPopupQty)
|
||||
end
|
||||
self.orderPopup:disable()
|
||||
orderPopupItem = nil
|
||||
self:draw()
|
||||
self:sync()
|
||||
return true
|
||||
|
||||
elseif event.type == 'order_cancel' then
|
||||
self.orderPopup:disable()
|
||||
orderPopupItem = nil
|
||||
self:draw()
|
||||
self:sync()
|
||||
return true
|
||||
|
||||
elseif event.type == 'amount_select' then
|
||||
@@ -446,10 +734,13 @@ local function buildMainPage()
|
||||
btnX = btnX + #tostring(amt) + 4
|
||||
end
|
||||
|
||||
-- Attach to device
|
||||
-- Attach to device (must resize to recompute all child dimensions
|
||||
-- from the monitor device, since Page:postInit defaulted to UI.term)
|
||||
mainDevice.currentPage = mainPage
|
||||
mainPage.parent = mainDevice
|
||||
mainPage:resize()
|
||||
mainPage:setParent()
|
||||
mainPage:enable()
|
||||
end
|
||||
|
||||
function D.updateAmountButtons()
|
||||
@@ -567,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,
|
||||
@@ -667,8 +977,9 @@ local function buildSmelterPage()
|
||||
turtleStatus = UI.Window {
|
||||
x = -14, y = 0, width = 14, height = 1,
|
||||
draw = function(self)
|
||||
local turtleOk = ctx.craftTurtleName
|
||||
and peripheral.isPresent(ctx.craftTurtleName)
|
||||
local turtleOk = ctx.craftTurtleOk
|
||||
or (ctx.craftTurtleName
|
||||
and peripheral.isPresent(ctx.craftTurtleName))
|
||||
local label = turtleOk and " Turtle OK " or " No Turtle "
|
||||
local bg = turtleOk and colors.lime or colors.red
|
||||
local fg = turtleOk and colors.black or colors.white
|
||||
@@ -802,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()
|
||||
@@ -849,9 +1150,22 @@ local function buildSmelterPage()
|
||||
local recipeIdx = event.selected.idx
|
||||
local recipe = cfg.CRAFTABLE[recipeIdx]
|
||||
if recipe then
|
||||
local turtleOk = ctx.craftTurtleName
|
||||
and peripheral.isPresent(ctx.craftTurtleName)
|
||||
-- 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
|
||||
@@ -885,7 +1199,9 @@ local function buildSmelterPage()
|
||||
-- Attach to device
|
||||
smelterDevice.currentPage = smelterPage
|
||||
smelterPage.parent = smelterDevice
|
||||
smelterPage:resize()
|
||||
smelterPage:setParent()
|
||||
smelterPage:enable()
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
@@ -939,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
|
||||
@@ -3,6 +3,13 @@
|
||||
|
||||
local REPO_RAW = "https://git.spatulaa.com/MayaTheShy/Inventory-Manager-CC/raw/branch/main"
|
||||
|
||||
-- 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 = {
|
||||
["inventoryClient.lua"] = "inventoryClient.lua",
|
||||
@@ -65,8 +72,103 @@ else
|
||||
print(string.format("All %d files up to date.", updated))
|
||||
end
|
||||
|
||||
-------------------------------------------------
|
||||
-- First-run setup
|
||||
-------------------------------------------------
|
||||
|
||||
local VALID_SIDES = { "top", "bottom", "left", "right", "front", "back" }
|
||||
local function isValidSide(s)
|
||||
for _, v in ipairs(VALID_SIDES) do
|
||||
if v == s then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function firstRunSetup()
|
||||
if fs.exists(SETUP_FILE) then return end
|
||||
|
||||
print("")
|
||||
print("========== First-Run Setup ==========")
|
||||
print("")
|
||||
write("Is there a dropper next to this computer? (y/n): ")
|
||||
local answer = read():lower():sub(1, 1)
|
||||
|
||||
if answer == "y" then
|
||||
-- Ask which side the dropper is on
|
||||
local side
|
||||
while true do
|
||||
print("")
|
||||
print("Valid sides: top, bottom, left, right, front, back")
|
||||
write("Which side is the dropper on? ")
|
||||
side = read():lower():gsub("%s+", "")
|
||||
if isValidSide(side) then
|
||||
break
|
||||
end
|
||||
print("Invalid side. Please try again.")
|
||||
end
|
||||
|
||||
-- Ask which side to pulse redstone from (defaults to same side)
|
||||
print("")
|
||||
write("Redstone output side [" .. side .. "]: ")
|
||||
local rsSide = read():lower():gsub("%s+", "")
|
||||
if rsSide == "" then rsSide = side end
|
||||
if not isValidSide(rsSide) then
|
||||
print("Invalid side, defaulting to " .. side)
|
||||
rsSide = side
|
||||
end
|
||||
|
||||
-- Save dropper config
|
||||
local cfg = textutils.serialiseJSON({
|
||||
dropperSide = side,
|
||||
redstoneSide = rsSide,
|
||||
enabled = true,
|
||||
})
|
||||
local f = fs.open(DROPPER_CONFIG, "w")
|
||||
f.write(cfg)
|
||||
f.close()
|
||||
print("")
|
||||
print("Dropper configured: peripheral=" .. side .. ", redstone=" .. rsSide)
|
||||
else
|
||||
-- No dropper - save config with enabled=false
|
||||
local f = fs.open(DROPPER_CONFIG, "w")
|
||||
f.write(textutils.serialiseJSON({ enabled = false }))
|
||||
f.close()
|
||||
print("No dropper configured.")
|
||||
end
|
||||
|
||||
-- Mark setup as complete
|
||||
local f = fs.open(SETUP_FILE, "w")
|
||||
f.write("done")
|
||||
f.close()
|
||||
|
||||
print("")
|
||||
print("Setup complete!")
|
||||
print("(Delete " .. SETUP_FILE .. " to re-run setup)")
|
||||
sleep(2)
|
||||
end
|
||||
|
||||
firstRunSetup()
|
||||
|
||||
-------------------------------------------------
|
||||
-- Determine if dropper controller should run
|
||||
-------------------------------------------------
|
||||
|
||||
local dropperEnabled = false
|
||||
if fs.exists(DROPPER_CONFIG) then
|
||||
local f = fs.open(DROPPER_CONFIG, "r")
|
||||
local ok, cfg = pcall(textutils.unserialiseJSON, f.readAll())
|
||||
f.close()
|
||||
if ok and cfg and cfg.enabled then
|
||||
dropperEnabled = true
|
||||
end
|
||||
end
|
||||
|
||||
print("")
|
||||
print("Starting inventoryClient + dropperController...")
|
||||
if dropperEnabled then
|
||||
print("Starting inventoryClient + dropperController...")
|
||||
else
|
||||
print("Starting inventoryClient (no dropper)...")
|
||||
end
|
||||
sleep(1)
|
||||
|
||||
-- Reboot listener: reboots this computer on remote command
|
||||
@@ -90,8 +192,12 @@ local function rebootListener()
|
||||
end
|
||||
end
|
||||
|
||||
parallel.waitForAny(
|
||||
local tasks = {
|
||||
function() shell.run("inventoryClient.lua") end,
|
||||
function() shell.run("dropperController.lua") end,
|
||||
rebootListener
|
||||
)
|
||||
rebootListener,
|
||||
}
|
||||
if dropperEnabled then
|
||||
table.insert(tasks, function() shell.run("dropperController.lua") end)
|
||||
end
|
||||
|
||||
parallel.waitForAny(table.unpack(tasks))
|
||||
|
||||
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:
|
||||
@@ -27,7 +21,7 @@ services:
|
||||
- inventory-network
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_started
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# Node.js backend
|
||||
FROM node:18-alpine
|
||||
FROM node:20-alpine
|
||||
|
||||
# Build tools needed for better-sqlite3 native compilation
|
||||
RUN apk add --no-cache python3 make g++
|
||||
# su-exec for dropping privileges in entrypoint
|
||||
# libstdc++ is kept at runtime (needed by better-sqlite3 native addon)
|
||||
RUN apk add --no-cache python3 make g++ su-exec libstdc++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -11,20 +13,23 @@ 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 . .
|
||||
|
||||
# Create data directory for SQLite with proper ownership
|
||||
RUN mkdir -p /data && chown node:node /data
|
||||
# Create data directory for SQLite
|
||||
RUN mkdir -p /data
|
||||
VOLUME /data
|
||||
|
||||
# Run as non-root user for security
|
||||
USER node
|
||||
# Entrypoint fixes /data permissions then drops to node user
|
||||
COPY docker-entrypoint.sh /usr/local/bin/
|
||||
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(`
|
||||
|
||||
13
web/server/docker-entrypoint.sh
Executable file
13
web/server/docker-entrypoint.sh
Executable file
@@ -0,0 +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 -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