diff --git a/.gitignore b/.gitignore index 4c7f38c077..97414c9429 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,4 @@ build/releases/ # Temporary files *.log .DS_Store +.cursor/rules/directive.mdc diff --git a/neuron/lib/deviceManager.js b/neuron/lib/deviceManager.js new file mode 100644 index 0000000000..8ce5245767 --- /dev/null +++ b/neuron/lib/deviceManager.js @@ -0,0 +1,245 @@ +const fs = require('fs'); +const path = require('path'); + +/** + * Central utility module for managing device files + * This module serves as the single source of truth for all filesystem operations + * related to device files in the Neuron system. + */ +class DeviceManager { + constructor() { + this.devicesDir = path.join(require('../services/NeuronUserHome').load(), 'devices'); + this.ensureDevicesDirectory(); + } + + /** + * Ensure the devices directory exists + */ + ensureDevicesDirectory() { + if (!fs.existsSync(this.devicesDir)) { + fs.mkdirSync(this.devicesDir, { recursive: true }); + } + } + + /** + * List all JSON files in the devices directory + * @returns {Array} Array of filenames (without .json extension) + */ + listAllDeviceFiles() { + try { + if (!fs.existsSync(this.devicesDir)) { + return []; + } + + const files = fs.readdirSync(this.devicesDir); + return files + .filter(file => file.endsWith('.json')) + .map(file => file.replace('.json', '')); + } catch (error) { + console.error('Error listing device files:', error); + return []; + } + } + + /** + * Determine the role of a device based on its content + * @param {Object} deviceData - The parsed device data + * @returns {string} 'buyer' or 'seller' + */ + determineDeviceRole(deviceData) { + // Explicit nodeType property takes precedence + if (deviceData.nodeType === 'buyer') { + return 'buyer'; + } + if (deviceData.nodeType === 'seller') { + return 'seller'; + } + + // Fallback logic: check for sellerEvmAddress property + // A file is a Buyer if it contains sellerEvmAddress property + if (deviceData.sellerEvmAddress !== undefined) { + return 'buyer'; + } + + // Default to seller if no sellerEvmAddress property + return 'seller'; + } + + /** + * List device files filtered by role and excluding active nodes + * @param {string} role - 'buyer' or 'seller' + * @param {Array} activeNodeIds - Array of currently active node IDs to exclude + * @returns {Array} Array of eligible device objects with filename and role + */ + listEligibleDevices(role, activeNodeIds = []) { + try { + const allFiles = this.listAllDeviceFiles(); + const eligibleDevices = []; + + for (const filename of allFiles) { + // Skip if this file corresponds to an active node (check filename first) + if (activeNodeIds.includes(filename)) { + console.log(`[DEVICE FILTER] Skipping ${filename}.json - filename matches active node ID`); + continue; + } + + try { + const deviceData = this.loadDeviceFile(filename); + + // Also check if the nodeId field inside the JSON matches an active node + if (deviceData.nodeId && activeNodeIds.includes(deviceData.nodeId)) { + console.log(`[DEVICE FILTER] Skipping ${filename}.json - nodeId field (${deviceData.nodeId}) matches active node ID`); + continue; + } + + const deviceRole = this.determineDeviceRole(deviceData); + + // Only include devices that match the requested role + if (deviceRole === role) { + eligibleDevices.push({ + filename: filename, + role: deviceRole, + deviceName: deviceData.deviceName || 'Unnamed Device', + evmAddress: deviceData.evmAddress || 'No EVM Address', + nodeId: deviceData.nodeId || null // Include the nodeId field for reference + }); + } + } catch (error) { + console.error(`Error processing device file ${filename}:`, error); + // Continue processing other files even if one fails + } + } + + return eligibleDevices; + } catch (error) { + console.error('Error listing eligible devices:', error); + return []; + } + } + + /** + * Load and parse a device file + * @param {string} filename - The filename without .json extension + * @returns {Object} Parsed device data + */ + loadDeviceFile(filename) { + try { + const filePath = path.join(this.devicesDir, `${filename}.json`); + + if (!fs.existsSync(filePath)) { + throw new Error(`Device file not found: ${filename}.json`); + } + + const fileContent = fs.readFileSync(filePath, 'utf-8'); + const deviceData = JSON.parse(fileContent); + + return deviceData; + } catch (error) { + console.error(`Error loading device file ${filename}:`, error); + throw error; + } + } + + /** + * Save device data to a file + * @param {string} nodeId - The node ID (used as filename) + * @param {Object} deviceData - The device data to save + */ + saveDeviceFile(nodeId, deviceData) { + try { + this.ensureDevicesDirectory(); + const filePath = path.join(this.devicesDir, `${nodeId}.json`); + + fs.writeFileSync(filePath, JSON.stringify(deviceData, null, 2), 'utf-8'); + console.log(`Device file saved: ${nodeId}.json`); + } catch (error) { + console.error(`Error saving device file ${nodeId}:`, error); + throw error; + } + } + + /** + * Rename a device file + * @param {string} oldFilename - Current filename (without .json extension) + * @param {string} newNodeId - New node ID (new filename without .json extension) + */ + renameDeviceFile(oldFilename, newNodeId) { + try { + const oldFilePath = path.join(this.devicesDir, `${oldFilename}.json`); + const newFilePath = path.join(this.devicesDir, `${newNodeId}.json`); + + if (!fs.existsSync(oldFilePath)) { + throw new Error(`Device file not found: ${oldFilename}.json`); + } + + if (fs.existsSync(newFilePath)) { + throw new Error(`Target device file already exists: ${newNodeId}.json`); + } + + fs.renameSync(oldFilePath, newFilePath); + console.log(`Device file renamed: ${oldFilename}.json -> ${newNodeId}.json`); + } catch (error) { + console.error(`Error renaming device file from ${oldFilename} to ${newNodeId}:`, error); + throw error; + } + } + + /** + * Check if a device file exists + * @param {string} nodeId - The node ID to check + * @returns {boolean} True if file exists + */ + deviceFileExists(nodeId) { + const filePath = path.join(this.devicesDir, `${nodeId}.json`); + return fs.existsSync(filePath); + } + + /** + * Delete a device file + * @param {string} nodeId - The node ID (filename without .json extension) + */ + deleteDeviceFile(nodeId) { + try { + const filePath = path.join(this.devicesDir, `${nodeId}.json`); + + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + console.log(`Device file deleted: ${nodeId}.json`); + } + } catch (error) { + console.error(`Error deleting device file ${nodeId}:`, error); + throw error; + } + } + + /** + * Get all active node IDs from the current Node-RED editor + * This is a helper method that can be called by the nodes to get active IDs + * @param {Object} RED - Node-RED instance + * @returns {Array} Array of active node IDs + */ + getActiveNodeIds(RED) { + try { + const activeNodeIds = []; + + // Get all nodes from the editor + RED.nodes.eachNode((node) => { + // Check for both buyer and seller config nodes + if (node.type === 'buyer config' || node.type === 'seller config' || + node.type === 'buyer' || node.type === 'seller') { + activeNodeIds.push(node.id); + console.log(`[ACTIVE NODES] Found active node: ${node.type} (${node.id})`); + } + }); + + console.log(`[ACTIVE NODES] Total active nodes found: ${activeNodeIds.length}`); + return activeNodeIds; + } catch (error) { + console.error('Error getting active node IDs:', error); + return []; + } + } +} + +// Export a singleton instance +module.exports = new DeviceManager(); diff --git a/neuron/nodes/buyer.html b/neuron/nodes/buyer.html index 839040130f..a343027b8e 100644 --- a/neuron/nodes/buyer.html +++ b/neuron/nodes/buyer.html @@ -8,7 +8,7 @@
- @@ -16,13 +16,33 @@
+
+
+ Select an existing device to reinstantiate its configuration +
+
+ + + + +
+ + +
+ + + - Sellers I want data from (Should be in selected Smart Contract above ) + Sellers I want data from (Should be in selected Smart Contract above )
@@ -80,7 +100,15 @@

Runtime Information

- +
+ + +
@@ -166,6 +194,20 @@

Runtime Information

diff --git a/neuron/nodes/buyer.js b/neuron/nodes/buyer.js index 6664bbde10..5d4099ee2d 100644 --- a/neuron/nodes/buyer.js +++ b/neuron/nodes/buyer.js @@ -317,11 +317,41 @@ module.exports = function (RED) { (async () => { let loadedDeviceInfo = null; + let isDeviceReinstatement = false; - const contextDevice = context.get('deviceInfo'); - if (contextDevice) { - loadedDeviceInfo = contextDevice; - console.log(`Node ${node.id}: Device loaded from context.`); + // Check if a device was loaded from the dropdown (device reinstatement) + if (config.loadedDeviceFilename && config.loadedDeviceFilename.trim() !== '') { + try { + const deviceManager = require('../lib/deviceManager'); + loadedDeviceInfo = deviceManager.loadDeviceFile(config.loadedDeviceFilename); + isDeviceReinstatement = true; + console.log(`Node ${node.id}: Device reinstatement - loaded device from: ${config.loadedDeviceFilename}`); + + // Rename the device file to match the new node ID if needed + if (config.loadedDeviceFilename !== `${node.id}.json`) { + console.log(`Node ${node.id}: Renaming device file from ${config.loadedDeviceFilename} to ${node.id}.json`); + deviceManager.renameDeviceFile(config.loadedDeviceFilename, node.id); + } + + // Clear the loadedDeviceFilename to forget about the old file + // From now on, this node will use its own file (node.id.json) + config.loadedDeviceFilename = ''; + console.log(`Node ${node.id}: Device reinstatement complete - now using standard deployment flow`); + } catch (err) { + // This is expected behavior - the device file may have been renamed in a previous deployment + console.log(`Node ${node.id}: Device file ${config.loadedDeviceFilename}.json not found (likely renamed in previous deployment). Proceeding with new device creation.`); + // Clear the failed filename and proceed with new device creation + config.loadedDeviceFilename = ''; + } + } + + // Fallback to existing logic for context and disk loading + if (!loadedDeviceInfo) { + const contextDevice = context.get('deviceInfo'); + if (contextDevice) { + loadedDeviceInfo = contextDevice; + console.log(`Node ${node.id}: Device loaded from context.`); + } } if (!loadedDeviceInfo && fs.existsSync(deviceFile)) { @@ -335,6 +365,7 @@ module.exports = function (RED) { } if (loadedDeviceInfo) { + // Update configuration fields while preserving existing Hedera account data loadedDeviceInfo.sellerEvmAddress = config.sellerEvmAddress; loadedDeviceInfo.description = config.description; loadedDeviceInfo.deviceName = config.deviceName; @@ -342,10 +373,29 @@ module.exports = function (RED) { loadedDeviceInfo.serialNumber = config.serialNumber; loadedDeviceInfo.deviceType = config.deviceType; loadedDeviceInfo.price = config.price; + + // Set smart contract from configuration + const contracts = { + "jetvision": process.env.JETVISION_CONTRACT_EVM, + "chat": process.env.CHAT_CONTRACT_EVM, + "challenges": process.env.CHALLENGES_CONTRACT_EVM, + }; + loadedDeviceInfo.smartContract = contracts[config.smartContract.toLowerCase()]; + node.deviceInfo = loadedDeviceInfo; + node.deviceInfo.nodeType = "buyer"; + node.deviceInfo.nodeId = node.id; + + // Save the updated device info fs.writeFileSync(deviceFile, JSON.stringify(node.deviceInfo, null, 2), 'utf-8'); context.set('deviceInfo', node.deviceInfo); - node.status({ fill: "green", shape: "dot", text: "Device loaded." }); + + if (isDeviceReinstatement) { + node.status({ fill: "green", shape: "dot", text: "Device reinstated." }); + console.log(`Node ${node.id}: Device reinstatement completed - using existing Hedera account and topics`); + } else { + node.status({ fill: "green", shape: "dot", text: "Device loaded." }); + } } if (!node.deviceInfo) { @@ -1255,7 +1305,7 @@ module.exports = function (RED) { RED.httpAdmin.get('/buyer/last-seen/:topicId', async function (req, res) { const topicId = req.params.topicId; - console.log(`[DEBUG] Last seen requested for topic: ${topicId}`); + // console.log(`[DEBUG] Last seen requested for topic: ${topicId}`); try { if (!hederaService) { @@ -1274,7 +1324,7 @@ module.exports = function (RED) { // Handle timestamp format: '1753899626.468846000' // This is Unix timestamp in seconds with nanosecond precision const timestampString = lastMessage.timestamp; - console.log(`[DEBUG] Raw timestamp: ${timestampString}`); + // console.log(`[DEBUG] Raw timestamp: ${timestampString}`); // Parse the timestamp string as a float (seconds.nanoseconds) const timestampSeconds = parseFloat(timestampString); @@ -1289,7 +1339,7 @@ module.exports = function (RED) { const millisecondsAgo = now - lastSeenTime; const secondsAgo = Math.floor(millisecondsAgo / 1000); - console.log(`[DEBUG] Timestamp: ${timestampString}, LastSeenTime: ${lastSeenTime}ms, Now: ${now}ms, SecondsAgo: ${secondsAgo}`); + // console.log(`[DEBUG] Timestamp: ${timestampString}, LastSeenTime: ${lastSeenTime}ms, Now: ${now}ms, SecondsAgo: ${secondsAgo}`); res.json({ success: true, @@ -1402,4 +1452,167 @@ module.exports = function (RED) { }); } }); + + // ===== NEW DEVICE MANAGEMENT ENDPOINTS ===== + + // Import deviceManager for new endpoints + const deviceManager = require('../lib/deviceManager'); + + // Endpoint for Buyer Device List - Get eligible buyer devices for reinstatement + RED.httpAdmin.get('/buyer/devices/eligible', function (req, res) { + try { + // Get currently active node IDs to exclude from the list + const activeNodeIds = deviceManager.getActiveNodeIds(RED); + + // Get eligible buyer devices + const eligibleDevices = deviceManager.listEligibleDevices('buyer', activeNodeIds); + + console.log(`[BUYER DEVICES] Found ${eligibleDevices.length} eligible buyer devices (excluding ${activeNodeIds.length} active nodes)`); + + res.json({ + success: true, + devices: eligibleDevices, + activeNodeIds: activeNodeIds, + count: eligibleDevices.length + }); + + } catch (error) { + console.error('[BUYER DEVICES] Error fetching eligible buyer devices:', error); + res.status(500).json({ + success: false, + error: `Failed to fetch eligible buyer devices: ${error.message}` + }); + } + }); + + // Endpoint for Device File Content - Get complete device data by filename + RED.httpAdmin.get('/device/:filename', function (req, res) { + const filename = req.params.filename; + + try { + const deviceData = deviceManager.loadDeviceFile(filename); + + console.log(`[DEVICE LOAD] Successfully loaded device file: ${filename}.json`); + + res.json({ + success: true, + filename: filename, + data: deviceData + }); + + } catch (error) { + console.error(`[DEVICE LOAD] Error loading device file ${filename}:`, error); + res.status(500).json({ + success: false, + error: `Failed to load device file: ${error.message}` + }); + } + }); + + // Endpoint to get balance by account ID (efficient direct access) + RED.httpAdmin.get('/device/balance/by-account/:accountId', async function (req, res) { + const accountId = req.params.accountId; + + try { + if (!hederaService) { + return res.status(500).json({ + success: false, + error: 'Hedera service not initialized' + }); + } + + console.log(`[DEVICE BALANCE] Fetching balance for account ID: ${accountId}`); + + // Get balance in tinybars directly using account ID + const balanceTinybars = await hederaService.getAccountBalanceTinybars(accountId); + + // Convert to HBAR (1 HBAR = 100,000,000 tinybars) + const balanceHbar = (balanceTinybars / 100000000).toFixed(8); + + console.log(`[DEVICE BALANCE] Balance for ${accountId}: ${balanceHbar} HBAR`); + + res.json({ + success: true, + balance: balanceHbar, + balanceTinybars: balanceTinybars.toString(), + accountId: accountId, + unit: 'HBAR' + }); + + } catch (error) { + console.error(`[DEVICE BALANCE] Error fetching balance for ${accountId}:`, error); + res.status(500).json({ + success: false, + error: `Failed to fetch balance: ${error.message}` + }); + } + }); + + // Endpoint to get balance by EVM address (fallback method) + RED.httpAdmin.get('/device/balance/:evmAddress', async function (req, res) { + const evmAddress = req.params.evmAddress; + + try { + if (!hederaService) { + return res.status(500).json({ + success: false, + error: 'Hedera service not initialized' + }); + } + + console.log(`[DEVICE BALANCE] Fetching balance for EVM address: ${evmAddress}`); + + // Find the device file that contains this EVM address + const deviceManager = require('../lib/deviceManager'); + const allFiles = deviceManager.listAllDeviceFiles(); + let accountId = null; + let deviceData = null; + + // Search through device files to find the one with matching EVM address + for (const filename of allFiles) { + try { + const data = deviceManager.loadDeviceFile(filename); + if (data.evmAddress && data.evmAddress.toLowerCase() === evmAddress.toLowerCase()) { + accountId = data.accountId; + deviceData = data; + break; + } + } catch (error) { + console.error(`[DEVICE BALANCE] Error loading device file ${filename}:`, error); + continue; + } + } + + if (!accountId) { + return res.status(404).json({ + success: false, + error: 'Device not found for EVM address' + }); + } + + // Get balance in tinybars + const balanceTinybars = await hederaService.getAccountBalanceTinybars(accountId); + + // Convert to HBAR (1 HBAR = 100,000,000 tinybars) + const balanceHbar = (balanceTinybars / 100000000).toFixed(8); + + console.log(`[DEVICE BALANCE] Balance for ${evmAddress} (${accountId}): ${balanceHbar} HBAR`); + + res.json({ + success: true, + balance: balanceHbar, + balanceTinybars: balanceTinybars.toString(), + accountId: accountId, + evmAddress: evmAddress, + unit: 'HBAR' + }); + + } catch (error) { + console.error(`[DEVICE BALANCE] Error fetching balance for ${evmAddress}:`, error); + res.status(500).json({ + success: false, + error: `Failed to fetch balance: ${error.message}` + }); + } + }); }; \ No newline at end of file diff --git a/neuron/nodes/process-manager.js b/neuron/nodes/process-manager.js index 10791da4d1..66e75cdc53 100644 --- a/neuron/nodes/process-manager.js +++ b/neuron/nodes/process-manager.js @@ -5,6 +5,23 @@ const WebSocket = require('ws'); const ProcessRegistry = require('./process-registry'); const PortManager = require('./port-manager'); +/** + * Helper function to ensure EVM address has '0x' prefix + */ +function formatEvmAddress(evmAddress) { + if (!evmAddress || evmAddress.trim() === '') { + return evmAddress; + } + + const trimmedAddress = evmAddress.trim(); + if (trimmedAddress.startsWith('0x')) { + // Remove any spaces after '0x' and return the clean address + return '0x' + trimmedAddress.substring(2).trim(); + } else { + return '0x' + trimmedAddress; + } +} + /** * ProcessManager - Orchestrates Go process lifecycle management * Handles process discovery, spawning, and persistence across redeploys @@ -152,7 +169,8 @@ class ProcessManager { // Wait for WebSocket to be ready const isReady = await this.testWebSocketConnection(port, 60, nodeType); // Wait up to 60 seconds if (isReady) { - node.status({ fill: "green", shape: "dot", text: `Active on ${port}. EVM: ${deviceInfo.evmAddress}` }); + const formattedEvmAddress = formatEvmAddress(deviceInfo.evmAddress); + node.status({ fill: "green", shape: "dot", text: `Active on ${port}. EVM: ${formattedEvmAddress}` }); console.log(`Go process for node ${node.id} is ready on port ${port}`); } else { throw new Error(`Go process for node ${node.id} failed to become ready on port ${port}`); @@ -340,8 +358,8 @@ class ProcessManager { const args = [ `--port=${port}`, - // '--use-local-address', - '--enable-upnp', + '--use-local-address', + // '--enable-upnp', '--mode=peer', `--buyer-or-seller=${nodeType}`, nodeType === 'seller' ? '--list-of-buyers-source=env' : '--list-of-sellers-source=env', @@ -710,43 +728,47 @@ class ProcessManager { async testWebSocketConnection(port, maxRetries = 10, nodeType = 'buyer') { const retryDelayMs = 1000; const endpoint = nodeType === 'seller' ? '/seller/p2p' : '/buyer/p2p'; - + for (let attempt = 1; attempt <= maxRetries; attempt++) { try { + console.log(`Attempting WebSocket connection to ws://localhost:${port}${endpoint} (attempt ${attempt}/${maxRetries})`); const ws = new WebSocket(`ws://localhost:${port}${endpoint}`); - + const result = await new Promise((resolve) => { const timeout = setTimeout(() => { + console.log(`WebSocket connection timed out on port ${port}`); ws.close(); resolve(false); }, 2000); - + ws.on('open', () => { + console.log(`WebSocket connection opened successfully on port ${port}`); clearTimeout(timeout); ws.close(); resolve(true); }); - - ws.on('error', () => { + + ws.on('error', (error) => { + console.error(`WebSocket error on port ${port}:`, error.message); clearTimeout(timeout); resolve(false); }); }); - + if (result) { console.log(`WebSocket connection successful on port ${port} (attempt ${attempt})`); return true; } } catch (error) { - // Connection failed, continue to retry + console.error(`WebSocket connection attempt failed on port ${port} (attempt ${attempt}):`, error.message); } - + if (attempt < maxRetries) { - console.log(`WebSocket connection failed on port ${port}, retrying in ${retryDelayMs}ms (attempt ${attempt}/${maxRetries})`); + console.log(`Retrying WebSocket connection to port ${port} in ${retryDelayMs}ms (attempt ${attempt + 1}/${maxRetries})`); await new Promise(resolve => setTimeout(resolve, retryDelayMs)); } } - + console.log(`WebSocket connection failed on port ${port} after ${maxRetries} attempts`); return false; } @@ -1111,4 +1133,4 @@ module.exports.killPid = async function(pid, signal = 'SIGTERM') { module.exports.listProcesses = function() { const manager = new ProcessManager(); return manager.listAllProcesses(); -}; \ No newline at end of file +}; \ No newline at end of file diff --git a/neuron/nodes/seller.html b/neuron/nodes/seller.html index f34224426f..346ece39f5 100644 --- a/neuron/nodes/seller.html +++ b/neuron/nodes/seller.html @@ -1,17 +1,11 @@