# Somfy SDK
A Rust library providing type-safe, async access to the Somfy API for controlling smart home devices.
[](https://github.com/markusz/somfy-sdk/actions/workflows/tests.yml)
[](https://crates.io/crates/somfy-sdk)
[](https://docs.rs/somfy-sdk)
## Overview
The SDK provides a comprehensive, type-safe interface for interacting with Somfy smart home devices through the [Somfy API](https://somfy-developer.github.io/Somfy-TaHoma-Developer-Mode/#/). 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`:
```toml
[dependencies]
somfy_sdk = { package = "somfy-sdk", version = "0.2" }
tokio = { version = "1.0", features = ["full"] }
```
## Quick Start
```rust
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](https://somfy-developer.github.io/Somfy-TaHoma-Developer-Mode/openapi.yaml):
| **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:
```rust
// 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:
```rust
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](https://ca.overkiz.com/overkiz-root-ca-2048.crt) 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.
```rust
let config = ApiClientConfig {
cert_handling: CertificateHandling::DefaultCert,
// ... other config
};
```
`DefaultCert` is the default strategy used for the shorthand `ApiClient::from(..)`.
```rust
// 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`
```rust
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
```rust
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`:
```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:
```rust
// ⚠️ 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](#extending-the-sdk-with-custom-commands) section). These provide compile-time safety and prevent accidental misuse.
## API Reference
### Core Types
#### `ApiClient`
The main client for interacting with Somfy APIs:
```rust
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
```rust
// 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
```rust
// 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
```rust
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:
```rust
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:
```bash
# 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](https://github.com/markusz/somfy-sdk-cli/issues)), 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.
```rust
// ./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:
```rust
// ❌ 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:
```rust
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:
```rust
#[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:
```rust
// 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](../LICENSE) file for details.