#smart-home #devices #somfy #overkiz

somfy-sdk

A Rust-based SDK for interacting with Somfy smart home devices and APIs

1 unstable release

0.2.2 Aug 19, 2025

#437 in Web programming

MIT license

98KB
2K SLoC

Somfy SDK

A Rust library providing type-safe, async access to the Somfy API for controlling smart home devices.

Unit & Integration Tests Crates.io Documentation

Overview

The SDK provides a comprehensive, type-safe interface for interacting with Somfy smart home devices through the Somfy API. It supports device discovery, state management, event handling, and action execution with built-in error handling and TLS support for self-signed certificates.

Features

  • Type-safe API client with async support using Tokio
  • Comprehensive API coverage - all Somfy API endpoints
  • Extensible command system for adding new API endpoints
  • Robust error handling with custom error types
  • TLS/SSL support with custom certificate handling
  • Bearer token authentication for secure API access
  • Structured logging with configurable log levels

Installation

Add this to your Cargo.toml:

[dependencies]
somfy_sdk = { package = "somfy-sdk", version = "0.2" }
tokio = { version = "1.0", features = ["full"] }

Quick Start

use somfy_sdk::api_client::ApiClient;
use somfy_sdk::err::http::RequestError;

#[tokio::main]
async fn main() -> Result<(), RequestError> {
    // Initialize logging, requires concrete logger
    // E.g. add env_logger = "0.11" to Cargo.toml
    env_logger::init();

    // Create API client using gateway ID and API key
    let client = ApiClient::from("0000-1111-2222", "your-api-key").await?;

    // Get API version
    let version = client.get_version().await?;
    println!("Protocol version: {}", version.protocol_version);

    // Get all devices
    let devices = client.get_devices().await?;
    for device in &devices {
        println!("Device: {} ({})", device.label, device.device_url);
    }

    Ok(())
}

Supported API Endpoints

This SDK implements the complete Somfy API:

Category Endpoint Method SDK Method Description
System /apiVersion GET get_version() Get API protocol version
Setup /setup/gateways GET get_gateways() List available gateways
Setup /setup GET get_setup() Get complete setup information
Setup /setup/devices GET get_devices() List all devices
Setup /setup/devices/{deviceURL} GET get_device() Get specific device details
Setup /setup/devices/{deviceURL}/states GET get_device_states() Get device states
Setup /setup/devices/{deviceURL}/states/{name} GET get_device_state() Get specific device state
Setup /setup/devices/controllables/{controllableName} GET get_devices_by_controllable() Get devices by controllable type
Events /events/register POST register_event_listener() Register event listener
Events /events/{listenerId}/fetch POST fetch_events() Fetch events from listener
Events /events/{listenerId}/unregister POST unregister_event_listener() Unregister event listener
Execution /exec/apply POST execute_actions() ⚠️ Execute action group (requires generic-exec feature)
Execution /exec/current GET get_current_executions() Get all current executions
Execution /exec/current/{executionId} GET get_execution() Get specific execution status
Execution /exec/current/setup DELETE cancel_all_executions() Cancel all executions
Execution /exec/current/setup/{executionId} DELETE cancel_execution() Cancel specific execution

Configuration

Easy Setup

The simplest way to create a client:

// Gateway ID format: "0000-1111-2222" 
// This automatically configures HTTPS, port 8443, and certificate handling
let client = ApiClient::from("your-gateway-id", "your-api-key").await?;

Advanced Configuration

For more control, use the full configuration:

use somfy_sdk::api_client::{ApiClient, ApiClientConfig, HttpProtocol, CertificateHandling};

let config = ApiClientConfig {
    url: "gateway-0000-1111-2222.local".to_string(),
    port: 8443,
    api_key: "your-api-key".to_string(),
    protocol: HttpProtocol::HTTPS,
    cert_handling: CertificateHandling::DefaultCert,
};

let client = ApiClient::new(config).await?;

Certificate Handling

Somfy gateways use self-signed certificates, requiring specific certificate handling strategies. The SDK provides three approaches:

Automatically transparently downloads the root CA from here to $HOME/.somfy_sdk/cert.crt and trusts it. The certificate will be cached indefinitely and will not be checked for expiry. Delete the local file to trigger a redownload.

let config = ApiClientConfig {
    cert_handling: CertificateHandling::DefaultCert,
    // ... other config
};

DefaultCert is the default strategy used for the shorthand ApiClient::from(..).

// This uses DefaultCert automatically
let client = ApiClient::from("0000-1111-2222", "your-api-key");

CertProvided(path)

Use a manually provided certificate file. The cert will not be cached and needs to be provided for every instantiation of ApiClient

let config = ApiClientConfig {
    cert_handling: CertificateHandling::CertProvided("/path/to/cert.pem".to_string()),
    // ... other config
};

NoCustomCert

Do not add a root certificate to the reqwest trust chain. This will only work against endpoints that present certificates of trusted CAs. somfy-sdk uses reqwest with rustls-tls-native-roots which respects certificates trusted at the OS level

let config = ApiClientConfig {
    cert_handling: CertificateHandling::NoCustomCert,
    // ... other config
};

Feature Flags

The SDK uses feature flags to control access to potentially dangerous functionality:

generic-exec feature

The execute_actions() method is gated behind the generic-exec feature flag because it provides raw access to the /exec/apply endpoint, which can potentially harm your Somfy devices if used incorrectly.

Enabling the feature

Add the feature to your Cargo.toml:

[dependencies]
somfy_sdk = { package = "somfy-sdk", version = "0.2", features = ["generic-exec"]}

Why is this feature gated?

The generic execution API allows sending arbitrary commands to any device:

// ⚠️ This can be dangerous - wrong device URL or command can cause damage
let actions = vec![Action {
    device_url: "io://0000-1111-2222/12345678".to_string(),
    commands: vec![Command {
        name: "writeManufacturerData".to_string(),  // 💀 Danger!
        parameters: vec!["invalid-data".to_string()],
    }],
}];

client.execute_actions(&ActionGroup { 
    label: Some("Dangerous operation".to_string()), 
    actions 
}).await;

Safer Alternative: Custom Commands

Instead of using the generic API, we strongly recommend creating type-safe, domain-specific commands (see Extending the SDK section). These provide compile-time safety and prevent accidental misuse.

API Reference

Core Types

ApiClient

The main client for interacting with Somfy APIs:

impl ApiClient {
    // Core client creation
    pub async fn new(config: ApiClientConfig) -> Result<Self, RequestError>;
    pub async fn from(id: &str, api_key: &str) -> Result<Self, RequestError>;
    
    // System information
    pub async fn get_version(&self) -> Result<GetVersionCommandResponse, RequestError>;
    
    // Setup and device discovery
    pub async fn get_gateways(&self) -> Result<GetGatewaysResponse, RequestError>;
    pub async fn get_setup(&self) -> Result<GetSetupResponse, RequestError>;
    pub async fn get_devices(&self) -> Result<GetDevicesResponse, RequestError>;
    pub async fn get_device(&self, device_url: &str) -> Result<GetDeviceResponse, RequestError>;
    pub async fn get_device_states(&self, device_url: &str) -> Result<GetDeviceStatesResponse, RequestError>;
    pub async fn get_device_state(&self, device_url: &str, state_name: &str) -> Result<GetDeviceStateResponse, RequestError>;
    pub async fn get_devices_by_controllable(&self, controllable_name: &str) -> Result<GetDevicesByControllableResponse, RequestError>;
    
    // Event management
    pub async fn register_event_listener(&self) -> Result<RegisterEventListenerResponse, RequestError>;
    pub async fn fetch_events(&self, listener_id: &str) -> Result<FetchEventsResponse, RequestError>;
    pub async fn unregister_event_listener(&self, listener_id: &str) -> Result<UnregisterEventListenerResponse, RequestError>;
    
    // Action execution
    // ⚠️ execute_actions needs to be enabled via the generic-exec feature flag. Be very careful when using it, as it can potentially harm your Somfy devices
    pub async fn execute_actions(&self, request: &ActionGroup) -> Result<ExecuteActionsResponse, RequestError>; 
    pub async fn get_current_executions(&self) -> Result<GetCurrentExecutionsResponse, RequestError>;
    pub async fn get_execution(&self, execution_id: &str) -> Result<GetExecutionResponse, RequestError>;
    pub async fn cancel_all_executions(&self) -> Result<CancelAllExecutionsResponse, RequestError>;
    pub async fn cancel_execution(&self, execution_id: &str) -> Result<CancelExecutionResponse, RequestError>;
}

Usage Examples

Device Discovery and Management

// Get complete setup information
let setup = client.get_setup().await?;
println!("Setup contains {} gateways and {} devices", 
         setup.gateways.len(), 
         setup.devices.len());

// Get all devices
let devices = client.get_devices().await?;
for device in devices {
    println!("Device: {} ({})", device.label, device.controllable_name);
}

// Get device states
if let Some(device) = devices.first() {
    let states = client.get_device_states(&device.device_url).await?;
    for state in states {
        println!("State {}: {:?}", state.name, state.value);
    }
}

Event Management

// Register event listener
let listener = client.register_event_listener().await?;
println!("Event listener registered with ID: {}", listener.id);

// Fetch events (typically done in a loop)
let events = client.fetch_events(&listener.id).await?;
println!("Fetched events: {:?}", events);

// Unregister when done
client.unregister_event_listener(&listener.id).await?;

Action Execution

use somfy_sdk::commands::types::{Action, Command, ActionGroup};

let actions = vec![Action {
    device_url: "io://0000-1111-2222/12345678".to_string(),
    commands: vec![Command {
        name: "open".to_string(),
        parameters: vec![],
    }]
}];

let request = ActionGroup {
    label: Some("Open blinds".to_string()),
    actions
};

let execution = client.execute_actions(&request).await?;
println!("Execution started: {}", execution.id);

// Monitor execution
let execution_details = client.get_execution(&execution.id).await?;
println!("Execution status: {:?}", execution_details);

Error Handling

The SDK provides comprehensive error handling through the RequestError enum:

use somfy_sdk::err::http::RequestError;

match client.get_version().await {
    Ok(version) => println!("Version: {}", version.protocol_version),
    Err(RequestError::CertError) => eprintln!("Certificate validation failed"),
    Err(RequestError::AuthError) => eprintln!("Authentication failed - check API key"),
    Err(RequestError::InvalidBody) => eprintln!("Invalid response format"),
    Err(RequestError::UnknownError) => eprintln!("Unknown error occurred"),
    // ... other error types
}

Error Types

  • CertError - TLS certificate validation issues (common with self-signed certs)
  • AuthError - Authentication failures (invalid API key, unauthorized)
  • InvalidBody - JSON parsing or response format errors
  • InvalidRequestError - Malformed requests
  • NotFoundError - Resource not found (404)
  • ServerError - Server-side errors (5xx)
  • UnknownError - Catch-all for unexpected errors

Testing

Run the SDK tests:

# Run SDK tests only
cargo test --lib

# Run Integration tests against local mock server
# Uses json-server@0.17.x
json-server ./tests/mock_api/db.json --routes ./tests/mock_api/routes.json --port 3000 --host 0.0.0.0  
cargo test --test http_tests

Architecture

SDK Structure

sdk/
├── src/
│   ├── api_client.rs           # Main API client implementation
│   ├── commands/               # API command definitions
│   │   ├── traits.rs           # Command traits and interfaces
│   │   ├── types.rs            # Shared types and data structures
│   │   ├── get_version.rs      # Version command implementation
│   │   ├── get_setup.rs        # Setup command implementation
│   │   └── ...                 # Other command implementations
│   ├── config/                 # Configuration modules
│   ├── err/                    # Error handling
│   └── lib.rs                  # Library root
└── tests/                      # Integration tests
    └── fixtures/               # Test data

Extending the SDK with Custom Commands

The SDK is built for extensibility. You can adapt to API changes, handle undocumented behaviors, and create type-safe, domain-specific commands by implementing the required traits.

Why Extend the SDK?

There are two primary use cases for creating custom commands:

1. Adapting to API Changes and Undocumented Behavior

The real-world API sometimes deviates from the API specification (e.g., see example below). While we strive to find and cover all such scenarios (please raise an issue here), this may happen with your specific Somfy configuration. In such scenarios, you can use a custom command to work around this behavior until the fix is implemented in mainline.

// ./sdk/src/get_execution.rs
impl SomfyApiRequestResponse for GetExecutionResponse {
    fn from_body(body: &str) -> Result<GetExecutionResponse, RequestError> {
        // Handle undocumented API behavior:
        // - For existing but past execId, returns "null"
        // - For non-existing execId, returns "[]"  
        if body == "null" || body == "[]" {
            return Err(RequestError::Status {
                source: None,
                status: StatusCode::NOT_FOUND,
            });
        }
        Ok(serde_json::from_str(body)?)
    }
}

2. Creating Type-Safe, Domain-Specific Commands

The generic execute actions API (/exec/apply) is powerful but can be dangerous if misused. It is thus disabled by default and needs to be enabled through the "generic-exec" feature flag.

Custom commands provide compile-time safety and prevent accidental misuse by making commands explicit and known at compile time.

Consider the following example:

// ❌ Generic API with client.execute_actions(action) enabled - easy to make potentially destructive mistakes
let request = ActionGroup {
    label: Some(action_group_label),
    actions: vec![Action {
        device_url: "device-url".to_string(),
        commands: vec![Command {
            name: "writeManufacturerData".to_string(),  // 💀 Running this can really ruin your day
            parameters: vec!["some-config".to_string()],
        }],
    }]
};

api_client.execute_actions(&request).await

// ✅ Type-safe domain command - impossible to misuse, client.execute_actions(..) not even available

let cmd = CloseLivingRoomShuttersCommand { position: 75 }; // see implementation below
client.execute(cmd).await?;

Implementation Examples

Type-Safe Device Commands

Here's how to create a domain-specific command that prevents dangerous mistakes:

use reqwest::Body;
use somfy_sdk::api_client::ApiClient;
use somfy_sdk::commands::execute_action_group::ExecuteActionGroupResponse;
use somfy_sdk::commands::traits::{HttpMethod, RequestData, SomfyApiRequestCommand};
use somfy_sdk::commands::types::{Action, ActionGroup, Command};
use somfy_sdk::err::http::RequestError;
use std::collections::HashMap;

// Type-safe command for a specific device with validation
#[derive(Debug, Clone, PartialEq)]
pub struct CloseLivingRoomShuttersCommand {
    pub position: u8, // 0-100, validated at compile time via newtypes if needed
}

impl SomfyApiRequestCommand for CloseLivingRoomShuttersCommand {
    type Response = ExecuteActionGroupResponse;

    fn to_request(&self) -> Result<RequestData, RequestError> {
        // Hard-coded device URLs - impossible to target wrong devices
        const LIVING_ROOM_SHUTTER_EAST_URL: &str = "io://0000-1111-2222/12345678";
        const LIVING_ROOM_SHUTTER_SOUTH_URL: &str = "io://0000-1111-2222/87654321";

        // Validate position at runtime (or use newtypes for compile-time validation)
        let position = self.position.min(100);

        let action_group = ActionGroup {
            label: Some("Close living room shutters".to_string()),
            actions: vec![
                Action {
                    device_url: LIVING_ROOM_SHUTTER_EAST_URL.to_string(),
                    commands: vec![Command {
                        name: "setClosure".to_string(),
                        parameters: vec![position.to_string()],
                    }],
                },
                Action {
                    device_url: LIVING_ROOM_SHUTTER_SOUTH_URL.to_string(),
                    commands: vec![Command {
                        name: "setClosure".to_string(),
                        parameters: vec![position.to_string()],
                    }],
                },
            ],
        };

        let body_json = serde_json::to_string(&action_group)?;

        Ok(RequestData {
            path: "/enduser-mobile-web/1/enduserAPI/exec/apply".to_string(),
            method: HttpMethod::POST,
            body: Body::from(body_json),
            query_params: HashMap::new(),
            header_map: RequestData::default_post_headers()?,
        })
    }
}

#[tokio::main]
async fn main() -> Result<(), RequestError> {
    let client = ApiClient::from("gateway-id", "api-key").await?;
    let response = client
        .execute(CloseLivingRoomShuttersCommand { position: 75 })
        .await?;
    println!("Started execution: {}", response.exec_id);
    Ok(())
}

Handling API Quirks and Custom Response Processing

Adapt to undocumented behaviors by customizing response handling. Here's a hypothetical example where the API introduces inconsistent response formats:

#[derive(Debug, Clone, PartialEq)]
pub struct GetDeviceStatusCommand<'a> {
    pub device_url: &'a str,
}

#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct DeviceStatusResponse {
    pub status: String,
    pub is_online: bool,
}

impl SomfyApiRequestResponse for DeviceStatusResponse {
    fn from_body(body: &str) -> Result<Self, RequestError> {
        // Handle API returning different formats based on device state
        if body.trim().is_empty() {
            // Empty response means device is offline
            return Ok(DeviceStatusResponse {
                status: "offline".to_string(),
                is_online: false,
            });
        }
        
        if body == "\"maintenance\"" {
            // API sometimes returns a plain string for maintenance mode
            return Ok(DeviceStatusResponse {
                status: "maintenance".to_string(),
                is_online: false,
            });
        }
        
        // Try to parse as regular JSON
        match serde_json::from_str::<serde_json::Value>(body)? {
            serde_json::Value::Object(map) => {
                let status = map.get("status")
                    .and_then(|v| v.as_str())
                    .unwrap_or("unknown")
                    .to_string();
                    
                let is_online = status == "available" || status == "online";
                
                Ok(DeviceStatusResponse { status, is_online })
            }
            _ => Err(RequestError::InvalidBody),
        }
    }
}

Best Practices for Custom Commands

  1. Safety First: Use type-safe, domain-specific commands for potentially harmful operations
  2. Handle API quirks: Override from_body() to handle undocumented behaviors gracefully
  3. Validation: Validate parameters at compile-time with newtypes or at runtime with bounds checking
  4. Hard-code device URLs: For device-specific commands, hard-code URLs to prevent targeting wrong devices
  5. Meaningful errors: Provide clear error messages for validation failures
  6. Testing: Add comprehensive unit tests, especially for edge cases and API quirks
  7. Documentation: Document any API behaviors your commands work around

Integration with Built-in Commands

Your custom commands work seamlessly with the existing SDK infrastructure:

// Mix custom and built-in commands
let version = client.get_version().await?;
let response = client.execute(CloseLivingRoomShuttersCommand { position: 50 }).await?;
let devices = client.get_devices().await?;

println!("API Version: {}, Execution: {}", version.protocol_version, response.exec_id);

License

This project is licensed under the MIT License - see the LICENSE file for details.

Dependencies

~6–21MB
~227K SLoC