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",
|
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.",
|
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 = {
|
exclude = {
|
||||||
"^web/", "^__tests__/", "^startup/",
|
"^web/", "^__tests__/", "^startup/",
|
||||||
"%.md$", "%.json$", "^%.git", "^LICENSE$", "^node_modules/",
|
"%.md$", "%.json$", "^%.git", "^LICENSE$", "^node_modules/",
|
||||||
|
"%.bak$", "^listDevicesByType%.lua$",
|
||||||
},
|
},
|
||||||
install = [[
|
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)
|
local function ask(prompt, default)
|
||||||
if default and #default > 0 then
|
if default and #default > 0 then
|
||||||
write(prompt .. " [" .. default .. "]: ")
|
write(prompt .. " [" .. default .. "]: ")
|
||||||
@@ -26,9 +28,10 @@
|
|||||||
print(" 1) Inventory Manager (main controller)")
|
print(" 1) Inventory Manager (main controller)")
|
||||||
print(" 2) Inventory Client (display-only)")
|
print(" 2) Inventory Client (display-only)")
|
||||||
print(" 3) Web Bridge (HTTP forwarder)")
|
print(" 3) Web Bridge (HTTP forwarder)")
|
||||||
print(" 4) Skip setup")
|
print(" 4) Mining Turtle (cobble miner)")
|
||||||
|
print(" 5) Skip setup")
|
||||||
print("")
|
print("")
|
||||||
write("Choice (1/2/3/4): ")
|
write("Choice (1/2/3/4/5): ")
|
||||||
local choice = read()
|
local choice = read()
|
||||||
|
|
||||||
if choice == "1" then
|
if choice == "1" then
|
||||||
@@ -46,14 +49,14 @@
|
|||||||
dropperName = dropperName,
|
dropperName = dropperName,
|
||||||
barrelName = barrelName,
|
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.write(textutils.serialiseJSON(cfg))
|
||||||
f.close()
|
f.close()
|
||||||
print("Saved manager config.")
|
print("Saved manager config.")
|
||||||
|
|
||||||
if serverUrl and #serverUrl > 0 then
|
if serverUrl and #serverUrl > 0 then
|
||||||
local bcfg = { serverUrl = serverUrl }
|
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.write(textutils.serialiseJSON(bcfg))
|
||||||
bf.close()
|
bf.close()
|
||||||
print("Saved web bridge config.")
|
print("Saved web bridge config.")
|
||||||
@@ -74,7 +77,7 @@
|
|||||||
if dropperName and #dropperName > 0 then cfg.dropperName = dropperName end
|
if dropperName and #dropperName > 0 then cfg.dropperName = dropperName end
|
||||||
if barrelName and #barrelName > 0 then cfg.barrelName = barrelName 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.write(textutils.serialiseJSON(cfg))
|
||||||
f.close()
|
f.close()
|
||||||
print("Saved client config.")
|
print("Saved client config.")
|
||||||
@@ -85,11 +88,26 @@
|
|||||||
local serverUrl = ask("Web server URL", "http://localhost")
|
local serverUrl = ask("Web server URL", "http://localhost")
|
||||||
|
|
||||||
local cfg = { serverUrl = serverUrl }
|
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.write(textutils.serialiseJSON(cfg))
|
||||||
f.close()
|
f.close()
|
||||||
print("Saved web bridge config.")
|
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
|
else
|
||||||
print("Skipped — edit config files manually later.")
|
print("Skipped — edit config files manually later.")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,18 +8,26 @@ local peripheral = _G.peripheral
|
|||||||
local shell = _ENV.shell
|
local shell = _ENV.shell
|
||||||
|
|
||||||
local BASE = 'packages/inventory-manager'
|
local BASE = 'packages/inventory-manager'
|
||||||
|
local CFG = 'usr/config/inventory-manager'
|
||||||
|
|
||||||
-------------------------------------------------
|
-------------------------------------------------
|
||||||
-- Determine role from config files written during install
|
-- 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
|
local role
|
||||||
if fs.exists(fs.combine(BASE, '.manager_config')) then
|
if cfgExists('.manager_config') then
|
||||||
role = 'manager'
|
role = 'manager'
|
||||||
elseif fs.exists(fs.combine(BASE, '.client_config')) then
|
elseif cfgExists('.client_config') then
|
||||||
role = 'client'
|
role = 'client'
|
||||||
elseif fs.exists(fs.combine(BASE, '.webbridge_config')) then
|
elseif cfgExists('.webbridge_config') then
|
||||||
role = 'bridge'
|
role = 'bridge'
|
||||||
|
elseif cfgExists('.miner_config') then
|
||||||
|
role = 'miner'
|
||||||
elseif _G.turtle then
|
elseif _G.turtle then
|
||||||
role = 'turtle'
|
role = 'turtle'
|
||||||
end
|
end
|
||||||
@@ -63,6 +71,7 @@ local programs = {
|
|||||||
client = 'inventoryClient.lua',
|
client = 'inventoryClient.lua',
|
||||||
bridge = 'inventoryWebBridge.lua',
|
bridge = 'inventoryWebBridge.lua',
|
||||||
turtle = 'craftingTurtle.lua',
|
turtle = 'craftingTurtle.lua',
|
||||||
|
miner = 'miningTurtle.lua',
|
||||||
}
|
}
|
||||||
|
|
||||||
local program = fs.combine(BASE, programs[role])
|
local program = fs.combine(BASE, programs[role])
|
||||||
|
|||||||
@@ -4,6 +4,15 @@
|
|||||||
-- Pulls ingredients from chests, crafts, and pushes results back.
|
-- Pulls ingredients from chests, crafts, and pushes results back.
|
||||||
-- Requires a wired modem attached to the turtle.
|
-- 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)
|
-- 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 _baseDir = fs.getDir(shell.getRunningProgram())
|
||||||
local function _path(rel) return fs.combine(_baseDir, rel) end
|
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()
|
local function loadConfig()
|
||||||
if not fs.exists(TURTLE_CONFIG_FILE) then return end
|
if not fs.exists(TURTLE_CONFIG_FILE) then return end
|
||||||
@@ -52,9 +71,7 @@ print("")
|
|||||||
|
|
||||||
-- Verify this is a crafting turtle
|
-- Verify this is a crafting turtle
|
||||||
if not turtle or not turtle.craft then
|
if not turtle or not turtle.craft then
|
||||||
print("[ERR] No turtle.craft() available!")
|
return fatal("[ERR] No turtle.craft() available!\n This must be a Crafting Turtle.")
|
||||||
print(" This must be a Crafting Turtle.")
|
|
||||||
return
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Find wired modem and get our network name
|
-- Find wired modem and get our network name
|
||||||
@@ -78,10 +95,7 @@ for _, side in ipairs({"top", "bottom", "left", "right", "front", "back"}) do
|
|||||||
end
|
end
|
||||||
|
|
||||||
if not modem or not selfName then
|
if not modem or not selfName then
|
||||||
print("[ERR] No wired modem found!")
|
return fatal("[ERR] No wired modem found!\n Attach a wired modem to the turtle\n and connect it to the network.")
|
||||||
print(" Attach a wired modem to the turtle")
|
|
||||||
print(" and connect it to the network.")
|
|
||||||
return
|
|
||||||
end
|
end
|
||||||
|
|
||||||
print("[OK] Modem: " .. modemSide)
|
print("[OK] Modem: " .. modemSide)
|
||||||
@@ -91,15 +105,6 @@ print("[OK] Network name: " .. selfName)
|
|||||||
modem.open(CRAFT_CHANNEL)
|
modem.open(CRAFT_CHANNEL)
|
||||||
print("[OK] Listening on channel " .. 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("")
|
||||||
print("Waiting for craft commands from master...")
|
print("Waiting for craft commands from master...")
|
||||||
print("")
|
print("")
|
||||||
@@ -109,15 +114,20 @@ print("")
|
|||||||
-------------------------------------------------
|
-------------------------------------------------
|
||||||
|
|
||||||
local function clearInventory(chests)
|
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
|
local cleared = 0
|
||||||
for slot = 1, 16 do
|
for slot = 1, 16 do
|
||||||
if turtle.getItemCount(slot) > 0 then
|
if turtle.getItemCount(slot) > 0 then
|
||||||
for _, chestName in ipairs(chests) do
|
for _, chestName in ipairs(chests) do
|
||||||
local ok, n = pcall(selfInv.pushItems, chestName, slot)
|
local chest = peripheral.wrap(chestName)
|
||||||
if ok and n and n > 0 then
|
if chest and chest.pullItems then
|
||||||
cleared = cleared + n
|
local ok, n = pcall(chest.pullItems, selfName, slot)
|
||||||
break
|
if ok and n and n > 0 then
|
||||||
|
cleared = cleared + n
|
||||||
|
if turtle.getItemCount(slot) == 0 then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
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",
|
print(string.format("[CRAFT] Pulling %s from %s slot %d -> turtle slot %d",
|
||||||
itemName, chestName, chestSlot, turtleSlot))
|
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
|
if ok and n and n > 0 then
|
||||||
placedItems[turtleSlot] = itemName
|
placedItems[turtleSlot] = itemName
|
||||||
print(string.format("[CRAFT] Placed %s x%d in slot %d", itemName, n, turtleSlot))
|
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
|
-- Main loop: listen for modem commands
|
||||||
-------------------------------------------------
|
-------------------------------------------------
|
||||||
|
|
||||||
while true do
|
local ok, err = pcall(function()
|
||||||
local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message")
|
while true do
|
||||||
|
local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message")
|
||||||
|
|
||||||
if channel == CRAFT_CHANNEL and type(message) == "table" then
|
if channel == CRAFT_CHANNEL and type(message) == "table" then
|
||||||
if message.type == "craft_request" then
|
if message.type == "craft_request" then
|
||||||
local result = handleCraftCommand(message)
|
local result = handleCraftCommand(message)
|
||||||
-- Send result back to master
|
-- Send result back to master
|
||||||
modem.transmit(CRAFT_REPLY_CHANNEL, CRAFT_CHANNEL, result)
|
modem.transmit(CRAFT_REPLY_CHANNEL, CRAFT_CHANNEL, result)
|
||||||
elseif message.type == "ping" then
|
elseif message.type == "ping" then
|
||||||
-- Health check from master
|
-- Health check from master
|
||||||
modem.transmit(CRAFT_REPLY_CHANNEL, CRAFT_CHANNEL, {
|
modem.transmit(CRAFT_REPLY_CHANNEL, CRAFT_CHANNEL, {
|
||||||
type = "pong",
|
type = "pong",
|
||||||
name = selfName,
|
name = selfName,
|
||||||
})
|
})
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
if not ok then
|
||||||
|
fatal("[ERR] Crafting turtle crashed:\n" .. tostring(err))
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,10 +1,41 @@
|
|||||||
-- Dropper Controller: Runs on Computer 1 (next to dropper_0)
|
-- Dropper Controller
|
||||||
-- Watches the dropper and pulses redstone until it's empty.
|
-- 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 PULSE_TIME = 0.3 -- redstone pulse duration in seconds
|
||||||
local POLL_INTERVAL = 0.5 -- how often to check the dropper
|
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
|
-- Helpers
|
||||||
|
|||||||
19
etc/apps.db
19
etc/apps.db
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
[ "im_inventory_manager" ] = {
|
[ "im_inventory_manager" ] = {
|
||||||
title = "Inventory Manager",
|
title = "Inv Manager",
|
||||||
category = "Inventory",
|
category = "Inventory",
|
||||||
run = "inventoryManager.lua",
|
run = "inventoryManager.lua",
|
||||||
},
|
},
|
||||||
[ "im_inventory_client" ] = {
|
[ "im_inventory_client" ] = {
|
||||||
title = "Inventory Display",
|
title = "Inv Display",
|
||||||
category = "Inventory",
|
category = "Inventory",
|
||||||
run = "inventoryClient.lua",
|
run = "inventoryClient.lua",
|
||||||
},
|
},
|
||||||
@@ -13,21 +13,16 @@
|
|||||||
title = "Crafting Turtle",
|
title = "Crafting Turtle",
|
||||||
category = "Inventory",
|
category = "Inventory",
|
||||||
run = "craftingTurtle.lua",
|
run = "craftingTurtle.lua",
|
||||||
requires = { turtle = true },
|
requires = "turtle",
|
||||||
},
|
|
||||||
[ "im_dropper_controller" ] = {
|
|
||||||
title = "Dropper Controller",
|
|
||||||
category = "Inventory",
|
|
||||||
run = "dropperController.lua",
|
|
||||||
},
|
},
|
||||||
[ "im_web_bridge" ] = {
|
[ "im_web_bridge" ] = {
|
||||||
title = "Inventory Web Bridge",
|
title = "Inv Web Bridge",
|
||||||
category = "Inventory",
|
category = "Inventory",
|
||||||
run = "inventoryWebBridge.lua",
|
run = "inventoryWebBridge.lua",
|
||||||
},
|
},
|
||||||
[ "im_list_devices" ] = {
|
[ "im_dropper" ] = {
|
||||||
title = "List Devices",
|
title = "Dropper Ctrl",
|
||||||
category = "Inventory",
|
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 _baseDir = fs.getDir(shell.getRunningProgram())
|
||||||
local function _path(rel) return fs.combine(_baseDir, rel) end
|
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
|
-- 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 log = dofile(_path("lib/log.lua"))
|
||||||
local ui = dofile(_path("lib/ui.lua"))
|
local ui = dofile(_path("lib/ui.lua"))
|
||||||
local itemDB = dofile(_path("lib/itemDB.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)
|
-- Load modules (factory pattern → shared context)
|
||||||
@@ -117,11 +148,12 @@ local function broadcastState()
|
|||||||
craftTurtleOk = ctx.craftTurtleName and peripheral.isPresent(ctx.craftTurtleName),
|
craftTurtleOk = ctx.craftTurtleName and peripheral.isPresent(ctx.craftTurtleName),
|
||||||
}
|
}
|
||||||
|
|
||||||
if state.configDirty then
|
-- Keep ctx in sync so display.lua can check ctx.craftTurtleOk directly
|
||||||
payload.smeltable = cfg.SMELTABLE
|
ctx.craftTurtleOk = payload.craftTurtleOk
|
||||||
payload.craftable = cfg.CRAFTABLE
|
|
||||||
state.configDirty = false
|
payload.smeltable = cfg.SMELTABLE
|
||||||
end
|
payload.craftable = cfg.CRAFTABLE
|
||||||
|
state.configDirty = false
|
||||||
|
|
||||||
ctx.networkModem.transmit(cfg.BROADCAST_CHANNEL, cfg.ORDER_CHANNEL, payload)
|
ctx.networkModem.transmit(cfg.BROADCAST_CHANNEL, cfg.ORDER_CHANNEL, payload)
|
||||||
state.lastBroadcastVersion = state.stateVersion
|
state.lastBroadcastVersion = state.stateVersion
|
||||||
@@ -163,24 +195,39 @@ local function main()
|
|||||||
log.warn("INIT", "No smelter monitor on %s", cfg.SMELTER_MONITOR_SIDE)
|
log.warn("INIT", "No smelter monitor on %s", cfg.SMELTER_MONITOR_SIDE)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Find modem for client communication
|
-- Find wired modem for client/turtle communication
|
||||||
for _, name in ipairs(peripheral.getNames()) do
|
for _, name in ipairs(peripheral.getNames()) do
|
||||||
if peripheral.getType(name) == "modem" then
|
if peripheral.getType(name) == "modem" then
|
||||||
ctx.networkModem = peripheral.wrap(name)
|
local m = peripheral.wrap(name)
|
||||||
ctx.networkModemName = name
|
-- Prefer wired modem (has getNameLocal); skip wireless
|
||||||
ctx.networkModem.open(cfg.ORDER_CHANNEL)
|
if m.isWireless and not m.isWireless() then
|
||||||
ctx.networkModem.open(cfg.CRAFT_REPLY_CHANNEL)
|
ctx.networkModem = m
|
||||||
ctx.networkModem.open(cfg.SYSTEM_CHANNEL)
|
ctx.networkModemName = name
|
||||||
break
|
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
|
||||||
end
|
end
|
||||||
if ctx.networkModem then
|
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
|
else
|
||||||
log.warn("INIT", "No modem found for client sync")
|
log.warn("INIT", "No modem found for client sync")
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Detect crafting turtle on network
|
-- 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
|
for _, name in ipairs(peripheral.getNames()) do
|
||||||
if name:match("^turtle_") then
|
if name:match("^turtle_") then
|
||||||
ctx.craftTurtleName = name
|
ctx.craftTurtleName = name
|
||||||
@@ -291,6 +338,11 @@ local function main()
|
|||||||
end
|
end
|
||||||
|
|
||||||
ops.refreshCache(drawBoot)
|
ops.refreshCache(drawBoot)
|
||||||
|
|
||||||
|
-- Destroy boot window so it doesn't intercept future monitor writes
|
||||||
|
buf.setVisible(true)
|
||||||
|
buf.clear()
|
||||||
|
buf = nil
|
||||||
else
|
else
|
||||||
ops.refreshCache()
|
ops.refreshCache()
|
||||||
end
|
end
|
||||||
@@ -392,7 +444,8 @@ local function main()
|
|||||||
while true do
|
while true do
|
||||||
if state.needsRedraw then
|
if state.needsRedraw then
|
||||||
state.needsRedraw = false
|
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
|
end
|
||||||
if state.statusTimer > 0 then
|
if state.statusTimer > 0 then
|
||||||
state.statusTimer = state.statusTimer - 0.1
|
state.statusTimer = state.statusTimer - 0.1
|
||||||
@@ -414,7 +467,8 @@ local function main()
|
|||||||
while true do
|
while true do
|
||||||
if state.smelterNeedsRedraw then
|
if state.smelterNeedsRedraw then
|
||||||
state.smelterNeedsRedraw = false
|
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
|
end
|
||||||
sleep(0.1)
|
sleep(0.1)
|
||||||
end
|
end
|
||||||
@@ -452,13 +506,31 @@ local function main()
|
|||||||
ops.invalidateWrapCache(name)
|
ops.invalidateWrapCache(name)
|
||||||
ops.invalidatePeripheralCaches()
|
ops.invalidatePeripheralCaches()
|
||||||
log.info("DETACH", "%s", name)
|
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
|
end
|
||||||
end,
|
end,
|
||||||
|
|
||||||
-- Task 12: Supply chest (builder / manifest-based stocking)
|
-- Task 12: Supply chest (builder / manifest-based stocking)
|
||||||
function()
|
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)
|
log.info("SUPPLY", "Stocking %s with %d item types", cfg.SUPPLY_CHEST, #cfg.SUPPLY_MANIFEST)
|
||||||
while true do
|
while true do
|
||||||
pcall(ops.supplyChest)
|
pcall(ops.supplyChest)
|
||||||
@@ -468,13 +540,24 @@ local function main()
|
|||||||
|
|
||||||
-- Task 13: Network order/command listener
|
-- Task 13: Network order/command listener
|
||||||
function()
|
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
|
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")
|
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 channel == cfg.ORDER_CHANNEL and type(message) == "table" then
|
||||||
if isCommandDuplicate(message.commandId) then
|
if isCommandDuplicate(message.commandId) then
|
||||||
log.debug("NET", "Duplicate command skipped: %s", tostring(message.commandId))
|
log.debug("NET", "Duplicate command skipped: %s", tostring(message.commandId))
|
||||||
else
|
else
|
||||||
|
cmdCount = cmdCount + 1
|
||||||
|
log.info("NET", "Command #%d accepted: type=%s cmdId=%s", cmdCount, tostring(message.type), tostring(message.commandId))
|
||||||
recordCommandId(message.commandId)
|
recordCommandId(message.commandId)
|
||||||
cleanupCommandIds()
|
cleanupCommandIds()
|
||||||
local handlerOk, handlerErr = pcall(function()
|
local handlerOk, handlerErr = pcall(function()
|
||||||
@@ -634,6 +717,21 @@ local function main()
|
|||||||
state.needsRedraw = true
|
state.needsRedraw = true
|
||||||
pcall(broadcastState)
|
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
|
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.recipeBook.learnCraftingRecipe(message.output, message.count, message.grid)
|
||||||
cfg.refreshRecipes()
|
cfg.refreshRecipes()
|
||||||
@@ -663,13 +761,54 @@ local function main()
|
|||||||
state.bumpStateVersion()
|
state.bumpStateVersion()
|
||||||
end
|
end
|
||||||
pcall(broadcastState)
|
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
|
||||||
|
|
||||||
end) -- pcall handler
|
end) -- pcall handler
|
||||||
if not handlerOk then
|
if not handlerOk then
|
||||||
log.error("NET", "Handler error: %s", tostring(handlerErr))
|
log.error("NET", "Handler error: %s", tostring(handlerErr))
|
||||||
|
else
|
||||||
|
log.info("NET", "Command #%d handled OK", cmdCount)
|
||||||
end
|
end
|
||||||
end -- idempotency else
|
end -- idempotency else
|
||||||
|
else
|
||||||
|
log.info("NET", "Ignored: ch=%d (want %d) or not table", channel or -1, cfg.ORDER_CHANNEL)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -677,3 +816,15 @@ local function main()
|
|||||||
end
|
end
|
||||||
|
|
||||||
main()
|
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 _baseDir = fs.getDir(shell.getRunningProgram())
|
||||||
local function _path(rel) return fs.combine(_baseDir, rel) end
|
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 API_KEY = nil -- optional API key for server auth
|
||||||
|
|
||||||
local function loadConfig()
|
local function loadConfig()
|
||||||
|
|||||||
@@ -6,6 +6,16 @@ return function(log, _path)
|
|||||||
-- Fall back to CWD-relative if _path not provided (standalone use)
|
-- Fall back to CWD-relative if _path not provided (standalone use)
|
||||||
if not _path then _path = function(p) return p end end
|
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 = {}
|
local C = {}
|
||||||
|
|
||||||
-------------------------------------------------
|
-------------------------------------------------
|
||||||
@@ -22,9 +32,9 @@ C.SMELT_RESERVE = 128
|
|||||||
C.DEFRAG_INTERVAL = 600
|
C.DEFRAG_INTERVAL = 600
|
||||||
C.COMPOST_INTERVAL = 3
|
C.COMPOST_INTERVAL = 3
|
||||||
C.ALERT_INTERVAL = 15
|
C.ALERT_INTERVAL = 15
|
||||||
C.CACHE_FILE = _path(".inventory_cache")
|
C.CACHE_FILE = _configPath(".inventory_cache")
|
||||||
C.SMELTER_MONITOR_SIDE = "top"
|
C.SMELTER_MONITOR_SIDE = "top"
|
||||||
C.DISABLED_RECIPES_FILE = _path(".disabled_recipes")
|
C.DISABLED_RECIPES_FILE = _configPath(".disabled_recipes")
|
||||||
|
|
||||||
-- Network
|
-- Network
|
||||||
C.BROADCAST_CHANNEL = 4200
|
C.BROADCAST_CHANNEL = 4200
|
||||||
@@ -71,7 +81,7 @@ C.SLOT_OUTPUT = 3
|
|||||||
-- Config file loader
|
-- Config file loader
|
||||||
-------------------------------------------------
|
-------------------------------------------------
|
||||||
|
|
||||||
local CONFIG_FILE = _path(".manager_config")
|
local CONFIG_FILE = _configPath(".manager_config")
|
||||||
|
|
||||||
function C.loadConfig()
|
function C.loadConfig()
|
||||||
if not fs.exists(CONFIG_FILE) then return end
|
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
|
-- Recipe book: merges built-in recipes + user-learned recipes
|
||||||
local recipeBook = dofile(_path("lib/recipeBook.lua"))
|
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.loadLegacyCrafting(dofile(_path("data/craftable.lua")))
|
||||||
recipeBook.loadLegacySmelting(dofile(_path("data/smeltable.lua")))
|
recipeBook.loadLegacySmelting(dofile(_path("data/smeltable.lua")))
|
||||||
C.recipeBook = recipeBook
|
C.recipeBook = recipeBook
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ local smelterPage = nil
|
|||||||
local selectedAmount = 1
|
local selectedAmount = 1
|
||||||
local amountOptions = {1, 4, 8, 16, 32, 64}
|
local amountOptions = {1, 4, 8, 16, 32, 64}
|
||||||
local searchQuery = ""
|
local searchQuery = ""
|
||||||
|
local showKeyboard = false
|
||||||
|
local orderPopupItem = nil
|
||||||
|
local orderPopupShort = nil
|
||||||
|
local orderPopupQty = 1
|
||||||
local smelterView = "status"
|
local smelterView = "status"
|
||||||
|
|
||||||
-------------------------------------------------
|
-------------------------------------------------
|
||||||
@@ -263,17 +267,46 @@ local function buildMainPage()
|
|||||||
searchRow = UI.Window {
|
searchRow = UI.Window {
|
||||||
x = 1, y = 6, ex = -1, height = 1,
|
x = 1, y = 6, ex = -1, height = 1,
|
||||||
backgroundColor = colors.black,
|
backgroundColor = colors.black,
|
||||||
},
|
draw = function(self)
|
||||||
|
self:clear(colors.black)
|
||||||
searchEntry = UI.TextEntry {
|
-- Keyboard toggle button
|
||||||
x = 3, y = 6,
|
local kbLabel = showKeyboard and " X " or " ? "
|
||||||
ex = '45%',
|
local kbBg = showKeyboard and colors.red or colors.purple
|
||||||
shadowText = "search...",
|
self:write(1, 1, kbLabel, kbBg, colors.white)
|
||||||
backgroundColor = colors.black,
|
-- Search query display
|
||||||
backgroundFocusColor = colors.gray,
|
local fieldW = math.floor(self.width * 0.4)
|
||||||
textColor = colors.white,
|
if fieldW < 10 then fieldW = 10 end
|
||||||
shadowTextColor = colors.gray,
|
local queryDisplay = searchQuery
|
||||||
limit = 30,
|
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 {
|
refreshBtn = UI.Button {
|
||||||
@@ -383,15 +416,229 @@ local function buildMainPage()
|
|||||||
end,
|
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 overlay
|
||||||
notification = UI.Notification {
|
notification = UI.Notification {
|
||||||
anchor = 'bottom',
|
anchor = 'bottom',
|
||||||
},
|
},
|
||||||
|
|
||||||
eventHandler = function(self, event)
|
eventHandler = function(self, event)
|
||||||
if event.type == 'text_change' then
|
if event.type == 'kb_key' then
|
||||||
searchQuery = event.text or ""
|
if #searchQuery < 30 then
|
||||||
|
searchQuery = searchQuery .. event.data
|
||||||
|
end
|
||||||
D.refreshItemGrid()
|
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.alertBar:draw()
|
||||||
self.footerBar:draw()
|
self.footerBar:draw()
|
||||||
self.bottomBar:draw()
|
self.bottomBar:draw()
|
||||||
@@ -401,14 +648,55 @@ local function buildMainPage()
|
|||||||
elseif event.type == 'grid_select' then
|
elseif event.type == 'grid_select' then
|
||||||
local row = event.selected
|
local row = event.selected
|
||||||
if row and row.name then
|
if row and row.name then
|
||||||
local short = shortName(row.name)
|
-- Hide keyboard if showing
|
||||||
state.statusMessage = string.format("Ordering %s x%d...", short, selectedAmount)
|
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.statusColor = colors.cyan
|
||||||
state.statusTimer = 10
|
state.statusTimer = 10
|
||||||
activity.dispensing = true
|
activity.dispensing = true
|
||||||
state.needsRedraw = true
|
state.needsRedraw = true
|
||||||
ops.orderItem(row.name, selectedAmount)
|
ops.orderItem(orderPopupItem, orderPopupQty)
|
||||||
end
|
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
|
return true
|
||||||
|
|
||||||
elseif event.type == 'amount_select' then
|
elseif event.type == 'amount_select' then
|
||||||
@@ -446,10 +734,13 @@ local function buildMainPage()
|
|||||||
btnX = btnX + #tostring(amt) + 4
|
btnX = btnX + #tostring(amt) + 4
|
||||||
end
|
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
|
mainDevice.currentPage = mainPage
|
||||||
mainPage.parent = mainDevice
|
mainPage.parent = mainDevice
|
||||||
|
mainPage:resize()
|
||||||
mainPage:setParent()
|
mainPage:setParent()
|
||||||
|
mainPage:enable()
|
||||||
end
|
end
|
||||||
|
|
||||||
function D.updateAmountButtons()
|
function D.updateAmountButtons()
|
||||||
@@ -567,6 +858,25 @@ local function buildSmelterPage()
|
|||||||
selectedBackgroundColor = colors.purple,
|
selectedBackgroundColor = colors.purple,
|
||||||
unselectedBackgroundColor = colors.gray,
|
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
|
-- Status tab
|
||||||
statusTab = UI.Tab {
|
statusTab = UI.Tab {
|
||||||
index = 1,
|
index = 1,
|
||||||
@@ -667,8 +977,9 @@ local function buildSmelterPage()
|
|||||||
turtleStatus = UI.Window {
|
turtleStatus = UI.Window {
|
||||||
x = -14, y = 0, width = 14, height = 1,
|
x = -14, y = 0, width = 14, height = 1,
|
||||||
draw = function(self)
|
draw = function(self)
|
||||||
local turtleOk = ctx.craftTurtleName
|
local turtleOk = ctx.craftTurtleOk
|
||||||
and peripheral.isPresent(ctx.craftTurtleName)
|
or (ctx.craftTurtleName
|
||||||
|
and peripheral.isPresent(ctx.craftTurtleName))
|
||||||
local label = turtleOk and " Turtle OK " or " No Turtle "
|
local label = turtleOk and " Turtle OK " or " No Turtle "
|
||||||
local bg = turtleOk and colors.lime or colors.red
|
local bg = turtleOk and colors.lime or colors.red
|
||||||
local fg = turtleOk and colors.black or colors.white
|
local fg = turtleOk and colors.black or colors.white
|
||||||
@@ -802,17 +1113,7 @@ local function buildSmelterPage()
|
|||||||
},
|
},
|
||||||
|
|
||||||
eventHandler = function(self, event)
|
eventHandler = function(self, event)
|
||||||
if event.type == 'tab_change' then
|
if event.type == 'enable_all' 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
|
|
||||||
state.disabledRecipes = {}
|
state.disabledRecipes = {}
|
||||||
log.debug("UI", "All recipes enabled")
|
log.debug("UI", "All recipes enabled")
|
||||||
ops.saveDisabledRecipes()
|
ops.saveDisabledRecipes()
|
||||||
@@ -849,9 +1150,22 @@ local function buildSmelterPage()
|
|||||||
local recipeIdx = event.selected.idx
|
local recipeIdx = event.selected.idx
|
||||||
local recipe = cfg.CRAFTABLE[recipeIdx]
|
local recipe = cfg.CRAFTABLE[recipeIdx]
|
||||||
if recipe then
|
if recipe then
|
||||||
local turtleOk = ctx.craftTurtleName
|
-- Re-scan for turtle if none known
|
||||||
and peripheral.isPresent(ctx.craftTurtleName)
|
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
|
if not turtleOk then
|
||||||
|
log.warn("CRAFT", "No crafting turtle! (name=%s)",
|
||||||
|
tostring(ctx.craftTurtleName))
|
||||||
self.notification:error("No crafting turtle!")
|
self.notification:error("No crafting turtle!")
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
@@ -885,7 +1199,9 @@ local function buildSmelterPage()
|
|||||||
-- Attach to device
|
-- Attach to device
|
||||||
smelterDevice.currentPage = smelterPage
|
smelterDevice.currentPage = smelterPage
|
||||||
smelterPage.parent = smelterDevice
|
smelterPage.parent = smelterDevice
|
||||||
|
smelterPage:resize()
|
||||||
smelterPage:setParent()
|
smelterPage:setParent()
|
||||||
|
smelterPage:enable()
|
||||||
end
|
end
|
||||||
|
|
||||||
-------------------------------------------------
|
-------------------------------------------------
|
||||||
@@ -939,22 +1255,24 @@ function D.refreshSmelterData()
|
|||||||
end
|
end
|
||||||
smelterPage.tabs.statusTab.grid:setValues(statusValues)
|
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 = {}
|
local recipeList = {}
|
||||||
for inputName, recipe in pairs(cfg.SMELTABLE) do
|
for inputName, recipe in pairs(cfg.SMELTABLE) do
|
||||||
local short = shortName(inputName)
|
local short = shortName(inputName)
|
||||||
local resultShort = shortName(recipe.result)
|
local resultShort = shortName(recipe.result)
|
||||||
local types = ""
|
local types = ""
|
||||||
if recipe.furnaceSet["minecraft:furnace"] then types = types .. "F" end
|
local fSet = recipe.furnaceSet or {}
|
||||||
if recipe.furnaceSet["minecraft:smoker"] then types = types .. "S" end
|
if fSet["minecraft:furnace"] then types = types .. "F" end
|
||||||
if recipe.furnaceSet["minecraft:blast_furnace"] then types = types .. "B" 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 enabled = not state.disabledRecipes[inputName]
|
||||||
local inStorage = 0
|
local inStorage = stockLookup[inputName] or 0
|
||||||
if cache.catalogue[inputName] then
|
|
||||||
for _, s in ipairs(cache.catalogue[inputName]) do
|
|
||||||
inStorage = inStorage + s.total
|
|
||||||
end
|
|
||||||
end
|
|
||||||
table.insert(recipeList, {
|
table.insert(recipeList, {
|
||||||
inputName = inputName,
|
inputName = inputName,
|
||||||
inputShort = short,
|
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"
|
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)
|
-- Files to download (destination -> repo path)
|
||||||
local FILES = {
|
local FILES = {
|
||||||
["inventoryClient.lua"] = "inventoryClient.lua",
|
["inventoryClient.lua"] = "inventoryClient.lua",
|
||||||
@@ -65,8 +72,103 @@ else
|
|||||||
print(string.format("All %d files up to date.", updated))
|
print(string.format("All %d files up to date.", updated))
|
||||||
end
|
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("")
|
||||||
print("Starting inventoryClient + dropperController...")
|
if dropperEnabled then
|
||||||
|
print("Starting inventoryClient + dropperController...")
|
||||||
|
else
|
||||||
|
print("Starting inventoryClient (no dropper)...")
|
||||||
|
end
|
||||||
sleep(1)
|
sleep(1)
|
||||||
|
|
||||||
-- Reboot listener: reboots this computer on remote command
|
-- Reboot listener: reboots this computer on remote command
|
||||||
@@ -90,8 +192,12 @@ local function rebootListener()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
parallel.waitForAny(
|
local tasks = {
|
||||||
function() shell.run("inventoryClient.lua") end,
|
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
|
# Stage 1: Build the React app
|
||||||
FROM node:18-alpine AS build
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function App() {
|
|||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
const [, forceRender] = useState(0);
|
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(() => {
|
useEffect(() => {
|
||||||
connect();
|
connect();
|
||||||
|
|||||||
@@ -9,12 +9,6 @@ services:
|
|||||||
- API_KEY=${API_KEY:-}
|
- API_KEY=${API_KEY:-}
|
||||||
- TURTLE_SERVER_URL=${TURTLE_SERVER_URL:-}
|
- TURTLE_SERVER_URL=${TURTLE_SERVER_URL:-}
|
||||||
restart: unless-stopped
|
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:
|
client:
|
||||||
build:
|
build:
|
||||||
@@ -27,7 +21,7 @@ services:
|
|||||||
- inventory-network
|
- inventory-network
|
||||||
depends_on:
|
depends_on:
|
||||||
server:
|
server:
|
||||||
condition: service_started
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
# Node.js backend
|
# Node.js backend
|
||||||
FROM node:18-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
# Build tools needed for better-sqlite3 native compilation
|
# 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
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -11,20 +13,23 @@ COPY package*.json ./
|
|||||||
RUN npm install --omit=dev
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
# Remove build tools after install to keep image small
|
# Remove build tools after install to keep image small
|
||||||
|
# libstdc++ and su-exec are kept for runtime
|
||||||
RUN apk del python3 make g++
|
RUN apk del python3 make g++
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Create data directory for SQLite with proper ownership
|
# Create data directory for SQLite
|
||||||
RUN mkdir -p /data && chown node:node /data
|
RUN mkdir -p /data
|
||||||
VOLUME /data
|
VOLUME /data
|
||||||
|
|
||||||
# Run as non-root user for security
|
# Entrypoint fixes /data permissions then drops to node user
|
||||||
USER node
|
COPY docker-entrypoint.sh /usr/local/bin/
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --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))"
|
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"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
@@ -1,16 +1,32 @@
|
|||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import { existsSync, mkdirSync } from 'fs';
|
import { existsSync, mkdirSync, accessSync, constants as fsConstants } from 'fs';
|
||||||
import { dirname } from 'path';
|
import { dirname } from 'path';
|
||||||
|
|
||||||
const DB_PATH = process.env.DB_PATH || '/data/inventory.db';
|
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
|
// Ensure the directory exists
|
||||||
const dbDir = dirname(DB_PATH);
|
const dbDir = dirname(DB_PATH);
|
||||||
if (!existsSync(dbDir)) {
|
if (!existsSync(dbDir)) {
|
||||||
mkdirSync(dbDir, { recursive: true });
|
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
|
// Performance pragmas
|
||||||
db.pragma('journal_mode = WAL');
|
db.pragma('journal_mode = WAL');
|
||||||
@@ -19,6 +35,8 @@ db.pragma('foreign_keys = ON');
|
|||||||
db.pragma('cache_size = -8000'); // 8MB cache
|
db.pragma('cache_size = -8000'); // 8MB cache
|
||||||
db.pragma('temp_store = MEMORY');
|
db.pragma('temp_store = MEMORY');
|
||||||
|
|
||||||
|
console.log('[db] Database ready');
|
||||||
|
|
||||||
// ========== Schema ==========
|
// ========== Schema ==========
|
||||||
|
|
||||||
db.exec(`
|
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