UNPKG

ecowitt-mcp

Version:

MCP server for Ecowitt Weather API v3 integration

268 lines (237 loc) 9.65 kB
import { CustomError, DataParsingError } from "../utils/errors.js"; import { EcowittApiError } from "./errors.js"; /** * Ecowitt API client for interacting with weather station data */ export class EcowittClient { /** * Create a new EcowittClient instance * @param {Object} config - Configuration object * @param {Object} config.ecowitt - Ecowitt API configuration * @param {string} config.ecowitt.applicationKey - Ecowitt application key * @param {string} config.ecowitt.apiKey - Ecowitt API key * @param {string} config.ecowitt.baseUrl - Base URL for the Ecowitt API * @param {number} config.ecowitt.requestTimeout - Request timeout in milliseconds * @param {Object} config.server - Server configuration * @param {string} config.server.version - Server version */ constructor(config) { this.config = { ...this._validateConfig(config) }; } /** * Validate the configuration object * @private * @param {Object} config - Configuration to validate * @throws {Error} If configuration is invalid * @returns {Object} Validated configuration object */ _validateConfig(config) { if (!config.ecowitt.applicationKey) { throw new CustomError("Application key is required", "MISSING_CONFIG", "configuration_error"); } if (!config.ecowitt.apiKey) { throw new CustomError("API key is required", "MISSING_CONFIG", "configuration_error"); } if (!config.ecowitt.baseUrl) { throw new CustomError("Base URL is required", "MISSING_CONFIG", "configuration_error"); } // Validate base URL format try { new URL(config.ecowitt.baseUrl); } catch (error) { throw new CustomError("Invalid base URL format", "INVALID_CONFIG", "configuration_error", error); } // Handle base URL with path correctly - normalize by removing trailing slash const normalizedBaseUrl = config.ecowitt.baseUrl.endsWith("/") ? config.ecowitt.baseUrl.slice(0, -1) : config.ecowitt.baseUrl; return { applicationKey: config.ecowitt.applicationKey, apiKey: config.ecowitt.apiKey, baseUrl: normalizedBaseUrl, requestTimeout: config.ecowitt.requestTimeout, version: config.server?.version, }; } /** * Build a complete URL for an API endpoint * @private * @param {string} endpoint - API endpoint path * @param {Object} additionalParams - Additional query parameters * @returns {string} Complete URL with authentication parameters */ _buildUrl(endpoint, additionalParams = {}) { // Ensure endpoint starts with / const normalizedEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`; const url = new URL(`${this.config.baseUrl}${normalizedEndpoint}`); // Add authentication parameters url.searchParams.set("application_key", this.config.applicationKey); url.searchParams.set("api_key", this.config.apiKey); // Add any additional parameters for (const [key, value] of Object.entries(additionalParams)) { if (value !== undefined && value !== null) { url.searchParams.set(key, value.toString()); } } return url.toString(); } /** * Make an HTTP request to the Ecowitt API * @private * @param {string} endpoint - API endpoint * @param {Object} options - Request options * @returns {Promise<Object>} API response */ async _makeRequest(endpoint, options = {}) { const url = this._buildUrl(endpoint, options.params); // Create abort controller for timeout const controller = new AbortController(); const timeoutId = setTimeout(() => { controller.abort(); }, this.config.requestTimeout); try { const response = await fetch(url, { method: "GET", headers: { "Content-Type": "application/json", "User-Agent": `ecowitt-mcp/${this.config.version}`, ...options.headers, }, signal: controller.signal, ...options.fetchOptions, }); clearTimeout(timeoutId); // Handle HTTP errors (response.ok is false, after checking Ecowitt specific errors) // This might happen if Ecowitt returns a non-200 status *and* a successful 'code: 0' in payload, // though typically a non-200 would also have a non-zero data.code. // This check ensures we catch any unexpected HTTP status codes. if (!response.ok) { throw new EcowittApiError( response.status, `HTTP Error: ${response.statusText}`, new Error(`HTTP status ${response.status}: ${response.statusText}`) ); } // Parse JSON response let data; try { data = await response.json(); } catch (error) { throw new DataParsingError(`Failed to parse API response as JSON: ${error.message}`, error); } // Handle Ecowitt API errors (non-zero code from the data payload) if (data.code !== 0) { throw new EcowittApiError(data.code, data.msg); } return data.data; } catch (error) { clearTimeout(timeoutId); if (error instanceof CustomError) { throw error; // Re-throw custom errors directly } // Handle timeout errors if (error.name === "AbortError") { throw new CustomError("Request timed out.", "TIMEOUT_ERROR", "timeout_error", error); } // Handle general network errors (e.g., DNS issues, connection refused) throw new CustomError(`Network error: ${error.message}`, "NETWORK_ERROR", "network_error", error); } } /** * Helper to build device identification parameters (mac or imei). * @private * @param {string} macOrImei - Device MAC or IMEI. * @returns {Object} Parameters object with either 'mac' or 'imei' key. * @throws {CustomError} If macOrImei is not provided. */ _buildDeviceParams(macOrImei) { if (!macOrImei) { throw new CustomError("MAC or IMEI is required.", "INVALID_PARAMETER", "parameter_error"); } return macOrImei.includes(":") ? { mac: macOrImei } : { imei: macOrImei }; } /** * Transform raw device data from API to normalized format * @private * @param {Object} rawDevice - Raw device data from API * @returns {Object} Normalized device data */ _transformDevice(rawDevice) { return { id: rawDevice.id, name: rawDevice.name, mac: rawDevice.mac, imei: rawDevice.imei, type: rawDevice.type, dateZoneId: rawDevice.date_zone_id, createTime: rawDevice.createtime, longitude: rawDevice.longitude, latitude: rawDevice.latitude, stationType: rawDevice.stationtype, iotDevices: (rawDevice.iotdevice_list || []).map((device) => ({ name: device.name, defaultTitle: device.default_title, deviceId: device.device_id, version: device.version, createTime: device.createtime, })), }; } /** * List all devices associated with the account * @returns {Promise<Array<Object>>} Array of transformed device objects */ async listDevices() { const data = await this._makeRequest("/device/list"); // 'data' here is the .data part of the Ecowitt response, which contains 'list' return (data.list || []).map((device) => this._transformDevice(device)); } /** * Get detailed information for a specific device by MAC or IMEI. * @param {string} macOrImei - Device MAC or IMEI. * @returns {Promise<Object>} Raw detailed device information. * @throws {EcowittApiError|CustomError|DataParsingError} On various errors. */ async getDeviceInfo(macOrImei) { const params = this._buildDeviceParams(macOrImei); return this._makeRequest("/device/info", { params }); } /** * Get real-time information for a specific device by MAC or IMEI. * @param {string} macOrImei - Device MAC or IMEI. * @param {string} [callback] - Comma-separated list of field types to return (e.g., "all", "outdoor", "indoor.humidity"). * @param {Object} [unitOptions] - Optional unit conversion parameters (temp_unitid, pressure_unitid, etc.). * @returns {Promise<Object>} Raw real-time device information. * @throws {EcowittApiError|CustomError|DataParsingError} On various errors. */ async getRealTimeInfo(macOrImei, callback, unitOptions = {}) { const params = { ...this._buildDeviceParams(macOrImei), ...(callback && { call_back: callback }), ...unitOptions, }; return this._makeRequest("/device/real_time", { params }); } /** * Get historical data for a specific device by MAC or IMEI. * @param {string} macOrImei - Device MAC or IMEI. * @param {string} startDate - Start time of data query (ISO8601: "YYYY-MM-DD HH:mm:ss"). * @param {string} endDate - End time of data query (ISO8601: "YYYY-MM-DD HH:mm:ss"). * @param {string} callback - Comma-separated list of field types to return. * @param {string} [cycleType] - Data resolution ("auto", "5min", "30min", "4hour", "1day"). * @param {Object} [unitOptions] - Optional unit parameters. * @returns {Promise<Object>} Historical device data. * @throws {EcowittApiError|CustomError|DataParsingError} On various errors. */ async getDeviceHistory(macOrImei, startDate, endDate, callback, cycleType, unitOptions = {}) { const params = { ...this._buildDeviceParams(macOrImei), ...(startDate && { start_date: startDate }), ...(endDate && { end_date: endDate }), ...(callback && { call_back: callback }), ...(cycleType && { cycle_type: cycleType }), ...unitOptions, }; return this._makeRequest("/device/history", { params }); } }