Compare commits

...

10 Commits

6 changed files with 119 additions and 45 deletions

View File

@@ -26,6 +26,13 @@ local DROPPER_ANNOUNCE_INTERVAL = 30 -- seconds between dropper announcements
local CLIENT_CONFIG_FILE = ".client_config"
-------------------------------------------------
-- Shared UI helpers (loaded early for config use)
-------------------------------------------------
local ui = dofile("lib/ui.lua")
local log = dofile("lib/log.lua")
local function loadConfig()
if not fs.exists(CLIENT_CONFIG_FILE) then return end
local f = fs.open(CLIENT_CONFIG_FILE, "r")
@@ -89,13 +96,6 @@ local CRAFTABLE = {} -- populated from master broadcast
local craftTurtleOk = false
local connected = false -- true once first state received
-------------------------------------------------
-- Shared UI helpers
-------------------------------------------------
local ui = dofile("lib/ui.lua")
local log = dofile("lib/log.lua")
-------------------------------------------------
-- UI State (local to client)
-------------------------------------------------
@@ -229,12 +229,25 @@ local function monCenter(y, text, fg, bg) ui.monCenter(y, text, fg, bg) end
local function monBar(x, y, w, r, bc, bgc) ui.monBar(x, y, w, r, bc, bgc) end
local function drawButton(x, y, t, fg, bg, pl, pr) return ui.drawButton(x, y, t, fg, bg, pl, pr) end
-------------------------------------------------
-- Command ID generation (idempotency)
-------------------------------------------------
local cmdCounter = 0
local function newCommandId()
cmdCounter = cmdCounter + 1
return string.format("client_%d_%d_%d", os.getComputerID(), os.epoch("utc"), cmdCounter)
end
-------------------------------------------------
-- Send command to master
-------------------------------------------------
local function sendToMaster(message)
if not networkModem then return end
if not message.commandId then
message.commandId = newCommandId()
end
networkModem.transmit(ORDER_CHANNEL, CLIENT_CHANNEL, message)
end
@@ -1310,7 +1323,7 @@ local function main()
elseif channel == CLIENT_CHANNEL and type(message) == "table" and message.type == "craft_result" then
-- Craft result from master
statusMessage = message.message or ""
statusMessage = message.error or (message.success and "Craft complete" or "Craft failed")
statusColor = message.success and colors.lime or colors.red
statusTimer = 5
smelterNeedsRedraw = true

View File

@@ -1816,7 +1816,7 @@ local function autoSmelt()
if smeltingPaused then return false end
local furnaces = getFurnaces()
if #furnaces == 0 then return end
if #furnaces == 0 then return false end
local chests = getChests()
local catalogue = cache.catalogue
@@ -1984,7 +1984,10 @@ local function autoSmelt()
local remaining = toLoad
local loaded = false
for _, source in ipairs(catalogue[itemName]) do
-- Snapshot: adjustCache may remove entries during iteration
local srcSnapshot = { table.unpack(catalogue[itemName]) }
for _, source in ipairs(srcSnapshot) do
if source.total > 0 then
local chest = wrapCached(source.chest)
if chest then
for slot, slotItem in pairs(chest.list()) do
@@ -2006,6 +2009,7 @@ local function autoSmelt()
end
end
end
end -- source.total > 0
if loaded then break end
end
@@ -2039,7 +2043,6 @@ local function defragInventory()
local inv = wrapCached(chestName)
if inv then
local contents = inv.list()
local detail_cache = {}
for slot, item in pairs(contents) do
if not itemSlots[item.name] then
itemSlots[item.name] = {}
@@ -2090,6 +2093,11 @@ local function defragInventory()
donor.count = donor.count - n
recv.count = recv.count + n
totalMerged = totalMerged + n
-- Update catalogue for cross-chest moves
if donor.chest ~= recv.chest then
adjustCache(itemName, donor.chest, -n)
adjustCache(itemName, recv.chest, n)
end
end
end
if donor.count <= 0 then i = i + 1 end
@@ -2149,7 +2157,6 @@ local function autoCompost()
end
end
local dropperSize = dropper.size()
local dropperFreeSlots = dropperSize - dropperUsedSlots
local dropperFreeItems = (dropperSize * 64) - dropperUsedItems
if dropperFreeItems <= 0 then return didWork end
@@ -2168,7 +2175,10 @@ local function autoCompost()
if available > 0 then
local toFeed = math.min(available, dropperFreeItems)
local fed = 0
for _, source in ipairs(catalogue[itemName]) do
-- Snapshot: adjustCache may remove entries during iteration
local srcSnapshot = { table.unpack(catalogue[itemName]) }
for _, source in ipairs(srcSnapshot) do
if source.total > 0 then
local chest = wrapCached(source.chest)
if chest then
for slot, slotItem in pairs(chest.list()) do
@@ -2186,6 +2196,7 @@ local function autoCompost()
end
end
end
end -- source.total > 0
if fed >= toFeed then break end
end
dropperFreeItems = dropperFreeItems - fed
@@ -2257,7 +2268,10 @@ local function orderItem(itemName, amount, dropperOverride)
end
local remaining = amount
for _, entry in ipairs(catalogue[itemName]) do
-- Snapshot: adjustCache may remove entries during iteration
local srcSnapshot = { table.unpack(catalogue[itemName]) }
for _, entry in ipairs(srcSnapshot) do
if entry.total > 0 then
local chest = wrapCached(entry.chest)
if chest then
for slot, slotItem in pairs(chest.list()) do
@@ -2275,6 +2289,7 @@ local function orderItem(itemName, amount, dropperOverride)
end
end
end
end -- entry.total > 0
if remaining <= 0 then break end
end
@@ -2851,6 +2866,7 @@ local function main()
statusMessage = "Order error"
statusColor = colors.red
statusTimer = 5
needsRedraw = true
end
pcall(function()
networkModem.transmit(replyChannel, ORDER_CHANNEL, {
@@ -2937,6 +2953,7 @@ local function main()
end
end
cache.droppers = merged
bumpStateVersion()
pcall(broadcastState)
elseif message.type == "craft" and message.recipeIdx then
log.info("NET", "Craft request: recipe #%d", message.recipeIdx)

View File

@@ -22,6 +22,7 @@ local ORDER_CHANNEL = 4201
-------------------------------------------------
local CONFIG_FILE = ".webbridge_config"
local API_KEY = nil -- optional API key for server auth
local function loadConfig()
if fs.exists(CONFIG_FILE) then
@@ -47,7 +48,6 @@ local latestState = nil -- last broadcast from master
local modem = nil
local modemName = nil
local running = true
local API_KEY = nil -- optional API key for server auth
-------------------------------------------------
-- Find modem
@@ -75,7 +75,7 @@ local function httpPost(path, body)
local headers = { ["Content-Type"] = "application/json" }
if API_KEY then headers["Authorization"] = "Bearer " .. API_KEY end
local ok, err = pcall(function()
local ok, result = pcall(function()
local response = http.post(url, data, headers)
if response then
local responseData = response.readAll()
@@ -84,10 +84,10 @@ local function httpPost(path, body)
end
end)
if not ok then
-- Silent fail, will retry
return nil
if ok then
return result
end
return nil
end
local function httpGet(path)

View File

@@ -29,6 +29,7 @@ function newCommandId() {
let _httpPollTimer = null;
let _wsHealthTimer = null;
let _lastWsMessage = 0;
let _reconnectDelay = 1000;
const HTTP_POLL_INTERVAL = 10_000; // Poll HTTP every 10 s as fallback
const WS_STALE_TIMEOUT = 35_000; // If no WS message for 35 s → reconnect
@@ -45,7 +46,7 @@ function _applyStateData(data, current) {
craftTurtleOk: data.craftTurtleOk !== undefined ? data.craftTurtleOk : current.craftTurtleOk,
bridgeConnected: data.bridgeConnected !== undefined ? data.bridgeConnected : current.bridgeConnected,
dropperNicknames: data.dropperNicknames || current.dropperNicknames,
lastUpdate: data.lastUpdate || Date.now(),
lastUpdate: data.lastUpdate != null ? data.lastUpdate : Date.now(),
};
}
@@ -140,6 +141,7 @@ export const useInventoryStore = create((set, get) => ({
ws.onopen = () => {
console.log('✅ Connected to server');
_lastWsMessage = Date.now();
_reconnectDelay = 1000;
set({ connected: true, ws });
};
@@ -170,9 +172,11 @@ export const useInventoryStore = create((set, get) => ({
ws.onclose = () => {
console.log('❌ Disconnected from server');
set({ connected: false, ws: null });
const delay = _reconnectDelay;
_reconnectDelay = Math.min(_reconnectDelay * 2, 30000);
setTimeout(() => {
get().connect();
}, 3000);
}, delay);
};
ws.onerror = (error) => {
@@ -253,25 +257,29 @@ export const useInventoryStore = create((set, get) => ({
enableAllRecipes: async () => {
try {
await fetch(`${API_URL}/recipes/enable-all`, {
const response = await fetch(`${API_URL}/recipes/enable-all`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ commandId: newCommandId() }),
});
return await response.json();
} catch (error) {
console.error('❌ Error enabling all recipes:', error);
return { success: false, error: error.message };
}
},
disableAllRecipes: async () => {
try {
await fetch(`${API_URL}/recipes/disable-all`, {
const response = await fetch(`${API_URL}/recipes/disable-all`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ commandId: newCommandId() }),
});
return await response.json();
} catch (error) {
console.error('❌ Error disabling all recipes:', error);
return { success: false, error: error.message };
}
},
@@ -309,7 +317,7 @@ export const useInventoryStore = create((set, get) => ({
const response = await fetch(`${API_URL}/dropper-nicknames`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ dropperName, nickname }),
body: JSON.stringify({ dropperName, nickname, commandId: newCommandId() }),
});
const result = await response.json();
if (result.nicknames) {

View File

@@ -475,7 +475,9 @@ export function flushPendingSave() {
*/
export function closeDb() {
flushPendingSave();
db.close();
if (db.open) {
db.close();
}
}
export default db;

View File

@@ -107,7 +107,11 @@ function broadcastToClients(data) {
const message = JSON.stringify(data);
webClients.forEach((client) => {
if (client.readyState === 1) {
client.send(message);
try {
client.send(message);
} catch (err) {
console.error('❌ WS send error (client):', err.message);
}
}
});
}
@@ -116,8 +120,12 @@ function pushCommandToBridge(command) {
let sent = false;
for (const bridge of bridgeClients) {
if (bridge.readyState === 1) {
bridge.send(JSON.stringify(command));
sent = true;
try {
bridge.send(JSON.stringify(command));
sent = true;
} catch (err) {
console.error('❌ WS send error (bridge):', err.message);
}
}
}
if (!sent) {
@@ -352,7 +360,11 @@ app.get('/api/dropper-nicknames', (req, res) => {
// Set a single dropper nickname
app.post('/api/dropper-nicknames', (req, res) => {
try {
const { dropperName, nickname } = req.body;
const { dropperName, nickname, commandId } = req.body;
const cached = checkIdempotent(commandId);
if (cached) return res.json(cached);
if (!dropperName) return res.status(400).json({ error: 'Missing dropperName' });
if (nickname && nickname.trim()) {
@@ -363,7 +375,9 @@ app.post('/api/dropper-nicknames', (req, res) => {
saveState('dropperNicknames', dropperNicknames);
console.log(`🏷️ Dropper nickname: ${dropperName}${nickname || '(removed)'}`);
res.json({ success: true, nicknames: dropperNicknames });
const result = { success: true, nicknames: dropperNicknames };
recordCommand(commandId, result);
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
@@ -427,25 +441,33 @@ app.post('/api/recipes/toggle', (req, res) => {
// Enable/disable all recipes
app.post('/api/recipes/enable-all', (req, res) => {
const { commandId } = req.body || {};
const cached = checkIdempotent(commandId);
if (cached) return res.json(cached);
try {
const { commandId } = req.body || {};
const cached = checkIdempotent(commandId);
if (cached) return res.json(cached);
pushCommandToBridge({ type: 'command', action: 'enable_all', commandId });
const result = { success: true, commandId };
recordCommand(commandId, result);
res.json(result);
pushCommandToBridge({ type: 'command', action: 'enable_all', commandId });
const result = { success: true, commandId };
recordCommand(commandId, result);
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.post('/api/recipes/disable-all', (req, res) => {
const { commandId } = req.body || {};
const cached = checkIdempotent(commandId);
if (cached) return res.json(cached);
try {
const { commandId } = req.body || {};
const cached = checkIdempotent(commandId);
if (cached) return res.json(cached);
pushCommandToBridge({ type: 'command', action: 'disable_all', commandId });
const result = { success: true, commandId };
recordCommand(commandId, result);
res.json(result);
pushCommandToBridge({ type: 'command', action: 'disable_all', commandId });
const result = { success: true, commandId };
recordCommand(commandId, result);
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Sort barrel
@@ -662,6 +684,7 @@ function updateStateFromBridge(data) {
smeltable: smeltableRecipes,
craftable: craftableRecipes,
craftTurtleOk,
dropperNicknames,
lastUpdate,
bridgeConnected: bridgeClients.size > 0,
});
@@ -865,13 +888,24 @@ server.listen(PORT, HOST, () => {
function shutdown() {
console.log('\n🛑 Shutting down server...');
try {
wss.close();
server.close();
closeDb();
console.log('💾 Database closed');
} catch (err) {
console.error('❌ Error closing database:', err.message);
console.error('❌ Error during shutdown:', err.message);
}
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
// Catch unhandled errors to prevent silent crashes
process.on('unhandledRejection', (reason) => {
console.error('❌ Unhandled rejection:', reason);
});
process.on('uncaughtException', (err) => {
console.error('❌ Uncaught exception:', err);
shutdown();
});