mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-07-27 11:06:32 +00:00
Support mute + applications from pulse (#1541)
* Support mute + applications from pulse * Fix lint * Fix application parser * Fix type * Add application endpoints * error handling * Fix
This commit is contained in:
parent
87170a4497
commit
e1cbfdd84b
84
API.md
84
API.md
@ -866,6 +866,7 @@ return:
|
|||||||
"card": [
|
"card": [
|
||||||
{
|
{
|
||||||
"name": "...",
|
"name": "...",
|
||||||
|
"index": 1,
|
||||||
"driver": "...",
|
"driver": "...",
|
||||||
"profiles": [
|
"profiles": [
|
||||||
{
|
{
|
||||||
@ -879,17 +880,56 @@ return:
|
|||||||
"input": [
|
"input": [
|
||||||
{
|
{
|
||||||
"name": "...",
|
"name": "...",
|
||||||
|
"index": 0,
|
||||||
"description": "...",
|
"description": "...",
|
||||||
"volume": 0.3,
|
"volume": 0.3,
|
||||||
"default": false
|
"mute": false,
|
||||||
|
"default": false,
|
||||||
|
"card": "null|int",
|
||||||
|
"applications": [
|
||||||
|
{
|
||||||
|
"name": "...",
|
||||||
|
"index": 0,
|
||||||
|
"stream_index": 0,
|
||||||
|
"stream_type": "INPUT",
|
||||||
|
"volume": 0.3,
|
||||||
|
"mute": false,
|
||||||
|
"addon": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"output": [
|
"output": [
|
||||||
{
|
{
|
||||||
"name": "...",
|
"name": "...",
|
||||||
|
"index": 0,
|
||||||
"description": "...",
|
"description": "...",
|
||||||
"volume": 0.3,
|
"volume": 0.3,
|
||||||
"default": false
|
"mute": false,
|
||||||
|
"default": false,
|
||||||
|
"card": "null|int",
|
||||||
|
"applications": [
|
||||||
|
{
|
||||||
|
"name": "...",
|
||||||
|
"index": 0,
|
||||||
|
"stream_index": 0,
|
||||||
|
"stream_type": "OUTPUT",
|
||||||
|
"volume": 0.3,
|
||||||
|
"mute": false,
|
||||||
|
"addon": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"application": [
|
||||||
|
{
|
||||||
|
"name": "...",
|
||||||
|
"index": 0,
|
||||||
|
"stream_index": 0,
|
||||||
|
"stream_type": "OUTPUT",
|
||||||
|
"volume": 0.3,
|
||||||
|
"mute": false,
|
||||||
|
"addon": ""
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -914,7 +954,7 @@ return:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "...",
|
"index": "...",
|
||||||
"volume": 0.5
|
"volume": 0.5
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -923,11 +963,47 @@ return:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "...",
|
"index": "...",
|
||||||
"volume": 0.5
|
"volume": 0.5
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- POST `/audio/volume/{output|input}/application`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"index": "...",
|
||||||
|
"volume": 0.5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- POST `/audio/mute/input`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"index": "...",
|
||||||
|
"active": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- POST `/audio/mute/output`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"index": "...",
|
||||||
|
"active": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- POST `/audio/mute/{output|input}/application`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"index": "...",
|
||||||
|
"active": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
- POST `/audio/default/input`
|
- POST `/audio/default/input`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
@ -330,7 +330,10 @@ class RestAPI(CoreSysAttributes):
|
|||||||
web.post("/audio/restart", api_audio.restart),
|
web.post("/audio/restart", api_audio.restart),
|
||||||
web.post("/audio/reload", api_audio.reload),
|
web.post("/audio/reload", api_audio.reload),
|
||||||
web.post("/audio/profile", api_audio.set_profile),
|
web.post("/audio/profile", api_audio.set_profile),
|
||||||
|
web.post("/audio/volume/{source}/application", api_audio.set_volume),
|
||||||
web.post("/audio/volume/{source}", api_audio.set_volume),
|
web.post("/audio/volume/{source}", api_audio.set_volume),
|
||||||
|
web.post("/audio/mute/{source}/application", api_audio.set_mute),
|
||||||
|
web.post("/audio/mute/{source}", api_audio.set_mute),
|
||||||
web.post("/audio/default/{source}", api_audio.set_default),
|
web.post("/audio/default/{source}", api_audio.set_default),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
@ -8,12 +8,15 @@ import attr
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from ..const import (
|
from ..const import (
|
||||||
|
ATTR_ACTIVE,
|
||||||
|
ATTR_APPLICATION,
|
||||||
ATTR_AUDIO,
|
ATTR_AUDIO,
|
||||||
ATTR_BLK_READ,
|
ATTR_BLK_READ,
|
||||||
ATTR_BLK_WRITE,
|
ATTR_BLK_WRITE,
|
||||||
ATTR_CARD,
|
ATTR_CARD,
|
||||||
ATTR_CPU_PERCENT,
|
ATTR_CPU_PERCENT,
|
||||||
ATTR_HOST,
|
ATTR_HOST,
|
||||||
|
ATTR_INDEX,
|
||||||
ATTR_INPUT,
|
ATTR_INPUT,
|
||||||
ATTR_LATEST_VERSION,
|
ATTR_LATEST_VERSION,
|
||||||
ATTR_MEMORY_LIMIT,
|
ATTR_MEMORY_LIMIT,
|
||||||
@ -38,11 +41,19 @@ SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): vol.Coerce(str)})
|
|||||||
|
|
||||||
SCHEMA_VOLUME = vol.Schema(
|
SCHEMA_VOLUME = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required(ATTR_NAME): vol.Coerce(str),
|
vol.Required(ATTR_INDEX): vol.Coerce(int),
|
||||||
vol.Required(ATTR_VOLUME): vol.Coerce(float),
|
vol.Required(ATTR_VOLUME): vol.Coerce(float),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# pylint: disable=no-value-for-parameter
|
||||||
|
SCHEMA_MUTE = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(ATTR_INDEX): vol.Coerce(int),
|
||||||
|
vol.Required(ATTR_ACTIVE): vol.Boolean(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
SCHEMA_DEFAULT = vol.Schema({vol.Required(ATTR_NAME): vol.Coerce(str)})
|
SCHEMA_DEFAULT = vol.Schema({vol.Required(ATTR_NAME): vol.Coerce(str)})
|
||||||
|
|
||||||
SCHEMA_PROFILE = vol.Schema(
|
SCHEMA_PROFILE = vol.Schema(
|
||||||
@ -68,6 +79,9 @@ class APIAudio(CoreSysAttributes):
|
|||||||
ATTR_OUTPUT: [
|
ATTR_OUTPUT: [
|
||||||
attr.asdict(stream) for stream in self.sys_host.sound.outputs
|
attr.asdict(stream) for stream in self.sys_host.sound.outputs
|
||||||
],
|
],
|
||||||
|
ATTR_APPLICATION: [
|
||||||
|
attr.asdict(stream) for stream in self.sys_host.sound.applications
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,10 +130,26 @@ class APIAudio(CoreSysAttributes):
|
|||||||
async def set_volume(self, request: web.Request) -> None:
|
async def set_volume(self, request: web.Request) -> None:
|
||||||
"""Set audio volume on stream."""
|
"""Set audio volume on stream."""
|
||||||
source: StreamType = StreamType(request.match_info.get("source"))
|
source: StreamType = StreamType(request.match_info.get("source"))
|
||||||
|
application: bool = request.path.endswith("application")
|
||||||
body = await api_validate(SCHEMA_VOLUME, request)
|
body = await api_validate(SCHEMA_VOLUME, request)
|
||||||
|
|
||||||
await asyncio.shield(
|
await asyncio.shield(
|
||||||
self.sys_host.sound.set_volume(source, body[ATTR_NAME], body[ATTR_VOLUME])
|
self.sys_host.sound.set_volume(
|
||||||
|
source, body[ATTR_INDEX], body[ATTR_VOLUME], application
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@api_process
|
||||||
|
async def set_mute(self, request: web.Request) -> None:
|
||||||
|
"""Mute audio volume on stream."""
|
||||||
|
source: StreamType = StreamType(request.match_info.get("source"))
|
||||||
|
application: bool = request.path.endswith("application")
|
||||||
|
body = await api_validate(SCHEMA_MUTE, request)
|
||||||
|
|
||||||
|
await asyncio.shield(
|
||||||
|
self.sys_host.sound.set_mute(
|
||||||
|
source, body[ATTR_INDEX], body[ATTR_ACTIVE], application
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
|
@ -234,6 +234,9 @@ ATTR_CLI = "cli"
|
|||||||
ATTR_DEFAULT = "default"
|
ATTR_DEFAULT = "default"
|
||||||
ATTR_VOLUME = "volume"
|
ATTR_VOLUME = "volume"
|
||||||
ATTR_CARD = "card"
|
ATTR_CARD = "card"
|
||||||
|
ATTR_INDEX = "index"
|
||||||
|
ATTR_ACTIVE = "active"
|
||||||
|
ATTR_APPLICATION = "application"
|
||||||
|
|
||||||
PROVIDE_SERVICE = "provide"
|
PROVIDE_SERVICE = "provide"
|
||||||
NEED_SERVICE = "need"
|
NEED_SERVICE = "need"
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
import logging
|
import logging
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
|
|
||||||
import attr
|
import attr
|
||||||
from pulsectl import Pulse, PulseError, PulseIndexError, PulseOperationFailed
|
from pulsectl import Pulse, PulseError, PulseIndexError, PulseOperationFailed
|
||||||
@ -24,13 +24,30 @@ class StreamType(str, Enum):
|
|||||||
|
|
||||||
|
|
||||||
@attr.s(frozen=True)
|
@attr.s(frozen=True)
|
||||||
class AudioStream:
|
class AudioApplication:
|
||||||
"""Represent a input/output profile."""
|
"""Represent a application on the stream."""
|
||||||
|
|
||||||
name: str = attr.ib()
|
name: str = attr.ib()
|
||||||
|
index: int = attr.ib()
|
||||||
|
stream_index: str = attr.ib()
|
||||||
|
stream_type: StreamType = attr.ib()
|
||||||
|
volume: float = attr.ib()
|
||||||
|
mute: bool = attr.ib()
|
||||||
|
addon: str = attr.ib()
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(frozen=True)
|
||||||
|
class AudioStream:
|
||||||
|
"""Represent a input/output stream."""
|
||||||
|
|
||||||
|
name: str = attr.ib()
|
||||||
|
index: int = attr.ib()
|
||||||
description: str = attr.ib()
|
description: str = attr.ib()
|
||||||
volume: float = attr.ib()
|
volume: float = attr.ib()
|
||||||
|
mute: bool = attr.ib()
|
||||||
default: bool = attr.ib()
|
default: bool = attr.ib()
|
||||||
|
card: Optional[int] = attr.ib()
|
||||||
|
applications: List[AudioApplication] = attr.ib()
|
||||||
|
|
||||||
|
|
||||||
@attr.s(frozen=True)
|
@attr.s(frozen=True)
|
||||||
@ -47,6 +64,7 @@ class SoundCard:
|
|||||||
"""Represent a Sound Card."""
|
"""Represent a Sound Card."""
|
||||||
|
|
||||||
name: str = attr.ib()
|
name: str = attr.ib()
|
||||||
|
index: int = attr.ib()
|
||||||
driver: str = attr.ib()
|
driver: str = attr.ib()
|
||||||
profiles: List[SoundProfile] = attr.ib()
|
profiles: List[SoundProfile] = attr.ib()
|
||||||
|
|
||||||
@ -60,6 +78,7 @@ class SoundControl(CoreSysAttributes):
|
|||||||
self._cards: List[SoundCard] = []
|
self._cards: List[SoundCard] = []
|
||||||
self._inputs: List[AudioStream] = []
|
self._inputs: List[AudioStream] = []
|
||||||
self._outputs: List[AudioStream] = []
|
self._outputs: List[AudioStream] = []
|
||||||
|
self._applications: List[AudioApplication] = []
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cards(self) -> List[SoundCard]:
|
def cards(self) -> List[SoundCard]:
|
||||||
@ -76,6 +95,11 @@ class SoundControl(CoreSysAttributes):
|
|||||||
"""Return a list of available output streams."""
|
"""Return a list of available output streams."""
|
||||||
return self._outputs
|
return self._outputs
|
||||||
|
|
||||||
|
@property
|
||||||
|
def applications(self) -> List[AudioApplication]:
|
||||||
|
"""Return a list of available application streams."""
|
||||||
|
return self._applications
|
||||||
|
|
||||||
async def set_default(self, stream_type: StreamType, name: str) -> None:
|
async def set_default(self, stream_type: StreamType, name: str) -> None:
|
||||||
"""Set a stream to default input/output."""
|
"""Set a stream to default input/output."""
|
||||||
|
|
||||||
@ -90,11 +114,12 @@ class SoundControl(CoreSysAttributes):
|
|||||||
# Get sink and set it as default
|
# Get sink and set it as default
|
||||||
sink = pulse.get_sink_by_name(name)
|
sink = pulse.get_sink_by_name(name)
|
||||||
pulse.sink_default_set(sink)
|
pulse.sink_default_set(sink)
|
||||||
|
|
||||||
except PulseIndexError:
|
except PulseIndexError:
|
||||||
_LOGGER.error("Can't find %s profile %s", source, name)
|
_LOGGER.error("Can't find %s stream %s", source, name)
|
||||||
raise PulseAudioError() from None
|
raise PulseAudioError() from None
|
||||||
except PulseError as err:
|
except PulseError as err:
|
||||||
_LOGGER.error("Can't set %s as default: %s", name, err)
|
_LOGGER.error("Can't set %s as stream: %s", name, err)
|
||||||
raise PulseAudioError() from None
|
raise PulseAudioError() from None
|
||||||
|
|
||||||
# Run and Reload data
|
# Run and Reload data
|
||||||
@ -102,32 +127,73 @@ class SoundControl(CoreSysAttributes):
|
|||||||
await self.update()
|
await self.update()
|
||||||
|
|
||||||
async def set_volume(
|
async def set_volume(
|
||||||
self, stream_type: StreamType, name: str, volume: float
|
self, stream_type: StreamType, index: int, volume: float, application: bool
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set a stream to volume input/output."""
|
"""Set a stream to volume input/output/application."""
|
||||||
|
|
||||||
def _set_volume():
|
def _set_volume():
|
||||||
try:
|
try:
|
||||||
with Pulse(PULSE_NAME) as pulse:
|
with Pulse(PULSE_NAME) as pulse:
|
||||||
if stream_type == StreamType.INPUT:
|
if stream_type == StreamType.INPUT:
|
||||||
# Get source and set it as default
|
if application:
|
||||||
stream = pulse.get_source_by_name(name)
|
stream = pulse.source_output_info(index)
|
||||||
else:
|
else:
|
||||||
# Get sink and set it as default
|
stream = pulse.source_info(index)
|
||||||
stream = pulse.get_sink_by_name(name)
|
else:
|
||||||
|
if application:
|
||||||
|
stream = pulse.sink_input_info(index)
|
||||||
|
else:
|
||||||
|
stream = pulse.sink_info(index)
|
||||||
|
|
||||||
|
# Set volume
|
||||||
pulse.volume_set_all_chans(stream, volume)
|
pulse.volume_set_all_chans(stream, volume)
|
||||||
except PulseIndexError:
|
except PulseIndexError:
|
||||||
_LOGGER.error("Can't find %s profile %s", stream_type, name)
|
_LOGGER.error(
|
||||||
|
"Can't find %s stream %d (App: %s)", stream_type, index, application
|
||||||
|
)
|
||||||
raise PulseAudioError() from None
|
raise PulseAudioError() from None
|
||||||
except PulseError as err:
|
except PulseError as err:
|
||||||
_LOGGER.error("Can't set %s volume: %s", name, err)
|
_LOGGER.error("Can't set %d volume: %s", index, err)
|
||||||
raise PulseAudioError() from None
|
raise PulseAudioError() from None
|
||||||
|
|
||||||
# Run and Reload data
|
# Run and Reload data
|
||||||
await self.sys_run_in_executor(_set_volume)
|
await self.sys_run_in_executor(_set_volume)
|
||||||
await self.update()
|
await self.update()
|
||||||
|
|
||||||
|
async def set_mute(
|
||||||
|
self, stream_type: StreamType, index: int, mute: bool, application: bool
|
||||||
|
) -> None:
|
||||||
|
"""Set a stream to mute input/output/application."""
|
||||||
|
|
||||||
|
def _set_mute():
|
||||||
|
try:
|
||||||
|
with Pulse(PULSE_NAME) as pulse:
|
||||||
|
if stream_type == StreamType.INPUT:
|
||||||
|
if application:
|
||||||
|
stream = pulse.source_output_info(index)
|
||||||
|
else:
|
||||||
|
stream = pulse.source_info(index)
|
||||||
|
else:
|
||||||
|
if application:
|
||||||
|
stream = pulse.sink_input_info(index)
|
||||||
|
else:
|
||||||
|
stream = pulse.sink_info(index)
|
||||||
|
|
||||||
|
# Mute stream
|
||||||
|
pulse.mute(stream, mute)
|
||||||
|
except PulseIndexError:
|
||||||
|
_LOGGER.error(
|
||||||
|
"Can't find %s stream %d (App: %s)", stream_type, index, application
|
||||||
|
)
|
||||||
|
raise PulseAudioError() from None
|
||||||
|
except PulseError as err:
|
||||||
|
_LOGGER.error("Can't set %d volume: %s", index, err)
|
||||||
|
raise PulseAudioError() from None
|
||||||
|
|
||||||
|
# Run and Reload data
|
||||||
|
await self.sys_run_in_executor(_set_mute)
|
||||||
|
await self.update()
|
||||||
|
|
||||||
async def ativate_profile(self, card_name: str, profile_name: str) -> None:
|
async def ativate_profile(self, card_name: str, profile_name: str) -> None:
|
||||||
"""Set a profile to volume input/output."""
|
"""Set a profile to volume input/output."""
|
||||||
|
|
||||||
@ -160,15 +226,59 @@ class SoundControl(CoreSysAttributes):
|
|||||||
with Pulse(PULSE_NAME) as pulse:
|
with Pulse(PULSE_NAME) as pulse:
|
||||||
server = pulse.server_info()
|
server = pulse.server_info()
|
||||||
|
|
||||||
|
# Update applications
|
||||||
|
self._applications.clear()
|
||||||
|
for application in pulse.sink_input_list():
|
||||||
|
self._applications.append(
|
||||||
|
AudioApplication(
|
||||||
|
application.proplist.get(
|
||||||
|
"application.name", application.name
|
||||||
|
),
|
||||||
|
application.index,
|
||||||
|
application.sink,
|
||||||
|
StreamType.OUTPUT,
|
||||||
|
application.volume.value_flat,
|
||||||
|
bool(application.mute),
|
||||||
|
application.proplist.get(
|
||||||
|
"application.process.machine_id", ""
|
||||||
|
).replace("-", "_"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for application in pulse.source_output_list():
|
||||||
|
self._applications.append(
|
||||||
|
AudioApplication(
|
||||||
|
application.proplist.get(
|
||||||
|
"application.name", application.name
|
||||||
|
),
|
||||||
|
application.index,
|
||||||
|
application.source,
|
||||||
|
StreamType.INPUT,
|
||||||
|
application.volume.value_flat,
|
||||||
|
bool(application.mute),
|
||||||
|
application.proplist.get(
|
||||||
|
"application.process.machine_id", ""
|
||||||
|
).replace("-", "_"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Update output
|
# Update output
|
||||||
self._outputs.clear()
|
self._outputs.clear()
|
||||||
for sink in pulse.sink_list():
|
for sink in pulse.sink_list():
|
||||||
self._outputs.append(
|
self._outputs.append(
|
||||||
AudioStream(
|
AudioStream(
|
||||||
sink.name,
|
sink.name,
|
||||||
|
sink.index,
|
||||||
sink.description,
|
sink.description,
|
||||||
sink.volume.value_flat,
|
sink.volume.value_flat,
|
||||||
|
bool(sink.mute),
|
||||||
sink.name == server.default_sink_name,
|
sink.name == server.default_sink_name,
|
||||||
|
sink.card if sink.card != 0xFFFFFFFF else None,
|
||||||
|
[
|
||||||
|
application
|
||||||
|
for application in self._applications
|
||||||
|
if application.stream_index == sink.index
|
||||||
|
and application.stream_type == StreamType.OUTPUT
|
||||||
|
],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -181,9 +291,18 @@ class SoundControl(CoreSysAttributes):
|
|||||||
self._inputs.append(
|
self._inputs.append(
|
||||||
AudioStream(
|
AudioStream(
|
||||||
source.name,
|
source.name,
|
||||||
|
source.index,
|
||||||
source.description,
|
source.description,
|
||||||
source.volume.value_flat,
|
source.volume.value_flat,
|
||||||
|
bool(source.mute),
|
||||||
source.name == server.default_source_name,
|
source.name == server.default_source_name,
|
||||||
|
source.card if source.card != 0xFFFFFFFF else None,
|
||||||
|
[
|
||||||
|
application
|
||||||
|
for application in self._applications
|
||||||
|
if application.stream_index == source.index
|
||||||
|
and application.stream_type == StreamType.INPUT
|
||||||
|
],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -205,7 +324,9 @@ class SoundControl(CoreSysAttributes):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self._cards.append(
|
self._cards.append(
|
||||||
SoundCard(card.name, card.driver, sound_profiles)
|
SoundCard(
|
||||||
|
card.name, card.index, card.driver, sound_profiles
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
except PulseOperationFailed as err:
|
except PulseOperationFailed as err:
|
||||||
|
Loading…
x
Reference in New Issue
Block a user