Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,52 @@
## [6.0.0]
- feat: Local control. Devices answer signed commands over the LAN on UDP 3333 (multicast 224.9.9.9, unicast too), so they keep working with the cloud unreachable. Requests dispatch through the existing capability callbacks.
- feat: mDNS announcement of `_sinricpro._udp.local.` (TXT: `deviceIds`, `sdk`, `udp`) for app discovery. `zeroconf` is a required dependency, so a default install announces without extra steps. Local control is on by default; set `local_control=False` to opt out.
- feat: `SinricProConfig.local_control`, `.mdns` and `.local_control_interface` to configure the feature out or pin it to a LAN interface on multi-homed hosts.
- fix: Signatures are now verified against the bytes received, sliced between `"payload":` and `,"signature"`, instead of re-serializing the parsed message. A sender using different key order or spacing was rejected before.
- fix: Outgoing messages are signed over the exact bytes transmitted - the payload is serialized once and spliced into the envelope.
- fix: The send queue is gated per message rather than as a whole, so a cloud message waiting on the socket no longer blocks a LAN reply queued behind it.
- fix: A request that fails verification is answered with a signed "Signature is invalid" response, letting a client tell a wrong app secret from an unreachable device.
- change: `begin()` no longer raises when the cloud is unreachable. The SDK starts, retries in the background and keeps answering local control; only invalid configuration raises. Call `is_connected()` for cloud state. Callers that relied on `begin()` raising to detect an outage must check it instead.


How Local control works:

- Listens on UDP port `3333`, joined to multicast group `224.9.9.9`, and answers
unicast to this host on the same port.
- Every request is HMAC-SHA256 verified with your `APP_SECRET`; responses are signed
the same way and go back only to the peer that asked. LAN responses are never
echoed to the cloud.
- A request that fails verification is answered with `"Signature is invalid"` rather
than dropped, so a client can tell a wrong secret from an unreachable device.
- The host announces `_sinricpro._udp.local.` over mDNS with TXT records
`deviceIds`, `sdk` and `udp=1`, refreshed whenever the device list changes.

Check the announcement from another machine on the same network:

```bash
avahi-browse -r _sinricpro._udp # Linux
dns-sd -B _sinricpro._udp # macOS / Windows (Bonjour)
```

### Configuration

```python
config = SinricProConfig(
app_key="your-app-key",
app_secret="your-app-secret",
local_control=True, # False disables LAN control entirely
mdns=True, # False keeps UDP but skips the announcement
local_control_interface=None, # pin to a LAN interface, e.g. "192.168.1.50"
)
```

Set `local_control_interface` on hosts with several interfaces (Docker, VPN, WSL).
Left unset, the SDK joins the group on the interface the OS picks and announces the
address of the default route, which is not always the LAN you want.

Without `zeroconf` installed the SDK logs a warning and skips the announcement only -
UDP control still works for a client that knows the device address.

## [5.3.2]
- feat: version number

Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Official Python SDK for [SinricPro](https://sinric.pro) - Control your IoT devic
- ✅ **Type Safe** - Full type hints for better IDE support and error detection
- ✅ **Voice Control** - Works with Alexa and Google Home
- ✅ **Real-time** - WebSocket-based bidirectional communication
- ✅ **Local Control** - Keeps answering the app over the LAN when the cloud is unreachable
- ✅ **Secure** - HMAC-SHA256 message signatures
- ✅ **Reliable** - Auto-reconnection and heartbeat monitoring
- ✅ **Flexible** - Support for multiple device types and capabilities
Expand Down Expand Up @@ -49,10 +50,18 @@ Official Python SDK for [SinricPro](https://sinric.pro) - Control your IoT devic
pip install sinricpro
```

Local control announces the device over mDNS so the app can discover it on the LAN.
The announcement uses `zeroconf`, installed with the SDK:

```bash
pip install sinricpro
```

## Requirements

- Python 3.10 or higher
- `websockets` library (automatically installed)
- `zeroconf` - the mDNS announcement used by local control

## Platform Support

Expand All @@ -63,6 +72,13 @@ The SDK works on:
- **macOS** 10.14+
- **Raspberry Pi** (All models with Python 3.10+)

## Local Control

Devices answer signed commands over the LAN as well as through the cloud, so they
keep responding to the app when sinric.pro is unreachable. It is on by default and
needs no code change - a LAN request runs the same capability callbacks a cloud
request does. UDP listener on port `3333`, joined to multicast group `224.9.9.9` and answering unicast on the same port. Replies go back to the peer that sent the request, never to the cloud websocket.

## Logging

Enable debug logging to see detailed information:
Expand Down Expand Up @@ -133,6 +149,17 @@ Full API documentation is available at [Read the Docs](https://sinricpro-python.
3. **Check network** - Ensure you have internet connectivity
4. **Enable debug logging** - Set `debug=True` in config to see detailed logs

### Local Control Issues

1. **No device found on the LAN** - check the log for
`Local control listening on UDP 3333`. A failed multicast join leaves nothing
listening, and the log line says so.
2. **No mDNS record** - confirm `zeroconf` imported cleanly; the SDK logs a warning and falls back to UDP-only when it did not.
3. **Discovery answers on the wrong network** - set `local_control_interface` to the
LAN address of the host.
4. **Android clients need a `WifiManager.MulticastLock`**, and iOS clients need
`_sinricpro._udp` listed in `NSBonjourServices`, or discovery returns nothing.

### Common Errors

**"Invalid app_key format"**
Expand Down
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "sinricpro"
version = "5.3.2"
version = "6.0.0"
description = "Official SinricPro SDK for Python - Control IoT devices with Alexa and Google Home"
authors = [{name = "SinricPro", email = "support@sinric.com"}]
readme = "README.md"
Expand All @@ -25,7 +25,8 @@ classifiers = [
]
dependencies = [
"websockets>=12.0",
"aiohttp>=3.9.0"
"aiohttp>=3.9.0",
"zeroconf>=0.131.0"
]

[project.optional-dependencies]
Expand Down
2 changes: 1 addition & 1 deletion sinricpro/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
This file is part of the SinricPro Python SDK (https://github.com/sinricpro/)
"""

__version__ = "5.3.2"
__version__ = "6.0.0"

from sinricpro.core.sinric_pro import SinricPro, SinricProConfig
from sinricpro.core.sinric_pro_device import SinricProDevice
Expand Down
6 changes: 6 additions & 0 deletions sinricpro/core/local_control/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Local control (LAN) components: UDP listener and mDNS announcement."""

from sinricpro.core.local_control.mdns import ZEROCONF_AVAILABLE, MdnsAnnouncer
from sinricpro.core.local_control.udp_listener import UdpListener

__all__ = ["ZEROCONF_AVAILABLE", "MdnsAnnouncer", "UdpListener"]
159 changes: 159 additions & 0 deletions sinricpro/core/local_control/mdns.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""
mDNS Announcer

Publishes ``_sinricpro._udp.local.`` so the app can find this host on the LAN
without asking the cloud for its address.

Requires the optional ``zeroconf`` dependency (``pip install sinricpro[mdns]``).
Without it local control still works - the app falls back to the address the
cloud reported for the device.
"""

import socket
from typing import Any

from sinricpro.core.types import MDNS_SERVICE_TYPE, UDP_MULTICAST_PORT
from sinricpro.utils.logger import SinricProLogger
from sinricpro.utils.network import get_local_ip, get_mdns_host_name

try:
from zeroconf import IPVersion, ServiceInfo
from zeroconf.asyncio import AsyncZeroconf

ZEROCONF_AVAILABLE = True
except ImportError: # pragma: no cover - depends on the install extras
ZEROCONF_AVAILABLE = False


class MdnsAnnouncer:
"""
Announces this host as a SinricPro local control endpoint.

TXT records:
deviceIds comma-separated ids this host answers for
sdk SDK version
udp always "1"

The record is refreshed when the device list changes - not on a timer and
not on every reconnect.
"""

def __init__(
self,
sdk_version: str,
interface_ip: str | None = None,
port: int = UDP_MULTICAST_PORT,
host_name: str | None = None,
) -> None:
"""
Initialize the announcer.

Args:
sdk_version: Value of the ``sdk`` TXT record
interface_ip: IPv4 address to announce. None discovers the interface
carrying the default route.
port: UDP port to advertise
host_name: mDNS host label; defaults to ``sinricpro-<mac>``
"""
self.sdk_version = sdk_version
self.interface_ip = interface_ip
self.port = port
self.host_name = host_name or get_mdns_host_name()
self._zeroconf: Any = None
self._info: Any = None
self._device_ids: str = ""

def _build_info(self, device_ids: str) -> Any:
return ServiceInfo(
MDNS_SERVICE_TYPE,
f"{self.host_name}.{MDNS_SERVICE_TYPE}",
addresses=[socket.inet_aton(self.interface_ip)] if self.interface_ip else [],
port=self.port,
properties={
"deviceIds": device_ids,
"sdk": self.sdk_version,
"udp": "1",
},
server=f"{self.host_name}.local.",
)

async def start(self, device_ids: list[str]) -> bool:
"""
Register the service.

Args:
device_ids: Device ids this host answers for

Returns:
True if the service was registered
"""
if not ZEROCONF_AVAILABLE:
SinricProLogger.warn(
"zeroconf is not installed, skipping mDNS announcement "
"(local control still works via the cloud-reported address). "
"Install with: pip install sinricpro[mdns]"
)
return False

if self._zeroconf is not None:
return True

if not self.interface_ip:
self.interface_ip = get_local_ip()
if not self.interface_ip:
SinricProLogger.warn(
"Could not determine a LAN address, skipping mDNS announcement"
)
return False

self._device_ids = ",".join(device_ids)

try:
self._zeroconf = AsyncZeroconf(
interfaces=[self.interface_ip], ip_version=IPVersion.V4Only
)
self._info = self._build_info(self._device_ids)
await self._zeroconf.async_register_service(self._info)
except Exception as e:
SinricProLogger.error(f"mDNS announcement failed: {e}")
await self.stop()
return False

SinricProLogger.info(
f"Announced {MDNS_SERVICE_TYPE} as {self.host_name}.local. "
f"on {self.interface_ip}:{self.port} deviceIds={self._device_ids}"
)
return True

async def update(self, device_ids: list[str]) -> None:
"""
Re-announce, but only if the device list actually changed.

Args:
device_ids: Device ids this host answers for
"""
joined = ",".join(device_ids)
if self._zeroconf is None or joined == self._device_ids:
return

self._device_ids = joined
try:
self._info = self._build_info(joined)
await self._zeroconf.async_update_service(self._info)
SinricProLogger.info(f"mDNS deviceIds updated: {joined}")
except Exception as e:
SinricProLogger.error(f"mDNS update failed: {e}")

async def stop(self) -> None:
"""Unregister the service and close the responder."""
if self._zeroconf is None:
return
try:
if self._info is not None:
await self._zeroconf.async_unregister_service(self._info)
await self._zeroconf.async_close()
except Exception as e:
SinricProLogger.error(f"mDNS shutdown failed: {e}")
finally:
self._zeroconf = None
self._info = None
Loading
Loading