CO2 Sensor MCP Server

by kmwebnet
Verified
#!/usr/bin/env node import * as readline from 'readline'; import { SerialPort } from 'serialport'; import { ReadlineParser } from '@serialport/parser-readline'; import { log } from 'console'; import fs from 'fs'; import path from 'path'; import os from 'os'; // Error codes for JSON-RPC var ErrorCode; (function (ErrorCode) { ErrorCode[ErrorCode["ParseError"] = -32700] = "ParseError"; ErrorCode[ErrorCode["InvalidRequest"] = -32600] = "InvalidRequest"; ErrorCode[ErrorCode["MethodNotFound"] = -32601] = "MethodNotFound"; ErrorCode[ErrorCode["InvalidParams"] = -32602] = "InvalidParams"; ErrorCode[ErrorCode["InternalError"] = -32603] = "InternalError"; })(ErrorCode || (ErrorCode = {})); // Function to log text to a file const log2text = (message) => { const logFilePath = path.join(os.homedir(), 'co2_level.log'); fs.appendFile(logFilePath, message + '\n', (err) => { if (err) { console.error(`Error writing to log file: ${err.message}`); } }); }; // Simulated device state class DeviceState { constructor() { // Device information this.deviceId = 'rpipico-' + Math.floor(Math.random() * 0xffff).toString(16); this.firmwareVersion = '1.0.0'; this.bootTime = new Date(); this.dataHandlerSet = false; // Sensor data this.co2Level = 0; this.lastSensorUpdate = new Date(); // Network status this.wifiConnected = true; this.wifiSSID = 'SimulatedWiFi'; this.ipAddress = '192.168.1.' + Math.floor(Math.random() * 255); // MQTT status this.mqttConnected = true; this.mqttBroker = '192.168.1.100'; this.mqttPort = 1883; this.mqttTopic = 'sensor/1'; // Power management this.batteryLevel = 85; // percentage // Simulate sensor data changes setInterval(() => { this.updateSensorData(); }, 5000); } handleData(data) { const dataStr = data.toString().trim(); const match = dataStr.match(/CO2 \(ppm\):(\d+)/); if (match) { const value = parseInt(match[1]); if (!isNaN(value) && value > 0) { this.co2Level = value; } } } async initializePort() { try { const ports = await SerialPort.list(); // Search for Raspberry Pi Pico USB serial port const portInfo = ports.find(port => port.vendorId === '2E8A' && port.productId === '0005'); if (portInfo) { // Create a serial port connection this.port = new SerialPort({ path: portInfo.path, baudRate: 115200 }); this.parser = this.port.pipe(new ReadlineParser({ delimiter: '\r\n' })); this.parser.on('data', this.handleData.bind(this)); this.dataHandlerSet = true; } else { console.log('No USB serial port found, running in simulation mode'); this.port = null; } } catch (err) { console.error(`Error listing serial ports: ${err.message}`); console.log('Running in simulation mode due to error'); this.port = null; } } updateSensorData = () => { // Simulate data if no serial port is available if (!this.port) { this.co2Level = Math.floor(400 + Math.random() * 600); } this.lastSensorUpdate = new Date(); // Simulate battery drain this.batteryLevel = Math.max(0, this.batteryLevel - 0.1); } // Getters for device information getDeviceInfo() { return { deviceId: this.deviceId, firmwareVersion: this.firmwareVersion, bootTime: this.bootTime.toLocaleDateString('ja-JP', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }), uptime: Math.floor((new Date().getTime() - this.bootTime.getTime()) / 1000), batteryLevel: this.batteryLevel, }; } // Getters for sensor data async getSensorData() { if (!this.port && !this.dataHandlerSet) { await this.initializePort(); } return new Promise((resolve, reject) => { // Maximum wait time for data (milliseconds) const maxWaitTime = 5000; // Timer ID for timeout let timeoutId; // If a serial port is available if (this.port) { log2text(`sensor data requested - waiting for data...`); // Event handler to wait for data const dataHandler = (data) => { this.handleData(data); if (this.co2Level > 0) { this.lastSensorUpdate = new Date(); // Clear the timeout clearTimeout(timeoutId); // Remove the event listener this.parser.removeListener('data', dataHandler); log2text(`sensor data received: ${this.co2Level}`); // Return the result resolve({ co2Level: this.co2Level, lastUpdate: this.lastSensorUpdate.toLocaleDateString('ja-JP', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }), status: "data_received" }); } }; // Add event listener to wait for data this.parser.on('data', dataHandler); // Timeout processing timeoutId = setTimeout(() => { // Remove the event listener this.parser.removeListener('data', dataHandler); // Use simulated data on timeout this.co2Level = Math.floor(400 + Math.random() * 600); this.lastSensorUpdate = new Date(); log2text(`sensor data wait timeout - using simulated value: ${this.co2Level}`); resolve({ co2Level: this.co2Level, lastUpdate: this.lastSensorUpdate.toLocaleDateString('ja-JP', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }), status: "timeout_simulated_data" }); }, maxWaitTime); // Send data request (if needed) this.port.write('getdata\r\n', (err) => { if (err) { log2text(`Error requesting data: ${err.message}`); // Do not reject, handle with timeout } }); } // If in simulation mode else { // Update if in simulation mode if (!this.port) { this.updateSensorData(); } log2text(`sensor data requested: ${this.co2Level}`); // Return the result resolve({ co2Level: this.co2Level, lastUpdate: this.lastSensorUpdate.toLocaleDateString('ja-JP', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }), status: "simulated_data" }); } }); } // Getters for network status getNetworkStatus() { return { wifiConnected: this.wifiConnected, wifiSSID: this.wifiSSID, ipAddress: this.ipAddress, mqttConnected: this.mqttConnected, mqttBroker: this.mqttBroker, mqttPort: this.mqttPort, mqttTopic: this.mqttTopic }; } // Method to simulate publishing data to MQTT publishToMQTT() { if (!this.mqttConnected) { return { success: false, message: 'MQTT not connected' }; } const data = `${this.co2Level}`; return { success: true, topic: this.mqttTopic, data: data, timestamp: new Date().toLocaleDateString('ja-JP', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }) }; } // Method to simulate WiFi reconnection reconnectWiFi() { // Simulate connection process this.wifiConnected = false; setTimeout(() => { this.wifiConnected = true; this.ipAddress = '192.168.1.' + Math.floor(Math.random() * 255); }, 2000); return { success: true, message: 'WiFi reconnection initiated' }; } // Method to simulate MQTT reconnection reconnectMQTT() { if (!this.wifiConnected) { return { success: false, message: 'WiFi not connected' }; } // Simulate connection process this.mqttConnected = false; setTimeout(() => { this.mqttConnected = true; }, 1500); return { success: true, message: 'MQTT reconnection initiated' }; } } // Simple MCP Server implementation class McpServer { constructor() { this.nextId = 1; this.deviceState = new DeviceState(); // Create readline interface for stdin/stdout this.rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false }); // Listen for incoming messages this.rl.on('line', (line) => { try { this.handleMessage(line); } catch (error) { // Handle error } }); // Handle process termination process.on('SIGINT', () => { this.close(); process.exit(0); }); // Handle uncaught exceptions process.on('uncaughtException', (error) => { // Handle error }); // Handle unhandled promise rejections process.on('unhandledRejection', (reason, promise) => { // Handle error }); // Send server info this.sendServerInfo(); } sendServerInfo() { const serverInfo = { jsonrpc: '2.0', method: 'server/info', params: { name: 'device-sensor', version: '1.0.0', capabilities: { resources: { supportsResourceTemplates: false, supportsResourceSearch: false }, tools: { supportsToolSearch: false } } } }; console.log(JSON.stringify(serverInfo)); } handleMessage(message) { try { const request = JSON.parse(message); // Check if it's a valid JSON-RPC request if (request.jsonrpc !== '2.0') { this.sendError(request.id, ErrorCode.InvalidRequest, 'Invalid Request: missing jsonrpc version'); return; } // Check if the method is missing if (!request.method) { this.sendError(request.id, ErrorCode.InvalidRequest, 'Invalid Request: missing method'); return; } // Determine if this is a notification (no id) or a request (with id) const isNotification = request.id === undefined || request.id === null; if (isNotification) { // Handle notifications (no id) switch (request.method) { case 'notifications/cancelled': break; case 'exit': this.close(); process.exit(0); break; default: break; } } else { // Handle requests with IDs switch (request.method) { case 'initialize': this.handleInitialize(request); break; case 'shutdown': this.handleShutdown(request); break; case 'resources/list': this.handleListResources(request); break; case 'resources/read': this.handleReadResource(request); break; case 'tools/list': this.handleListTools(request); break; case 'tools/call': this.handleCallTool(request); break; default: // For unknown methods, just return an empty success response // This helps prevent timeouts this.sendResponse(request.id, {}); break; } } } catch (error) { this.sendError(null, ErrorCode.ParseError, 'Parse error'); } } handleListResources(request) { const resources = [ { uri: 'device://device/info', name: ' Device Information', mimeType: 'application/json', description: 'Basic information about the device including ID, firmware version, and uptime' }, { uri: 'device://sensor/data', name: 'MH-Z19B Sensor Data', mimeType: 'application/json', description: 'Current CO2 ppm readings from the MH-Z19B sensor' }, { uri: 'device://network/status', name: 'Network Connection Status', mimeType: 'application/json', description: 'WiFi and MQTT connection status information' } ]; this.sendResponse(request.id, { resources }); } async handleReadResource(request) { const uri = request.params?.uri; if (!uri) { this.sendError(request.id, ErrorCode.InvalidParams, 'Invalid params'); return; } let content; switch (uri) { case 'device://device/info': content = JSON.stringify(this.deviceState.getDeviceInfo(), null, 2); break; case 'device://sensor/data': const sensorData = await this.deviceState.getSensorData(); content = JSON.stringify(sensorData, null, 2); break; case 'device://network/status': content = JSON.stringify(this.deviceState.getNetworkStatus(), null, 2); break; default: this.sendError(request.id, ErrorCode.InvalidParams, 'Invalid resource URI'); return; } this.sendResponse(request.id, { contents: [ { uri: uri, mimeType: 'application/json', text: content } ] }); } handleListTools(request) { const tools = [ { name: 'get_sensor_data', description: 'Get current CO2 ppm readings from the MH-Z19B sensor', inputSchema: { type: 'object', properties: {}, required: [] } }, { name: 'get_device_info', description: 'Get information about the device', inputSchema: { type: 'object', properties: {}, required: [] } }, { name: 'get_network_status', description: 'Get WiFi and MQTT connection status (NOT IMPLEMENTED)', inputSchema: { type: 'object', properties: {}, required: [] } }, { name: 'publish_mqtt_data', description: 'Publish current sensor data to the MQTT topic (NOT IMPLEMENTED)', inputSchema: { type: 'object', properties: {}, required: [] } }, { name: 'reconnect_wifi', description: 'Force the device to reconnect to WiFi (NOT IMPLEMENTED)', inputSchema: { type: 'object', properties: {}, required: [] } }, { name: 'reconnect_mqtt', description: 'Force the device to reconnect to the MQTT broker (NOT IMPLEMENTED)', inputSchema: { type: 'object', properties: {}, required: [] } } ]; this.sendResponse(request.id, { tools }); } handleCallTool(request) { const toolName = request.params?.name; const args = request.params?.arguments || {}; if (!toolName) { this.sendError(request.id, ErrorCode.InvalidParams, 'Invalid params'); return; } let result; try { switch (toolName) { case 'get_sensor_data': // Handle asynchronous processing this.deviceState.getSensorData() .then(result => { this.sendResponse(request.id, { content: [ { type: 'text', text: JSON.stringify(result, null, 2) } ] }); }) .catch(error => { this.sendError(request.id, ErrorCode.InternalError, 'Internal error'); }); return; // Early return for asynchronous processing case 'get_device_info': result = this.deviceState.getDeviceInfo(); break; case 'get_network_status': result = this.deviceState.getNetworkStatus(); break; case 'publish_mqtt_data': result = this.deviceState.publishToMQTT(); break; case 'reconnect_wifi': result = this.deviceState.reconnectWiFi(); break; case 'reconnect_mqtt': result = this.deviceState.reconnectMQTT(); break; default: this.sendError(request.id, ErrorCode.MethodNotFound, `Unknown tool: ${toolName}`); return; } this.sendResponse(request.id, { content: [ { type: 'text', text: JSON.stringify(result, null, 2) } ] }); } catch (error) { this.sendError(request.id, ErrorCode.InternalError, 'Internal error'); } } sendResponse(id, result) { // Make sure we use the exact same ID from the request // Don't use this.nextId++ as a fallback const response = { jsonrpc: '2.0', id: id !== null ? id : 0, // Use 0 as fallback instead of this.nextId++ result }; console.log(JSON.stringify(response)); } handleInitialize(request) { // Respond with server capabilities // Make sure the server name matches the name in Claude Desktop config this.sendResponse(request.id, { serverInfo: { name: 'co2-sensor', // Changed to match the name in Claude Desktop config version: '1.0.0' }, capabilities: { resources: { supportsResourceTemplates: false, supportsResourceSearch: false }, tools: { supportsToolSearch: false } }, protocolVersion: request.params.protocolVersion || '2024-11-05' }); } handleShutdown(request) { // Respond with success this.sendResponse(request.id, {}); // Don't exit immediately, wait a bit to ensure the response is sent setTimeout(() => { this.close(); process.exit(0); }, 100); } sendError(id, code, message) { const response = { jsonrpc: '2.0', id: id !== null ? id : 0, // Use 0 as fallback instead of this.nextId++ error: { code, message } }; console.log(JSON.stringify(response)); } close() { this.rl.close(); } } // Start the server const server = new McpServer();