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
2 changes: 1 addition & 1 deletion .github/actions/setup/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ runs:
- name: pre-commit Cache
id: pre-commit-cache
if: inputs.cache-pre-commit == 'true'
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.cache/pre-commit/
key: cache-${{ inputs.cache-version }}-${{ runner.os }}-${{ runner.arch }}-pre-commit-${{ steps.pre-commit-version.outputs.pre-commit-version }}-python-${{ inputs.python-version }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}
2 changes: 2 additions & 0 deletions .github/workflows/codeql-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
env:
CODEQL_ACTION_FILE_COVERAGE_ON_PRS: true
permissions:
actions: read
contents: read
Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Many thanks to testers and new contributors - @steveredden, @DawidPietrykowski,
**Breaking changes:**

- `uses_http` is now a readonly property of device config. Consumers that relied on `uses_http` to be persisted with `DeviceConfig.to_dict()` will need to store the value separately.
- `is_color`, `is_dimmable`, `is_variable_color_temp`, `valid_temperate_range`, and `has_effects` attributes from the `Light` module are deprecated, consumers should use `has_feature("hsv")`, `has_feature("brightness")`, `has_feature("color_temp")`, `get_feature("color_temp").range`, and `Module.LightEffect in dev.modules` respectively. Calling the deprecated attributes will emit a `DeprecationWarning` and type checkers will fail them.
- `is_color`, `is_dimmable`, `is_variable_color_temp`, `valid_temperature_range`, and `has_effects` attributes from the `Light` module are deprecated, consumers should use `has_feature("hsv")`, `has_feature("brightness")`, `has_feature("color_temp")`, `get_feature("color_temp").range`, and `Module.LightEffect in dev.modules` respectively. Calling the deprecated attributes will emit a `DeprecationWarning` and type checkers will fail them.
- `alarm_volume` on the `smart.Alarm` module is changed from `str` to `int`

**Breaking changes:**
Expand Down
62 changes: 36 additions & 26 deletions kasa/iot/iotbulb.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ class IotBulb(IotDevice):

All changes to the device are done using awaitable methods,
which will not change the cached values,
so you must await :func:`update()` to fetch updates values from the device.
so you must await :func:`update()` to fetch updated values from the device.

Errors reported by the device are raised as
:class:`KasaException <kasa.exceptions.KasaException>`,
Expand All @@ -118,7 +118,7 @@ class IotBulb(IotDevice):
>>> bulb = IotBulb("127.0.0.1")
>>> asyncio.run(bulb.update())
>>> print(bulb.alias)
Bulb2
Bedroom Bulb

Bulbs, like any other supported devices, can be turned on and off:

Expand All @@ -128,43 +128,51 @@ class IotBulb(IotDevice):
>>> print(bulb.is_on)
True

You can use the ``is_``-prefixed properties to check for supported features:
Get the light module to interact with light-specific features:

>>> bulb.is_dimmable
>>> light = bulb.modules[Module.Light]

You can use the :func:`has_feature` method to check for supported light features:

>>> light.has_feature("brightness")
True
>>> bulb.is_color
>>> light.has_feature("hsv")
True
>>> bulb.is_variable_color_temp
>>> light.has_feature("color_temp")
True

All known bulbs support changing the brightness:

>>> bulb.brightness
>>> light.brightness
30
>>> asyncio.run(bulb.set_brightness(50))
>>> asyncio.run(light.set_brightness(50))
>>> asyncio.run(bulb.update())
>>> bulb.brightness
>>> light.brightness
50

Bulbs supporting color temperature can be queried for the supported range:

>>> bulb.valid_temperature_range
ColorTempRange(min=2500, max=9000)
>>> asyncio.run(bulb.set_color_temp(3000))
>>> if color_temp_feature := light.get_feature("color_temp"):
>>> print(
>>> f"{color_temp_feature.minimum_value}, "
>>> f"{color_temp_feature.maximum_value}"
>>> )
2500, 9000
>>> asyncio.run(light.set_color_temp(3000))
>>> asyncio.run(bulb.update())
>>> bulb.color_temp
>>> light.color_temp
3000

Color bulbs can be adjusted by passing hue, saturation and value:

>>> asyncio.run(bulb.set_hsv(180, 100, 80))
>>> asyncio.run(light.set_hsv(180, 100, 80))
>>> asyncio.run(bulb.update())
>>> bulb.hsv
>>> light.hsv
HSV(hue=180, saturation=100, value=80)

If you don't want to use the default transitions,
you can pass `transition` in milliseconds.
All methods changing the state of the device support this parameter:
All methods changing the light state support this parameter:

* :func:`turn_on`
* :func:`turn_off`
Expand All @@ -176,24 +184,26 @@ class IotBulb(IotDevice):
but silently ignore the parameter.
The following changes the brightness over a period of 10 seconds:

>>> asyncio.run(bulb.set_brightness(100, transition=10_000))
>>> asyncio.run(light.set_brightness(100, transition=10_000))

Bulb configuration presets can be accessed using the :func:`presets` property:
Bulb configuration presets can be accessed using the light preset module:

>>> [ preset.to_dict() for preset in bulb.presets }
[{'brightness': 50, 'hue': 0, 'saturation': 0, 'color_temp': 2700, 'index': 0}, {'brightness': 100, 'hue': 0, 'saturation': 75, 'color_temp': 0, 'index': 1}, {'brightness': 100, 'hue': 120, 'saturation': 75, 'color_temp': 0, 'index': 2}, {'brightness': 100, 'hue': 240, 'saturation': 75, 'color_temp': 0, 'index': 3}]
>>> light_preset = bulb.modules[Module.LightPreset]
>>> light_preset.preset_states_list[0]
IotLightPreset(light_on=None, brightness=50, hue=0, saturation=0, color_temp=2700, transition=None)

To modify an existing preset, pass :class:`~kasa.interfaces.light.LightPreset`
instance to :func:`save_preset` method:
To modify an existing preset, update one of the entries in
``preset_states_list`` and pass it to
:func:`save_preset`:

>>> preset = bulb.presets[0]
>>> preset = light_preset.preset_states_list[0]
>>> preset.brightness
50
>>> preset.brightness = 100
>>> asyncio.run(bulb.save_preset(preset))
>>> asyncio.run(light_preset.save_preset("Light preset 1", preset))
>>> asyncio.run(bulb.update())
>>> bulb.presets[0].brightness
100
>>> light_preset.preset_states_list[0]
IotLightPreset(light_on=None, brightness=100, hue=0, saturation=0, color_temp=2700, transition=None)

""" # noqa: E501

Expand Down
1 change: 1 addition & 0 deletions kasa/smart/smartchilddevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class SmartChildDevice(SmartDevice):

CHILD_DEVICE_TYPE_MAP = {
"plug.powerstrip.sub-plug": DeviceType.Plug,
"plug.powerstrip.sub-bulb": DeviceType.Bulb,
"subg.plugswitch.switch": DeviceType.WallSwitch,
"subg.trigger.contact-sensor": DeviceType.Sensor,
"subg.trigger.temp-hmdt-sensor": DeviceType.Sensor,
Expand Down
4 changes: 1 addition & 3 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,7 @@ async def test_list_devices(discovery_mock, runner):

async def test_discover_raw(discovery_mock, runner, mocker):
"""Test the discover raw command."""
redact_spy = mocker.patch(
"kasa.protocols.protocol.redact_data", side_effect=redact_data
)
redact_spy = mocker.patch("kasa.cli.discover.redact_data", side_effect=redact_data)
res = await runner.invoke(
cli,
["--username", "foo", "--password", "bar", "discover", "raw"],
Expand Down
24 changes: 17 additions & 7 deletions tests/test_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,13 @@ def test_deprecated_classes(deprecated_class, use_class):
"is_strip": DeviceType.Strip,
"is_strip_socket": DeviceType.StripSocket,
}
deprecated_warns_before_attribute_error = {
"has_effects",
"is_color",
"is_dimmable",
"is_variable_color_temp",
"valid_temperature_range",
}
deprecated_is_light_function_smart_module = {
"is_color": "Color",
"is_dimmable": "Brightness",
Expand Down Expand Up @@ -244,20 +251,23 @@ def _test_attr(attribute):
async def _test_attribute(
dev: Device, attribute_name, is_expected, module_name, *args, will_raise=False
):
if is_expected and will_raise:
will_warn = is_expected or attribute_name in deprecated_warns_before_attribute_error

if will_raise:
ctx: AbstractContextManager | nullcontext = pytest.raises(will_raise)
dep_context: pytest.WarningsRecorder | nullcontext = pytest.deprecated_call(
match=(f"{attribute_name} is deprecated, use:")
)
dep_context: pytest.WarningsRecorder | nullcontext
elif is_expected:
ctx = nullcontext()
dep_context = pytest.deprecated_call(
match=(f"{attribute_name} is deprecated, use:")
)
else:
ctx = pytest.raises(
AttributeError, match=f"Device has no attribute '{attribute_name}'"
)

if will_warn:
dep_context = pytest.deprecated_call(
match=(f"{attribute_name} is deprecated, use:")
)
else:
dep_context = nullcontext()

with dep_context, ctx:
Expand Down
94 changes: 55 additions & 39 deletions tests/test_deviceconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,17 @@

async def test_serialization():
"""Test device config serialization."""
config = DeviceConfig(host="Foo", http_client=aiohttp.ClientSession())
config_dict = config.to_dict()
config_json = json_dumps(config_dict)
config2_dict = json_loads(config_json)
config2 = DeviceConfig.from_dict(config2_dict)
assert config == config2
assert config.to_dict_control_credentials() == config.to_dict()
http_client = aiohttp.ClientSession()
try:
config = DeviceConfig(host="Foo", http_client=http_client)
config_dict = config.to_dict()
config_json = json_dumps(config_dict)
config2_dict = json_loads(config_json)
config2 = DeviceConfig.from_dict(config2_dict)
assert config == config2
assert config.to_dict_control_credentials() == config.to_dict()
finally:
await http_client.close()


@pytest.mark.parametrize(
Expand Down Expand Up @@ -96,41 +100,53 @@ def test_deserialization_errors(input_value, expected_error):


async def test_credentials_hash():
config = DeviceConfig(
host="Foo",
http_client=aiohttp.ClientSession(),
credentials=Credentials("foo", "bar"),
)
config_dict = config.to_dict_control_credentials(credentials_hash="credhash")
config_json = json_dumps(config_dict)
config2_dict = json_loads(config_json)
config2 = DeviceConfig.from_dict(config2_dict)
assert config2.credentials_hash == "credhash"
assert config2.credentials is None
http_client = aiohttp.ClientSession()
try:
config = DeviceConfig(
host="Foo",
http_client=http_client,
credentials=Credentials("foo", "bar"),
)
config_dict = config.to_dict_control_credentials(credentials_hash="credhash")
config_json = json_dumps(config_dict)
config2_dict = json_loads(config_json)
config2 = DeviceConfig.from_dict(config2_dict)
assert config2.credentials_hash == "credhash"
assert config2.credentials is None
finally:
await http_client.close()


async def test_blank_credentials_hash():
config = DeviceConfig(
host="Foo",
http_client=aiohttp.ClientSession(),
credentials=Credentials("foo", "bar"),
)
config_dict = config.to_dict_control_credentials(credentials_hash="")
config_json = json_dumps(config_dict)
config2_dict = json_loads(config_json)
config2 = DeviceConfig.from_dict(config2_dict)
assert config2.credentials_hash is None
assert config2.credentials is None
http_client = aiohttp.ClientSession()
try:
config = DeviceConfig(
host="Foo",
http_client=http_client,
credentials=Credentials("foo", "bar"),
)
config_dict = config.to_dict_control_credentials(credentials_hash="")
config_json = json_dumps(config_dict)
config2_dict = json_loads(config_json)
config2 = DeviceConfig.from_dict(config2_dict)
assert config2.credentials_hash is None
assert config2.credentials is None
finally:
await http_client.close()


async def test_exclude_credentials():
config = DeviceConfig(
host="Foo",
http_client=aiohttp.ClientSession(),
credentials=Credentials("foo", "bar"),
)
config_dict = config.to_dict_control_credentials(exclude_credentials=True)
config_json = json_dumps(config_dict)
config2_dict = json_loads(config_json)
config2 = DeviceConfig.from_dict(config2_dict)
assert config2.credentials is None
http_client = aiohttp.ClientSession()
try:
config = DeviceConfig(
host="Foo",
http_client=http_client,
credentials=Credentials("foo", "bar"),
)
config_dict = config.to_dict_control_credentials(exclude_credentials=True)
config_json = json_dumps(config_dict)
config2_dict = json_loads(config_json)
config2 = DeviceConfig.from_dict(config2_dict)
assert config2.credentials is None
finally:
await http_client.close()
Loading