From a37adba7d93b86f48f8657c9329e311d47647864 Mon Sep 17 00:00:00 2001 From: Aruna Tennakoon Date: Sun, 28 Dec 2025 13:31:34 +0700 Subject: [PATCH 1/2] - feat: Module settings commands. - fix: Missing scope in response. - fix: Missing mac in websocket header. --- CHANGELOG.md | 5 + examples/settings/device/README.md | 83 ++++++++ .../device/device_settings_example.py | 186 ++++++++++++++++++ examples/settings/module/README.md | 59 ++++++ .../module/module_settings_example.py | 141 +++++++++++++ pyproject.toml | 2 +- sinricpro/__init__.py | 2 +- sinricpro/core/sinric_pro.py | 87 +++++++- sinricpro/core/types.py | 3 + sinricpro/core/websocket_client.py | 15 ++ 10 files changed, 580 insertions(+), 3 deletions(-) create mode 100644 examples/settings/device/README.md create mode 100644 examples/settings/device/device_settings_example.py create mode 100644 examples/settings/module/README.md create mode 100644 examples/settings/module/module_settings_example.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 82683b5..8009e53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## [5.1.1] +- feat: Module settings commands. +- fix: Missing scope in response. +- fix: Missing mac in websocket header. + ## [5.0.1] - fix: [Periodic disconnects of WebSocketClient](https://github.com/sinricpro/python-sdk/issues/79) diff --git a/examples/settings/device/README.md b/examples/settings/device/README.md new file mode 100644 index 0000000..5edd57c --- /dev/null +++ b/examples/settings/device/README.md @@ -0,0 +1,83 @@ +# Device Settings Example + +This example demonstrates how to handle **device-level settings** in SinricPro. + +## Device Settings vs Module Settings + +SinricPro supports two types of settings: + +### Device Settings +- Configuration for **this specific device** +- Registered via: `device.on_setting(callback)` +- Examples: Tilt angle, speed, direction, auto-close timeout +- Callback signature: `async def callback(setting_id: str, value: Any) -> bool` +- Settings are configured per-device in the SinricPro portal + +### Module Settings +- Configuration for the **module (dev board)** itself +- Registered via: `sinric_pro.on_set_setting(callback)` +- Examples: WiFi retry count, log level, heartbeat interval +- Callback signature: `async def callback(setting_id: str, value: Any) -> bool` + +## Usage + +```python +from sinricpro import SinricProBlinds + +blinds = SinricProBlinds("your-device-id") + +# Register device-level setting callback +async def on_device_setting(setting_id: str, value: Any) -> bool: + if setting_id == "tilt": + set_blinds_tilt(value) + return True + elif setting_id == "speed": + set_motor_speed(value) + return True + return False + +blinds.on_setting(on_device_setting) +``` + +## Setting Value Types + +Device settings can have different value types: + +| Setting | Type | Example Values | +|---------|------|----------------| +| `tilt` | Integer | 0-100 | +| `direction` | String | "up", "down" | +| `speed` | String | "slow", "normal", "fast" | +| `auto_close` | Boolean | true, false | +| `close_timeout` | Integer | 60-3600 (seconds) | + +## Running the Example + +1. Replace `YOUR_DEVICE_ID_HERE` with your actual device ID +2. Set environment variables or replace credentials: + ```bash + export SINRICPRO_APP_KEY="your-app-key" + export SINRICPRO_APP_SECRET="your-app-secret" + ``` +3. Run the example: + ```bash + python device_settings_example.py + ``` + +## Configuring Settings in SinricPro Portal + +Device settings are configured in the SinricPro portal: + +1. Go to [sinric.pro](https://sinric.pro) +2. Navigate to your device +3. Click on "Settings" tab +4. Add or modify settings with their IDs and values +5. Save changes - the SDK will receive the new values via the callback + +## Best Practices + +1. **Validate Values**: Always validate setting values before applying them +2. **Type Checking**: Check the value type matches what you expect +3. **Range Validation**: Ensure numeric values are within valid ranges +4. **Return False on Error**: Return `False` if a setting cannot be applied +5. **Persist Settings**: Consider saving settings to non-volatile storage diff --git a/examples/settings/device/device_settings_example.py b/examples/settings/device/device_settings_example.py new file mode 100644 index 0000000..caf1eb3 --- /dev/null +++ b/examples/settings/device/device_settings_example.py @@ -0,0 +1,186 @@ +"""SinricPro Device Settings Example - Handle device-level configuration.""" +import asyncio +import os +from typing import Any + +from sinricpro import SinricPro, SinricProBlinds, SinricProConfig + +# Device ID from SinricPro portal +DEVICE_ID = "YOUR_DEVICE_ID_HERE" # Replace with your device ID + +# Credentials from SinricPro portal +APP_KEY = os.getenv("SINRICPRO_APP_KEY", "YOUR_APP_KEY_HERE") +APP_SECRET = os.getenv("SINRICPRO_APP_SECRET", "YOUR_APP_SECRET_HERE") + +# Device state +power_state = False +position = 0 # 0-100 + +# Device-specific settings +device_settings = { + "id_tilt": 50, # Tilt angle (0-100) + "id_direction": "up", # Movement direction preference + "id_speed": "normal", # Movement speed: slow, normal, fast + "id_auto_close": False, # Auto-close after timeout + "id_close_timeout": 300, # Auto-close timeout in seconds +} + + +async def on_power_state(state: bool) -> bool: + """Handle power state change requests.""" + global power_state + print(f"\n[Callback] Power: {'ON' if state else 'OFF'}") + power_state = state + return True + + +async def on_device_setting(setting_id: str, value: Any) -> bool: + """ + Handle device-level setting changes. + + Device settings are configuration values specific to this device, + such as tilt angle, movement direction, speed preferences, etc. + + Args: + setting_id: The setting identifier (e.g., "tilt", "direction") + value: The new value for the setting (can be int, float, bool, or string) + + Returns: + True if the setting was applied successfully, False otherwise + """ + print(f"\n[Device Setting] {setting_id} = {value} (type: {type(value).__name__})") + + # Handle tilt setting + if setting_id == "id_tilt": + if isinstance(value, (int, float)) and 0 <= value <= 100: + device_settings["tilt"] = int(value) + print(f" Tilt angle set to {int(value)}%") + # TODO: Apply tilt to physical device + # set_blinds_tilt(int(value)) + return True + else: + print(f" Invalid tilt value: {value} (must be 0-100)") + return False + + # Handle direction setting + elif setting_id == "id_direction": + valid_directions = ["up", "down"] + if isinstance(value, str) and value.lower() in valid_directions: + device_settings["direction"] = value.lower() + print(f" Direction preference set to '{value.lower()}'") + return True + else: + print(f" Invalid direction value: {value} (must be 'up' or 'down')") + return False + + # Handle speed setting + elif setting_id == "id_speed": + valid_speeds = ["slow", "normal", "fast"] + if isinstance(value, str) and value.lower() in valid_speeds: + device_settings["speed"] = value.lower() + print(f" Movement speed set to '{value.lower()}'") + # TODO: Apply speed to physical device + # set_motor_speed(value.lower()) + return True + else: + print(f" Invalid speed value: {value} (must be 'slow', 'normal', or 'fast')") + return False + + # Handle auto_close setting + elif setting_id == "id_auto_close": + if isinstance(value, bool): + device_settings["auto_close"] = value + print(f" Auto-close {'enabled' if value else 'disabled'}") + return True + else: + print(f" Invalid auto_close value: {value} (must be boolean)") + return False + + # Handle close_timeout setting + elif setting_id == "id_close_timeout": + if isinstance(value, (int, float)) and 60 <= value <= 3600: + device_settings["close_timeout"] = int(value) + print(f" Close timeout set to {int(value)} seconds") + return True + else: + print(f" Invalid close_timeout value: {value} (must be 60-3600)") + return False + + else: + print(f" Unknown setting: {setting_id}") + return False + + +async def main() -> None: + # Get SinricPro instance + sinric_pro = SinricPro.get_instance() + + # Create a blinds device + blinds = SinricProBlinds(DEVICE_ID) + + # Register device callbacks + blinds.on_power_state(on_power_state) + + # Register device-level setting callback + # This handles settings specific to this device + blinds.on_setting(on_device_setting) + + # Add device to SinricPro + sinric_pro.add(blinds) + + # Configure connection + config = SinricProConfig(app_key=APP_KEY, app_secret=APP_SECRET) + + try: + print("=" * 60) + print("SinricPro Device Settings Example") + print("=" * 60) + print("\nConnecting to SinricPro...") + await sinric_pro.begin(config) + print("Connected!") + + print("\n" + "=" * 60) + print("Device Settings vs Module Settings:") + print("=" * 60) + print(" Device Settings: Configuration for THIS specific device") + print(" - Registered via: device.on_setting(callback)") + print(" - Examples: Tilt angle, speed, direction, auto-close") + print(" - Callback receives: (setting_id, value)") + print("") + print(" Module Settings: Configuration for the module/board") + print(" - Registered via: sinric_pro.on_set_setting(callback)") + print(" - Examples: WiFi retry count, log level") + + print("\n" + "=" * 60) + print("Current Device Settings:") + print("=" * 60) + for key, value in device_settings.items(): + print(f" {key}: {value}") + + print("\n" + "=" * 60) + print("Voice Commands:") + print("=" * 60) + print(" 'Alexa, turn on [device name]'") + print(" 'Alexa, set [device name] to 50 percent'") + print(" (Device settings are configured via SinricPro portal)") + + print("\n" + "=" * 60) + print("Press Ctrl+C to exit") + print("=" * 60) + + while True: + await asyncio.sleep(1) + + except KeyboardInterrupt: + print("\n\nShutting down...") + except Exception as e: + print(f"\nError: {e}") + import traceback + traceback.print_exc() + finally: + await sinric_pro.stop() + print("Disconnected.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/settings/module/README.md b/examples/settings/module/README.md new file mode 100644 index 0000000..ba93897 --- /dev/null +++ b/examples/settings/module/README.md @@ -0,0 +1,59 @@ +# Module Settings Example + +This example demonstrates how to handle **module-level settings** in SinricPro. + +## Module Settings vs Device Settings + +SinricPro supports two types of settings: + +### Module Settings +- Configuration for the **module (dev board)** itself +- Registered via: `sinric_pro.on_set_setting(callback)` +- Examples: WiFi retry count, log level, heartbeat interval +- Callback signature: `async def callback(setting_id: str, value: Any) -> bool` + +### Device Settings +- Configuration for **individual devices** +- Registered via: `device.on_setting(callback)` +- Examples: Device-specific modes, thresholds, tilt settings +- Callback signature: `async def callback(setting_id: str, value: Any) -> bool` + +## Usage + +```python +from sinricpro import SinricPro + +sinric_pro = SinricPro.get_instance() + +# Register module-level setting callback +async def on_module_setting(setting_id: str, value: Any) -> bool: + if setting_id == "wifi_retry_count": + set_wifi_retry_count(value) + return True + return False + +sinric_pro.on_set_setting(on_module_setting) +``` + +## Running the Example + +1. Replace `YOUR_DEVICE_ID_HERE` with your actual device ID +2. Set environment variables or replace credentials: + ```bash + export SINRICPRO_APP_KEY="your-app-key" + export SINRICPRO_APP_SECRET="your-app-secret" + ``` +3. Run the example: + ```bash + python modulesettings_example.py + ``` + +## Setting Value Types + +Module settings can have different value types: +- **Integer**: `wifi_retry_count`, `heartbeat_interval` +- **String**: `log_level` +- **Boolean**: `debug_mode` +- **Float**: `temperature_offset` + +Always validate the value type and range before applying settings. diff --git a/examples/settings/module/module_settings_example.py b/examples/settings/module/module_settings_example.py new file mode 100644 index 0000000..dbd64cc --- /dev/null +++ b/examples/settings/module/module_settings_example.py @@ -0,0 +1,141 @@ +"""SinricPro Module Settings Example - Handle module-level configuration.""" +import asyncio +import os +from typing import Any + +from sinricpro import SinricPro, SinricProSwitch, SinricProConfig + +# Device ID from SinricPro portal +DEVICE_ID = "YOUR_DEVICE_ID_HERE" # Replace with your device ID + +# Credentials from SinricPro portal +APP_KEY = os.getenv("SINRICPRO_APP_KEY", "YOUR_APP_KEY_HERE") +APP_SECRET = os.getenv("SINRICPRO_APP_SECRET", "YOUR_APP_SECRET_HERE") + +# Module configuration values +module_config = { + "id_wifi_retry_count": 3, + "id_log_level": "INFO", + "id_heartbeat_interval": 300, +} + +async def on_power_state(state: bool) -> bool: + """Handle power state change for the switch device.""" + print(f"\n[Device] Power: {'ON' if state else 'OFF'}") + return True + + +async def on_module_setting(setting_id: str, value: Any) -> bool: + """ + Handle module-level setting changes. + + Module settings are configuration values for the module (dev board) itself, + not for individual devices. Examples include WiFi settings, logging level, + or other module-wide configurations. + + Args: + setting_id: The setting identifier (e.g., "wifi_retry_count") + value: The new value for the setting (can be int, float, bool, or string) + + Returns: + True if the setting was applied successfully, False otherwise + """ + print(f"\n[Module Setting] {setting_id} = {value}") + + # Handle different setting types + if setting_id == "id_wifi_retry_count": + if isinstance(value, int) and 1 <= value <= 10: + module_config["wifi_retry_count"] = value + print(f" WiFi retry count set to {value}") + return True + else: + print(f" Invalid wifi_retry_count value: {value}") + return False + + elif setting_id == "id_log_level": + valid_levels = ["DEBUG", "INFO", "WARN", "ERROR"] + if isinstance(value, str) and value.upper() in valid_levels: + module_config["log_level"] = value.upper() + print(f" Log level set to {value.upper()}") + return True + else: + print(f" Invalid log_level value: {value}") + return False + + elif setting_id == "id_heartbeat_interval": + if isinstance(value, int) and 60 <= value <= 600: + module_config["heartbeat_interval"] = value + print(f" Heartbeat interval set to {value} seconds") + return True + else: + print(f" Invalid heartbeat_interval value: {value}") + return False + + else: + print(f" Unknown setting: {setting_id}") + return False + + +async def main() -> None: + # Get SinricPro instance + sinric_pro = SinricPro.get_instance() + + # Create a switch device (module settings work alongside device settings) + switch = SinricProSwitch(DEVICE_ID) + switch.on_power_state(on_power_state) + + # Add device to SinricPro + sinric_pro.add(switch) + + # Register module-level setting callback + # This is separate from device-level settings (device.on_setting()) + sinric_pro.on_set_setting(on_module_setting) + + # Configure connection + config = SinricProConfig(app_key=APP_KEY, app_secret=APP_SECRET) + + try: + print("=" * 60) + print("SinricPro Module Settings Example") + print("=" * 60) + print("\nConnecting to SinricPro...") + await sinric_pro.begin(config) + print("Connected!") + + print("\n" + "=" * 60) + print("Module Settings vs Device Settings:") + print("=" * 60) + print(" Module Settings: Configuration for the module/board itself") + print(" - Registered via: sinric_pro.on_set_setting(callback)") + print(" - Examples: WiFi retry count, log level, heartbeat interval") + print("") + print(" Device Settings: Configuration for individual devices") + print(" - Registered via: device.on_setting(callback)") + print(" - Examples: Device-specific modes, thresholds, etc.") + + print("\n" + "=" * 60) + print("Current Module Configuration:") + print("=" * 60) + for key, value in module_config.items(): + print(f" {key}: {value}") + + print("\n" + "=" * 60) + print("Press Ctrl+C to exit") + print("=" * 60) + + while True: + await asyncio.sleep(1) + + except KeyboardInterrupt: + print("\n\nShutting down...") + except Exception as e: + print(f"\nError: {e}") + import traceback + traceback.print_exc() + finally: + await sinric_pro.stop() + print("Disconnected.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index d6d6f2c..2d95e1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sinricpro" -version = "5.0.1" +version = "5.1.1" description = "Official SinricPro SDK for Python - Control IoT devices with Alexa and Google Home" authors = [{name = "SinricPro", email = "support@sinric.pro"}] readme = "README.md" diff --git a/sinricpro/__init__.py b/sinricpro/__init__.py index 27ffd5e..1ecda46 100644 --- a/sinricpro/__init__.py +++ b/sinricpro/__init__.py @@ -9,7 +9,7 @@ This file is part of the SinricPro Python SDK (https://github.com/sinricpro/) """ -__version__ = "5.0.1" +__version__ = "5.1.1" from sinricpro.core.sinric_pro import SinricPro, SinricProConfig from sinricpro.core.sinric_pro_device import SinricProDevice diff --git a/sinricpro/core/sinric_pro.py b/sinricpro/core/sinric_pro.py index 30ded79..1f7b8f8 100644 --- a/sinricpro/core/sinric_pro.py +++ b/sinricpro/core/sinric_pro.py @@ -23,6 +23,7 @@ ConnectedCallback, DisconnectedCallback, PongCallback, + ModuleSettingCallback, ) from sinricpro.core.websocket_client import WebSocketClient, WebSocketConfig from sinricpro.utils.logger import SinricProLogger, LogLevel @@ -58,6 +59,7 @@ def __init__(self) -> None: self._connected_callbacks: list[ConnectedCallback] = [] self._disconnected_callbacks: list[DisconnectedCallback] = [] self._pong_callbacks: list[PongCallback] = [] + self._module_setting_callback: ModuleSettingCallback | None = None @classmethod def get_instance(cls) -> "SinricPro": @@ -226,6 +228,27 @@ def on_pong(self, callback: PongCallback) -> None: """ self._pong_callbacks.append(callback) + def on_set_setting(self, callback: ModuleSettingCallback) -> None: + """ + Register a callback for module-level setting changes. + + Module settings are configuration values for the module (dev board) itself, + not for individual devices. Use this to handle settings like WiFi retry count, + logging level, or other module-wide configurations. + + Args: + callback: Async function that receives setting_id and value, returns True on success. + Signature: async def callback(setting_id: str, value: Any) -> bool + + Example: + >>> async def on_module_setting(setting_id: str, value: Any) -> bool: + ... if setting_id == "wifi_retry_count": + ... set_wifi_retry_count(value) + ... return True + >>> sinric_pro.on_set_setting(on_module_setting) + """ + self._module_setting_callback = callback + def is_connected(self) -> bool: """ Check if currently connected to SinricPro. @@ -362,7 +385,12 @@ async def _handle_message(self, message_str: str) -> None: # Route message if message["payload"]["type"] == "request": - await self._handle_request(message) + # Check scope to determine if this is a module or device request + scope = message["payload"].get("scope", "device") + if scope == "module": + await self._handle_module_request(message) + else: + await self._handle_request(message) elif message["payload"]["type"] == "response": # Response messages (not typically used in device SDK) pass @@ -389,6 +417,62 @@ async def _handle_request(self, message: dict[str, Any]) -> None: success = await device.handle_request(request) self._send_response(message, success, request.response_value, request.error_message) + async def _handle_module_request(self, message: dict[str, Any]) -> None: + """Handle an incoming module-level request.""" + action = message["payload"].get("action", "") + request_value = message["payload"].get("value", {}) + + if action == "setSetting": + if not self._module_setting_callback: + SinricProLogger.error("No module setting callback registered") + self._send_module_response(message, False, {}, "No module setting callback registered") + return + + setting_id = request_value.get("id", "") + value = request_value.get("value") + + try: + success = await self._module_setting_callback(setting_id, value) + response_value = {"id": setting_id, "value": value} if success else {} + self._send_module_response(message, success, response_value) + except Exception as e: + SinricProLogger.error(f"Error in module setting callback: {e}") + self._send_module_response(message, False, {}, str(e)) + else: + SinricProLogger.error(f"Unknown module action: {action}") + self._send_module_response(message, False, {}, f"Unknown module action: {action}") + + def _send_module_response( + self, + request_message: dict[str, Any], + success: bool, + value: dict[str, Any], + error_message: str | None = None, + ) -> None: + """Send a module-level response message (without deviceId).""" + response_message: dict[str, Any] = { + "header": { + "payloadVersion": 2, + "signatureVersion": 1, + }, + "payload": { + "action": request_message["payload"]["action"], + "clientId": request_message["payload"]["clientId"], + "createdAt": self.get_timestamp(), + "message": error_message if error_message else ("OK" if success else "Request failed"), + "replyToken": request_message["payload"]["replyToken"], + "scope": "module", + "success": success, + "type": "response", + "value": value, + }, + } + + if self.signature: + self.signature.sign(response_message) + + self.send_queue.push_sync(json.dumps(response_message, separators=(",", ":"), sort_keys=False)) + def _send_response( self, request_message: dict[str, Any], @@ -409,6 +493,7 @@ def _send_response( "deviceId": request_message["payload"]["deviceId"], "message": error_message if error_message else ("OK" if success else "Request failed"), "replyToken": request_message["payload"]["replyToken"], + "scope": "device", "success": success, "type": "response", "value": value, diff --git a/sinricpro/core/types.py b/sinricpro/core/types.py index f1016fb..6404d68 100644 --- a/sinricpro/core/types.py +++ b/sinricpro/core/types.py @@ -32,6 +32,9 @@ DisconnectedCallback = Callable[[], None] PongCallback = Callable[[int], None] +# Module-level setting callback: (setting_id, value) -> bool +ModuleSettingCallback = Callable[[str, Any], Awaitable[bool]] + @dataclass class SinricProConfig: diff --git a/sinricpro/core/websocket_client.py b/sinricpro/core/websocket_client.py index ddb9f88..ab3959d 100644 --- a/sinricpro/core/websocket_client.py +++ b/sinricpro/core/websocket_client.py @@ -7,9 +7,23 @@ import asyncio import time +import uuid from typing import Callable import websockets + + +def get_mac_address() -> str: + """Get the MAC address of this machine. + + Returns: + MAC address string in format XX:XX:XX:XX:XX:XX + """ + mac = uuid.getnode() + # Format as XX:XX:XX:XX:XX:XX + return ":".join(f"{(mac >> (8 * i)) & 0xFF:02X}" for i in range(5, -1, -1)) + + from websockets.client import WebSocketClientProtocol from sinricpro import __version__ @@ -108,6 +122,7 @@ async def connect(self) -> None: "deviceids": ";".join(self.config.device_ids), "platform": self.config.platform, "SDKVersion": self.config.sdk_version, + "mac": get_mac_address(), } SinricProLogger.debug(f"Connecting to {uri}") From 2bc96f916c58c1afa454465791e4eb9b6d40c116 Mon Sep 17 00:00:00 2001 From: Aruna Tennakoon Date: Sun, 4 Jan 2026 09:15:44 +0700 Subject: [PATCH 2/2] feat: Send a device setting event to SinricPro --- CHANGELOG.md | 3 + .../device/device_settings_example.py | 17 ++++- .../module/module_settings_example.py | 20 +++++- pyproject.toml | 2 +- sinricpro/__init__.py | 2 +- sinricpro/capabilities/setting_controller.py | 27 ++++++++ sinricpro/core/sinric_pro.py | 69 +++++++++++++++++++ 7 files changed, 134 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8009e53..dd730e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [5.2.0] +- feat: Send a device setting event to SinricPro + ## [5.1.1] - feat: Module settings commands. - fix: Missing scope in response. diff --git a/examples/settings/device/device_settings_example.py b/examples/settings/device/device_settings_example.py index caf1eb3..dc888bc 100644 --- a/examples/settings/device/device_settings_example.py +++ b/examples/settings/device/device_settings_example.py @@ -128,6 +128,14 @@ async def main() -> None: # Add device to SinricPro sinric_pro.add(blinds) + # Example function to demonstrate sending device setting events + async def send_example_setting(): + """Send an example device setting event after connection.""" + await asyncio.sleep(5) # Wait for connection to stabilize + print("\n[Example] Sending device setting event...") + sent = await blinds.send_setting_event("id_tilt", 75) + print(f" Device setting event sent: {sent}") + # Configure connection config = SinricProConfig(app_key=APP_KEY, app_secret=APP_SECRET) @@ -143,12 +151,14 @@ async def main() -> None: print("Device Settings vs Module Settings:") print("=" * 60) print(" Device Settings: Configuration for THIS specific device") - print(" - Registered via: device.on_setting(callback)") + print(" - Receive via: device.on_setting(callback)") + print(" - Send via: device.send_setting_event(setting_id, value)") print(" - Examples: Tilt angle, speed, direction, auto-close") print(" - Callback receives: (setting_id, value)") print("") print(" Module Settings: Configuration for the module/board") - print(" - Registered via: sinric_pro.on_set_setting(callback)") + print(" - Receive via: sinric_pro.on_set_setting(callback)") + print(" - Send via: sinric_pro.send_setting_event(setting_id, value)") print(" - Examples: WiFi retry count, log level") print("\n" + "=" * 60) @@ -168,6 +178,9 @@ async def main() -> None: print("Press Ctrl+C to exit") print("=" * 60) + # Start the example setting event task + #asyncio.create_task(send_example_setting()) + while True: await asyncio.sleep(1) diff --git a/examples/settings/module/module_settings_example.py b/examples/settings/module/module_settings_example.py index dbd64cc..5e5a540 100644 --- a/examples/settings/module/module_settings_example.py +++ b/examples/settings/module/module_settings_example.py @@ -1,4 +1,5 @@ """SinricPro Module Settings Example - Handle module-level configuration.""" + import asyncio import os from typing import Any @@ -19,6 +20,7 @@ "id_heartbeat_interval": 300, } + async def on_power_state(state: bool) -> bool: """Handle power state change for the switch device.""" print(f"\n[Device] Power: {'ON' if state else 'OFF'}") @@ -91,6 +93,14 @@ async def main() -> None: # This is separate from device-level settings (device.on_setting()) sinric_pro.on_set_setting(on_module_setting) + # Example function to demonstrate sending module setting events + async def send_example_module_setting(): + """Send an example module setting event after connection.""" + await asyncio.sleep(5) # Wait for connection to stabilize + print("\n[Example] Sending module setting event...") + sent = await sinric_pro.send_setting_event("id_wifi_retry_count", 5) + print(f" Module setting event sent: {sent}") + # Configure connection config = SinricProConfig(app_key=APP_KEY, app_secret=APP_SECRET) @@ -106,11 +116,13 @@ async def main() -> None: print("Module Settings vs Device Settings:") print("=" * 60) print(" Module Settings: Configuration for the module/board itself") - print(" - Registered via: sinric_pro.on_set_setting(callback)") + print(" - Receive via: sinric_pro.on_set_setting(callback)") + print(" - Send via: sinric_pro.send_setting_event(setting_id, value)") print(" - Examples: WiFi retry count, log level, heartbeat interval") print("") print(" Device Settings: Configuration for individual devices") - print(" - Registered via: device.on_setting(callback)") + print(" - Receive via: device.on_setting(callback)") + print(" - Send via: device.send_setting_event(setting_id, value)") print(" - Examples: Device-specific modes, thresholds, etc.") print("\n" + "=" * 60) @@ -123,6 +135,9 @@ async def main() -> None: print("Press Ctrl+C to exit") print("=" * 60) + # Start the example setting event task + #asyncio.create_task(send_example_module_setting()) + while True: await asyncio.sleep(1) @@ -131,6 +146,7 @@ async def main() -> None: except Exception as e: print(f"\nError: {e}") import traceback + traceback.print_exc() finally: await sinric_pro.stop() diff --git a/pyproject.toml b/pyproject.toml index 2d95e1a..ffa2e66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sinricpro" -version = "5.1.1" +version = "5.2.0" description = "Official SinricPro SDK for Python - Control IoT devices with Alexa and Google Home" authors = [{name = "SinricPro", email = "support@sinric.pro"}] readme = "README.md" diff --git a/sinricpro/__init__.py b/sinricpro/__init__.py index 1ecda46..598f70d 100644 --- a/sinricpro/__init__.py +++ b/sinricpro/__init__.py @@ -9,7 +9,7 @@ This file is part of the SinricPro Python SDK (https://github.com/sinricpro/) """ -__version__ = "5.1.1" +__version__ = "5.2.0" from sinricpro.core.sinric_pro import SinricPro, SinricProConfig from sinricpro.core.sinric_pro_device import SinricProDevice diff --git a/sinricpro/capabilities/setting_controller.py b/sinricpro/capabilities/setting_controller.py index cd42152..58ae78d 100644 --- a/sinricpro/capabilities/setting_controller.py +++ b/sinricpro/capabilities/setting_controller.py @@ -1,6 +1,8 @@ """SettingController Capability - Generic settings management.""" from typing import Any, Callable, Awaitable, TYPE_CHECKING from sinricpro.utils.logger import SinricProLogger +from sinricpro.core.event_limiter import EventLimiter +from sinricpro.core.types import EVENT_LIMIT_STATE, PHYSICAL_INTERACTION if TYPE_CHECKING: from sinricpro.core.sinric_pro_device import SinricProDevice @@ -12,11 +14,36 @@ class SettingController: def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self._setting_callback: SettingCallback | None = None + self._setting_event_limiter = EventLimiter(EVENT_LIMIT_STATE) def on_setting(self, callback: SettingCallback) -> None: """Register callback for setting changes (setting id, value).""" self._setting_callback = callback + async def send_setting_event( + self, + setting_id: str, + value: Any, + cause: str = PHYSICAL_INTERACTION + ) -> bool: + """ + Send a setting event to SinricPro server. + + Args: + setting_id: The setting identifier + value: The setting value (can be any JSON-serializable type) + cause: Reason for the event (default: 'PHYSICAL_INTERACTION') + + Returns: + True if event was sent, False if rate limited or failed + """ + if self._setting_event_limiter.is_limited(): + return False + + # Access self as a SinricProDevice + device: "SinricProDevice" = self # type: ignore[assignment] + return await device.send_event("setSetting", {"id": setting_id, "value": value}, cause) + async def handle_setting_request(self, setting_id: str, value: Any, device: "SinricProDevice") -> tuple[bool, dict[str, Any]]: """Handle setSetting request.""" if not self._setting_callback: diff --git a/sinricpro/core/sinric_pro.py b/sinricpro/core/sinric_pro.py index 1f7b8f8..924da62 100644 --- a/sinricpro/core/sinric_pro.py +++ b/sinricpro/core/sinric_pro.py @@ -24,7 +24,10 @@ DisconnectedCallback, PongCallback, ModuleSettingCallback, + EVENT_LIMIT_STATE, + PHYSICAL_INTERACTION, ) +from sinricpro.core.event_limiter import EventLimiter from sinricpro.core.websocket_client import WebSocketClient, WebSocketConfig from sinricpro.utils.logger import SinricProLogger, LogLevel @@ -60,6 +63,7 @@ def __init__(self) -> None: self._disconnected_callbacks: list[DisconnectedCallback] = [] self._pong_callbacks: list[PongCallback] = [] self._module_setting_callback: ModuleSettingCallback | None = None + self._setting_event_limiter = EventLimiter(EVENT_LIMIT_STATE) @classmethod def get_instance(cls) -> "SinricPro": @@ -249,6 +253,71 @@ def on_set_setting(self, callback: ModuleSettingCallback) -> None: """ self._module_setting_callback = callback + async def send_setting_event( + self, + setting_id: str, + value: Any, + cause: str = PHYSICAL_INTERACTION + ) -> bool: + """ + Send a module-level setting event to SinricPro server. + + Module settings are configuration values for the module (dev board) itself. + Use this to report setting changes like WiFi configuration, logging level, + or other module-wide settings. + + Args: + setting_id: The setting identifier + value: The setting value (can be any JSON-serializable type) + cause: Reason for the event (default: 'PHYSICAL_INTERACTION') + + Returns: + True if event was sent, False if rate limited or failed + + Example: + >>> await sinric_pro.send_setting_event('wifi_retry_count', 5) + >>> await sinric_pro.send_setting_event('debug_mode', True) + """ + if self._setting_event_limiter.is_limited(): + return False + + if not self.is_connected(): + SinricProLogger.error("Cannot send setting event: Not connected to SinricPro") + return False + + event_message: dict[str, Any] = { + "header": { + "payloadVersion": 2, + "signatureVersion": 1, + }, + "payload": { + "action": "setSetting", + "replyToken": self._generate_message_id(), + "type": "event", + "createdAt": self.get_timestamp(), + "cause": {"type": cause}, + "scope": "module", + "value": {"id": setting_id, "value": value}, + }, + } + + try: + await self.send_message(event_message) + SinricProLogger.debug(f"Module setting event sent: {setting_id} = {value}") + return True + except Exception as e: + SinricProLogger.error(f"Failed to send module setting event {setting_id}: {e}") + return False + + def _generate_message_id(self) -> str: + """Generate a unique message ID.""" + import random + import string + + timestamp = int(time.time() * 1000) + random_str = "".join(random.choices(string.ascii_lowercase + string.digits, k=9)) + return f"{timestamp}-{random_str}" + def is_connected(self) -> bool: """ Check if currently connected to SinricPro.