UNPKG

@neirth/sony-camera-mcp

Version:

MCP Server for controlling Sony Alpha 6100 camera

807 lines 36 kB
import { XMLParser } from 'fast-xml-parser'; import fetch from 'node-fetch'; export default class SonyCamera { serviceEndpoints = {}; deviceDescription = {}; cameraIp; cameraPort; ddXmlPort; debug; connected = false; cameraEndpoint; ddXmlUrl; /** * Creates a new instance of the Sony Camera client * @param {CameraConfig} config - The camera configuration object * @param {string} [config.cameraIp='192.168.122.1'] - The IP address of the camera * @param {string} [config.cameraPort='10000'] - The port used for camera control * @param {string} [config.ddXmlPort='64321'] - The port used for device description XML * @param {boolean} [config.debug=false] - Enable debug logging */ constructor(config = {}) { this.cameraIp = config.cameraIp || '192.168.122.1'; this.cameraPort = config.cameraPort || '10000'; this.ddXmlPort = config.ddXmlPort || '64321'; this.debug = config.debug || false; this.cameraEndpoint = `http://${this.cameraIp}:${this.cameraPort}`; this.ddXmlUrl = `http://${this.cameraIp}:${this.ddXmlPort}/dd.xml`; } /** * Internal method for logging debug messages * @param {string} message - The message to log * @private */ log(message) { if (this.debug) { console.error(`[SonyCamera] ${message}`); } } /** * Internal method for logging objects with debug information * @param {string} message - The message to log * @param {unknown} obj - The object to stringify and log * @private */ logObject(message, obj) { if (this.debug) { console.error(`[SonyCamera] ${message}`, JSON.stringify(obj, null, 2)); } } /** * Makes an API call to the camera * @param {ServiceType} service - The service to call (camera, guide, etc.) * @param {string} method - The method name to call * @param {any[]} params - Parameters to pass to the method * @returns {Promise<UnwrappedApiResponse<T>>} The API response * @private * @template T - The expected response type * @throws {Error} If the camera is not connected or if the API call fails */ async apiCall(service, method, params = []) { if (!this.connected) { throw new Error("Camera is not connected"); } try { let url; if (this.serviceEndpoints[service]) { // Base URL must end with /service (example: http://192.168.122.1:10000/sony/camera) url = `${this.serviceEndpoints[service]}`; this.log(`Calling API ${method} with URL: ${url}`); } else { throw new Error(`Service not available: ${service}`); } this.log(`Sending request to ${method} with parameters: ${JSON.stringify(params)}`); const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ method: method, params: params, id: 1, version: "1.0" }) }); if (!response.ok) { this.log(`HTTP Error: ${response.status} ${response.statusText}`); // If 404 error, try alternative URL if (response.status === 404) { // Try with base URL + service const altUrl = `${this.cameraEndpoint}/sony/${service}`; this.log(`Retrying with alternative URL: ${altUrl}`); const altResponse = await fetch(altUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ method: method, params: params, id: 1, version: "1.0" }) }); if (!altResponse.ok) { throw new Error(`HTTP Error in alternative URL: ${altResponse.status} ${altResponse.statusText}`); } const altData = await altResponse.json(); this.logObject(`Response from ${method} (alternative URL):`, altData); if (altData.error) { if (altData.error[1] && altData.error[1].trim() !== '') { throw new Error(`API Error ${altData.error[0]}: ${altData.error[1]}`); } else { throw new Error(`API Error ${altData.error[0]}: Error code without message`); } } if (!altData.result || altData.result.length === 0) { throw new Error("No results received from API (alternative URL)"); } // If result is a single-element array, return that element if (altData.result.length === 1) { return altData.result[0]; } // If there are multiple elements, return the complete array return altData.result; } throw new Error(`HTTP Error: ${response.status} ${response.statusText}`); } const data = await response.json(); this.logObject(`Response from ${method}:`, data); if (data.error) { if (data.error[1] && data.error[1].trim() !== '') { throw new Error(`API Error ${data.error[0]}: ${data.error[1]}`); } else { throw new Error(`API Error ${data.error[0]}: Error code without message`); } } if (!data.result) { throw new Error("No results received from API"); } if (data.result.length === 0) { // If response is an empty array, return it instead of error return []; } // If result is a single-element array, return that element if (data.result.length === 1) { return data.result[0]; } // If there are multiple elements, return the complete array return data.result; } catch (error) { // Propagate error with additional information this.log(`Error in API call ${service}/${method}: ${error instanceof Error ? error.message : String(error)}`); throw error; } } /** * Main method to establish connection with the camera * @returns {Promise<boolean>} True if connection is successful * @throws {Error} If connection fails or XML parsing fails */ async connect() { try { this.log(`Attempting to connect to camera at ${this.ddXmlUrl}`); // Get dd.xml directly const response = await fetch(this.ddXmlUrl); if (!response.ok) { throw new Error(`Error obtaining dd.xml: ${response.statusText}`); } const xmlData = await response.text(); this.log(`dd.xml obtained successfully`); // Parse dd.xml with a flexible parser try { const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "", parseAttributeValue: true, trimValues: true, parseTagValue: true, allowBooleanAttributes: true, ignoreDeclaration: true, numberParseOptions: { hex: true, leadingZeros: true }, removeNSPrefix: true, isArray: (name) => { // Define which tags should always be arrays return name === 'X_ScalarWebAPI_Service' || name === 'X_ScalarWebAPI_ServiceList' || name === 'service' || name === 'serviceList' || name === 'X_ScalarWebAPI_ActionList_URL'; } }); this.deviceDescription = parser.parse(xmlData); this.logObject(`Device description parsed`, this.deviceDescription); } catch (error) { if (error instanceof Error) { throw new Error(`Error parsing dd.xml: ${error.message}`); } throw new Error(`Error parsing dd.xml: ${String(error)}`); } // Extract service endpoints await this.extractServiceEndpoints(); this.connected = true; this.log("Connection established successfully"); return true; } catch (error) { const extractError = error instanceof Error ? error : new Error(String(error)); this.log(`Connection error: ${extractError.message}`); this.connected = false; throw extractError; } } /** * Extract service endpoints from the device description * Goes through multiple strategies to find service endpoints in the XML structure * @private * @throws {Error} If no service endpoints are found or if the XML structure is invalid */ async extractServiceEndpoints() { try { this.logObject('Complete dd.xml structure', this.deviceDescription); if (!this.deviceDescription.root) { throw new Error('Invalid XML structure: root element is missing'); } // Try multiple possible paths to find service information let serviceList = null; const device = this.deviceDescription.root.device; // Strategy 1: Standard X_ScalarWebAPI_ServiceList if (device?.X_ScalarWebAPI_DeviceInfo?.X_ScalarWebAPI_ServiceList?.[0]?.X_ScalarWebAPI_Service) { serviceList = device.X_ScalarWebAPI_DeviceInfo.X_ScalarWebAPI_ServiceList[0].X_ScalarWebAPI_Service; } // Strategy 2: Look in standard UPnP serviceList else if (device?.serviceList?.service) { // Filter only ScalarWebAPI services serviceList = Array.isArray(device.serviceList.service) ? device.serviceList.service.filter(svc => svc.serviceType?.includes('ScalarWebAPI')) : [device.serviceList.service].filter(svc => svc.serviceType?.includes('ScalarWebAPI')); } // Strategy 3: av:X_ScalarWebAPI_DeviceInfo node else if (device?.['av:X_ScalarWebAPI_DeviceInfo']?.['av:X_ScalarWebAPI_ServiceList']?.['av:X_ScalarWebAPI_Service']) { const avServices = device['av:X_ScalarWebAPI_DeviceInfo']['av:X_ScalarWebAPI_ServiceList']['av:X_ScalarWebAPI_Service']; serviceList = Array.isArray(avServices) ? avServices : [avServices]; } // Strategy 4: Directly in root else if (this.deviceDescription.root['av:X_ScalarWebAPI_DeviceInfo']?.['av:X_ScalarWebAPI_ServiceList']?.['av:X_ScalarWebAPI_Service']) { const avServices = this.deviceDescription.root['av:X_ScalarWebAPI_DeviceInfo']['av:X_ScalarWebAPI_ServiceList']['av:X_ScalarWebAPI_Service']; serviceList = Array.isArray(avServices) ? avServices : [avServices]; } if (!serviceList) { throw new Error('Could not find service list in XML'); } this.logObject('Service list found', serviceList); // Process endpoints looking for different patterns in property names this.serviceEndpoints = {}; for (const service of serviceList) { // Try different property name patterns const type = service['X_ScalarWebAPI_ServiceType'] || service['av:X_ScalarWebAPI_ServiceType'] || service['serviceType']?.split(':').pop(); const urlData = service['X_ScalarWebAPI_ActionList_URL'] || service['av:X_ScalarWebAPI_ActionList_URL'] || service['controlURL'] || service['SCPDURL']; if (!type || !urlData) continue; // Handle if urlData is array or string const url = Array.isArray(urlData) ? urlData[0] : urlData; // If URL doesn't start with http, add it to base const finalUrl = url.startsWith('http') ? url : `http://${this.cameraIp}:${this.cameraPort}${url}`; this.serviceEndpoints[type] = finalUrl; } this.logObject('Service endpoints extracted', this.serviceEndpoints); if (Object.keys(this.serviceEndpoints).length === 0) { throw new Error('No valid service endpoints found'); } } catch (error) { const extractError = error instanceof Error ? error : new Error(String(error)); this.log(`Error extracting service endpoints: ${extractError.message}`); throw extractError; } } /** * Retrieves the device description from a specific URL * @param {string} locationUrl - The URL to fetch the device description from * @returns {Promise<DeviceDescription>} A promise that resolves with the device description * @throws {Error} If unable to fetch or parse the device description */ async getDeviceDescription(locationUrl) { this.log(`Getting device description from: ${locationUrl}`); try { const response = await fetch(locationUrl); if (!response.ok) { throw new Error(`Error fetching device description: ${response.status}`); } const xmlData = await response.text(); // Parse XML const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_" }); const parsedData = parser.parse(xmlData); this.log("Device description obtained and parsed successfully"); return parsedData; } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); this.log(`Error getting device description: ${err.message}`); throw err; } } /** * Retrieves the list of available API methods for a given service * @param {ServiceType} service - The service to get API list from (defaults to "camera") * @returns {Promise<string[]>} Array of available API methods * @throws {Error} If unable to retrieve API list from both camera and guide services */ async getAvailableApiList(service = "camera") { try { const response = await this.apiCall(service, "getAvailableApiList"); return Array.isArray(response) ? response : [response]; } catch (error) { // If camera fails, try with guide service if (service === "camera") { try { const altService = "guide"; const response = await this.apiCall(altService, "getAvailableApiList"); return Array.isArray(response) ? response : [response]; } catch (serviceError) { this.log(`Error getting API list with guide service: ${serviceError}`); throw serviceError; } } throw error; } } /** * Gets information about the camera application * @returns {Promise<Record<string, any>>} Object containing application information * @throws {Error} If unable to retrieve application information */ async getApplicationInfo() { return this.apiCall("camera", "getApplicationInfo"); } /** * Gets the available exposure compensation values * @returns {Promise<[number, number, number, number]>} Array containing [current, max, min, step] values * @throws {Error} If unable to retrieve exposure compensation values */ async getAvailableExposureCompensation() { try { const response = await this.apiCall("camera", "getAvailableExposureCompensation"); const defaultValue = [0, 15, -15, 1]; if (Array.isArray(response) && response.length === 4) { const [current, max, min, step] = response; return [current, max, min, step]; } return defaultValue; } catch (error) { this.log(`Error getting available exposure compensation values: ${error}`); throw error; } } /** * Gets the current exposure compensation value from the camera * @returns {Promise<number[]>} Array with the current exposure compensation value. Returns [0] as default if error occurs */ async getExposureCompensation() { try { const response = await this.apiCall("camera", "getExposureCompensation"); // Always return an array as expected by tests if (Array.isArray(response)) { return response; } else { return [response]; } } catch (error) { this.log(`Error getting exposure value: ${error instanceof Error ? error.message : String(error)}`); return [0]; // Default value in array format } } /** * Sets the exposure compensation value * @param {number} value - The exposure compensation value to set * @throws {Error} If value is out of valid range or if setting fails */ async setExposureCompensation(value) { try { const [current, max, min, step] = await this.getAvailableExposureCompensation(); if (value < min || value > max) { throw new Error(`Exposure value ${value} out of range (${min} to ${max})`); } await this.apiCall("camera", "setExposureCompensation", [value]); } catch (error) { this.log(`Error setting exposure value: ${error}`); throw error; } } /** * Takes a picture and returns the URL where the image can be downloaded * @returns {Promise<TakePictureUrl>} The URL to download the captured image * @throws {Error} If unable to take picture or get valid response after multiple attempts */ async takePicture() { try { this.log("📸 Starting capture..."); // 1. Check camera status const initialEvent = await this.apiCall("camera", "getEvent", [false]); this.log(`📊 Initial camera status: ${JSON.stringify(initialEvent)}`); // 2. Start capture and get response let attempts = 0; const maxAttempts = 3; let actResult; while (attempts < maxAttempts) { try { actResult = await this.apiCall("camera", "actTakePicture"); this.log(`📸 actTakePicture response: ${JSON.stringify(actResult)}`); // Verify if we have a valid URL if (Array.isArray(actResult) && actResult[0] !== "") { const url = actResult[0]; if (typeof url === 'string' && url.startsWith('http')) { this.log("✅ URL obtained directly from actTakePicture"); return url; } } // If we get here, response doesn't have expected format throw new Error("Unexpected response format"); } catch (error) { this.log(`⚠️ Attempt ${attempts + 1}/${maxAttempts} failed: ${error.message}`); attempts++; if (attempts < maxAttempts) { const delay = Math.pow(2, attempts) * 1000; // Exponential backoff await new Promise(resolve => setTimeout(resolve, delay)); } } } // If all attempts fail, throw error throw new Error("Could not obtain photo URL after multiple attempts"); } catch (error) { this.log(`❌ Error taking picture: ${error.message}`); throw error; } } /** * Sets the shooting mode of the camera * @param {ShootMode} mode - The shooting mode to set * @throws {Error} If mode is null or if setting the mode fails */ async setShootMode(mode) { if (!mode) { throw new Error("Shoot mode cannot be null"); } try { // First check if the mode is available const availableModes = await this.getAvailableShootMode(); let modesArray = []; // Normalize the response to extract the array of available modes if (Array.isArray(availableModes)) { if (availableModes.length > 0) { // If first element is an array, that's the modes array if (Array.isArray(availableModes[0])) { modesArray = availableModes[0]; } // If there's at least one element and it's a string, use that else { modesArray = availableModes; } } } // Check if requested mode is available // If we can't determine available modes, try anyway if (modesArray.length > 0 && !modesArray.includes(mode)) { this.log(`Warning: Mode ${mode} might not be available. Available modes: ${modesArray.join(', ')}`); } this.log(`Setting shoot mode to: ${mode}`); await this.apiCall("camera", "setShootMode", [mode]); this.log(`Shoot mode successfully set to: ${mode}`); } catch (error) { // Detailed error logging if (error instanceof Error) { this.log(`Error setting shoot mode ${mode}: ${error.message}`); // Handle specific error codes if (error.message.includes("40400")) { throw new Error(`Error taking picture. Camera might not be ready. Original error: ${error.message}`); } else if (error.message.includes("40401")) { throw new Error(`Camera not ready. Please try again in a moment. Original error: ${error.message}`); } } throw error; } } /** * Gets the current shooting mode * @returns {Promise<ShootMode[]>} Array containing the current shoot mode */ async getShootMode() { try { const response = await this.apiCall("camera", "getShootMode"); // Ensure we always return an array as expected by tests if (Array.isArray(response)) { return response; } else { return [response]; } } catch (error) { this.log(`Error getting shoot mode: ${error instanceof Error ? error.message : String(error)}`); // In case of error, return a default value that matches expected format return ["still"]; } } /** * Gets the list of available shooting modes * @returns {Promise<ShootMode[]>} Array of available shooting modes */ async getAvailableShootMode() { try { this.log("Getting available shoot modes..."); const result = await this.apiCall("camera", "getAvailableShootMode"); this.log(`📊 Raw response from getAvailableShootMode: ${JSON.stringify(result)}`); // Normalize response to standard format if (Array.isArray(result)) { // Case 1: If complete result is an array if (result.length > 0) { // Case 1.1: If first element is an array, assume it contains modes if (Array.isArray(result[0])) { const modes = result[0].filter(mode => typeof mode === 'string'); if (modes.length > 0) { this.log(`📊 Modes found in result[0]: ${JSON.stringify(modes)}`); return modes; } } // Case 1.2: If second element exists and is an array, it's another common format (current value + available values) else if (result.length > 1 && Array.isArray(result[1])) { const modes = result[1].filter(mode => typeof mode === 'string'); if (modes.length > 0) { this.log(`📊 Modes found in result[1]: ${JSON.stringify(modes)}`); return modes; } } // Case 1.3: Check if elements are strings directly const stringModes = result.filter(item => typeof item === 'string'); if (stringModes.length > 0) { this.log(`📊 Modes found as strings in main array: ${JSON.stringify(stringModes)}`); return stringModes; } } } else if (typeof result === 'object' && result !== null) { // Case 2: If result is an object, look for properties that might contain modes // Case 2.1: Check if there's an 'availableShootModes' property if ('availableShootModes' in result && Array.isArray(result.availableShootModes)) { const modes = result.availableShootModes.filter((mode) => typeof mode === 'string'); if (modes.length > 0) { this.log(`📊 Modes found in result.availableShootModes: ${JSON.stringify(modes)}`); return modes; } } // Case 2.2: Look for any property that is an array of strings for (const key in result) { if (Array.isArray(result[key])) { const modes = result[key].filter(item => typeof item === 'string' && ['still', 'movie', 'audio', 'intervalstill', 'loop'].includes(item)); if (modes.length > 0) { this.log(`📊 Modes found in result.${key}: ${JSON.stringify(modes)}`); return modes; } } } } // Try to get current mode as alternative try { const currentMode = await this.getShootMode(); if (Array.isArray(currentMode) && currentMode.length > 0 && typeof currentMode[0] === 'string') { this.log(`📊 Using current mode as available value: ${currentMode[0]}`); return [currentMode[0]]; } } catch (e) { this.log(`Error getting current mode as alternative: ${e}`); } // If no valid format found, return default value this.log("⚠️ Could not determine response format, using default values"); return ["still", "movie"]; } catch (error) { this.log(`❌ Error getting shoot modes: ${error instanceof Error ? error.message : String(error)}`); // Return default value in case of error return ["still", "movie"]; } } /** * Starts the liveview feed * @param {LiveviewSize} [size=null] - The size of the liveview feed * @returns {Promise<string[]>} Array containing the liveview URL(s) * @throws {Error} If size is invalid or if starting liveview fails */ async startLiveview(size = null) { // If size is provided, validate it's not an empty string if (size !== null && (!size || typeof size !== 'string')) { throw new Error("Liveview size must be a valid string or null"); } try { let response; if (size) { response = await this.apiCall("camera", "startLiveviewWithSize", [size]); } else { response = await this.apiCall("camera", "startLiveview"); } // Ensure we always return an array with the URL if (Array.isArray(response)) { return response; } else if (typeof response === 'string') { return [response]; } else { this.log(`Unexpected liveview response: ${JSON.stringify(response)}`); throw new Error("Unexpected liveview response format"); } } catch (error) { this.log(`Error starting liveview: ${error instanceof Error ? error.message : String(error)}`); throw error; } } /** * Stops the liveview feed * @returns {Promise<void>} */ async stopLiveview() { return this.apiCall("camera", "stopLiveview"); } /** * Controls the camera's zoom * @param {ZoomDirection} direction - The direction to zoom (in/out) * @param {ZoomMovement} movement - The type of zoom movement * @returns {Promise<void>} * @throws {Error} If direction or movement parameters are null */ async zoom(direction, movement) { if (!direction) { throw new Error("Zoom direction cannot be null"); } if (!movement) { throw new Error("Zoom movement type cannot be null"); } return this.apiCall("camera", "actZoom", [direction, movement]); } // ---- Methods for exposure, ISO and shutter speed control ---- /** * Gets the list of available ISO speed rates * @returns {Promise<string[]>} Array of available ISO values * @throws {Error} If unable to retrieve ISO values */ async getAvailableIsoSpeedRate() { try { const result = await this.apiCall("camera", "getAvailableIsoSpeedRate"); return result.length > 1 && Array.isArray(result[1]) ? result[1] : result; } catch (error) { this.log(`Error getting available ISO values: ${error}`); throw error; } } /** * Gets the current ISO speed rate * @returns {Promise<string[]>} Array containing the current ISO value */ async getIsoSpeedRate() { try { const response = await this.apiCall("camera", "getIsoSpeedRate"); // Ensure we always return an array for tests if (Array.isArray(response)) { return response; } else { return [response]; } } catch (error) { this.log(`Error getting current ISO value: ${error}`); return ["AUTO"]; // Return default value in array format } } /** * Sets the ISO speed rate * @param {string} isoValue - The ISO value to set * @throws {Error} If ISO value is null or not in the list of valid values */ async setIsoSpeedRate(isoValue) { if (!isoValue) { throw new Error("ISO value cannot be null"); } try { const response = await this.getAvailableIsoSpeedRate(); let validValues = []; if (Array.isArray(response)) { // Response format is [currentValue, availableValues] if (response.length >= 2 && Array.isArray(response[1])) { validValues = response[1]; } else { validValues = response; } } if (!validValues.includes(isoValue)) { this.log(`ISO value ${isoValue} not available. Allowed values: ${JSON.stringify(validValues)}`); throw new Error(`ISO value ${isoValue} is not in the list of allowed values: ${validValues.join(", ")}`); } await this.apiCall("camera", "setIsoSpeedRate", [isoValue]); this.log(`ISO set to ${isoValue}`); } catch (error) { this.log(`Error setting ISO value: ${error}`); throw error; } } /** * Gets the list of available shutter speeds * @returns {Promise<string[]>} Array of available shutter speed values * @throws {Error} If unable to retrieve shutter speed values */ async getAvailableShutterSpeed() { try { const result = await this.apiCall("camera", "getAvailableShutterSpeed"); return result.length > 1 && Array.isArray(result[1]) ? result[1] : result; } catch (error) { this.log(`Error getting available shutter speed values: ${error}`); throw error; } } /** * Gets the current shutter speed * @returns {Promise<string[]>} Array containing the current shutter speed value */ async getShutterSpeed() { try { const response = await this.apiCall("camera", "getShutterSpeed"); // Ensure we always return an array for tests if (Array.isArray(response)) { return response; } else { return [response]; } } catch (error) { this.log(`Error getting current shutter speed value: ${error}`); return ["1/60"]; // Return default value in array format } } /** * Sets the shutter speed * @param {string} shutterSpeedValue - The shutter speed value to set * @throws {Error} If shutter speed value is null or not in the list of valid values */ async setShutterSpeed(shutterSpeedValue) { if (!shutterSpeedValue) { throw new Error("Shutter speed value cannot be null"); } try { const response = await this.getAvailableShutterSpeed(); let validValues = []; if (Array.isArray(response)) { // Response format is [currentValue, availableValues] if (response.length >= 2 && Array.isArray(response[1])) { validValues = response[1]; } else { validValues = response; } } if (!validValues.includes(shutterSpeedValue)) { this.log(`Shutter speed ${shutterSpeedValue} not available. Allowed values: ${JSON.stringify(validValues)}`); throw new Error(`Shutter speed ${shutterSpeedValue} is not in the list of allowed values: ${validValues.join(", ")}`); } await this.apiCall("camera", "setShutterSpeed", [shutterSpeedValue]); this.log(`Shutter speed set to ${shutterSpeedValue}`); } catch (error) { this.log(`Error setting shutter speed: ${error}`); throw error; } } } //# sourceMappingURL=sony-camera.js.map