Compare commits
10 Commits
5162a71be4
...
8c02dc15c0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c02dc15c0 | ||
|
|
4045def0a7 | ||
|
|
460cf34252 | ||
|
|
ea75c1eabc | ||
|
|
0fb57d7c94 | ||
|
|
edcc19e5a4 | ||
|
|
8ad05cdeb9 | ||
|
|
e7e605ea00 | ||
|
|
6fa7ae5e8f | ||
|
|
b3c3faa06d |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -475,7 +475,9 @@ export function flushPendingSave() {
|
||||
*/
|
||||
export function closeDb() {
|
||||
flushPendingSave();
|
||||
db.close();
|
||||
if (db.open) {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export default db;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user