mirror of
				https://github.com/python-kasa/python-kasa.git
				synced 2025-10-30 20:21:54 +00:00 
			
		
		
		
	Add vacuum speaker controls (#1332)
	
		
			
	
		
	
	
		
	
		
			Some checks are pending
		
		
	
	
		
			
				
	
				CI / Perform linting checks (3.13) (push) Waiting to run
				
			
		
			
				
	
				CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
				
			
		
			
				
	
				CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
				
			
		
			
				
	
				CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
				
			
		
			
				
	
				CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
				
			
		
			
				
	
				CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
				
			
		
			
				
	
				CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
				
			
		
			
				
	
				CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
				
			
		
			
				
	
				CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
				
			
		
			
				
	
				CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
				
			
		
			
				
	
				CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
				
			
		
			
				
	
				CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
				
			
		
			
				
	
				CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
				
			
		
			
				
	
				CodeQL checks / Analyze (python) (push) Waiting to run
				
			
		
		
	
	
				
					
				
			
		
			Some checks are pending
		
		
	
	CI / Perform linting checks (3.13) (push) Waiting to run
				
			CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.11) (push) Blocked by required conditions
				
			CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.12) (push) Blocked by required conditions
				
			CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, macos-latest, 3.13) (push) Blocked by required conditions
				
			CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.11) (push) Blocked by required conditions
				
			CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.12) (push) Blocked by required conditions
				
			CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, ubuntu-latest, 3.13) (push) Blocked by required conditions
				
			CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.11) (push) Blocked by required conditions
				
			CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.12) (push) Blocked by required conditions
				
			CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (false, windows-latest, 3.13) (push) Blocked by required conditions
				
			CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.11) (push) Blocked by required conditions
				
			CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.12) (push) Blocked by required conditions
				
			CI / Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} (true, ubuntu-latest, 3.13) (push) Blocked by required conditions
				
			CodeQL checks / Analyze (python) (push) Waiting to run
				
			Implements `speaker` and adds the following features: * `volume` to control the speaker volume * `locate` to play "I'm here sound"
This commit is contained in:
		| @@ -449,6 +449,7 @@ COMPONENT_REQUESTS = { | ||||
|     "speaker": [ | ||||
|         SmartRequest.get_raw_request("getSupportVoiceLanguage"), | ||||
|         SmartRequest.get_raw_request("getCurrentVoiceLanguage"), | ||||
|         SmartRequest.get_raw_request("getVolume"), | ||||
|     ], | ||||
|     "map": [ | ||||
|         SmartRequest.get_raw_request("getMapInfo"), | ||||
|   | ||||
| @@ -164,6 +164,7 @@ class Module(ABC): | ||||
|     # Vacuum modules | ||||
|     Clean: Final[ModuleName[smart.Clean]] = ModuleName("Clean") | ||||
|     Dustbin: Final[ModuleName[smart.Dustbin]] = ModuleName("Dustbin") | ||||
|     Speaker: Final[ModuleName[smart.Speaker]] = ModuleName("Speaker") | ||||
|  | ||||
|     def __init__(self, device: Device, module: str) -> None: | ||||
|         self._device = device | ||||
|   | ||||
| @@ -30,6 +30,7 @@ from .matter import Matter | ||||
| from .motionsensor import MotionSensor | ||||
| from .overheatprotection import OverheatProtection | ||||
| from .reportmode import ReportMode | ||||
| from .speaker import Speaker | ||||
| from .temperaturecontrol import TemperatureControl | ||||
| from .temperaturesensor import TemperatureSensor | ||||
| from .thermostat import Thermostat | ||||
| @@ -71,6 +72,7 @@ __all__ = [ | ||||
|     "Clean", | ||||
|     "SmartLightEffect", | ||||
|     "OverheatProtection", | ||||
|     "Speaker", | ||||
|     "HomeKit", | ||||
|     "Matter", | ||||
|     "Dustbin", | ||||
|   | ||||
							
								
								
									
										67
									
								
								kasa/smart/modules/speaker.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								kasa/smart/modules/speaker.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,67 @@ | ||||
| """Implementation of vacuum speaker.""" | ||||
|  | ||||
| from __future__ import annotations | ||||
|  | ||||
| import logging | ||||
| from typing import Annotated | ||||
|  | ||||
| from ...feature import Feature | ||||
| from ...module import FeatureAttribute | ||||
| from ..smartmodule import SmartModule | ||||
|  | ||||
| _LOGGER = logging.getLogger(__name__) | ||||
|  | ||||
|  | ||||
| class Speaker(SmartModule): | ||||
|     """Implementation of vacuum speaker.""" | ||||
|  | ||||
|     REQUIRED_COMPONENT = "speaker" | ||||
|  | ||||
|     def _initialize_features(self) -> None: | ||||
|         """Initialize features.""" | ||||
|         self._add_feature( | ||||
|             Feature( | ||||
|                 self._device, | ||||
|                 id="locate", | ||||
|                 name="Locate device", | ||||
|                 container=self, | ||||
|                 attribute_setter="locate", | ||||
|                 category=Feature.Category.Primary, | ||||
|                 type=Feature.Action, | ||||
|             ) | ||||
|         ) | ||||
|         self._add_feature( | ||||
|             Feature( | ||||
|                 self._device, | ||||
|                 id="volume", | ||||
|                 name="Volume", | ||||
|                 container=self, | ||||
|                 attribute_getter="volume", | ||||
|                 attribute_setter="set_volume", | ||||
|                 range_getter=lambda: (0, 100), | ||||
|                 category=Feature.Category.Config, | ||||
|                 type=Feature.Type.Number, | ||||
|             ) | ||||
|         ) | ||||
|  | ||||
|     def query(self) -> dict: | ||||
|         """Query to execute during the update cycle.""" | ||||
|         return { | ||||
|             "getVolume": None, | ||||
|         } | ||||
|  | ||||
|     @property | ||||
|     def volume(self) -> Annotated[str, FeatureAttribute()]: | ||||
|         """Return volume.""" | ||||
|         return self.data["volume"] | ||||
|  | ||||
|     async def set_volume(self, volume: int) -> Annotated[dict, FeatureAttribute()]: | ||||
|         """Set volume.""" | ||||
|         if volume < 0 or volume > 100: | ||||
|             raise ValueError("Volume must be between 0 and 100") | ||||
|  | ||||
|         return await self.call("setVolume", {"volume": volume}) | ||||
|  | ||||
|     async def locate(self) -> dict: | ||||
|         """Play sound to locate the device.""" | ||||
|         return await self.call("playSelectAudio", {"audio_type": "seek_me"}) | ||||
| @@ -637,6 +637,9 @@ class FakeSmartTransport(BaseTransport): | ||||
|             return self._set_on_off_gradually_info(info, params) | ||||
|         elif method == "set_child_protection": | ||||
|             return self._update_sysinfo_key(info, "child_protection", params["enable"]) | ||||
|         # Vacuum special actions | ||||
|         elif method in ["playSelectAudio"]: | ||||
|             return {"error_code": 0} | ||||
|         elif method[:3] == "set": | ||||
|             target_method = f"get{method[3:]}" | ||||
|             # Some vacuum commands do not have a getter | ||||
|   | ||||
| @@ -187,6 +187,9 @@ | ||||
|         "name": "2", | ||||
|         "version": 1 | ||||
|     }, | ||||
|     "getVolume": { | ||||
|         "volume": 84 | ||||
|     }, | ||||
|     "getDoNotDisturb": { | ||||
|         "do_not_disturb": true, | ||||
|         "e_min": 480, | ||||
|   | ||||
							
								
								
									
										71
									
								
								tests/smart/modules/test_speaker.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								tests/smart/modules/test_speaker.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,71 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import pytest | ||||
| from pytest_mock import MockerFixture | ||||
|  | ||||
| from kasa import Module | ||||
| from kasa.smart import SmartDevice | ||||
|  | ||||
| from ...device_fixtures import get_parent_and_child_modules, parametrize | ||||
|  | ||||
| speaker = parametrize( | ||||
|     "has speaker", component_filter="speaker", protocol_filter={"SMART"} | ||||
| ) | ||||
|  | ||||
|  | ||||
| @speaker | ||||
| @pytest.mark.parametrize( | ||||
|     ("feature", "prop_name", "type"), | ||||
|     [ | ||||
|         ("volume", "volume", int), | ||||
|     ], | ||||
| ) | ||||
| async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: type): | ||||
|     """Test that features are registered and work as expected.""" | ||||
|     speaker = next(get_parent_and_child_modules(dev, Module.Speaker)) | ||||
|     assert speaker is not None | ||||
|  | ||||
|     prop = getattr(speaker, prop_name) | ||||
|     assert isinstance(prop, type) | ||||
|  | ||||
|     feat = speaker._device.features[feature] | ||||
|     assert feat.value == prop | ||||
|     assert isinstance(feat.value, type) | ||||
|  | ||||
|  | ||||
| @speaker | ||||
| async def test_set_volume(dev: SmartDevice, mocker: MockerFixture): | ||||
|     """Test speaker settings.""" | ||||
|     speaker = next(get_parent_and_child_modules(dev, Module.Speaker)) | ||||
|     assert speaker is not None | ||||
|  | ||||
|     call = mocker.spy(speaker, "call") | ||||
|  | ||||
|     volume = speaker._device.features["volume"] | ||||
|     assert speaker.volume == volume.value | ||||
|  | ||||
|     new_volume = 15 | ||||
|     await speaker.set_volume(new_volume) | ||||
|  | ||||
|     call.assert_called_with("setVolume", {"volume": new_volume}) | ||||
|  | ||||
|     await dev.update() | ||||
|  | ||||
|     assert speaker.volume == new_volume | ||||
|  | ||||
|     with pytest.raises(ValueError, match="Volume must be between 0 and 100"): | ||||
|         await speaker.set_volume(-10) | ||||
|  | ||||
|     with pytest.raises(ValueError, match="Volume must be between 0 and 100"): | ||||
|         await speaker.set_volume(110) | ||||
|  | ||||
|  | ||||
| @speaker | ||||
| async def test_locate(dev: SmartDevice, mocker: MockerFixture): | ||||
|     """Test the locate method.""" | ||||
|     speaker = next(get_parent_and_child_modules(dev, Module.Speaker)) | ||||
|     call = mocker.spy(speaker, "call") | ||||
|  | ||||
|     await speaker.locate() | ||||
|  | ||||
|     call.assert_called_with("playSelectAudio", {"audio_type": "seek_me"}) | ||||
		Reference in New Issue
	
	Block a user
	 Teemu R.
					Teemu R.