From 1f57333cb0be1f5b5ea0e4f001c79a3dcc2cf357 Mon Sep 17 00:00:00 2001 From: MayaTheShy Date: Sat, 21 Mar 2026 16:42:30 -0400 Subject: [PATCH] Implement initial server setup with Express, WebSocket support, and API endpoints for inventory management --- web/server/server.js | 434 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 434 insertions(+) create mode 100644 web/server/server.js diff --git a/web/server/server.js b/web/server/server.js new file mode 100644 index 0000000..048c791 --- /dev/null +++ b/web/server/server.js @@ -0,0 +1,434 @@ +import express from 'express'; +import { WebSocketServer } from 'ws'; +import cors from 'cors'; +import { createServer } from 'http'; + +const app = express(); +const PORT = 3001; + +app.use(cors()); +app.use(express.json({ limit: '5mb' })); + +// ========== State ========== +const webClients = new Set(); +const bridgeClients = new Set(); + +// Latest inventory state from the CC:Tweaked bridge +let inventoryState = { + itemList: [], + grandTotal: 0, + chestCount: 0, + totalSlots: 0, + usedSlots: 0, + freeSlots: 0, + usedRatio: 0, + dropperOk: false, + barrelOk: false, + furnaceCount: 0, + furnaceStatus: {}, +}; + +let activityState = {}; +let alertsState = []; +let smeltingPaused = false; +let disabledRecipes = {}; +let smeltableRecipes = {}; +let craftableRecipes = []; +let craftTurtleOk = false; +let lastUpdate = 0; + +// Pending commands for bridge HTTP polling (fallback) +let pendingCommands = []; + +// ========== Helpers ========== + +function broadcastToClients(data) { + const message = JSON.stringify(data); + webClients.forEach((client) => { + if (client.readyState === 1) { + client.send(message); + } + }); +} + +function pushCommandToBridge(command) { + let sent = false; + for (const bridge of bridgeClients) { + if (bridge.readyState === 1) { + bridge.send(JSON.stringify(command)); + sent = true; + } + } + if (!sent) { + // Fallback: queue for HTTP polling + pendingCommands.push({ ...command, timestamp: Date.now() }); + console.log(`[Bridge] Queued command via HTTP poll (no WS bridge)`); + } +} + +// ========== HTTP Server ========== +const server = createServer(app); + +// Health check +app.get('/api/health', (req, res) => { + res.json({ status: 'ok', lastUpdate, uptime: process.uptime() }); +}); + +// Get current inventory state +app.get('/api/inventory', (req, res) => { + res.json({ + inventory: inventoryState, + activity: activityState, + alerts: alertsState, + smeltingPaused, + disabledRecipes, + smeltable: smeltableRecipes, + craftable: craftableRecipes, + craftTurtleOk, + lastUpdate, + }); +}); + +// Get just the item list +app.get('/api/items', (req, res) => { + res.json({ items: inventoryState.itemList || [] }); +}); + +// Get furnace status +app.get('/api/furnaces', (req, res) => { + res.json({ + furnaceCount: inventoryState.furnaceCount, + furnaceStatus: inventoryState.furnaceStatus, + smeltingPaused, + }); +}); + +// Get alerts +app.get('/api/alerts', (req, res) => { + res.json({ alerts: alertsState }); +}); + +// Get recipes (smeltable + craftable) +app.get('/api/recipes', (req, res) => { + res.json({ + smeltable: smeltableRecipes, + craftable: craftableRecipes, + disabledRecipes, + smeltingPaused, + }); +}); + +// ========== Command Endpoints ========== + +// Order an item from storage +app.post('/api/order', (req, res) => { + try { + const { itemName, amount, dropperName } = req.body; + if (!itemName || !amount) { + return res.status(400).json({ error: 'Missing itemName or amount' }); + } + + const command = { + type: 'command', + action: 'order', + itemName, + amount: parseInt(amount), + dropperName: dropperName || null, + }; + + pushCommandToBridge(command); + console.log(`šŸ“¦ Order: ${itemName} x${amount}`); + res.json({ success: true, message: `Order sent: ${itemName} x${amount}` }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Request inventory scan +app.post('/api/scan', (req, res) => { + try { + pushCommandToBridge({ type: 'command', action: 'scan' }); + console.log('šŸ” Scan requested'); + res.json({ success: true, message: 'Scan requested' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Toggle smelting pause +app.post('/api/smelting/toggle', (req, res) => { + try { + pushCommandToBridge({ type: 'command', action: 'toggle_pause' }); + console.log('šŸ”„ Smelting toggle requested'); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Toggle a specific recipe +app.post('/api/recipes/toggle', (req, res) => { + try { + const { recipe } = req.body; + if (!recipe) return res.status(400).json({ error: 'Missing recipe name' }); + + pushCommandToBridge({ type: 'command', action: 'toggle_recipe', recipe }); + console.log(`šŸ”„ Recipe toggle: ${recipe}`); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Enable/disable all recipes +app.post('/api/recipes/enable-all', (req, res) => { + pushCommandToBridge({ type: 'command', action: 'enable_all' }); + res.json({ success: true }); +}); + +app.post('/api/recipes/disable-all', (req, res) => { + pushCommandToBridge({ type: 'command', action: 'disable_all' }); + res.json({ success: true }); +}); + +// Sort barrel +app.post('/api/sort-barrel', (req, res) => { + try { + const { barrelName } = req.body; + if (!barrelName) return res.status(400).json({ error: 'Missing barrelName' }); + + pushCommandToBridge({ type: 'command', action: 'sort_barrel', barrelName }); + console.log(`šŸ“¦ Sort barrel: ${barrelName}`); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Craft item +app.post('/api/craft', (req, res) => { + try { + const { recipeIdx } = req.body; + if (recipeIdx === undefined) return res.status(400).json({ error: 'Missing recipeIdx' }); + + pushCommandToBridge({ type: 'command', action: 'craft', recipeIdx: parseInt(recipeIdx) }); + console.log(`šŸ”Ø Craft request: recipe #${recipeIdx}`); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// ========== Bridge Endpoints (for HTTP polling fallback) ========== + +// Bridge sends inventory state +app.post('/api/bridge/state', (req, res) => { + try { + const data = req.body; + updateStateFromBridge(data); + res.json({ success: true }); + } catch (error) { + console.error('āŒ Error processing bridge state:', error); + res.status(500).json({ error: error.message }); + } +}); + +// Bridge polls for pending commands +app.get('/api/bridge/commands', (req, res) => { + try { + const now = Date.now(); + // Clear old commands (>30s) + pendingCommands = pendingCommands.filter(cmd => (now - cmd.timestamp) < 30000); + + const commands = [...pendingCommands]; + res.json({ commands }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Bridge acknowledges commands +app.post('/api/bridge/commands/ack', (req, res) => { + try { + const cleared = pendingCommands.length; + pendingCommands = []; + res.json({ success: true, cleared }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Bridge sends command result +app.post('/api/bridge/result', (req, res) => { + try { + const { action, success, message, error: err } = req.body; + + broadcastToClients({ + type: 'command_result', + action, + success, + message, + error: err, + }); + + console.log(`šŸ“© Result: ${action} - ${success ? 'OK' : 'FAIL'}`); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// ========== State Update ========== + +function updateStateFromBridge(data) { + if (data.cache) { + inventoryState = { + itemList: data.cache.itemList || [], + grandTotal: data.cache.grandTotal || 0, + chestCount: data.cache.chestCount || 0, + totalSlots: data.cache.totalSlots || 0, + usedSlots: data.cache.usedSlots || 0, + freeSlots: data.cache.freeSlots || 0, + usedRatio: data.cache.usedRatio || 0, + dropperOk: data.cache.dropperOk || false, + barrelOk: data.cache.barrelOk || false, + furnaceCount: data.cache.furnaceCount || 0, + furnaceStatus: data.cache.furnaceStatus || {}, + }; + } + + if (data.activity !== undefined) activityState = data.activity; + if (data.alerts !== undefined) alertsState = data.alerts; + if (data.smeltingPaused !== undefined) smeltingPaused = data.smeltingPaused; + if (data.disabledRecipes !== undefined) disabledRecipes = data.disabledRecipes; + if (data.smeltable !== undefined) smeltableRecipes = data.smeltable; + if (data.craftable !== undefined) craftableRecipes = data.craftable; + if (data.craftTurtleOk !== undefined) craftTurtleOk = data.craftTurtleOk; + + lastUpdate = Date.now(); + + // Broadcast to all web clients + broadcastToClients({ + type: 'state_update', + inventory: inventoryState, + activity: activityState, + alerts: alertsState, + smeltingPaused, + disabledRecipes, + craftTurtleOk, + lastUpdate, + }); +} + +// ========== WebSocket Server ========== + +const wss = new WebSocketServer({ server }); + +console.log(`šŸš€ Inventory Manager Web Server starting...`); +console.log(`šŸ“” HTTP Server: http://localhost:${PORT}`); +console.log(`šŸ”Œ WebSocket Server: ws://localhost:${PORT}/ws`); + +wss.on('connection', (ws, req) => { + const url = req.url || ''; + + // ---- Bridge WebSocket connection ---- + if (url.startsWith('/ws/bridge')) { + console.log('šŸŒ‰ CC:Tweaked bridge connected via WebSocket'); + bridgeClients.add(ws); + + ws.on('message', (raw) => { + try { + const data = JSON.parse(raw); + + if (data.type === 'ping') { + ws.send(JSON.stringify({ type: 'pong' })); + return; + } + + if (data.type === 'state') { + // Full state update from bridge + updateStateFromBridge(data); + } else if (data.type === 'command_result') { + // Command result from bridge + broadcastToClients({ + type: 'command_result', + action: data.action, + success: data.success, + message: data.message, + error: data.error, + }); + } + } catch (error) { + console.error('āŒ Bridge WS message error:', error.message); + } + }); + + ws.on('close', () => { + console.log('šŸŒ‰ CC:Tweaked bridge disconnected'); + bridgeClients.delete(ws); + }); + + ws.on('error', (error) => { + console.error('āŒ Bridge WS error:', error); + bridgeClients.delete(ws); + }); + + return; + } + + // ---- Web client WebSocket connection ---- + console.log('🌐 New web client connected'); + webClients.add(ws); + + // Send current state to new client + ws.send(JSON.stringify({ + type: 'initial_state', + inventory: inventoryState, + activity: activityState, + alerts: alertsState, + smeltingPaused, + disabledRecipes, + smeltable: smeltableRecipes, + craftable: craftableRecipes, + craftTurtleOk, + lastUpdate, + })); + + ws.on('message', (message) => { + try { + const data = JSON.parse(message); + console.log('šŸ“Ø Received from web client:', data); + + if (data.type === 'command') { + // Forward command to bridge + pushCommandToBridge(data); + } + } catch (error) { + console.error('āŒ Error processing web client message:', error); + } + }); + + ws.on('close', () => { + console.log('šŸ‘‹ Web client disconnected'); + webClients.delete(ws); + }); + + ws.on('error', (error) => { + console.error('āŒ WebSocket error:', error); + }); +}); + +// ========== Start Server ========== + +server.listen(PORT, () => { + console.log(`āœ… Inventory Manager Web Server ready on port ${PORT}`); + console.log(`\nBridge HTTP endpoint: http://localhost:${PORT}/api/bridge/state`); + console.log(`Bridge WebSocket: ws://localhost:${PORT}/ws/bridge`); + console.log(`Web client WebSocket: ws://localhost:${PORT}/ws`); +}); + +// Graceful shutdown +process.on('SIGINT', () => { + console.log('\nšŸ›‘ Shutting down server...'); + process.exit(0); +});