Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## [6.1.0]
- fix: `adjustVolume` read the delta from a nonexistent `volumeDelta` field, so every volume adjustment reached the callback as `0` and reported `0` back to SinricPro, which stores it as the device's volume. The delta now comes from the request's `volume` field.
- fix: An `on_adjust_volume` callback can return `{"success": True, "volume": <new volume>}` to report the volume after the adjustment, which SinricPro stores as the device's absolute level. Returning `True` still echoes the delta.

## [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.
Expand Down
5 changes: 3 additions & 2 deletions examples/speaker/speaker_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,13 @@ async def on_volume(volume: int) -> bool:
return True


async def on_adjust_volume(volume_delta: int) -> bool:
async def on_adjust_volume(volume_delta: int) -> dict:
"""Handle volume adjustments from SinricPro."""
print(f"[Volume] Adjust by {'+' if volume_delta > 0 else ''}{volume_delta}")
speaker_state["volume"] = max(0, min(100, speaker_state["volume"] + volume_delta))
print(f" New volume: {speaker_state['volume']}")
return True
# Report the adjusted level; SinricPro stores it as the device's volume.
return {"success": True, "volume": speaker_state["volume"]}


async def on_mute(mute: bool) -> bool:
Expand Down
5 changes: 3 additions & 2 deletions examples/tv/tv_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,13 @@ async def on_volume(volume: int) -> bool:
return True


async def on_adjust_volume(volume_delta: int) -> bool:
async def on_adjust_volume(volume_delta: int) -> dict:
"""Handle volume adjustments from SinricPro."""
print(f"[Volume] Adjust by {'+' if volume_delta > 0 else ''}{volume_delta}")
tv_state["volume"] = max(0, min(100, tv_state["volume"] + volume_delta))
print(f" New volume: {tv_state['volume']}")
return True
# Report the adjusted level; SinricPro stores it as the device's volume.
return {"success": True, "volume": tv_state["volume"]}


async def on_mute(mute: bool) -> bool:
Expand Down
2 changes: 1 addition & 1 deletion 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 = "6.0.0"
version = "6.1.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 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__ = "6.0.0"
__version__ = "6.1.0"

from sinricpro.core.sinric_pro import SinricPro, SinricProConfig
from sinricpro.core.sinric_pro_device import SinricProDevice
Expand Down
27 changes: 23 additions & 4 deletions sinricpro/capabilities/volume_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@
from sinricpro.core.sinric_pro_device import SinricProDevice

VolumeCallback = Callable[[int], Awaitable[bool]]
AdjustVolumeCallback = Callable[[int], Awaitable[bool]]

# An adjust-volume callback returns True/False, or a mapping carrying the volume
# after the adjustment: {"success": True, "volume": 45}.
AdjustVolumeResult = bool | dict[str, Any]
AdjustVolumeCallback = Callable[[int], Awaitable[AdjustVolumeResult]]


class VolumeController:
Expand All @@ -39,8 +43,13 @@ def on_volume(self, callback: VolumeCallback) -> None:
def on_adjust_volume(self, callback: AdjustVolumeCallback) -> None:
"""Register callback for volume adjustments.

SinricPro stores the volume in the response as the device's absolute level,
so return {"success": True, "volume": <new volume>} to report it. Returning
True echoes the delta back instead.

Args:
callback: Async function that receives volume delta and returns True on success
callback: Async function that receives volume delta and returns True on
success, or a mapping with "success" and the new absolute "volume"
"""
self._adjust_volume_callback = callback

Expand Down Expand Up @@ -71,9 +80,19 @@ async def handle_adjust_volume_request(
return False, {}

try:
success = await self._adjust_volume_callback(volume_delta)
result = await self._adjust_volume_callback(volume_delta)

# Fall back to the delta so callbacks that only return a bool keep working.
volume = volume_delta
if isinstance(result, dict):
success = bool(result.get("success", False))
if result.get("volume") is not None:
volume = result["volume"]
else:
success = bool(result)

if success:
return True, {"volume": volume_delta}
return True, {"volume": volume}
else:
return False, {}
except Exception as e:
Expand Down
3 changes: 2 additions & 1 deletion sinricpro/devices/sinric_pro_speaker.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ async def handle_request(self, request: SinricProRequest) -> bool:

# Handle adjustVolume action
elif action == ACTION_ADJUST_VOLUME:
volume_delta = request.request_value.get("volumeDelta", 0)
# The protocol uses "volume" for both absolute values and relative deltas.
volume_delta = request.request_value.get("volume", 0)
success, response_value = await self.handle_adjust_volume_request(volume_delta, self)
request.response_value = response_value
return success
Expand Down
3 changes: 2 additions & 1 deletion sinricpro/devices/sinric_pro_tv.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,8 @@ async def handle_request(self, request: SinricProRequest) -> bool:

# Handle adjustVolume action
elif action == ACTION_ADJUST_VOLUME:
volume_delta = request.request_value.get("volumeDelta", 0)
# The protocol uses "volume" for both absolute values and relative deltas.
volume_delta = request.request_value.get("volume", 0)
success, response_value = await self.handle_adjust_volume_request(volume_delta, self)
request.response_value = response_value
return success
Expand Down
105 changes: 105 additions & 0 deletions tests/unit/test_volume_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""Volume request dispatch and the absolute volume reported back to SinricPro."""

import pytest

from sinricpro.core.types import SinricProRequest
from sinricpro.devices.sinric_pro_speaker import SinricProSpeaker
from sinricpro.devices.sinric_pro_tv import SinricProTV

DEVICES = [SinricProTV, SinricProSpeaker]


def volume_request(action: str, volume: int, volume_default: bool | None = None) -> SinricProRequest:
value: dict[str, object] = {"volume": volume}
if volume_default is not None:
value["volumeDefault"] = volume_default
return SinricProRequest(action=action, request_value=value)


@pytest.mark.parametrize("create_device", DEVICES)
@pytest.mark.parametrize("volume", [0, 50, 100])
async def test_set_volume_dispatches_to_on_volume(create_device, volume):
device = create_device("test-device")
seen = []
adjusted = []
device.on_volume(lambda v: _record(seen, v))
device.on_adjust_volume(lambda v: _record(adjusted, v))
request = volume_request("setVolume", volume)

assert await device.handle_request(request) is True
assert seen == [volume]
assert adjusted == []
assert request.response_value == {"volume": volume}


@pytest.mark.parametrize("create_device", DEVICES)
@pytest.mark.parametrize("delta", [-5, 0, 5])
async def test_adjust_volume_reports_absolute_volume(create_device, delta):
device = create_device("test-device")
current = {"volume": 50}
seen = []

async def on_adjust(volume_delta: int) -> dict:
seen.append(volume_delta)
current["volume"] += volume_delta
return {"success": True, "volume": current["volume"]}

device.on_adjust_volume(on_adjust)
request = volume_request("adjustVolume", delta, volume_default=False)

assert await device.handle_request(request) is True
# The delta arrives from the request's "volume" field, not "volumeDelta".
assert seen == [delta]
# SinricPro stores this as the device's absolute level, not the delta.
assert request.response_value == {"volume": 50 + delta}


@pytest.mark.parametrize("create_device", DEVICES)
@pytest.mark.parametrize("delta", [-5, 0, 5])
async def test_adjust_volume_echoes_delta_without_reported_volume(create_device, delta):
device = create_device("test-device")
device.on_adjust_volume(lambda _volume_delta: _true())
request = volume_request("adjustVolume", delta, volume_default=False)

assert await device.handle_request(request) is True
assert request.response_value == {"volume": delta}


@pytest.mark.parametrize("create_device", DEVICES)
async def test_adjust_volume_reports_zero(create_device):
device = create_device("test-device")
device.on_adjust_volume(lambda _volume_delta: _result({"success": True, "volume": 0}))
request = volume_request("adjustVolume", -50, volume_default=False)

assert await device.handle_request(request) is True
assert request.response_value == {"volume": 0}


@pytest.mark.parametrize("create_device", DEVICES)
async def test_adjust_volume_ignores_volume_on_failure(create_device):
device = create_device("test-device")
device.on_adjust_volume(lambda _volume_delta: _result({"success": False, "volume": 55}))
request = volume_request("adjustVolume", 5, volume_default=False)

assert await device.handle_request(request) is False
assert request.response_value == {}


@pytest.mark.parametrize("create_device", DEVICES)
@pytest.mark.parametrize("action", ["setVolume", "adjustVolume"])
async def test_returns_false_without_callback(create_device, action):
device = create_device("test-device")
assert await device.handle_request(volume_request(action, 5)) is False


async def _record(sink: list, value: int) -> bool:
sink.append(value)
return True


async def _true() -> bool:
return True


async def _result(value: dict) -> dict:
return value
Loading