diff --git a/CHANGELOG.md b/CHANGELOG.md index f8f722a..8cc5538 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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": }` 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. diff --git a/examples/speaker/speaker_example.py b/examples/speaker/speaker_example.py index d914c94..995a1ec 100644 --- a/examples/speaker/speaker_example.py +++ b/examples/speaker/speaker_example.py @@ -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: diff --git a/examples/tv/tv_example.py b/examples/tv/tv_example.py index 5aaf7fa..648aaca 100644 --- a/examples/tv/tv_example.py +++ b/examples/tv/tv_example.py @@ -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: diff --git a/pyproject.toml b/pyproject.toml index 84e7ac2..14d6ba1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/sinricpro/__init__.py b/sinricpro/__init__.py index 950ece4..2cdd4ca 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__ = "6.0.0" +__version__ = "6.1.0" from sinricpro.core.sinric_pro import SinricPro, SinricProConfig from sinricpro.core.sinric_pro_device import SinricProDevice diff --git a/sinricpro/capabilities/volume_controller.py b/sinricpro/capabilities/volume_controller.py index 28d1eeb..e2dfbb2 100644 --- a/sinricpro/capabilities/volume_controller.py +++ b/sinricpro/capabilities/volume_controller.py @@ -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: @@ -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": } 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 @@ -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: diff --git a/sinricpro/devices/sinric_pro_speaker.py b/sinricpro/devices/sinric_pro_speaker.py index 64fb88d..9b9f01c 100644 --- a/sinricpro/devices/sinric_pro_speaker.py +++ b/sinricpro/devices/sinric_pro_speaker.py @@ -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 diff --git a/sinricpro/devices/sinric_pro_tv.py b/sinricpro/devices/sinric_pro_tv.py index c3b7716..9fa17c8 100644 --- a/sinricpro/devices/sinric_pro_tv.py +++ b/sinricpro/devices/sinric_pro_tv.py @@ -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 diff --git a/tests/unit/test_volume_controller.py b/tests/unit/test_volume_controller.py new file mode 100644 index 0000000..a956180 --- /dev/null +++ b/tests/unit/test_volume_controller.py @@ -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