mirror of
https://github.com/python-kasa/python-kasa.git
synced 2024-12-22 19:23:34 +00:00
Add effect support for light strips (#293)
* Add effect support for KL430 * KL400 supports effects * Add KL400 fixture * Comments from review * actually commit the remove
This commit is contained in:
parent
b22f6b4eef
commit
58f6517445
21
kasa/cli.py
21
kasa/cli.py
@ -371,6 +371,27 @@ async def temperature(dev: SmartBulb, temperature: int, transition: int):
|
||||
return await dev.set_color_temp(temperature, transition=transition)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("effect", type=click.STRING, default=None, required=False)
|
||||
@click.pass_context
|
||||
@pass_dev
|
||||
async def effect(dev, ctx, effect):
|
||||
"""Set an effect."""
|
||||
if not dev.has_effects:
|
||||
click.echo("Device does not support effects")
|
||||
return
|
||||
if effect is None:
|
||||
raise click.BadArgumentUsage(
|
||||
f"Setting an effect requires a named built-in effect: {dev.effect_list}",
|
||||
ctx,
|
||||
)
|
||||
if effect not in dev.effect_list:
|
||||
raise click.BadArgumentUsage(f"Effect must be one of: {dev.effect_list}", ctx)
|
||||
|
||||
click.echo(f"Setting Effect: {effect}")
|
||||
return await dev.set_effect(effect)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("h", type=click.IntRange(0, 360), default=None, required=False)
|
||||
@click.argument("s", type=click.IntRange(0, 100), default=None, required=False)
|
||||
|
296
kasa/effects.py
Normal file
296
kasa/effects.py
Normal file
@ -0,0 +1,296 @@
|
||||
"""Module for light strip effects (LB*, KL*, KB*)."""
|
||||
|
||||
from typing import List, cast
|
||||
|
||||
EFFECT_AURORA = {
|
||||
"custom": 0,
|
||||
"id": "xqUxDhbAhNLqulcuRMyPBmVGyTOyEMEu",
|
||||
"brightness": 100,
|
||||
"name": "Aurora",
|
||||
"segments": [0],
|
||||
"expansion_strategy": 1,
|
||||
"enable": 1,
|
||||
"type": "sequence",
|
||||
"duration": 0,
|
||||
"transition": 1500,
|
||||
"direction": 4,
|
||||
"spread": 7,
|
||||
"repeat_times": 0,
|
||||
"sequence": [[120, 100, 100], [240, 100, 100], [260, 100, 100], [280, 100, 100]],
|
||||
}
|
||||
EFFECT_BUBBLING_CAULDRON = {
|
||||
"custom": 0,
|
||||
"id": "tIwTRQBqJpeNKbrtBMFCgkdPTbAQGfRP",
|
||||
"brightness": 100,
|
||||
"name": "Bubbling Cauldron",
|
||||
"segments": [0],
|
||||
"expansion_strategy": 1,
|
||||
"enable": 1,
|
||||
"type": "random",
|
||||
"hue_range": [100, 270],
|
||||
"saturation_range": [80, 100],
|
||||
"brightness_range": [50, 100],
|
||||
"duration": 0,
|
||||
"transition": 200,
|
||||
"init_states": [[270, 100, 100]],
|
||||
"fadeoff": 1000,
|
||||
"random_seed": 24,
|
||||
"backgrounds": [[270, 40, 50]],
|
||||
}
|
||||
EFFECT_CANDY_CANE = {
|
||||
"custom": 0,
|
||||
"id": "HCOttllMkNffeHjEOLEgrFJjbzQHoxEJ",
|
||||
"brightness": 100,
|
||||
"name": "Candy Cane",
|
||||
"segments": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||
"expansion_strategy": 1,
|
||||
"enable": 1,
|
||||
"type": "sequence",
|
||||
"duration": 700,
|
||||
"transition": 500,
|
||||
"direction": 1,
|
||||
"spread": 1,
|
||||
"repeat_times": 0,
|
||||
"sequence": [
|
||||
[0, 0, 100],
|
||||
[0, 0, 100],
|
||||
[360, 81, 100],
|
||||
[0, 0, 100],
|
||||
[0, 0, 100],
|
||||
[360, 81, 100],
|
||||
[360, 81, 100],
|
||||
[0, 0, 100],
|
||||
[0, 0, 100],
|
||||
[360, 81, 100],
|
||||
[360, 81, 100],
|
||||
[360, 81, 100],
|
||||
[360, 81, 100],
|
||||
[0, 0, 100],
|
||||
[0, 0, 100],
|
||||
[360, 81, 100],
|
||||
],
|
||||
}
|
||||
EFFECT_CHRISTMAS = {
|
||||
"custom": 0,
|
||||
"id": "bwTatyinOUajKrDwzMmqxxJdnInQUgvM",
|
||||
"brightness": 100,
|
||||
"name": "Christmas",
|
||||
"segments": [0],
|
||||
"expansion_strategy": 1,
|
||||
"enable": 1,
|
||||
"type": "random",
|
||||
"hue_range": [136, 146],
|
||||
"saturation_range": [90, 100],
|
||||
"brightness_range": [50, 100],
|
||||
"duration": 5000,
|
||||
"transition": 0,
|
||||
"init_states": [[136, 0, 100]],
|
||||
"fadeoff": 2000,
|
||||
"random_seed": 100,
|
||||
"backgrounds": [[136, 98, 75], [136, 0, 0], [350, 0, 100], [350, 97, 94]],
|
||||
}
|
||||
EFFECT_FLICKER = {
|
||||
"custom": 0,
|
||||
"id": "bCTItKETDFfrKANolgldxfgOakaarARs",
|
||||
"brightness": 100,
|
||||
"name": "Flicker",
|
||||
"segments": [1],
|
||||
"expansion_strategy": 1,
|
||||
"enable": 1,
|
||||
"type": "random",
|
||||
"hue_range": [30, 40],
|
||||
"saturation_range": [100, 100],
|
||||
"brightness_range": [50, 100],
|
||||
"duration": 0,
|
||||
"transition": 0,
|
||||
"transition_range": [375, 500],
|
||||
"init_states": [[30, 81, 80]],
|
||||
}
|
||||
EFFECT_HANUKKAH = {
|
||||
"custom": 0,
|
||||
"id": "CdLeIgiKcQrLKMINRPTMbylATulQewLD",
|
||||
"brightness": 100,
|
||||
"name": "Hanukkah",
|
||||
"segments": [1],
|
||||
"expansion_strategy": 1,
|
||||
"enable": 1,
|
||||
"type": "random",
|
||||
"hue_range": [200, 210],
|
||||
"saturation_range": [0, 100],
|
||||
"brightness_range": [50, 100],
|
||||
"duration": 1500,
|
||||
"transition": 0,
|
||||
"transition_range": [400, 500],
|
||||
"init_states": [[35, 81, 80]],
|
||||
}
|
||||
EFFECT_HAUNTED_MANSION = {
|
||||
"custom": 0,
|
||||
"id": "oJnFHsVQzFUTeIOBAhMRfVeujmSauhjJ",
|
||||
"brightness": 80,
|
||||
"name": "Haunted Mansion",
|
||||
"segments": [80],
|
||||
"expansion_strategy": 2,
|
||||
"enable": 1,
|
||||
"type": "random",
|
||||
"hue_range": [45, 45],
|
||||
"saturation_range": [10, 10],
|
||||
"brightness_range": [0, 80],
|
||||
"duration": 0,
|
||||
"transition": 0,
|
||||
"transition_range": [50, 1500],
|
||||
"init_states": [[45, 10, 100]],
|
||||
"fadeoff": 200,
|
||||
"random_seed": 1,
|
||||
"backgrounds": [[45, 10, 100]],
|
||||
}
|
||||
EFFECT_ICICLE = {
|
||||
"custom": 0,
|
||||
"id": "joqVjlaTsgzmuQQBAlHRkkPAqkBUiqeb",
|
||||
"brightness": 70,
|
||||
"name": "Icicle",
|
||||
"segments": [0],
|
||||
"expansion_strategy": 1,
|
||||
"enable": 1,
|
||||
"type": "sequence",
|
||||
"duration": 0,
|
||||
"transition": 400,
|
||||
"direction": 4,
|
||||
"spread": 3,
|
||||
"repeat_times": 0,
|
||||
"sequence": [
|
||||
[190, 100, 70],
|
||||
[190, 100, 70],
|
||||
[190, 30, 50],
|
||||
[190, 100, 70],
|
||||
[190, 100, 70],
|
||||
],
|
||||
}
|
||||
EFFECT_LIGHTNING = {
|
||||
"custom": 0,
|
||||
"id": "ojqpUUxdGHoIugGPknrUcRoyJiItsjuE",
|
||||
"brightness": 100,
|
||||
"name": "Lightning",
|
||||
"segments": [7, 20, 23, 32, 34, 35, 49, 65, 66, 74, 80],
|
||||
"expansion_strategy": 1,
|
||||
"enable": 1,
|
||||
"type": "random",
|
||||
"hue_range": [240, 240],
|
||||
"saturation_range": [10, 11],
|
||||
"brightness_range": [90, 100],
|
||||
"duration": 0,
|
||||
"transition": 50,
|
||||
"init_states": [[240, 30, 100]],
|
||||
"fadeoff": 150,
|
||||
"random_seed": 600,
|
||||
"backgrounds": [[200, 100, 100], [200, 50, 10], [210, 10, 50], [240, 10, 0]],
|
||||
}
|
||||
EFFECT_OCEAN = {
|
||||
"custom": 0,
|
||||
"id": "oJjUMosgEMrdumfPANKbkFmBcAdEQsPy",
|
||||
"brightness": 30,
|
||||
"name": "Ocean",
|
||||
"segments": [0],
|
||||
"expansion_strategy": 1,
|
||||
"enable": 1,
|
||||
"type": "sequence",
|
||||
"duration": 0,
|
||||
"transition": 2000,
|
||||
"direction": 3,
|
||||
"spread": 16,
|
||||
"repeat_times": 0,
|
||||
"sequence": [[198, 84, 30], [198, 70, 30], [198, 10, 30]],
|
||||
}
|
||||
EFFECT_RAINBOW = {
|
||||
"custom": 0,
|
||||
"id": "izRhLCQNcDzIKdpMPqSTtBMuAIoreAuT",
|
||||
"brightness": 100,
|
||||
"name": "Rainbow",
|
||||
"segments": [0],
|
||||
"expansion_strategy": 1,
|
||||
"enable": 1,
|
||||
"type": "sequence",
|
||||
"duration": 0,
|
||||
"transition": 1500,
|
||||
"direction": 1,
|
||||
"spread": 12,
|
||||
"repeat_times": 0,
|
||||
"sequence": [[0, 100, 100], [100, 100, 100], [200, 100, 100], [300, 100, 100]],
|
||||
}
|
||||
EFFECT_RAINDROP = {
|
||||
"custom": 0,
|
||||
"id": "QbDFwiSFmLzQenUOPnJrsGqyIVrJrRsl",
|
||||
"brightness": 30,
|
||||
"name": "Raindrop",
|
||||
"segments": [0],
|
||||
"expansion_strategy": 1,
|
||||
"enable": 1,
|
||||
"type": "random",
|
||||
"hue_range": [200, 200],
|
||||
"saturation_range": [10, 20],
|
||||
"brightness_range": [10, 30],
|
||||
"duration": 0,
|
||||
"transition": 1000,
|
||||
"init_states": [[200, 40, 100]],
|
||||
"fadeoff": 1000,
|
||||
"random_seed": 24,
|
||||
"backgrounds": [[200, 40, 0]],
|
||||
}
|
||||
EFFECT_SPRING = {
|
||||
"custom": 0,
|
||||
"id": "URdUpEdQbnOOechDBPMkKrwhSupLyvAg",
|
||||
"brightness": 100,
|
||||
"name": "Spring",
|
||||
"segments": [0],
|
||||
"expansion_strategy": 1,
|
||||
"enable": 1,
|
||||
"type": "random",
|
||||
"hue_range": [0, 90],
|
||||
"saturation_range": [30, 100],
|
||||
"brightness_range": [90, 100],
|
||||
"duration": 600,
|
||||
"transition": 0,
|
||||
"transition_range": [2000, 6000],
|
||||
"init_states": [[80, 30, 100]],
|
||||
"fadeoff": 1000,
|
||||
"random_seed": 20,
|
||||
"backgrounds": [[130, 100, 40]],
|
||||
}
|
||||
EFFECT_VALENTINES = {
|
||||
"custom": 0,
|
||||
"id": "QglBhMShPHUAuxLqzNEefFrGiJwahOmz",
|
||||
"brightness": 100,
|
||||
"name": "Valentines",
|
||||
"segments": [0],
|
||||
"expansion_strategy": 1,
|
||||
"enable": 1,
|
||||
"type": "random",
|
||||
"hue_range": [340, 340],
|
||||
"saturation_range": [30, 40],
|
||||
"brightness_range": [90, 100],
|
||||
"duration": 600,
|
||||
"transition": 2000,
|
||||
"init_states": [[340, 30, 100]],
|
||||
"fadeoff": 3000,
|
||||
"random_seed": 100,
|
||||
"backgrounds": [[340, 20, 50], [20, 50, 50], [0, 100, 50]],
|
||||
}
|
||||
|
||||
EFFECTS_LIST_V1 = [
|
||||
EFFECT_AURORA,
|
||||
EFFECT_BUBBLING_CAULDRON,
|
||||
EFFECT_CANDY_CANE,
|
||||
EFFECT_CHRISTMAS,
|
||||
EFFECT_FLICKER,
|
||||
EFFECT_HANUKKAH,
|
||||
EFFECT_HAUNTED_MANSION,
|
||||
EFFECT_ICICLE,
|
||||
EFFECT_LIGHTNING,
|
||||
EFFECT_OCEAN,
|
||||
EFFECT_RAINBOW,
|
||||
EFFECT_RAINDROP,
|
||||
EFFECT_SPRING,
|
||||
EFFECT_VALENTINES,
|
||||
]
|
||||
|
||||
EFFECT_NAMES_V1: List[str] = [cast(str, effect["name"]) for effect in EFFECTS_LIST_V1]
|
||||
EFFECT_MAPPING_V1 = {effect["name"]: effect for effect in EFFECTS_LIST_V1}
|
@ -172,6 +172,12 @@ class SmartBulb(SmartDevice):
|
||||
|
||||
return light_state
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def has_effects(self) -> bool:
|
||||
"""Return True if the device supports effects."""
|
||||
return "lighting_effect_state" in self.sys_info
|
||||
|
||||
async def get_light_details(self) -> Dict[str, int]:
|
||||
"""Return light details.
|
||||
|
||||
|
@ -1,8 +1,9 @@
|
||||
"""Module for light strips (KL430)."""
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1
|
||||
from .smartbulb import SmartBulb
|
||||
from .smartdevice import DeviceType, requires_update
|
||||
from .smartdevice import DeviceType, SmartDeviceException, requires_update
|
||||
|
||||
|
||||
class SmartLightStrip(SmartBulb):
|
||||
@ -64,6 +65,16 @@ class SmartLightStrip(SmartBulb):
|
||||
"""
|
||||
return self.sys_info["lighting_effect_state"]
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def effect_list(self) -> Optional[List[str]]:
|
||||
"""Return built-in effects list.
|
||||
|
||||
Example:
|
||||
['Aurora', 'Bubbling Cauldron', ...]
|
||||
"""
|
||||
return EFFECT_NAMES_V1 if self.has_effects else None
|
||||
|
||||
@property # type: ignore
|
||||
@requires_update
|
||||
def state_information(self) -> Dict[str, Any]:
|
||||
@ -71,5 +82,37 @@ class SmartLightStrip(SmartBulb):
|
||||
info = super().state_information
|
||||
|
||||
info["Length"] = self.length
|
||||
if self.has_effects:
|
||||
info["Effect"] = self.effect["name"]
|
||||
|
||||
return info
|
||||
|
||||
@requires_update
|
||||
async def set_effect(
|
||||
self,
|
||||
effect: str,
|
||||
) -> None:
|
||||
"""Set an effect on the device.
|
||||
|
||||
:param str effect: The effect to set
|
||||
"""
|
||||
if effect not in EFFECT_MAPPING_V1:
|
||||
raise SmartDeviceException(f"The effect {effect} is not a built in effect.")
|
||||
await self.set_custom_effect(EFFECT_MAPPING_V1[effect])
|
||||
|
||||
@requires_update
|
||||
async def set_custom_effect(
|
||||
self,
|
||||
effect_dict: Dict,
|
||||
) -> None:
|
||||
"""Set a custom effect on the device.
|
||||
|
||||
:param str effect_dict: The custom effect dict to set
|
||||
"""
|
||||
if not self.has_effects:
|
||||
raise SmartDeviceException("Bulb does not support effects.")
|
||||
await self._query_helper(
|
||||
"smartlife.iot.lighting_effect",
|
||||
"set_lighting_effect",
|
||||
effect_dict,
|
||||
)
|
||||
|
57
kasa/tests/fixtures/KL400L5(US)_1.0_1.0.8.json
vendored
Normal file
57
kasa/tests/fixtures/KL400L5(US)_1.0_1.0.8.json
vendored
Normal file
@ -0,0 +1,57 @@
|
||||
{
|
||||
"smartlife.iot.common.emeter": {
|
||||
"get_realtime": {
|
||||
"err_code": 0,
|
||||
"power_mw": 10800,
|
||||
"total_wh": 1
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"get_sysinfo": {
|
||||
"LEF": 0,
|
||||
"active_mode": "none",
|
||||
"alias": "Kl400",
|
||||
"ctrl_protocols": {
|
||||
"name": "Linkie",
|
||||
"version": "1.0"
|
||||
},
|
||||
"description": "Kasa Smart Light Strip, Multicolor",
|
||||
"dev_state": "normal",
|
||||
"deviceId": "0000000000000000000000000000000000000000",
|
||||
"disco_ver": "1.0",
|
||||
"err_code": 0,
|
||||
"hwId": "00000000000000000000000000000000",
|
||||
"hw_ver": "1.0",
|
||||
"is_color": 1,
|
||||
"is_dimmable": 1,
|
||||
"is_factory": false,
|
||||
"is_variable_color_temp": 0,
|
||||
"latitude_i": 0,
|
||||
"length": 1,
|
||||
"light_state": {
|
||||
"brightness": 100,
|
||||
"color_temp": 6500,
|
||||
"hue": 0,
|
||||
"mode": "normal",
|
||||
"on_off": 1,
|
||||
"saturation": 0
|
||||
},
|
||||
"lighting_effect_state": {
|
||||
"brightness": 100,
|
||||
"custom": 0,
|
||||
"enable": 1,
|
||||
"id": "CdLeIgiKcQrLKMINRPTMbylATulQewLD",
|
||||
"name": "Hanukkah"
|
||||
},
|
||||
"longitude_i": 0,
|
||||
"mic_mac": "00:00:00:00:00:00",
|
||||
"mic_type": "IOT.SMARTBULB",
|
||||
"model": "KL400L5(US)",
|
||||
"oemId": "00000000000000000000000000000000",
|
||||
"preferred_state": [],
|
||||
"rssi": -44,
|
||||
"status": "new",
|
||||
"sw_ver": "1.0.8 Build 211018 Rel.162056"
|
||||
}
|
||||
}
|
||||
}
|
57
kasa/tests/fixtures/KL430(US)_2.0_1.0.9.json
vendored
Normal file
57
kasa/tests/fixtures/KL430(US)_2.0_1.0.9.json
vendored
Normal file
@ -0,0 +1,57 @@
|
||||
{
|
||||
"smartlife.iot.common.emeter": {
|
||||
"get_realtime": {
|
||||
"err_code": 0,
|
||||
"power_mw": 11150,
|
||||
"total_wh": 18
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"get_sysinfo": {
|
||||
"LEF": 1,
|
||||
"active_mode": "none",
|
||||
"alias": "kl430 updated",
|
||||
"ctrl_protocols": {
|
||||
"name": "Linkie",
|
||||
"version": "1.0"
|
||||
},
|
||||
"description": "Kasa Smart Light Strip, Multicolor",
|
||||
"dev_state": "normal",
|
||||
"deviceId": "0000000000000000000000000000000000000000",
|
||||
"disco_ver": "1.0",
|
||||
"err_code": 0,
|
||||
"hwId": "00000000000000000000000000000000",
|
||||
"hw_ver": "2.0",
|
||||
"is_color": 1,
|
||||
"is_dimmable": 1,
|
||||
"is_factory": false,
|
||||
"is_variable_color_temp": 1,
|
||||
"latitude_i": 0,
|
||||
"length": 16,
|
||||
"light_state": {
|
||||
"brightness": 100,
|
||||
"color_temp": 0,
|
||||
"hue": 194,
|
||||
"mode": "normal",
|
||||
"on_off": 1,
|
||||
"saturation": 50
|
||||
},
|
||||
"lighting_effect_state": {
|
||||
"brightness": 100,
|
||||
"custom": 0,
|
||||
"enable": 1,
|
||||
"id": "izRhLCQNcDzIKdpMPqSTtBMuAIoreAuT",
|
||||
"name": "Rainbow"
|
||||
},
|
||||
"longitude_i": 0,
|
||||
"mic_mac": "00:00:00:00:00:00",
|
||||
"mic_type": "IOT.SMARTBULB",
|
||||
"model": "KL430(US)",
|
||||
"oemId": "00000000000000000000000000000000",
|
||||
"preferred_state": [],
|
||||
"rssi": -58,
|
||||
"status": "new",
|
||||
"sw_ver": "1.0.9 Build 210915 Rel.170534"
|
||||
}
|
||||
}
|
||||
}
|
@ -359,6 +359,10 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol):
|
||||
self.proto["system"]["get_sysinfo"]["relay_state"] = 1
|
||||
self.proto["system"]["get_sysinfo"]["brightness"] = x["brightness"]
|
||||
|
||||
def set_lighting_effect(self, effect, *args):
|
||||
_LOGGER.debug("Setting light effect to %s", effect)
|
||||
self.proto["system"]["get_sysinfo"]["lighting_effect_state"] = dict(effect)
|
||||
|
||||
def transition_light_state(self, state_changes, *args):
|
||||
_LOGGER.debug("Setting light state to %s", state_changes)
|
||||
light_state = self.proto["system"]["get_sysinfo"]["light_state"]
|
||||
@ -422,6 +426,9 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol):
|
||||
"get_light_state": light_state,
|
||||
"transition_light_state": transition_light_state,
|
||||
},
|
||||
"smartlife.iot.lighting_effect": {
|
||||
"set_lighting_effect": set_lighting_effect,
|
||||
},
|
||||
# lightstrip follows the same payloads but uses different module & method
|
||||
"smartlife.iot.lightStrip": {
|
||||
"set_light_state": transition_light_state,
|
||||
|
@ -1,4 +1,7 @@
|
||||
import pytest
|
||||
|
||||
from kasa import DeviceType, SmartLightStrip
|
||||
from kasa.exceptions import SmartDeviceException
|
||||
|
||||
from .conftest import lightstrip, pytestmark
|
||||
|
||||
@ -15,3 +18,19 @@ async def test_lightstrip_effect(dev: SmartLightStrip):
|
||||
assert isinstance(dev.effect, dict)
|
||||
for k in ["brightness", "custom", "enable", "id", "name"]:
|
||||
assert k in dev.effect
|
||||
|
||||
|
||||
@lightstrip
|
||||
async def test_effects_lightstrip_set_effect(dev: SmartLightStrip):
|
||||
with pytest.raises(SmartDeviceException):
|
||||
await dev.set_effect("Not real")
|
||||
|
||||
await dev.set_effect("Candy Cane")
|
||||
assert dev.effect["name"] == "Candy Cane"
|
||||
assert dev.state_information["Effect"] == "Candy Cane"
|
||||
|
||||
|
||||
@lightstrip
|
||||
async def test_effects_lightstrip_has_effects(dev: SmartLightStrip):
|
||||
assert dev.has_effects is True
|
||||
assert dev.effect_list
|
||||
|
Loading…
Reference in New Issue
Block a user