1 unstable release
| 0.2.2 | Aug 19, 2025 |
|---|
#437 in Web programming
98KB
2K
SLoC
Somfy SDK
A Rust library providing type-safe, async access to the Somfy API for controlling smart home devices.
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:
DefaultCert (Recommended & Default)
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 errorsInvalidRequestError- Malformed requestsNotFoundError- 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
- Safety First: Use type-safe, domain-specific commands for potentially harmful operations
- Handle API quirks: Override
from_body()to handle undocumented behaviors gracefully - Validation: Validate parameters at compile-time with newtypes or at runtime with bounds checking
- Hard-code device URLs: For device-specific commands, hard-code URLs to prevent targeting wrong devices
- Meaningful errors: Provide clear error messages for validation failures
- Testing: Add comprehensive unit tests, especially for edge cases and API quirks
- 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