# -*- coding: utf-8 -*-
"""
<parameters>
<company>AATrubilin</company>
<title>trassir_script_framework</title>
<version>0.64</version>
</parameters>
"""
# _SERVICE_VERSION = 0.64
tbot_service = """
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
"""
import re
import os
import sys
import ssl
import time
import host
import json
import pickle
import base64
import ftplib
import urllib
import urllib2
import httplib
import logging
import threading
import subprocess
if os.name == "nt":
import winsound
from Queue import Queue
from functools import wraps
from collections import deque
from xml.etree import ElementTree
from __builtin__ import object as py_object
from datetime import datetime, date, timedelta
from logging.handlers import RotatingFileHandler
logger = logging.getLogger()
class ScriptError(Exception):
"""Base script exception"""
_host_api = host
def __init__(self, *args):
super(ScriptError, self).__init__(*args)
self._host_api.timeout(1, self.rise_from_thread)
def rise_from_thread(self):
raise self
class HostLogHandler(logging.Handler):
"""Trassir main log handler"""
def __init__(self, host_api=host):
super(HostLogHandler, self).__init__()
self._host_api = host_api
def emit(self, record):
msg = self.format(record)
self._host_api.log_message(msg)
class PopupHandler(logging.Handler):
"""Trassir popup handler"""
def __init__(self, host_api=host):
super(PopupHandler, self).__init__()
self._host_api = host_api
self._popups = {
"CRITICAL": host_api.error,
"FATAL": host_api.error,
"ERROR": host_api.error,
"WARN": host_api.alert,
"WARNING": host_api.alert,
"INFO": host_api.message,
"DEBUG": host_api.message,
"NOTSET": host_api.message,
}
def emit(self, record):
msg = self.format(record)
self._popups[record.levelname](msg)
class DuplicateFilter(logging.Filter):
"""Suppressing multiple messages with same content.
Tracking last logged record and filter out any
repeated (similar) records. Output something more rsyslog style.
Example:
--- The last message repeated 3 times
"""
def __init__(self):
super(DuplicateFilter, self).__init__()
self._last_log = None
self._last_log_count = 1
def filter(self, record):
record.duplicates = ""
current_log = (record.module, record.levelno, record.msg)
if current_log == self._last_log:
self._last_log_count += 1
return False
else:
if self._last_log_count > 1:
record.duplicates = (
"--- The last message repeated %s times\n" % self._last_log_count
)
self._last_log = current_log
self._last_log_count = 1
return True
class BaseUtils:
"""Base utils for your scripts"""
_host_api = host
_FOLDERS = {obj[1]: obj[3] for obj in host.objects_list("Folder")}
_TEXT_FILE_EXTENSIONS = [".txt", ".csv", ".log"]
_LPR_FLAG_BITS = {
"LPR_UP": 0x00001,
"LPR_DOWN": 0x00002,
"LPR_BLACKLIST": 0x00004,
"LPR_WHITELIST": 0x00008,
"LPR_INFO": 0x00010,
"LPR_FIRST_LANE": 0x01000,
"LPR_SECOND_LANE": 0x02000,
"LPR_THIRD_LANE": 0x04000,
"LPR_EXT_DB_ERROR": 0x00020,
"LPR_CORRECTED": 0x00040,
}
_EVENT_STR_TO_INT = {
"Border Crossed A -> B": -2010220362,
"Border Crossed B -> A": 881900680,
"Border %1 A-B Crossing": 1745631458,
"Border %1 B-A Crossing": 1382034490,
"Border %1 Unique Object A-B Crossing": -1764400102,
"Border %1 Unique Object B-A Crossing": -755097134,
"Connected To %1 under %2": -567223767,
"Connection Established": 1689573124,
"Connection Lost": -1739961019,
"Deny: %1 (%2)": 1400866841,
"Disconnected From %1": 854687023,
"FACS Connected": 928164014,
"FACS Disconnected": -528751441,
"Face Detected": -145480902,
"Face Recognized": 1904675878,
"Fire Detected": -2095846277,
"Fire Stopped": 1556160195,
"HDD Broken": -359176531,
"HDD Error": -2035571413,
"HDD Restored": 2054776042,
"Health Turns Bad": -1338064969,
"Health Turns Good": 1737407416,
"Input High to Low": 1260011944,
"Input Low to High": 108469542,
"Login Failed, %1 from %2": -1785217387,
"Login Successful, %1 from %2": 1634136664,
"Logout, %1 from %2": 334348171,
"Motion Start": -1960416690,
"Motion Stop": 452886769,
"No Connection to Cloud": -1220531757,
"Object Entered the Zone": -1484834142,
"Object Left the Zone": 1838034845,
"Output High to Low": -994975116,
"Output Low to High": 842360770,
"Pass: %1 (%2)": 1944146750,
"Photo Detected": -220640968,
"Script: %1": 865778551,
"Shutdown": 390175606,
"Signal Lost": -997068283,
"Signal Restored": -1801421619,
"Slow Down Detected": -438590449,
"Software update to version %1 succeeded": 1188419157,
"Startup": -37228692,
"Tracked Object Left Zone %1": 456308509,
"Tracked Unique Object Entered Zone %1": -1766980008,
}
_EVENT_INT_TO_STR = {v: k for k, v in _EVENT_STR_TO_INT.iteritems()}
_IMAGE_EXT = [".png", ".jpg", ".jpeg", ".bmp"]
_HTML_IMG_TEMPLATE = """<img src="data:image/png;base64,{img}" {attr}>"""
_SCR_DEFAULT_NAMES = [
"Yeni skript",
"Unnamed Script",
"უსახელო სკრიპტი",
"Жаңа скрипт",
"Script nou",
"Новый скрипт",
"Yeni skript dosyası",
"Новий скрипт",
"未命名脚本",
]
def __init__(self):
pass
# noinspection PyUnusedLocal
@staticmethod
def do_nothing(*args, **kwargs):
"""Ничего не делает.
Returns:
:obj:`bool`: ``True``
"""
return True
@classmethod
def run_as_thread_v2(cls, locked=False, daemon=True):
"""Декоратор для запуска функций в отдельном потоке.
Args:
locked (:obj:`bool`, optional): Если :obj:`True` - запускает поток с блокировкой
доступа к ресурсам. По умолчанию :obj:`False`
daemon (:obj:`bool`, optional): Устанавливает значение :obj:`threading.Thread.daemon`.
По умолчанию :obj:`True`
Examples:
>>> import time
>>>
>>>
>>> @BaseUtils.run_as_thread_v2()
>>> def run_count_timer():
... time.sleep(1)
... host.stats()["run_count"] += 1
>>>
>>>
>>> run_count_timer()
"""
lock = threading.Lock()
def wrapped(fn):
@wraps(fn)
def run(*args, **kwargs):
def raise_exc(err):
# noinspection PyShadowingNames
args = list(err.args)
args[0] = "[{}]: {}".format(fn.__name__, args[0])
err.args = args
raise err
def locked_fn(*args_, **kwargs_):
lock.acquire()
try:
return fn(*args_, **kwargs_)
except Exception as err:
cls._host_api.timeout(1, lambda: raise_exc(err))
finally:
lock.release()
def unlocked_fn(*args_, **kwargs_):
try:
return fn(*args_, **kwargs_)
except Exception as err:
cls._host_api.timeout(1, lambda: raise_exc(err))
t = threading.Thread(
target=locked_fn if locked else unlocked_fn,
args=args,
kwargs=kwargs,
)
t.daemon = daemon
t.start()
return t
return run
return wrapped
[документация] @staticmethod
def run_as_thread(fn):
"""Декоратор для запуска функций в отдельном потоке.
Returns:
:obj:`threading.Thread`: Функция в отдельном потоке
Examples:
>>> import time
>>>
>>>
>>> @BaseUtils.run_as_thread
>>> def run_count_timer():
... time.sleep(1)
... host.stats()["run_count"] += 1
>>>
>>>
>>> run_count_timer()
"""
@wraps(fn)
def run(*args, **kwargs):
t = threading.Thread(target=fn, args=args, kwargs=kwargs)
t.daemon = True
t.start()
return t
return run
@staticmethod
def catch_request_exceptions(func):
"""Catch request errors"""
@wraps(func)
def wrapped(self, *args, **kwargs):
try:
return func(self, *args, **kwargs)
except urllib2.HTTPError as e:
return e.code, "HTTPError: {}".format(e.code)
except urllib2.URLError as e:
return e.reason, "URLError: {}".format(e.reason)
except httplib.HTTPException as e:
return e, "HTTPException: {}".format(e)
except ssl.SSLError as e:
return e.errno, "SSLError: {}".format(e)
return wrapped
[документация] @staticmethod
def win_encode_path(path):
"""Изменяет кодировку на ``"cp1251"`` для WinOS.
Args:
path (:obj:`str`): Путь до файла или папки
Returns:
:obj:`str`: Декодированый путь до файла или папки
Examples:
>>> path = r"D:/Shots/Скриншот.jpeg"
>>> os.path.isfile(path)
False
>>> os.path.isfile(BaseUtils.win_encode_path(path))
True
"""
if os.name == "nt":
try:
path = path.decode("utf8")
except (UnicodeDecodeError, UnicodeEncodeError):
pass
return path
[документация] @staticmethod
def is_file_exists(file_path, tries=1):
"""Проверяет, существует ли файл.
Проверка происходит в течении ``tries`` секунд.
Warning:
| Запускайте функцию только в отдельном потоке если ``tries > 1``
| Вторая и последующие проверки производятся с ``time.sleep(1)``
Args:
file_path (:obj:`str`): Полный путь до файла
tries (:obj:`int`, optional): Количество проверок. По умолчанию ``tries=1``
Returns:
:obj:`bool`: ``True`` if file exists, ``False`` otherwise
Examples:
>>> BaseUtils.is_file_exists("_t1server.settings")
True
"""
file_path_encoded = BaseUtils.win_encode_path(file_path)
if os.path.isfile(file_path) or os.path.isfile(file_path_encoded):
return True
for x in xrange(tries - 1):
time.sleep(1)
if os.path.isfile(file_path) or os.path.isfile(file_path_encoded):
return True
return False
[документация] @staticmethod
def is_folder_exists(folder):
"""Проверяет существование папки и доступ на запись.
Args:
folder (:obj:`str`): Путь к папке.
Raises:
IOError: Если папка не существует
Examples:
>>> BaseUtils.is_folder_exists("/test_path")
IOError: Folder '/test_path' is not exists
"""
if not os.path.isdir(folder):
raise IOError("Folder '{}' is not exists".format(folder))
readme_file = os.path.join(folder, "readme.txt")
with open(readme_file, "w") as f:
f.write(
"If you see this file - Trassir script have no access to remove it!"
)
os.remove(readme_file)
[документация] @classmethod
def is_template_exists(cls, template_name):
"""Проверяет существование шаблона
Args:
template_name (:obj:`str`): Имя шаблона
Returns:
:obj:`bool`: :obj:`True` если шаблон существует, иначе :obj:`False`
"""
for tmpl_ in cls._host_api.settings("templates").ls():
if tmpl_.name == template_name:
return True
return False
[документация] @classmethod
def cat(cls, filepath, check_ext=True):
"""Выводит на отображение текстовую инфомрацию.
Tip:
- *WinOS*: открывает файл программой по умолчанию
- *TrassirOS*: открывает файл в терминале с помощью утилиты `cat`
Note:
| Доступные расширения файлов: ``[".txt", ".csv", ".log"]``
| Если открываете файл с другим расширением установите ``check_ext=False``
Args:
filepath (:obj:`str`): Полный путь до файла
check_ext (:obj:`bool`, optional): Если ``True`` - проверяет расширение файла.
По умолчанию ``True``
Examples:
>>> BaseUtils.cat("/home/trassir/ Trassir 3 License.txt")
.. image:: images/base_utils.cat.png
Raises:
:class:`TypeError`: Если ``check_ext=True`` расширение файла нет в списке :obj:`_TEXT_FILE_EXTENSIONS`
"""
if check_ext:
_, ext = os.path.splitext(filepath)
if ext not in cls._TEXT_FILE_EXTENSIONS:
raise TypeError(
"Bad file extension: {}. To ignore this: set check_ext=False".format(
ext
)
)
if os.name == "nt":
os.startfile(filepath)
else:
subprocess.Popen(
[
"xterm -fg black -bg white -geometry 90x35 -fn "
"-misc-fixed-medium-r-normal--18-120-100-100-c-90-iso10646-1 -e bash -c \"cat '{}'; "
"read -n 1 -s -r -p '\n\nPress any key to exit'; exit\"".format(
filepath
)
],
shell=True,
close_fds=True,
)
@classmethod
def _json_serializer(cls, data):
"""JSON serializer for objects not serializable by default"""
if isinstance(data, (datetime, date)):
return data.isoformat()
elif isinstance(data, cls._host_api.ScriptHost.SE_Settings):
return "settings('{}')".format(data.path)
elif isinstance(data, cls._host_api.ScriptHost.SE_Object):
return "object('{}')".format(data.guid)
return type(data).__name__
[документация] @classmethod
def to_json(cls, data, **kwargs):
"""Сериализация объекта в JSON стрку
Note:
Не вызывает ошибку при сериализации объектов :obj:`datetime`,
:obj:`date`, :obj:`SE_Settings`, :obj:`SE_Object`
Args:
data (:obj:`obj`): Объект для сериализации
Returns:
:obj:`str`: JSON строка
Examples:
>>> obj = {"now": datetime.now()}
>>> json.dumps(obj)
TypeError: datetime.datetime(2019, 4, 2, 18, 01, 33, 881000) is not JSON serializable
>>> BaseUtils.to_json(obj, indent=None)
'{"now": "2019-04-02T18:01:33.881000"}'
"""
return json.dumps(data, default=cls._json_serializer, **kwargs)
[документация] @staticmethod
def ts_to_dt(ts):
"""Конвертирует timestamp в :obj:`datetime` объект
Args:
ts (:obj:`int`): Timestamp
Returns:
:obj:`datetime`: Datetime объект
Examples:
>>> BaseUtils.ts_to_dt(1564109694242000)
datetime.datetime(2019, 7, 26, 9, 54, 54, 242000)
"""
if ts > 1e10:
ts_sec = int(ts / 1e6)
ts_ms = int(ts - ts_sec * 1e6)
else:
ts_sec = int(ts)
ts_ms = 0
return datetime.fromtimestamp(ts_sec) + timedelta(microseconds=ts_ms)
[документация] @staticmethod
def dt_to_ts(dt):
"""Конвертирует :obj:`datetime` объект в trassir timestamp
Args:
dt (:obj:`datetime`): Datetime
Returns:
:obj:`int`: Trassir timestamp
Examples:
>>> BaseUtils.ts_to_dt(datetime(2019, 7, 26, 9, 54, 54, 242000))
1564109694242000
"""
return int(int(time.mktime(dt.timetuple())) * 1e6 + dt.microsecond)
[документация] @classmethod
def lpr_flags_decode(cls, flags):
"""Преобразует флаги события AutoTrassir
Приводит флаги события человекочитаемый список
Note:
Список доступных флагов:
- ``LPR_UP`` - Направление движения вверх
- ``LPR_DOWN`` - Направление движения вниз
- ``LPR_BLACKLIST`` - Номер в черном списке
- ``LPR_WHITELIST`` - Номер в черном списке
- ``LPR_INFO`` - Номер в информационном списке
- ``LPR_FIRST_LANE`` - Автомобиль двигается по первой полосе
- ``LPR_SECOND_LANE`` - Автомобиль двигается по второй полосе
- ``LPR_THIRD_LANE`` - Автомобиль двигается по третей полосе
- ``LPR_EXT_DB_ERROR`` - Ошибка во внешнем списке
- ``LPR_CORRECTED`` - Номер исправлен оператором
Args:
flags (:obj:`int`): Биты LPR события. Как правило аргумент :obj:`ev.flags`
события :obj:`SE_LprEvent` AutoTrassir. Например :obj:`536870917`
Returns:
List[:obj:`str`]: Список флагов
Examples:
>>> BaseUtils.lpr_flags_decode(536870917)
['LPR_UP', 'LPR_BLACKLIST']
"""
return [bit for bit, code in cls._LPR_FLAG_BITS.iteritems() if (flags & code)]
[документация] @classmethod
def event_type_encode(cls, event_type):
"""Преобразует тип события :obj:`str` -> :obj:`int`
Note:
События в БД хранятся в :obj:`int`, в скриптах
приходят в человекочитаемом, строковом формате.
Args:
event_type (:obj:`str`): Тип события как в скриптах.
Examples:
>>> BaseUtils.event_type_encode("Border Crossed A -> B")
-2010220362
Returns:
:obj:`int`: Тип события как в БД
"""
if not isinstance(event_type, str):
raise TypeError("Expected str, got {}".format(type(event_type).__name__))
return cls._EVENT_STR_TO_INT.get(event_type)
[документация] @classmethod
def event_type_decode(cls, event_type):
"""Преобразует тип события :obj:`int` -> :obj:`str`
Note:
События в БД хранятся в :obj:`int`, в скриптах
приходят в человекочитаемом, строковом формате.
Args:
event_type (:obj:`int`): Тип события как в БД.
Examples:
>>> BaseUtils.event_type_encode(-2010220362)
"Border Crossed A -> B"
Returns:
:obj:`str`: Тип события как в скриптах
"""
if not isinstance(event_type, int):
raise TypeError("Expected int, got {}".format(type(event_type).__name__))
return cls._EVENT_INT_TO_STR.get(event_type)
[документация] @classmethod
def image_to_base64(cls, image):
"""Создает base64 из изображения
Args:
image (:obj:`str`): Путь к изображению или изображение
Returns:
:obj:`str`: Base64 image
Examples:
>>> BaseUtils.image_to_base64(r"manual/en/cloud-devices-16.png")
'iVBORw0KGgoAAAANSUhEUgAAB1MAAAH0CAYAAABo5wRhAAAACXBIWXMAAC4jA...'
>>> BaseUtils.image_to_base64(open(r"manual/en/cloud-devices-16.png", "rb").read())
'iVBORw0KGgoAAAANSUhEUgAAB1MAAAH0CAYAAABo5wRhAAAACXBIWXMAAC4jA...'
"""
_, ext = os.path.splitext(image)
if ext.lower() in cls._IMAGE_EXT:
image = cls.win_encode_path(image)
if not BaseUtils.is_file_exists(image):
return ""
with open(image, "rb") as image_file:
image = image_file.read()
return base64.b64encode(image)
[документация] @classmethod
def base64_to_html_img(cls, image_base64, **kwargs):
"""Возвращает base64 изображение в `<img>` html теге
Args:
image_base64 (:obj:`str`): Base64 image
**kwargs: HTML `<img>` tag attributes. Подробнее на `html.com
<https://html.com/tags/img/#Attributes_of_img>`_
Returns:
:obj:`str`: html image
Examples:
>>> base64_image = BaseUtils.image_to_base64(r"manual/en/cloud-devices-16.png")
>>> html_image = BaseUtils.base64_to_html_img(base64_image, width=280, height=75)
>>> html_image
'<img src="data:image/png;base64,iVBORw0KGgoAA...Jggg==" width="280" height="75">'
>>> host.message(html_image)
.. image:: images/popup_sender.image.png
"""
html_img = cls._HTML_IMG_TEMPLATE.format(
img=image_base64,
attr=" ".join(
'%s="%s"' % (key, value) for key, value in kwargs.iteritems()
),
)
return html_img
[документация] @staticmethod
def save_pkl(file_path, data):
"""Сохраняет данные в `.pkl` файл
Args:
file_path (:obj:`str`): Путь до файла
data: Данные для сохранения
Returns:
:obj:`str`: Абсолютный путь до файла
Examples:
>>> data = {"key": "value"}
>>> BaseUtils.save_pkl("saved_data.pkl", data)
'D:\\DSSL\\Trassir-4.1-Client\\saved_data.pkl'
"""
if not file_path.endswith(".pkl"):
file_path = file_path + ".pkl"
with open(file_path, "wb") as opened_file:
pickle.dump(data, opened_file)
return os.path.abspath(file_path)
[документация] @staticmethod
def load_pkl(file_path, default_type=dict):
"""Загружает данные из `.pkl` файла
Args:
file_path (:obj:`str`): Путь до файла
default_type (optional):
Тип данных, возвращаемый при неудачной загрузке данных из файла.
По умолчанию :obj:`dict`
Returns:
Данные из файла или :obj:`default_type()`
Examples:
>>> BaseUtils.load_pkl("fake_saved_data.pkl")
{}
>>> BaseUtils.load_pkl("fake_saved_data.pkl", default_type=list)
[]
>>> BaseUtils.load_pkl("fake_saved_data.pkl", default_type=int)
0
>>> BaseUtils.load_pkl("fake_saved_data.pkl", default_type=str)
''
>>> BaseUtils.load_pkl("saved_data.pkl")
{'key': 'value'}
"""
if not file_path.endswith(".pkl"):
file_path = file_path + ".pkl"
data = default_type()
if os.path.isfile(file_path):
try:
with open(file_path, "rb") as opened_file:
data = pickle.load(opened_file)
except (EOFError, IndexError, ValueError, TypeError):
""" dump file is empty or broken """
return data
@classmethod
def get_object(cls, obj_id):
"""Возвращает объект Trassir, если он доступен, иначе ``None``
Args:
obj_id (:obj:`str`): Guid объекта или его имя
Returns:
:obj:`ScriptHost.SE_Object`: Объект Trassir или ``None``
Examples:
>>> obj = BaseUtils.get_object("EZJ4QnbC")
>>> if obj is None:
... host.error("Object not found")
... else:
... host.message("Object name is {0.name}".format(obj))
"""
if not isinstance(obj_id, (str, unicode)):
raise TypeError(
"Expected str or unicode, got '{}'".format(type(obj_id).__name__)
)
obj = cls._host_api.object(obj_id)
try:
obj.name
except EnvironmentError:
"""Object not found"""
obj = None
return obj
@classmethod
def get_object_name_by_guid(cls, guid):
"""Возвращает имя объекта Trassir по его guid
Tip:
Можно использовать:
- guid объекта ``"CFsuNBzt"``
- guid объекта + guid сервера ``"CFsuNBzt_pV4ggECb"``
Args:
guid (:obj:`str`): Guid объекта Trassir
Returns:
:obj:`str`: Имя объекта, если объект найден, иначе ``guid``
Examples:
>>> BaseUtils.get_object_name_by_guid("EZJ4QnbC")
'AC-D2141IR3'
>>> BaseUtils.get_object_name_by_guid("EZJ4QnbC-")
'EZJ4QnbC-'
"""
guid = guid.split("_", 1)[0]
obj = cls.get_object(guid)
if obj is None:
name = guid
else:
name = obj.name
return name
@classmethod
def get_full_guid(cls, obj_id):
"""Возвращает полный guid объекта
Args:
obj_id (:obj:`str`): Guid объекта или его имя
Returns:
:obj:`str`: Полный guid объекта
"""
tr_obj = cls.get_object(obj_id)
if tr_obj is not None:
for obj in cls._host_api.objects_list(""):
if tr_obj.guid == obj[1]:
return "{}_{}".format(obj[1], cls._FOLDERS.get(obj[3], obj[3]))
[документация] @classmethod
def get_operator_gui(cls):
"""Возвращает объект интерфейса оператора
Returns:
:obj:`OperatorGUI`: Объект интерфейса оператора
Raises:
ScriptError: Если не удается загрузить интерфейс
Examples:
Открыть интерфейс Trassir а мониторе №1
>>> operator_gui = BaseUtils.get_operator_gui()
>>> operator_gui.raise_monitor(1)
"""
obj = cls.get_object("operatorgui_{}".format(cls._host_api.settings("").guid))
if obj is None:
raise ScriptError("Failed to load operator gui")
return obj
[документация] @classmethod
def get_server_guid(cls):
"""Возвращает guid текущего сервра
Returns:
:obj:`str`: Guid сервера
Examples:
>>> BaseUtils.get_server_guid()
'client'
"""
return cls._host_api.settings("").guid
[документация] @classmethod
def get_script_name(cls):
"""Возвращает имя текущего скрипта
Returns:
:obj:`str`: Имя скрипта
Examples:
>>> BaseUtils.get_script_name()
'Новый скрипт'
"""
return cls._host_api.stats().parent()["name"] or __name__
[документация] @classmethod
def get_screenshot_folder(cls):
"""Возвращает путь до папки скриншотов
При этом производит проверку папки методом
:meth:`BaseUtils.is_folder_exists`
Returns:
:obj:`str`: Полный путь к папке скриншотов
Examples:
>>> BaseUtils.get_screenshot_folder()
'/home/trassir/shots'
"""
folder = cls._host_api.settings("system_wide_options")["screenshots_folder"]
cls.is_folder_exists(folder)
return folder
[документация] @classmethod
def get_logger(
cls,
host_log="WARNING",
popup_log="ERROR",
file_log=None,
file_name=None,
file_max_bytes=5 * 1024 * 1024,
file_backup_count=2,
):
"""Возвращает логгер с предустановленными хэндлерами
Доступные хэндлеры:
- *host_log*: Пишет сообщения в основной лог сервера _t1server.log
- *popup_log*: Показывает всплывающие сообщения ``message/alert/error``
- *file_log*: Пишет сообщения в отдельный файл в папку скриншотов
Для каждого хэндлера можно установить разный уровень логирования
По умолчанию ``host_log="WARNING"`` и ``popup_log="ERROR"``
Note:
Имя файла лога можно указать с расширение ".log" или без.
See Also:
`Logging levels на сайте docs.python.org
<https://docs.python.org/2/library/logging.html#logging-levels>`_
Args:
host_log (:obj:`str`, optional): Уровень логирования в основной лог.
По умолчанию ``"WARNING"``
popup_log (:obj:`str`, optional): Уровень логирования во всплывающих
сообщениях. По умолчанию ``"ERROR"``
file_log (:obj:`str`, optional): Уровень логирования в отдельный файл
По умолчанию :obj:`None`
file_name (:obj:`str`, optional): Имя файла для логирования.
По умолчанию :obj:`None` и равно ``<имени скрипта>.log``
file_max_bytes (:obj:`int`, optional): Максимальный размер файла лога
в байтах. По умолчанию :obj:`5 * 1024 * 1024`
file_backup_count (:obj:`int`, optional): Макссимальное кол-во бэкапов лога.
По умолчанию :obj:`2`
Returns:
:obj:`logging.logger`: Логгер
Examples:
>>> logger = BaseUtils.get_logger()
>>> logger.warning("My warning message")
>>> try:
... # noinspection PyUnresolvedReferences
... do_something()
... except NameError:
... logger.error("Function is not defined", exc_info=True)
"""
logger_ = logging.getLogger(__name__)
logger_.setLevel("DEBUG")
def _remove_handlers():
"""Close and remove handlers on disable script"""
logger_.info("Remove %s handlers..." % len(logger_.handlers))
for handler in logger_.handlers[:]:
handler.close()
logger_.removeHandler(handler)
cls._host_api.register_finalizer(_remove_handlers)
if host_log:
host_handler = HostLogHandler()
host_handler.setLevel(host_log)
host_formatter = logging.Formatter(
"[%(levelname)-8s] %(lineno)-4s <%(funcName)s> - %(message)s"
)
host_handler.setFormatter(host_formatter)
logger_.addHandler(host_handler)
if popup_log:
popup_handler = PopupHandler()
popup_handler.setLevel(popup_log)
popup_formatter = logging.Formatter(
fmt="<b>[%(levelname)s]</b> Line: %(lineno)s<br><i>%(message).630s</i>"
)
popup_handler.setFormatter(popup_formatter)
logger_.addHandler(popup_handler)
if file_log:
if file_name is None:
file_name = cls.get_script_name()
if not file_name.endswith(".log"):
file_name = "{}.log".format(file_name)
file_path = os.path.join(cls.get_screenshot_folder(), file_name)
file_path = cls.win_encode_path(file_path)
file_handler = RotatingFileHandler(
file_path, maxBytes=file_max_bytes, backupCount=file_backup_count
)
file_handler.setLevel(file_log)
file_formatter = logging.Formatter(
fmt="%(duplicates)s%(asctime)s [%(levelname)-8s] %(lineno)-4s <%(funcName)s> - %(message)s",
datefmt="%Y/%m/%d %H:%M:%S",
)
file_handler.setFormatter(file_formatter)
file_handler.addFilter(DuplicateFilter())
logger_.addHandler(file_handler)
return logger_
[документация] @classmethod
def set_script_name(cls, fmt=None, script_name=None):
"""Автоматически изменяет имя скрипта
Новое имя скрипта создается на основе `параметров
<https://www.dssl.ru/files/trassir/manual/ru/setup-script-parameters.html>`_
скрипта. По желанию можно изменить шаблон имени. По умолчанию
:obj:`"{title} v{version}"`
Note:
Имя изменяется только если сейчас у скрипта стандартное имя,
например :obj:`"Новый скрипт"` или :obj:`"Unnamed Script"` и др.
Args:
fmt (:obj:`str`, optional): Шаблон имени скрипта. По умолчанию :obj:`None`
script_name (:obj:`str`, optional): Имя скрипта. Если не задано - парсит
имя из параметров. По умолчанию :obj:`None`
Examples:
>>> BaseUtils.set_script_name()
'trassir_script_framework v0.4'
>>> BaseUtils.set_script_name(fmt="{title}")
'trassir_script_framework'
"""
if cls._host_api.stats().parent()["name"] in cls._SCR_DEFAULT_NAMES:
if script_name is None:
try:
root = ElementTree.fromstring(__doc__)
except ElementTree.ParseError:
root = None
if root is None:
company, title, version = None, None, None
else:
company = root.find("company") if root else None
title = root.find("title") if root else None
version = root.find("version") if root else None
if fmt is None:
fmt = "{title} v{version}"
script_name = fmt.format(
company="DSSL" if company is None else company.text,
title="Script" if title is None else title.text,
version="0.1" if version is None else version.text,
)
cls._host_api.stats().parent()["name"] = script_name
return script_name
if globals().get("DEBUG", False):
logger = BaseUtils.get_logger(host_log="DEBUG", popup_log="WARNING", file_log="DEBUG")
else:
logger = BaseUtils.get_logger()
class Worker(threading.Thread):
"""Thread executing tasks from a given tasks queue"""
def __init__(self, tasks):
super(Worker, self).__init__()
self.tasks = tasks
self.daemon = True
self.start()
self.task_working = False
def run(self):
while __name__ in sys.modules.keys():
if not self.tasks.empty():
self.task_working = True
func, args, kwargs = self.tasks.get(timeout=1)
# noinspection PyBroadException
try:
func(*args, **kwargs)
except:
logger.exception("ThreadPool Worker error")
finally:
self.tasks.task_done()
else:
self.task_working = False
class ThreadPool:
"""Pool of threads consuming tasks from a queue"""
def __init__(self, num_threads, host_api=host):
self._host_api = host_api
self.tasks = Queue()
self.workers = [Worker(self.tasks) for _ in xrange(num_threads)]
@property
def working(self):
for worker in self.workers:
if worker.task_working:
return True
return False
def add_task(self, func, *args, **kargs):
"""Add a task to the queue"""
self.tasks.put((func, args, kargs))
def wait_completion(self):
"""Wait for completion of all the tasks in the queue"""
self.tasks.join()
[документация]class HTTPRequester(py_object):
"""Framework for urllib2
See Also:
https://docs.python.org/2/library/urllib2.html#urllib2.build_opener
Args:
opener (:obj:`urllib2.OpenerDirector`, optional): Обработчик запросов.
По умолчанию :obj:`None`
timeout (:obj:`int`, optional): Время ожидания запроса, в секундах.
По умолчанию :obj:`timeout=10`
Examples:
Пример запроса к SDK Trassir
>>> # Отключение проверки сертификата
>>> context = ssl.create_default_context()
>>> context.check_hostname = False
>>> context.verify_mode = ssl.CERT_NONE
>>>
>>> handler = urllib2.HTTPSHandler(context=context)
>>> opener = urllib2.build_opener(handler)
>>>
>>> requests = HTTPRequester(opener, timeout=20)
>>> response = requests.get(
... "https://172.20.0.101:8080/login",
... params={"username": "Admin", "password": "12345"}
... )
>>>
>>> response.code
200
>>> response.text
'{\\n "sid" : "T6LAAcxg",\\n "success" : 1\\n}\\n'
>>> response.json
{u'success': 1, u'sid': u'T6LAAcxg'}
"""
[документация] class Response(py_object):
"""Класс ответа от сервера
Attributes:
code (:obj:`str` | :obj:`int`): Код ответа сервера
text (:obj:`str`): Текст ответа
json (:obj:`dict` | :obj:`list`): Создает объект из json ответа
"""
def __init__(self, *args):
self.code, self.text = args
@property
def json(self):
return json.loads(self.text)
def __init__(self, opener=None, timeout=10):
if opener is None:
handler = urllib2.BaseHandler()
opener = urllib2.build_opener(handler)
self._opener = opener
self.timeout = timeout
@BaseUtils.catch_request_exceptions
def _get_response(self, request):
"""Returns response
Args:
request (:obj:`urllib2.Request`): This class is an abstraction of a URL request
"""
response = self._opener.open(request, timeout=self.timeout)
return response.code, response.read()
@staticmethod
def _parse_params(**params):
"""Params get string params
Args:
**params (dict): Keyword arguments
Returns:
str: params string
"""
return "&".join(
"{key}={value}".format(key=key, value=value)
for key, value in params.iteritems()
)
@staticmethod
def _prepare_headers(headers):
"""Prepare headers for request"""
if headers is None:
headers = {}
if "User-Agent" not in headers:
headers["User-Agent"] = "TrassirScript"
return headers
[документация] def get(self, url, params=None, headers=None):
"""Создает GET запрос по указанному :obj:`url`
Args:
url (:obj:`str`): Url для запроса
params (:obj:`dict`, optional): Параметры GET запроса
headers (:obj:`dict`, optional): Заголовки запроса
Examples:
>>> requests = HTTPRequester()
>>> response = requests.get(
... "http://httpbin.org/get",
... params={"PARAMETER": "TEST"},
... )
>>> response.code
200
>>> response.text
'{\\n "args": {\\n "PARAMETER": "TEST"\\n }, \\n ...'
>>> response.json
{u'args': {u'PARAMETER': u'TEST'}, ...}
Returns:
:class:`HTTPRequester.Response`: Response instance
"""
if params is not None:
url += "?{params}".format(params=self._parse_params(**params))
headers = self._prepare_headers(headers)
request = urllib2.Request(url, headers=headers)
response = self._get_response(request)
return self.Response(*response)
[документация] def post(self, url, data=None, headers=None):
"""Создает POST запрос по указанному :obj:`url`
Args:
url (:obj:`str`): Url для запроса
data (:obj:`dict`, optional): Данные POST запроса
headers (:obj:`dict`, optional): Заголовки запроса
Examples:
>>> requests = HTTPRequester()
>>> response = requests.post(
... "http://httpbin.org/post",
... data={"PARAMETER": "TEST"},
... headers={"Content-Type": "application/json"},
... )
>>> response.code
200
>>> response.text
'{\\n "args": {\\n "PARAMETER": "TEST"\\n }, \\n ...'
>>> response.json
{u'args': {u'PARAMETER': u'TEST'}, ...}
Returns:
:class:`HTTPRequester.Response`: Response instance
"""
if data is None:
data = {}
if isinstance(data, dict):
data = urllib.urlencode(data)
headers = self._prepare_headers(headers)
request = urllib2.Request(url, data=data, headers=headers)
response = self._get_response(request)
return self.Response(*response)
[документация]class ScriptObject(host.TrassirObject, py_object):
"""Создает объект для генерации событий
Args:
name (:obj:`str`, optional): Имя объекта. По умолчанию :obj:`None`
guid (:obj:`str`, optional): Guid объекта. По умолчанию :obj:`None`
parent (:obj:`str`, optional): Guid родительского объекта. По умолчанию :obj:`None`
Note:
- Имя объекта по умолчанию - :meth:`BaseUtils.get_script_name`
- Guid объекта по умолчанию строится по шаблноу ``"{script_guid}_object"``
- Guid родительского объекта по умолчанию -
:meth:`BaseUtils.get_server_guid`
Examples:
>>> # Создаем объект
>>> scr_obj = ScriptObject()
>>> # Проверяем текущее состояние объекта
>>> scr_obj.health
'OK'
>>> # Установить флаг возле объекта
>>> scr_obj.check_me = True
>>> # Сгенерировать событие с текстом
>>> scr_obj.fire_event_v2("New event")
"""
def __init__(self, name=None, guid=None, parent=None, host_api=host):
super(ScriptObject, self).__init__("Script")
self._host_api = host_api
scr_parent = host_api.stats().parent()
self._name = name or BaseUtils.get_script_name()
self.set_name(self._name)
self._guid = guid or "{}-object".format(scr_parent.guid)
self.set_guid(self._guid)
self._parent = parent or BaseUtils.get_server_guid()
self.set_parent(self._parent)
self._folder = ""
self._health = "OK"
self._check_me = True
self.set_initial_state([self._health, self._check_me])
host_api.object_add(self)
self.context_menu = []
@property
def health(self):
""":obj:`"OK"` | :obj:`"Error"`: Состояние объекта"""
return self._health
@health.setter
def health(self, value):
if value in ["OK", "Error"]:
self.set_state([value, self._check_me])
self._health = value
else:
raise ValueError("Expected 'OK' or 'Error', got '{}'".format(value))
@property
def check_me(self):
""":obj:`bool`: Флаг ``check_me`` объекта"""
return bool(1 - self._check_me)
@check_me.setter
def check_me(self, value):
if isinstance(value, bool) or value in [1, 0]:
value = 1 - value
self.set_state([self._health, value])
self._check_me = value
else:
raise ValueError("Expected bool or 1|0, got '{}'".format(value))
@property
def name(self):
""":obj:`str`: Имя объекта"""
return self._name
@name.setter
def name(self, value):
if isinstance(value, str):
self.set_name(value)
self._name = value
else:
raise ValueError("Expected str, got {}".format(type(value).__name__))
@property
def folder(self):
""":obj:`str`: Папка объекта"""
return self._folder
@folder.setter
def folder(self, value):
if not value:
raise ValueError("Object guid can't be empty")
if isinstance(value, str):
if self._folder:
self.change_folder(value)
else:
self.set_folder(value)
self._folder = value
else:
raise ValueError("Expected str, got {}".format(type(value).__name__))
[документация] def fire_event_v2(self, message, channel="", data=""):
"""Создает событие в Trassir
Args:
message (:obj:`str`): Сообщение события (``p1``)
channel (:obj:`str`, optional): Ассоциированный с событием канал (``p2``)
data (:obj:`str`, optional): Дополнительные данные (``p3``)
"""
if not isinstance(data, str):
data = BaseUtils.to_json(data, indent=None)
self.fire_event("Script: %1", message, channel, data)
class ShotSaverError(ScriptError):
"""Base ShotSaver Exception"""
pass
[документация]class ShotSaver(py_object):
"""Класс для сохранения скриншотов
Args:
shot_awaiting_time (:obj:`int`, optional): Время ожидания скриншота, с. По умолчанию :obj:`5`.
tries_to_make_shot (:obj:`int`, optional): Кол-во попыток сохранить скриншот.
Если в течении времени `shot_awaiting_time` скриншот не был сохранен - производится
следующая попытка сохранить скриншот. По умолчанию :obj:`2`
pool_size (:obj:`int`): Размер пула. По умолчанию :obj:`10`
"""
_SHOT_NAME_TEMPLATE = (
"{name} (%Y.%m.%d %H-%M-%S).jpg"
) # Template for shot file name
def __init__(
self, shot_awaiting_time=5, tries_to_make_shot=2, pool_size=10, host_api=host
):
self._shot_awaiting_time = shot_awaiting_time
self._tries_to_make_shot = tries_to_make_shot
self._thread_pool = None
self._pool_size = pool_size
self._host_api = host_api
self._screenshots_folder = BaseUtils.get_screenshot_folder()
@property
def pool_size(self):
""":obj:`int`: Размер пула для метода :obj:`pool_shot`
Устанавливает размер пула (кол-во одновременно созданных задач
сохранения скриншотов). По умолчанию :obj:`10`.
Warnings:
Изменить данный параметр можно только до первого вызова
метода :obj:`pool_shot`. После вызовет :obj:`RuntimeError`
Raises:
RuntimeError: Если пул уже создан.
"""
return self._pool_size
@pool_size.setter
def pool_size(self, value):
if self._thread_pool is None:
self._pool_size = value
else:
raise RuntimeError("You can't change pool size when workers created")
@property
def pool_queue_size(self):
""":obj:`int`: Размер текущей очереди в пуле
Возвращает текущий размер очереди в пуле.
Note:
Если пул еще не был созда (метод :obj:`pool_shot`
не вызывался) данный метод вернет :obj:`-1`
"""
if self._thread_pool is None:
return -1
else:
return self._thread_pool.tasks.qsize()
@property
def pool_working(self):
""":obj:`bool`: :obj:`True` если в пуле есть не законченные задачи
Note:
Если пул еще не был созда (метод :obj:`pool_shot`
не вызывался) данный метод вернет :obj:`None`
"""
if self._thread_pool is None:
return None
else:
return self._thread_pool.working
@property
def screenshots_folder(self):
""":obj:`str`: Папка для сохранения скриншотов по умолчанию
Устанавливает новый путь по умолчанию для сохранения скриншотов,
если папка не существует - создает папку. Или возвращает текущий
путь для сохранения скриншотов.
Note:
По молчанию :obj:`screenshots_folder` =
:meth:`BaseUtils.get_screenshot_folder`
Raises:
OSError: Если возникает ошибка при создании папки
"""
return self._screenshots_folder
@screenshots_folder.setter
def screenshots_folder(self, folder):
if not os.path.isdir(folder):
try:
os.makedirs(folder)
except OSError as err:
raise OSError("Can't make dir '{}': {}".format(folder, err))
self._screenshots_folder = folder
[документация] def shot(self, channel_full_guid, dt=None, file_name=None, file_path=None):
"""Делает скриншот с указанного канала
Note:
По умолчанию:
- :obj:`dt=datetime.now()`
- :obj:`file_name="{name} (%Y.%m.%d %H-%M-%S).jpg"`, где ``{name}`` - имя канала
Args:
channel_full_guid (:obj:`str`): Полный guid анала. Например: ``"CFsuNBzt_pV4ggECb"``
dt (:obj:`datetime.datetime`, optional): :obj:`datetime.datetime` для скриншота.
По умолчанию :obj:`None`
file_name (:obj:`str`, optional): Имя файла с расширением. По умолчанию :obj:`None`
file_path (:obj:`str`, optional): Путь для сохранения скриншота. По умолчанию :obj:`None`
Returns:
:obj:`str`: Полный путь до скриншота
Raises:
ValueError: Если в guid канала отсутствует guid сервера
TypeError: Если ``isinstance(dt, (datetime, date)) is False``
Examples:
>>> ss = ShotSaver()
>>> ss.shot("e80kgBLh_pV4ggECb")
'/home/trassir/shots/AC-D2141IR3 Склад (2019.04.03 15-58-26).jpg'
"""
logger.debug(
"ShotSaver.shot({channel_full_guid}, dt={dt}, file_name={file_name}, file_path={file_path})".format(
channel_full_guid=repr(channel_full_guid),
dt=repr(dt),
file_name=repr(file_name),
file_path=repr(file_path),
)
)
if "_" not in channel_full_guid:
raise ValueError(
"Expected full channel guid, got {}".format(channel_full_guid)
)
if dt is None:
ts = "0"
dt = datetime.now()
else:
if not isinstance(dt, (datetime, date)):
raise TypeError("Expected datetime, got {}".format(type(dt).__name__))
ts = dt.strftime("%Y%m%d_%H%M%S")
if file_name is None:
file_name = dt.strftime(
self._SHOT_NAME_TEMPLATE.format(
name=BaseUtils.get_object_name_by_guid(channel_full_guid)
)
)
if file_path is None:
file_path = self.screenshots_folder
self._host_api.screenshot_v2_figures(
channel_full_guid, file_name, file_path, ts
)
return os.path.join(file_path, file_name)
def _async_shot(
self, channel_full_guid, dt=None, file_name=None, file_path=None, callback=None
):
"""Вызывает ``callback`` после сохнанения скриншота
* Метод работает в отдельном потоке
* Вызывает функцию :meth:`ShotSaver.shot`
* Ждет выполнения функции :meth:`BaseUtils.check_file` ``tries=10``
* Вызвает ``callback`` функцию
Args:
channel_full_guid (:obj:`str`): Полный guid канала. Например: ``"CFsuNBzt_pV4ggECb"``
dt (:obj:`datetime.datetime`, optional): :obj:`datetime.datetime` для скриншота.
По умолчанию :obj:`None`
file_name (:obj:`str`, optional): Имя файла с расширением. По умолчанию :obj:`None`
file_path (:obj:`str`, optional): Путь для сохранения скриншота. По умолчанию :obj:`None`
callback (:obj:`function`): Callable function
"""
if callback is None:
callback = BaseUtils.do_nothing
shot_file = ""
for _ in xrange(self._tries_to_make_shot):
shot_file = self.shot(
channel_full_guid, dt=dt, file_name=file_name, file_path=file_path
)
if BaseUtils.is_file_exists(
BaseUtils.win_encode_path(shot_file), self._shot_awaiting_time
):
self._host_api.timeout(100, lambda: callback(True, shot_file))
break
else:
self._host_api.timeout(100, lambda: callback(False, shot_file))
[документация] @BaseUtils.run_as_thread
def async_shot(
self, channel_full_guid, dt=None, file_name=None, file_path=None, callback=None
):
"""async_shot(channel_full_guid, dt=None, file_name=None, file_path=None, callback=None)
Вызывает ``callback`` после сохнанения скриншота
* Метод работает в отдельном потоке
* Вызывает функцию :meth:`ShotSaver.shot`
* Ждет выполнения функции :meth:`BaseUtils.check_file` ``tries=10``
* Вызвает ``callback`` функцию
Args:
channel_full_guid (:obj:`str`): Полный guid канала. Например: ``"CFsuNBzt_pV4ggECb"``
dt (:obj:`datetime.datetime`, optional): :obj:`datetime.datetime` для скриншота.
По умолчанию :obj:`None`
file_name (:obj:`str`, optional): Имя файла с расширением. По умолчанию :obj:`None`
file_path (:obj:`str`, optional): Путь для сохранения скриншота. По умолчанию :obj:`None`
callback (:obj:`function`, optional): Функциюя, которая вызывается после сохранения скриншота.
В качестве аргументов должна принимать `success`, `shot_path`. По умолчанию :obj:`None`
Returns:
:obj:`threading.Thread`: Thread object
Examples:
>>> # noinspection PyUnresolvedReferences
>>> def callback(success, shot_path):
... # Пример callback функции
... # Args:
... # success (bool): True если скриншот успешно сохранен, иначе False
... # shot_path (str): Полный путь до скриншота
... if success:
... host.message("Скриншот успешно сохранен<br>%s" % shot_path)
... else:
... host.error("Ошибка сохранения скриншота <br>%s" % shot_path)
>>>
>>> ss = ShotSaver()
>>> ss.async_shot("e80kgBLh_pV4ggECb", callback=callback)
"""
self._async_shot(
channel_full_guid,
dt=dt,
file_name=file_name,
file_path=file_path,
callback=callback,
)
@BaseUtils.run_as_thread
def _pool_awaiting(self):
self._thread_pool.wait_completion()
# noinspection PyIncorrectDocstring
[документация] def pool_shot(self, *args, **kwargs):
"""pool_shot(channel_full_guid, dt=None, file_name=None, file_path=None, callback=None)
Сохраняет скриншоты в пуле.
Одновременно в работе не более :obj:`ShotSaver.pool_size` задач.
Warnings:
Данный метод создает :obj:`ShotSaver.pool_size` доп. потоков.
Потоки удаляются при отключении скрипта.
Args:
channel_full_guid (:obj:`str`): Полный guid канала. Например: ``"CFsuNBzt_pV4ggECb"``
dt (:obj:`datetime.datetime`, optional): :obj:`datetime.datetime` для скриншота.
По умолчанию :obj:`None`
file_name (:obj:`str`, optional): Имя файла с расширением. По умолчанию :obj:`None`
file_path (:obj:`str`, optional): Путь для сохранения скриншота. По умолчанию :obj:`None`
callback (:obj:`function`, optional): Функциюя, которая вызывается после сохранения скриншота.
В качестве аргументов должна принимать `success`, `shot_path`. По умолчанию :obj:`None`
Examples:
>>> ss = ShotSaver()
>>> ss.pool_size = 2
>>>
>>> ss.pool_shot("e80kgBLh_pV4ggECb")
>>> ss.pool_shot("e80kgBLh_pV4ggECb")
>>> ss.pool_shot("e80kgBLh_pV4ggECb")
>>> ss.pool_shot("e80kgBLh_pV4ggECb")
>>>
>>> ss.pool_queue_size
4
"""
if self._thread_pool is None:
self._thread_pool = ThreadPool(self._pool_size)
self._thread_pool.add_task(self._async_shot, *args, **kwargs)
class VideoExporterError(ScriptError):
"""Base ShotSaver Exception"""
pass
[документация]class VideoExporter(py_object):
"""Класс для экспорта видео
Examples:
Смена папки экспорта видео по умолчанию
>>> ss = VideoExporter()
>>> ss.export_folder
'/home/trassir/shots'
>>> ss.export_folder += "/my_videos"
>>> ss.export_folder
'/home/trassir/shots/my_videos'
| Экспорт видео с вызовом ``callback`` функции после выполнения.
| Начало экспорта - 120 секунд назад, продолжительность 60 сек.
>>> # noinspection PyUnresolvedReferences
>>> def callback(success, file_path, channel_full_guid):
... # Пример callback функции
... # Args:
... # success (bool): True если видео экспортировано успешно, иначе False
... # file_path (str): Полный путь до видеофайла
... # channel_full_guid (str) : Полный guid канала
... if success:
... host.message("Экспорт успешно завершен<br>%s" % file_path)
... else:
... host.error("Ошибка экспорта<br>%s" % file_path)
>>> ss = VideoExporter()
>>> dt_start = datetime.now() - timedelta(seconds=120)
>>> ss.export(callback, "e80kgBLh_pV4ggECb", dt_start)
"""
_EXPORTED_VIDEO_NAME_TEMPLATE = (
"{name} ({dt_start} - {dt_end}){sub}.avi"
) # Template for shot file name
def __init__(self, host_api=host):
self._host_api = host_api
self._export_folder = BaseUtils.get_screenshot_folder()
self._now_exporting = False
self._queue = deque()
self._default_prebuffer = host_api.settings("archive")["prebuffer"] + 2
@property
def export_folder(self):
""":obj:`str`: Папка для экспорта видео по умолчанию
Устанавливает новый путь по умолчанию для экспорта видео,
если папка не существует - создает папку. Или возвращает текущий
путь для экспорта видео.
Note:
По молчанию ``export_folder`` = :meth:`BaseUtils.get_screenshot_folder`
Raises:
OSError: Если возникает ошибка при создании папки
"""
return self._export_folder
@export_folder.setter
def export_folder(self, folder):
if not os.path.isdir(folder):
try:
os.makedirs(folder)
except OSError as err:
raise OSError("Can't make dir '{}': {}".format(folder, err))
self._export_folder = folder
def _get_prebuffer(self, server_guid, dt_end):
"""Get prebuffer delay
Args:
server_guid (str): Full channel guid include server guid
Returns:
int: Prebuffer delay
"""
setting_path = "/{}/archive".format(server_guid)
try:
prebuffer = self._host_api.settings(setting_path)["prebuffer"] + 2
except KeyError:
prebuffer = self._default_prebuffer
wait_dt_end = (int(time.mktime(dt_end.timetuple())) + prebuffer) * 1000000
return "%.0f" % wait_dt_end
def clear_complete_tasks(self):
for task in self._host_api.archive_export_tasks_get():
if task["state"] != 1:
self._host_api.archive_export_task_cancel(
task["id"], # task id from archive_export_tasks_get
-1, # -1 - do not wait for result, 0 - wait forever, > 0 - wait timeout_sec seconds
BaseUtils.do_nothing, # callback_success
BaseUtils.do_nothing, # callback_error
)
def _check_queue(self):
self._host_api.timeout(10, self.clear_complete_tasks)
if self._queue:
args, kwargs = self._queue.popleft()
self._export(*args, **kwargs)
def _export_checker(self, status, callback, file_path, channel_full_guid):
if status == 1:
return
elif status in [0, 2]:
"""Export failed"""
self._host_api.timeout(
100, lambda: callback(False, file_path, channel_full_guid)
)
else:
"""Export success"""
self._host_api.timeout(
100, lambda: callback(True, file_path, channel_full_guid)
)
self._now_exporting = False
self._check_queue()
def _export(
self,
channel_full_guid,
dt_start,
dt_end=None,
duration=60,
prefer_substream=False,
file_name=None,
file_path=None,
callback=None,
):
"""Exporting file
Call callback(success: bool, file_path: str, channel_full_guid: str)
when export finished, and clear tasks in trassir main control panel
Note:
Export task adding only when previous task finished
You can set dt_start, dt_end, or dt_start, duration for export
if dt_end is None: dt_end = dt_start + timedelta(seconds=duration)
Args:
channel_full_guid (str): Full channel guid; example: "CFsuNBzt_pV4ggECb"
dt_start (datetime): datetime instance for export start
dt_end (datetime, optional): datetime instance for export end; default: None
duration (int, optional): Export duration (dt_start + duration seconds) if dt_end is None; default: 10
prefer_substream (bool, optional): If True - export substream; default: False
file_name (str, optional): File name with extension; default: _EXPORTED_VIDEO_NAME_TEMPLATE
file_path (str, optional): Path to save shot; default: screenshots_folder
callback (function, optional): Function that calling when export finished
"""
if "_" not in channel_full_guid:
raise ValueError(
"Expected full channel guid, got {}".format(channel_full_guid)
)
if not isinstance(dt_start, (datetime, date)):
raise TypeError("Expected datetime, got {}".format(type(dt_start).__name__))
if dt_end:
if not isinstance(dt_end, (datetime, date)):
raise TypeError(
"Expected datetime, got {}".format(type(dt_end).__name__)
)
else:
dt_end = dt_start + timedelta(seconds=duration)
ts_start = "%.0f" % (time.mktime(dt_start.timetuple()) * 1000000)
ts_end = "%.0f" % (time.mktime(dt_end.timetuple()) * 1000000)
channel_guid, server_guid = channel_full_guid.split("_")
options = {
"prefer_substream": prefer_substream,
"postponed_until_ts": self._get_prebuffer(server_guid, dt_end),
}
if file_name is None:
file_name = self._EXPORTED_VIDEO_NAME_TEMPLATE.format(
name=BaseUtils.get_object_name_by_guid(channel_guid),
dt_start=dt_start.strftime("%Y.%m.%d %H-%M-%S"),
dt_end=dt_end.strftime("%Y.%m.%d %H-%M-%S"),
sub="_sub" if prefer_substream else "",
)
if file_path is None:
file_path = self.export_folder
exporting_path = os.path.join(file_path, file_name)
if callback is None:
callback = BaseUtils.do_nothing
self._now_exporting = True
def checker(status):
self._export_checker(status, callback, exporting_path, channel_full_guid)
self._host_api.archive_export(
server_guid,
channel_guid,
exporting_path,
ts_start,
ts_end,
options,
checker,
)
[документация] def export(
self,
channel_full_guid,
dt_start,
dt_end=None,
duration=60,
prefer_substream=False,
file_name=None,
file_path=None,
callback=None,
):
"""Запускает экспорт или добавляет задачу экспорта в очередь.
После завершения экспорта вызывает ``callback`` функцию
а также очищает список задач экспорта в панеле управления Trassir.
Note:
Задача экспорта добавляется только после завершения предыдущей.
Tip:
- Вы можете задать время начала и окончания экспорта
``dt_start``, ``dt_end``.
- Или можно задать время начала экспорта ``dt_start`` и
продолжительность экспорта (в сек.) ``duration``. По умолчнию
``duration=60``.
- Если ``dt_end=None`` фунция использует ``duration`` для вычисления
времени окончания ``dt_end = dt_start + timedelta(seconds=duration)``.
Args:
channel_full_guid (:obj:`str`): Полный guid канала. Например: ``"CFsuNBzt_pV4ggECb"``
dt_start (:obj:`datetime.datetime`): :obj:`datetime.datetime` начала экспорта
dt_end (:obj:`datetime.datetime`, optional): :obj:`datetime.datetime` окончания экспорта.
По умолчанию :obj:`None`
duration (:obj:`int`, optional): Продолжительность экспорта, в секундах. Используется если
``dt_end is None``. По умолчанию ``60``
prefer_substream (:obj:`bool`, optional): Если ``True`` - Экспортирует субпоток.
По умолчанию ``False``
file_name (:obj:`str`, optional): Имя экспортируемого файла. По умолчанию :obj:`None`
file_path (:obj:`str`, optional): Путь для экспорта. По умолчанию :obj:`None`
callback (:obj:`function`, optional): Функция, которая вызывается после завершения экспорта.
По умолчанию :obj:`None`
"""
args = (channel_full_guid, dt_start)
kwargs = {
"dt_end": dt_end,
"duration": duration,
"prefer_substream": prefer_substream,
"file_name": file_name,
"file_path": file_path,
"callback": callback,
}
if self._now_exporting:
self._queue.append((args, kwargs))
else:
self._export(*args, **kwargs)
class TemplateError(ScriptError):
"""Raised by Template class"""
pass
[документация]class GUITemplate(py_object):
"""Класс для работы с шаблонами Trassir
При инициализации находит существующий шаблон по имени или создает новый.
Note:
Если вручную создать два или большее шаблона с одинаковыми именами
данный класс выберет первый попавшийся шаблон с заданным именем.
Warning:
Работа с контентом шаблона может привести к падениям трассира.
Используйте данный класс на свой страх и риск!
Tip:
Для понимания, как формируется контент отредактируйте любой шаблон
вручную и посмотрите что получится в скрытых параметрах трассира
(активируются нажатием клавиши F4 в настройках трассира)
`Настройки/Шабоны/<Имя шаблона>/content`
Ниже предсталвены некоторые примеры шаблонов
- Вывод одного канала ``S0tE8nfg_Or3QZu4D``
:obj:`gui7(DEWARP_SETTINGS,zwVj07w0,dewarp(),1,S0tE8nfg_Or3QZu4D)`
- Вывод шаблона 4х4 с каналами двумя ``Kpid6EC0_Or3QZu4D``, ``ZRtXLrgu_Or3QZu4D``
:obj:`gui7(DEWARP_SETTINGS,zwVj07w0,dewarp(),4,Kpid6EC0_Or3QZu4D,ZRtXLrgu_Or3QZu4D,,)`
- Вывод шаблон с минибраузером и ссылкой на https://www.google.com/
:obj:`minibrowser(0,htmltab(,https://www.google.com/))`
Args:
template_name (:obj:`str`): Имя шаблон
Examples:
>>> # Создаем шаблон с именем "New template" и получаем его guid
>>> template = GUITemplate("New template")
>>> template.guid
'Y2YFAkeZ'
>>> # Устанавливаем на шаблон минибраузер с ссылкой на google
>>> template.content = "minibrowser(0,htmltab(,https://www.google.com/))"
>>> # Изменяем имя шаблона на "Google search"
>>> template.name = "Google search"
>>> # Открываем шаблон на первом мониторе
>>> template.show(1)
"""
_DEFAULT_TEMPLATE = ""
def __init__(self, template_name, host_api=host):
self._name = template_name
self._host_api = host_api
self._operator_gui = BaseUtils.get_operator_gui()
try:
self._guid, self._template_settings = self._find_template_guid(
template_name
)
except KeyError:
self._guid, self._template_settings = self._init_template(template_name)
def _find_template_guid(self, name):
"""Find template guid by name
Args:
name (str) : Template name
Raises:
KeyError if can't find template
"""
templates = self._host_api.settings("templates")
for template_ in templates.ls():
if name == template_.name:
return (
template_.guid,
self._host_api.settings("templates/{}".format(template_.guid)),
)
raise KeyError
def _init_template(self, name):
"""Create new template
Args:
name (str) : Template name
"""
self._host_api.object(self._host_api.settings("").guid + "T").create_template(
name, self._DEFAULT_TEMPLATE
)
try:
return self._find_template_guid(name)
except KeyError:
raise TemplateError("Failed to create template {}".format(self._name))
@property
def guid(self):
""":obj:`str`: Guid шаблона"""
return self._guid
@guid.setter
def guid(self, value):
raise RuntimeError("You can't change object guid")
@property
def name(self):
""":obj:`str`: Имя шаблона"""
return self._name
@name.setter
def name(self, value):
if isinstance(value, str):
self._name = value
self._template_settings["name"] = value
else:
raise TypeError("Expected str, got {}".format(type(value).__name__))
@property
def content(self):
""":obj:`str`: Контент шаблона"""
return self._template_settings["content"]
@content.setter
def content(self, value):
if isinstance(value, str):
self._template_settings["content"] = value
else:
raise TypeError("Expected str, got {}".format(type(value).__name__))
[документация] def delete(self):
"""Удаляет шаблон"""
obj = BaseUtils.get_object(self.guid)
if obj is None:
raise TemplateError("Template object not found!")
obj.delete_template()
[документация] def show(self, monitor=1):
"""Открывает шаблон на указаном мониторе
Args:
monitor (:obj:`int`, optional): Номер монитора. По умолчанию ``monitor=1``
"""
self._operator_gui.show(self.guid, monitor)
[документация]class TrObject(py_object):
"""Вспомогательный класс для работы с объектами Trassir
Attributes:
obj (:obj:`SE_Object`): Объект trassir :obj:`object('{guid}')` или :obj:`None`
obj_methods (List[:obj:`str`]): Список методов объекта :attr:`TrObject.obj`
name (:obj:`str`): Имя объекта или его guid
guid (:obj:`str`): Guid объекта
full_guid (:obj:`str`): Полный guid :obj:`{guid объекта}_{guid сервера}`
или :obj:`None`
type (:obj:`str`): Тип объекта, например :obj:`"RemoteServer"`, :obj:`"Channel"`,
:obj:`"Grabber"`, :obj:`"User"`, и др.
path (:obj:`str`): Путь в настройках или :obj:`None`
parent (:obj:`str`): Guid родительского объекта или :obj:`None`
server (:obj:`str`): Guid сервера или :obj:`None`
settings (:obj:`SE_Settings`): Объект настроек ``settings('{path}')`` или :obj:`None`
Raises:
TypeError: Если неправильные параметры объекта
ValueError: Если в имени объекта есть запятые
"""
obj, name, guid, full_guid, type = None, None, None, None, None
path, parent, server, settings = None, None, None, None
def __init__(self, obj, host_api=host):
self._host_api = host_api
if isinstance(obj, host_api.ScriptHost.SE_Settings):
self._load_from_settings(obj)
elif isinstance(obj, tuple):
if len(obj) == 4:
self._load_from_tuple(obj)
else:
raise TypeError(
"Expected tuple(name, guid, type, parent), got tuple'{}'".format(
obj
)
)
else:
raise TypeError("Unexpected object type '{}'".format(type(obj).__name__))
@staticmethod
def _check_object_name(object_name):
"""Check if object name hasn't got commas
Args:
object_name (str):
Returns:
str: object_name.strip()
Raises:
ValueError: If "," found in object name
"""
if "," in object_name:
raise ValueError(
"Please, rename object '{}' without commas".format(object_name)
)
return object_name.strip()
@staticmethod
def _parse_server_from_path(path):
"""Parse server guid from full path
Args:
path (str): Full Trassir settings path;
example: '/pV4ggECb/_persons/n68LOBhG' returns 'pV4ggECb'
"""
try:
server = path.split("/", 2)[1]
except IndexError:
server = None
return server
def _find_server_guid_for_object(self, object_guid):
"""Find server guid for object
Args:
object_guid (str): Object guid
Returns:
str: Server guid if server found
None: If server not found
"""
all_objects = {
obj[1]: {"name": obj[0], "guid": obj[1], "type": obj[2], "parent": obj[3]}
for obj in self._host_api.objects_list("")
}
def get_parent(child_guid):
child = all_objects.get(child_guid, None)
if child:
if child["type"] == "Server":
return child["guid"]
else:
return get_parent(child["parent"])
else:
return None
return get_parent(object_guid)
def _get_object_methods(self):
"""Get object methods"""
if self.obj:
return [method for method in dir(self.obj) if not method.startswith("__")]
else:
return []
def _load_from_settings(self, obj):
"""Preparing attributes from SE_Settings object"""
self.obj = BaseUtils.get_object(obj.guid)
self.obj_methods = self._get_object_methods()
try:
obj_name = obj.name
except KeyError:
obj_name = obj.guid
self.name = self._check_object_name(obj_name)
self.guid = obj.guid
self.type = obj.type
self.path = obj.path
self.server = self._parse_server_from_path(obj.path)
self.settings = obj
if self.server and self.server != self.guid:
self.full_guid = "{0.guid}_{0.server}".format(self)
def _load_from_tuple(self, obj):
"""Preparing attributes from tuple object"""
self.obj = BaseUtils.get_object(obj[1])
self.obj_methods = self._get_object_methods()
self.name = self._check_object_name(obj[0])
self.guid = obj[1]
self.type = obj[2]
self.parent = obj[3]
self.server = self._find_server_guid_for_object(obj[1])
if self.server and self.server != self.guid:
self.full_guid = "{0.guid}_{0.server}".format(self)
def __repr__(self):
return "TrObject('{}')".format(self.name)
def __str__(self):
return "{self.type}: {self.name} ({self.guid})".format(self=self)
class ParameterError(ScriptError):
"""Ошибка в параметрах скрипта"""
pass
class BasicObject(py_object):
""""""
def __init__(self, host_api=host):
self._host_api = host_api
self.this_server_guid = BaseUtils.get_server_guid()
class UniqueNameError(ScriptError):
"""Имя объекта не уникально"""
pass
class ObjectsNotFoundError(ScriptError):
"""Не найдены объекты с заданными именами"""
pass
def _check_unique_name(self, objects, object_names):
"""Check if all objects name are unique
Args:
objects (list): Objects list from _get_objects_from_settings
Raises:
UniqueNameError: If some object name is not uniques
"""
unique_names = []
for obj in objects:
if obj.name in object_names:
if obj.name not in unique_names:
unique_names.append(obj.name)
else:
raise self.UniqueNameError(
"Найдено несколько объектов {obj.type} с одинаковым именем '{obj.name}'! "
"Задайте уникальные имена".format(obj=obj)
)
@staticmethod
def _objects_str_to_list(objects):
"""Split object names if objects is str and strip each name
Args:
objects (str|list): Trassir object names in comma spaced string or list
Returns:
list: Stripped Trassir object names
Raises:
ScriptError: If object name selected more than once
"""
if isinstance(objects, str):
objects = objects.split(",")
names = []
for name in objects:
strip_name = name.strip()
if strip_name in names:
raise ParameterError("Объект '{}' выбран несколько раз".format(name))
names.append(strip_name)
return names
def _filter_objects_by_name(self, objects, object_names):
"""Filter object by names
Args:
objects (list): TrObject objects list
object_names (str|list): Trassir object names in comma spaced string or list
Raises:
ObjectsNotFoundError: If len(object_name) != len(filtered_object)
"""
object_names = self._objects_str_to_list(object_names)
self._check_unique_name(objects, object_names)
filtered_object = [obj for obj in objects if obj.name in object_names]
if len(filtered_object) != len(object_names):
channels_not_found = set(object_names) - set(
obj.name for obj in filtered_object
)
try:
object_type = objects[0].type
except IndexError:
object_type = "Unknown"
raise self.ObjectsNotFoundError(
"Не найдены объекты {object_type}: {names}".format(
object_type=object_type,
names=", ".join(name for name in channels_not_found),
)
)
return filtered_object
class ObjectFromSetting(BasicObject):
""""""
def __init__(self):
super(ObjectFromSetting, self).__init__()
def _load_objects_from_settings(self, settings_path, obj_type, sub_condition=None):
"""Load objects from Trassir settings
Args:
settings_path (:obj:`str`): Trassir settings path. Example ``"scripts"``.
Click F4 in the Trassir settings window to show hidden parameters.
obj_type (:obj:`str` | :obj:`list`): Loading object type. Example ``"EmailAccount"``
sub_condition (function, optional): Function with SE_Settings as argument to filter objects
Returns:
list: TrObject objects list
Example [TrObject(...), TrObject(...), ...]
"""
try:
settings = self._host_api.settings(settings_path)
except KeyError:
settings = None
objects = []
if settings is not None:
if isinstance(obj_type, str):
obj_type = [obj_type]
if sub_condition is None:
sub_condition = BaseUtils.do_nothing
for obj in settings.ls():
if obj.type in obj_type:
if sub_condition(obj):
objects.append(TrObject(obj))
return objects
def _get_objects_from_settings(
self,
settings_path,
object_type,
object_names=None,
server_guid=None,
ban_empty_result=False,
sub_condition=None,
):
"""Check if objects exists and returns list from _load_objects_from_settings
Note:
If object_names is not None - checking if all object names are unique
Args:
settings_path (:obj:`str`): Trassir settings path. Example ``"scripts"``.
Click F4 in the Trassir settings window to show hidden parameters.
object_type (:obj:`str` | :obj:`list`): Loading object type. Example ``"EmailAccount"``
object_names (:obj:`str` | :obj:`list`, optional): Comma spaced string or
list of object names. Default :obj:`None`
server_guid (:obj:`str` | :obj:`list`, optional): Server guid. Default :obj:`None`
ban_empty_result (:obj:`bool`, optional): If True - raise error if no one object found
sub_condition (:obj:`func`, optional) : Function with SE_Settings as argument to filter objects
Returns:
list: Trassir list from _load_objects_from_settings
Raises:
ObjectsNotFoundError: If can't find channel
"""
if object_names == "":
raise ParameterError("'{}' не выбраны".format(object_type))
if server_guid is None:
server_guid = self.this_server_guid
if isinstance(server_guid, str):
server_guid = [server_guid]
objects = []
for guid in server_guid:
objects += self._load_objects_from_settings(
settings_path.format(server_guid=guid), object_type, sub_condition
)
if ban_empty_result and not objects:
raise self.ObjectsNotFoundError(
"Не найдено ниодного объекта '{}'".format(object_type)
)
if object_names is None:
return objects
else:
return self._filter_objects_by_name(objects, object_names)
[документация]class Servers(ObjectFromSetting):
"""Класс для работы с серверами
Examples:
>>> srvs = Servers()
>>> local_srv = srvs.get_local()
[TrObject('Клиент')]
>>> # Првоерим "Здоровье" локального сервера
>>> local_srv[0].obj.state("server_health")
'Health Problem'
"""
def __init__(self):
super(Servers, self).__init__()
[документация] def get_local(self):
"""Возвращает локальный сервер (на котором запущен скрипт)
Returns:
List[:class:`TrObject`]: Список объектов
"""
return self._load_objects_from_settings("/", ["Client", "LocalServer"])
[документация] def get_remote(self):
"""Возвращает список удаленных серверов
Returns:
List[:class:`TrObject`]: Список объектов
"""
return self._load_objects_from_settings("/", "RemoteServer")
[документация] def get_all(self):
"""Возвращает список всех доступных серверов
Returns:
List[:class:`TrObject`]: Список объектов
"""
return self._load_objects_from_settings(
"/", ["Client", "LocalServer", "RemoteServer"]
)
[документация]class Channels(ObjectFromSetting):
"""Класс для работы с каналами
See Also:
`Каналы - Руководство пользователя Trassir
<https://www.dssl.ru/files/trassir/manual/ru/setup-channels-folder.html>`_
Args:
server_guid (:obj:`str` | List[:obj:`str`], optional): Guid сервера или список guid.
По умолчанию :obj:`None`, что соотвествует всем доступным серверам.
Examples:
>>> channels = Channels()
>>> selected_channels = channels.get_enabled("AC-D2121IR3W 2,AC-D9141IR2 1")
>>> selected_channels
[TrObject('AC-D2121IR3W 2'), TrObject('AC-D9141IR2 1')]
>>>
>>> # Включим ручную запись на выбранных каналах
>>> for channel in selected_channels:
... channel.obj.manual_record_start()
>>>
>>> # Или добавим к имени канала его guid
>>> for channel in selected_channels:
... channel.settings["name"] += " ({})".format(channel.guid)
"""
def __init__(self, server_guid=None):
super(Channels, self).__init__()
if server_guid is None:
server_guid = [srv.guid for srv in Servers().get_all()]
self.server_guid = server_guid
[документация] def get_enabled(self, names=None):
"""Возвращает список активных каналов
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
def sub_condition(sett):
not_zombie = 1 - sett["archive_zombie_flag"]
if not_zombie:
try:
return self._host_api.settings(sett.cd("info")["grabber_path"])[
"grabber_enabled"
]
except KeyError:
return 0
return 0
return self._get_objects_from_settings(
"/{server_guid}/channels",
"Channel",
object_names=names,
server_guid=self.server_guid,
sub_condition=sub_condition,
)
[документация] def get_disabled(self, names=None):
"""Возвращает список неактивных каналов
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
def sub_condition(sett):
zombie = sett["archive_zombie_flag"]
if not zombie:
try:
return (
1
- self._host_api.settings(sett.cd("info")["grabber_path"])[
"grabber_enabled"
]
)
except KeyError:
return 1
return 1
return self._get_objects_from_settings(
"/{server_guid}/channels",
"Channel",
object_names=names,
server_guid=self.server_guid,
sub_condition=sub_condition,
)
[документация] def get_all(self, names=None):
"""Возвращает список всех каналов
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
return self._get_objects_from_settings(
"/{server_guid}/channels",
"Channel",
object_names=names,
server_guid=self.server_guid,
sub_condition=None,
)
[документация]class Devices(ObjectFromSetting):
"""Класс для работы с ip устройствами
See Also:
`IP-устройства - Руководство пользователя Trassir
<https://www.dssl.ru/files/trassir/manual/ru/setup-ip-cameras-folder.html>`_
Args:
server_guid (:obj:`str` | List[:obj:`str`], optional): Guid сервера или список guid.
По умолчанию :obj:`None`, что соотвествует всем доступным серверам.
Examples:
>>> devices = Devices()
>>> enabled_devices = devices.get_enabled()
>>> enabled_devices
[TrObject('AC-D2121IR3W'), TrObject('AC-D5123IR32'), ...]
>>>
>>> # Перезагрузим все устройства
>>> for dev in enabled_devices:
... dev.settings["reboot"] = 1
"""
def __init__(self, server_guid=None):
super(Devices, self).__init__()
if server_guid is None:
server_guid = [srv.guid for srv in Servers().get_all()]
self.server_guid = server_guid
[документация] def get_enabled(self, names=None):
"""Возвращает список активных устройств
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
def sub_condition(sett):
try:
return sett["grabber_enabled"]
except KeyError:
return 0
return self._get_objects_from_settings(
"/{server_guid}/ip_cameras",
"Grabber",
object_names=names,
server_guid=self.server_guid,
sub_condition=sub_condition,
)
[документация] def get_disabled(self, names=None):
"""Возвращает список неактивных устройств
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
def sub_condition(sett):
try:
return 1 - sett["grabber_enabled"]
except KeyError:
return 1
return self._get_objects_from_settings(
"/{server_guid}/ip_cameras",
"Grabber",
object_names=names,
server_guid=self.server_guid,
sub_condition=sub_condition,
)
[документация] def get_all(self, names=None):
"""Возвращает список всех устройств
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
return self._get_objects_from_settings(
"/{server_guid}/ip_cameras",
"Grabber",
object_names=names,
server_guid=self.server_guid,
sub_condition=None,
)
[документация]class Scripts(ObjectFromSetting):
"""Класс для работы со скриптами
See Also:
`Скрипты - Руководство пользователя Trassir
<https://www.dssl.ru/files/trassir/manual/ru/setup-script-feature.html>`_
Args:
server_guid (:obj:`str` | List[:obj:`str`], optional): Guid сервера или список guid.
По умолчанию :obj:`None`, что соотвествует всем доступным серверам.
Examples:
>>> scripts = Scripts()
>>> all_scripts = scripts.get_all()
>>> all_scripts
[TrObject('Новый скрипт'), TrObject('HDD Health Monitor'), TrObject('Password Reminder')]
>>>
>>> # Отключим все скрипты
>>> for script in all_scripts:
... script.settings["enable"] = 0
"""
def __init__(self, server_guid=None):
super(Scripts, self).__init__()
if server_guid is None:
server_guid = [srv.guid for srv in Servers().get_all()]
self.server_guid = server_guid
[документация] def get_enabled(self, names=None):
"""Возвращает список активных скриптов
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
def sub_condition(sett):
try:
return sett["enable"]
except KeyError:
return 0
return self._get_objects_from_settings(
"/{server_guid}/scripts",
"Script",
object_names=names,
server_guid=self.server_guid,
sub_condition=sub_condition,
)
[документация] def get_disabled(self, names=None):
"""Возвращает список неактивных скриптов
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
def sub_condition(sett):
try:
return 1 - sett["enable"]
except KeyError:
return 1
return self._get_objects_from_settings(
"/{server_guid}/scripts",
"Script",
object_names=names,
server_guid=self.server_guid,
sub_condition=sub_condition,
)
[документация] def get_all(self, names=None):
"""Возвращает список всех скриптов
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
return self._get_objects_from_settings(
"/{server_guid}/scripts",
"Script",
object_names=names,
server_guid=self.server_guid,
sub_condition=None,
)
[документация]class StockScripts(ObjectFromSetting):
"""Класс для работы со встроенными скриптами
Args:
server_guid (:obj:`str` | List[:obj:`str`], optional): Guid сервера или список guid.
По умолчанию :obj:`None`, что соотвествует всем доступным серверам.
Examples:
>>> stock_scripts = StockScripts()
>>> all_scripts = stock_scripts.get_all()
>>> all_scripts
[TrObject('MegaRAID Monitor')]
>>>
>>> # Отключим все скрипты
>>> for script in all_scripts:
... script.settings["enable"] = 0
"""
def __init__(self, server_guid=None):
super(StockScripts, self).__init__()
if server_guid is None:
server_guid = [srv.guid for srv in Servers().get_all()]
self.server_guid = server_guid
[документация] def get_enabled(self, names=None):
"""Возвращает список активных скриптов
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
def sub_condition(sett):
try:
return sett["enable"]
except KeyError:
return 0
return self._get_objects_from_settings(
"/{server_guid}/scripts",
"StockScript",
object_names=names,
server_guid=self.server_guid,
sub_condition=sub_condition,
)
[документация] def get_disabled(self, names=None):
"""Возвращает список неактивных скриптов
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
def sub_condition(sett):
try:
return 1 - sett["enable"]
except KeyError:
return 1
return self._get_objects_from_settings(
"/{server_guid}/scripts",
"StockScript",
object_names=names,
server_guid=self.server_guid,
sub_condition=sub_condition,
)
[документация] def get_all(self, names=None):
"""Возвращает список всех скриптов
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
return self._get_objects_from_settings(
"/{server_guid}/scripts",
"StockScript",
object_names=names,
server_guid=self.server_guid,
sub_condition=None,
)
[документация]class Rules(ObjectFromSetting):
"""Класс для работы с правилами
See Also:
`Правила - Руководство пользователя Trassir
<https://www.dssl.ru/files/trassir/manual/ru/setup-rule.html>`_
Args:
server_guid (:obj:`str` | List[:obj:`str`], optional): Guid сервера или список guid.
По умолчанию :obj:`None`, что соотвествует всем доступным серверам.
Examples:
>>> rules = Rules()
>>> all_rules = rules.get_all()
>>> all_rules
[TrObject('!Rule'), TrObject('NEW RULE'), TrObject('Новое правило')]
>>>
>>> # Отключим все правила
>>> for rule in all_rules:
... rule.settings["enable"] = 0
"""
def __init__(self, server_guid=None):
super(Rules, self).__init__()
if server_guid is None:
server_guid = [srv.guid for srv in Servers().get_all()]
self.server_guid = server_guid
[документация] def get_enabled(self, names=None):
"""Возвращает список активных правил
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
def sub_condition(sett):
try:
return sett["enable"]
except KeyError:
return 0
return self._get_objects_from_settings(
"/{server_guid}/scripts",
"Rule",
object_names=names,
server_guid=self.server_guid,
sub_condition=sub_condition,
)
[документация] def get_disabled(self, names=None):
"""Возвращает список неактивных правил
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
def sub_condition(sett):
try:
return 1 - sett["enable"]
except KeyError:
return 1
return self._get_objects_from_settings(
"/{server_guid}/scripts",
"Rule",
object_names=names,
server_guid=self.server_guid,
sub_condition=sub_condition,
)
[документация] def get_all(self, names=None):
"""Возвращает список всех правил
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен. По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
return self._get_objects_from_settings(
"/{server_guid}/scripts",
"Rule",
object_names=names,
server_guid=self.server_guid,
sub_condition=None,
)
[документация]class Schedules(ObjectFromSetting):
"""Класс для работы с расписаниями
See Also:
`Расписания - Руководство пользователя Trassir
<https://www.dssl.ru/files/trassir/manual/ru/setup-schedule.html>`_
Args:
server_guid (:obj:`str` | List[:obj:`str`], optional): Guid сервера или список guid.
По умолчанию :obj:`None`, что соотвествует всем доступным серверам.
Examples:
>>> schedules = Schedules()
>>> my_schedule = schedules.get_enabled("!Schedule")[0]
>>> my_schedule.obj.state("color")
'Red'
"""
def __init__(self, server_guid=None):
super(Schedules, self).__init__()
if server_guid is None:
server_guid = [srv.guid for srv in Servers().get_all()]
self.server_guid = server_guid
[документация] @BaseUtils.run_as_thread
def on_load(self, schedule_name, callback, tries=5):
"""on_load(schedule_name, callback, tries=5)
Вызывает `callback` после загрузки расписания
Note:
При загрузке сервера, объект расписания становится не сразу доступен.
Данный метод помогает предотвратить данную ошибку.
Args:
schedule_name (:obj:`str`): Имя расписания
callback (:obj:`function`): Функция, которая вызывается после
загрузки расписания.
tries (:obj:`int`, optional): Кол-во попыток загрузки расписания.
Каждая попытка производится с интервалом 1 с. По умолчанию :obj:`5`
Examples
>>> schedule = None
>>> # noinspection PyGlobalUndefined,PyUnresolvedReferences
>>> def on_schedule_loaded(schedule_obj):
... global schedule
... schedule = schedule_obj
...
... message("Schedule '{obj.name}' ({obj.guid}) loaded".format(obj=schedule))
... schedule.activate_on_state_changes(lambda: alert(schedule.state("color")))
>>>
>>> Schedules().on_load("Unnamed Schedule", on_schedule_loaded)
"""
if not schedule_name:
raise ParameterError("Empty schedule name")
tmp_server_guid = self.server_guid
self.server_guid = BaseUtils.get_server_guid()
while tries:
obj = self.get_enabled(schedule_name)[0].obj
if obj is None:
tries -= 1
time.sleep(1)
else:
self.server_guid = tmp_server_guid
self._host_api.timeout(1, lambda: callback(obj))
break
else:
self.server_guid = tmp_server_guid
raise ScriptError(
"Ошибка получения объекта расписания '{}'".format(schedule_name)
)
[документация] def get_enabled(self, names=None):
"""Возвращает список активных расписаний
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
def sub_condition(sett):
try:
return sett["enable"]
except KeyError:
return 0
return self._get_objects_from_settings(
"/{server_guid}/scripts",
"Schedule",
object_names=names,
server_guid=self.server_guid,
sub_condition=sub_condition,
)
[документация] def get_disabled(self, names=None):
"""Возвращает список неактивных расписаний
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
def sub_condition(sett):
try:
return 1 - sett["enable"]
except KeyError:
return 1
return self._get_objects_from_settings(
"/{server_guid}/scripts",
"Schedule",
object_names=names,
server_guid=self.server_guid,
sub_condition=sub_condition,
)
[документация] def get_all(self, names=None):
"""Возвращает список всех расписаний
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
return self._get_objects_from_settings(
"/{server_guid}/scripts",
"Schedule",
object_names=names,
server_guid=self.server_guid,
sub_condition=None,
)
[документация]class TemplateLoops(ObjectFromSetting):
"""Класс для работы с циклическими просмотрами шаблонов
Args:
server_guid (:obj:`str` | List[:obj:`str`], optional): Guid сервера или список guid.
По умолчанию :obj:`None`, что соотвествует всем доступным серверам.
Examples:
>>> tmplate_loops = TemplateLoops()
>>> tmplate_loops.get_all()
[TrObject('Новый циклический просмотр')]
"""
def __init__(self, server_guid=None):
super(TemplateLoops, self).__init__()
if server_guid is None:
server_guid = [srv.guid for srv in Servers().get_all()]
self.server_guid = server_guid
[документация] def get_enabled(self, names=None):
"""Возвращает список активных циклических просмотров шаблонов
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
def sub_condition(sett):
try:
return sett["enable"]
except KeyError:
return 0
return self._get_objects_from_settings(
"/{server_guid}/scripts",
"TemplateLoop",
object_names=names,
server_guid=self.server_guid,
sub_condition=sub_condition,
)
[документация] def get_disabled(self, names=None):
"""Возвращает список неактивных циклических просмотров шаблонов
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
def sub_condition(sett):
try:
return 1 - sett["enable"]
except KeyError:
return 1
return self._get_objects_from_settings(
"/{server_guid}/scripts",
"TemplateLoop",
object_names=names,
server_guid=self.server_guid,
sub_condition=sub_condition,
)
[документация] def get_all(self, names=None):
"""Возвращает список всех циклических просмотров шаблонов
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
return self._get_objects_from_settings(
"/{server_guid}/scripts",
"TemplateLoop",
object_names=names,
server_guid=self.server_guid,
sub_condition=None,
)
[документация]class EmailAccounts(ObjectFromSetting):
"""Класс для работы с E-Mail аккаунтами
See Also:
`Добавление учетной записи e-mail - Руководство пользователя Trassir
<https://www.dssl.ru/files/trassir/manual/ru/setup-email-account.html>`_
Args:
server_guid (:obj:`str` | List[:obj:`str`], optional): Guid сервера или список guid.
По умолчанию :obj:`None`, что соотвествует всем доступным серверам.
Examples:
>>> email_accounts = EmailAccounts()
>>> email_accounts.get_all()
[TrObject('Новая учетная запись e-mail'), TrObject('MyAccount')]
"""
def __init__(self, server_guid=None):
super(EmailAccounts, self).__init__()
if server_guid is None:
server_guid = [srv.guid for srv in Servers().get_all()]
self.server_guid = server_guid
[документация] def get_all(self, names=None):
"""Возвращает список всех E-Mail аккаунтов
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
return self._get_objects_from_settings(
"/{server_guid}/scripts",
"EmailAccount",
object_names=names,
server_guid=self.server_guid,
sub_condition=None,
)
[документация]class NetworkNodes(ObjectFromSetting):
"""Класс для работы с сетевыми подключениями
See Also:
`Сеть - Руководство пользователя Trassir
<https://www.dssl.ru/files/trassir/manual/ru/setup-network-folder.html>`_
Args:
server_guid (:obj:`str` | List[:obj:`str`], optional): Guid сервера или список guid.
По умолчанию :obj:`None`, что соотвествует всем доступным серверам.
Examples:
>>> network_nodes = NetworkNodes("client")
>>> network_nodes.get_enabled()
[TrObject('QuattroStationPro (172.20.0.101)'), TrObject('NSK-HD-01 (127.0.0.1)')]
"""
def __init__(self, server_guid=None):
super(NetworkNodes, self).__init__()
if server_guid is None:
server_guid = [srv.guid for srv in Servers().get_all()]
self.server_guid = server_guid
[документация] def get_enabled(self, names=None):
"""Возвращает список активных сетевых подключений
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
def sub_condition(sett):
try:
return sett["should_be_connected"]
except KeyError:
return 0
return self._get_objects_from_settings(
"/{server_guid}/network",
"NetworkNode",
object_names=names,
server_guid=self.server_guid,
sub_condition=sub_condition,
)
[документация] def get_disabled(self, names=None):
"""Возвращает список неактивных сетевых подключений
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
def sub_condition(sett):
try:
return 1 - sett["should_be_connected"]
except KeyError:
return 1
return self._get_objects_from_settings(
"/{server_guid}/network",
"NetworkNode",
object_names=names,
server_guid=self.server_guid,
sub_condition=sub_condition,
)
[документация] def get_all(self, names=None):
"""Возвращает список всех сетевых подключений
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
return self._get_objects_from_settings(
"/{server_guid}/network",
"NetworkNode",
object_names=names,
server_guid=self.server_guid,
sub_condition=None,
)
[документация]class PosTerminals(ObjectFromSetting):
"""Класс для работы с POS Терминалами
See Also:
`Настройка POS-терминалов - Руководство пользователя Trassir
<https://www.dssl.ru/files/trassir/manual/ru/setup-pos-terminals-folder.html>`_
Args:
server_guid (:obj:`str` | List[:obj:`str`], optional): Guid сервера или список guid.
По умолчанию :obj:`None`, что соотвествует всем доступным серверам.
Examples:
>>> pos_terminals = PosTerminals()
>>> pos_terminals.get_disabled()
[TrObject('Касса (1)')]
"""
def __init__(self, server_guid=None):
super(PosTerminals, self).__init__()
if server_guid is None:
server_guid = [srv.guid for srv in Servers().get_all()]
self.server_guid = server_guid
[документация] def get_enabled(self, names=None):
"""Возвращает список активных POS Терминалов
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
def sub_condition(sett):
try:
return sett["pos_enable"]
except KeyError:
return 0
return self._get_objects_from_settings(
"/{server_guid}/pos_folder2/terminals",
"PosTerminal",
object_names=names,
server_guid=self.server_guid,
sub_condition=sub_condition,
)
[документация] def get_disabled(self, names=None):
"""Возвращает список неактивных POS Терминалов
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
def sub_condition(sett):
try:
return 1 - sett["pos_enable"]
except KeyError:
return 1
return self._get_objects_from_settings(
"/{server_guid}/pos_folder2/terminals",
"PosTerminal",
object_names=names,
server_guid=self.server_guid,
sub_condition=sub_condition,
)
[документация] def get_all(self, names=None):
"""Возвращает список всех POS Терминалов
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
return self._get_objects_from_settings(
"/{server_guid}/pos_folder2/terminals",
"PosTerminal",
object_names=names,
server_guid=self.server_guid,
sub_condition=None,
)
[документация]class Users(ObjectFromSetting):
"""Класс для работы с пользователями и их группами.
See Also:
`Пользователи - Руководство пользователя Trassir
<https://www.dssl.ru/files/trassir/manual/ru/setup-users-folder.html>`_
Args:
server_guid (:obj:`str` | List[:obj:`str`], optional): Guid сервера или список guid.
По умолчанию :obj:`None`, что соотвествует всем доступным серверам.
Examples:
>>> users = Users()
>>> users.get_groups()
[TrObject('TEST')]
"""
def __init__(self, server_guid=None):
super(Users, self).__init__()
if server_guid is None:
server_guid = [srv.guid for srv in Servers().get_all()]
self.server_guid = server_guid
[документация] def get_groups(self, names=None):
"""Возвращает список групп пользователей
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
return self._get_objects_from_settings(
"/{server_guid}/users",
"Group",
object_names=names,
server_guid=self.server_guid,
sub_condition=None,
)
[документация] def get_users(self, names=None):
"""Возвращает список пользователей
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
return self._get_objects_from_settings(
"/{server_guid}/users",
"User",
object_names=names,
server_guid=self.server_guid,
sub_condition=None,
)
[документация] def get_users_by_groups(self, group_names):
"""Возвращает список пользователей из указанных групп
Args:
group_names (:obj:`str` | :obj:`list`): :obj:`str` - имена групп,
разделенные запятыми или :obj:`list` - список имен.
Returns:
List[:class:`TrObject`]: Список объектов
"""
if group_names is None:
groups = [""]
else:
groups = [group.guid for group in self.get_groups(names=group_names)]
def sub_condition(sett):
return sett["group"] in groups
return self._get_objects_from_settings(
"/{server_guid}/users",
"User",
object_names=None,
server_guid=self.server_guid,
sub_condition=sub_condition,
)
[документация]class Templates(ObjectFromSetting):
"""Класс для работы с существующими шаблонами.
Args:
server_guid (:obj:`str` | List[:obj:`str`], optional): Guid сервера или список guid.
По умолчанию :obj:`None`, что соотвествует всем доступным серверам.
Examples:
>>> templates = Templates(BaseUtils.get_server_guid())
>>> templates.get_all()
[TrObject('Parking'), TrObject('FR'), TrObject('AT'), TrObject('AD+')]
"""
def __init__(self, server_guid=None):
super(Templates, self).__init__()
if server_guid is None:
server_guid = [srv.guid for srv in Servers().get_all()]
self.server_guid = server_guid
[документация] def get_all(self, names=None):
"""Возвращает список шаблонов
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
return self._get_objects_from_settings(
"/{server_guid}/templates",
"Template",
object_names=names,
server_guid=self.server_guid,
sub_condition=None,
)
[документация]class Persons(ObjectFromSetting):
"""Класс для работы с персонами и их папками.
See Also:
`Персоны - Руководство пользователя Trassir
<https://www.dssl.ru/files/trassir/manual/ru/setup-persons-folder.html>`_
Args:
server_guid (:obj:`str` | List[:obj:`str`], optional): Guid сервера или список guid.
По умолчанию :obj:`None`, что соотвествует всем доступным серверам.
Examples:
>>> persons = Persons()
>>> persons.get_folders()
[TrObject('Мошенники'), TrObject('DSSL'), TrObject('persons')]
>>> persons.get_persons()
[
{
'name': 'Leonardo',
'guid': 'cJuJYAha',
'gender': 0,
'birth_date': '1980-01-01',
'comment': 'Comment',
'contact_info': 'Contact info',
'folder_guid': 'n68LOBhG',
'image': <image, str>,
'image_guid': 'gBHZ2vpz',
'effective_rights': 0,
},
...
]
>>> persons.get_person_by_guid("cJuJYAha")
{
'name': 'Leonardo',
'guid': 'cJuJYAha',
'gender': 0,
'birth_date': '1980-01-01',
'comment': 'Comment',
'contact_info': 'Contact info',
'folder_guid': 'n68LOBhG',
'image': <image, str>,
'image_guid': 'gBHZ2vpz',
'effective_rights': 0,
}
"""
_PERSONS_UPDATE_TIMEOUT = 10 * 60 # Time in sec between update _persons dict
def __init__(self, server_guid=None):
super(Persons, self).__init__()
if server_guid is None:
server_guid = [srv.guid for srv in Servers().get_all()]
if isinstance(server_guid, str):
server_guid = [server_guid]
self.server_guid = server_guid
self._persons = None
def _update_persons_dict(self, timeout=10):
"""Updating self._persons dict"""
persons = self.get_persons(timeout=timeout)
by_guid, by_name = {}, {}
for person in persons:
by_guid[person["guid"]] = person
by_name[person["name"]] = person
self._persons = {
"update_ts": int(time.time()),
"by_guid": by_guid,
"by_name": by_name,
}
def _check_loaded_persons(self, timeout=10):
"""This method check if self._persons dict is need to be updated"""
ts_now = int(time.time())
if (
self._persons is None
or (ts_now - self._persons["update_ts"]) > self._PERSONS_UPDATE_TIMEOUT
):
self._update_persons_dict(timeout=timeout)
[документация] def get_folders(self, names=None):
"""Возвращает список папок персон
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
try:
folders = self._get_objects_from_settings(
"/{server_guid}/persons",
"PersonsSubFolder",
object_names=names,
server_guid=self.server_guid,
)
if names is None or "persons" in names:
for guid in self.server_guid:
try:
settings = self._host_api.settings("/{}/persons".format(guid))
except KeyError:
continue
folders.append(TrObject(settings))
except self.ObjectsNotFoundError as err:
folders = []
names = self._objects_str_to_list(names)
if names is None or "persons" in names:
for guid in self.server_guid:
try:
settings = self._host_api.settings("/{}/persons".format(guid))
except KeyError:
continue
folders.append(TrObject(settings))
if not folders:
raise err
return folders
[документация] def get_persons(self, folder_names=None, timeout=10):
"""Возвращает список персон
Note:
Данный метод работает только с локальной БД.
Args:
folder_names (:obj:`str` | List[:obj:`str`], optional): :obj:`str` -
названия папок персон, разделенные запятыми или :obj:`list` -
список папок персон. По умолчанию :obj:`None`
timeout (:obj:`int`, optional): Макс. время запроса к БД.
По умолчанию ``timeout=10``
Returns:
List[:obj:`dict`]: Список персон - если персоны найдены
Raises:
EnvironmentError: Если произошла ошибка при запросе в БД.
TrassirError: Если в данной сборке Trassir нет метода :obj:`host.service_persons_get`
"""
tmp_server_guid = self.server_guid[:]
self.server_guid = [self.this_server_guid]
persons_folders = self.get_folders(names=folder_names)
self.server_guid = tmp_server_guid[:]
try:
persons = self._host_api.service_persons_get(
[folder.guid for folder in persons_folders], True, 0, 0, timeout
)
except AttributeError:
raise TrassirError(
"Данный функционал не поддерживается вашей сборкой Trassir. "
"Попробуйте обновить ПО."
)
if isinstance(persons, str):
raise EnvironmentError(persons)
return persons
[документация] def get_person_by_guid(self, person_guid, timeout=10):
"""Возвращает информацию о персоне по его guid
Note:
Для уменьшения кол-ва запросов к БД - метод создает локальную
копию всех персон при первом запросе и обновляет ее вместе
с последующими запросами не чаще чем 1 раз в 10 минут.
Args:
person_guid (:obj:`str`): Guid персоны
timeout (:obj:`int`, optional): Макс. время запроса к БД.
По умолчанию ``timeout=10``
Returns:
:obj:`dict`: Даные о персоне или :obj:`None` если персона не найдена
"""
self._check_loaded_persons(timeout=timeout)
return self._persons["by_guid"].get(person_guid)
[документация] def get_person_by_name(self, person_name, timeout=10):
"""Возвращает информацию о персоне по его имени
Note:
Для уменьшения кол-ва запросов к БД - метод создает локальную
копию всех персон при первом запросе и обновляет ее вместе
с последующими запросами не чаще чем 1 раз в 10 минут.
Args:
person_name (:obj:`str`): Имя персоны
timeout (:obj:`int`, optional): Макс. время запроса к БД.
По умолчанию ``timeout=10``
Returns:
:obj:`dict`: Даные о персоне или :obj:`None` если персона не найдена
"""
self._check_loaded_persons(timeout=timeout)
return self._persons["by_name"].get(person_name)
class ObjectFromList(BasicObject):
""""""
def __init__(self):
super(ObjectFromList, self).__init__()
def _load_objects_from_list(self, obj_type, sub_condition=None):
"""Load objects from Trassir objects_list method
Args:
obj_type (str | list): Loading object type; example: "EmailAccount"
sub_condition (function, optional): Function with SE_Settings as argument to filter objects
Returns:
list: TrObject objects list
Example [TrObject(...), TrObject(...), ...]
"""
if sub_condition is None:
sub_condition = BaseUtils.do_nothing
objects = []
for obj in self._host_api.objects_list(obj_type):
if sub_condition(obj):
objects.append(TrObject(obj))
return objects
def _get_objects_from_list(
self,
object_type,
object_names=None,
server_guid=None,
ban_empty_result=False,
sub_condition=None,
):
"""Check if objects exists and returns list from _load_objects_from_settings
Note:
If object_names is not None - checking if all object names are unique
Args:
object_type (str|list): Loading object type; example: "EmailAccount"
object_names (str|list, optional): Comma spaced string or list of object names; default: None
server_guid (str|list, optional): Server guids; default: None
ban_empty_result (bool, optional): If True - raise ObjectsNotFoundError if no one object found
sub_condition (func, optional) : Function with SE_Settings as argument to filter objects
Returns:
list: Trassir list from _load_objects_from_settings
Raises:
ObjectsNotFoundError: If can't find channel
"""
if object_names == "":
raise ParameterError("'{}' не выбраны".format(object_type))
if server_guid is None:
server_guid = self.this_server_guid
else:
if isinstance(server_guid, str):
server_guid = [server_guid]
objects = self._load_objects_from_list(object_type, sub_condition)
objects = [obj for obj in objects if obj.server in server_guid]
if ban_empty_result and not objects:
raise self.ObjectsNotFoundError(
"Не найдено ниодного объекта '{}'".format(object_type)
)
if object_names is None:
return objects
else:
return self._filter_objects_by_name(objects, object_names)
def _zone_type(self, zone_obj):
"""Возвращает тип зоны для объекта
Args:
zone_obj (:obj:`SE_Object`): Объект trassir ``object('{guid}')``
Returns:
:obj:`str`: Тип объекта
:obj:`None`: Если тип зоны неизвестен
"""
if not isinstance(zone_obj, self._host_api.ScriptHost.SE_Object):
raise TypeError(
"Expected SE_Object, got '{}'".format(type(zone_obj).__name__)
)
try:
guid = zone_obj.guid
channel, server = zone_obj.associated_channel.split("_")
except (AttributeError, ValueError):
return None
try:
zones_dir = self._host_api.settings(
"/{}/channels/{}/people_zones".format(server, channel)
)
for i in xrange(16):
if zones_dir["zone%02d_guid" % i] == guid:
func_type = zones_dir["zone%02d_func_type" % i]
if isinstance(func_type, int):
return (
["Queue", "Workplace"][func_type]
if func_type in range(2)
else "Queue"
)
else:
return func_type
except KeyError:
"not a queue or workplace"
try:
zones_dir = self._host_api.settings(
"/{}/channels/{}/workplace_zones".format(server, channel)
)
for i in xrange(16):
if zones_dir["zone%02d_guid" % i] == guid:
return "Workplace"
except KeyError:
"not a workplace"
try:
zones_dir = self._host_api.settings(
"/%s/channels/%s/deep_people" % (server, channel)
)
for i in xrange(16):
if zones_dir["zone%02d_guid" % i] == guid:
if zones_dir["zone%02d_type" % i] in ["border", "border_swapped"]:
return "Border"
else:
return "Queue"
except KeyError:
"not a deep people queue"
[документация]class GPIO(ObjectFromList):
"""Класс для работы с тревожными входами/выходами
Args:
server_guid (:obj:`str` | List[:obj:`str`], optional): Guid сервера или список guid.
По умолчанию :obj:`None`, что соотвествует всем доступным серверам.
Examples:
>>> gpio = GPIO()
>>> gpio_door = gpio.get_inputs("Door")[0]
>>> gpio_door.obj.state("gpio_input_level")
'Input Low (Normal High)'
>>> gpio_light = gpio.get_outputs("Light")[0]
>>> gpio_light.obj.set_output_high()
"""
def __init__(self, server_guid=None):
super(GPIO, self).__init__()
if server_guid is None:
server_guid = [srv.guid for srv in Servers().get_all()]
self.server_guid = server_guid
[документация] def get_outputs(self, names=None):
"""Возвращает список тревожных выходов
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
return self._get_objects_from_list(
"GPIO Output",
object_names=names,
server_guid=self.server_guid,
sub_condition=None,
)
[документация]class Zones(ObjectFromList):
"""Класс для работы с зонами
Args:
server_guid (:obj:`str` | List[:obj:`str`], optional): Guid сервера или список guid.
По умолчанию :obj:`None`, что соотвествует всем доступным серверам.
Examples:
>>> zones = Zones()
>>> zones.get_queues("Касса 1")[0].obj.state("zone_queue")
'5+'
"""
def __init__(self, server_guid=None):
super(Zones, self).__init__()
if server_guid is None:
server_guid = [srv.guid for srv in Servers().get_all()]
self.server_guid = server_guid
[документация] def get_people(self, names=None):
"""Возвращает список PeopleZones
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
return self._get_objects_from_list(
"PeopleZone",
object_names=names,
server_guid=self.server_guid,
sub_condition=None,
)
[документация] def get_simt(self, names=None):
"""Возвращает список зон SIMT
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
return self._get_objects_from_list(
"SIMT Zone",
object_names=names,
server_guid=self.server_guid,
sub_condition=None,
)
[документация] def get_workplaces(self, names=None):
"""Возвращает список рабочих зон
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
people_zones = self.get_people(names=names)
return [
zone
for zone in people_zones
if self._zone_type(zone.obj) in ["Workplace", "Рабочее место"]
]
[документация] def get_queues(self, names=None):
"""Возвращает список зон очередей
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
people_zones = self.get_people(names=names)
return [
zone
for zone in people_zones
if self._zone_type(zone.obj) in ["", "Queue", "Очередь"]
]
[документация] def get_shelves(self, names=None):
"""Возвращает список зон полок
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
return self._get_objects_from_list(
"Shelf",
object_names=names,
server_guid=self.server_guid,
sub_condition=None,
)
[документация]class Borders(ObjectFromList):
"""Класс для работы с линиями пересечения
Args:
server_guid (:obj:`str` | List[:obj:`str`], optional): Guid сервера или список guid.
По умолчанию :obj:`None`, что соотвествует всем доступным серверам.
Examples:
>>> borders = Borders()
>>> borders.get_simt()
[TrObject('DBOP')]
>>> borders.get_all()
[TrObject('Вход в офис'), TrObject('DBOP')]
"""
def __init__(self, server_guid=None):
super(Borders, self).__init__()
if server_guid is None:
server_guid = [srv.guid for srv in Servers().get_all()]
self.server_guid = server_guid
[документация] def get_head(self, names=None):
"""Возвращает список HeadBorders
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
return self._get_objects_from_list(
"HeadBorder",
object_names=names,
server_guid=self.server_guid,
sub_condition=None,
)
[документация] def get_people(self, names=None):
"""Возвращает список PeopleBorders
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
return self._get_objects_from_list(
"PeopleBorder",
object_names=names,
server_guid=self.server_guid,
sub_condition=None,
)
[документация] def get_simt(self, names=None):
"""Возвращает список SIMT Borders
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
return self._get_objects_from_list(
"SIMT Border",
object_names=names,
server_guid=self.server_guid,
sub_condition=None,
)
[документация] def get_deep_people(self, names=None):
"""Возвращает список DeepPeopleBorders
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
people_zones = self._get_objects_from_list(
"PeopleZone",
object_names=names,
server_guid=self.server_guid,
sub_condition=None,
)
return [zone for zone in people_zones if self._zone_type(zone.obj) == "Border"]
[документация] def get_all(self, names=None):
"""Возвращает список всех линий пересечения
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
all_borders = (
self.get_head()
+ self.get_people()
+ self.get_simt()
+ self.get_deep_people()
)
if names is None:
return all_borders
else:
return self._filter_objects_by_name(all_borders, names)
class Sigur(ObjectFromList):
"""Класс для работы со СКУД Sigur
Args:
server_guid (:obj:`str` | List[:obj:`str`], optional): Guid сервера или список guid.
По умолчанию :obj:`None`, что соотвествует всем доступным серверам.
"""
def __init__(self, server_guid=None):
super(Sigur, self).__init__()
if server_guid is None:
server_guid = [srv.guid for srv in Servers().get_all()]
self.server_guid = server_guid
def get_access_points(self, names=None):
"""Возвращает список точек доступа
Args:
names (:obj:`str` | :obj:`list`, optional): :obj:`str` - имена,
разделенные запятыми или :obj:`list` - список имен.
По умолчанию :obj:`None`
Returns:
List[:class:`TrObject`]: Список объектов
"""
return self._get_objects_from_list(
"Access Point",
object_names=names,
server_guid=self.server_guid,
sub_condition=None,
)
class TrassirError(ScriptError):
"""Exception if bad trassir version"""
pass
[документация]class PokaYoke(py_object):
"""Класс для защиты от дурака
Позволяет блокировать запуск скрипта на ПО, где это
не предусмотрено (например, на клиенте или TOS).
А также производить некоторые другие проверки.
"""
_EMAIL_REGEXP = re.compile(
r"[^@]+@[^@]+\.[^@]+"
) # Default regex to check emails list
_PHONE_REGEXP = re.compile(r"[^\d,;]") # Default regex to check phone list
_host_api = host
def __init__(self):
pass
[документация] @staticmethod
def ban_tos():
"""Блокирует запуск скрипта на `Trassir OS`
Raises:
OSError: Если скрипт запускается на `Trassir OS`
Examples:
>>> PokaYoke.ban_tos()
OSError: Скрипт недоступен для TrassirOS
"""
if os.name != "nt":
raise OSError("Скрипт недоступен для TrassirOS")
[документация] @staticmethod
def ban_win():
"""Блокирует запуск скрипта на `Windows OS`
Raises:
OSError: Если скрипт запускается на `Windows OS`
Examples:
>>> PokaYoke.ban_win()
OSError: Скрипт недоступен для WindowsOS
"""
if os.name == "nt":
raise OSError("Скрипт недоступен для WindowsOS")
[документация] @staticmethod
def ban_client():
"""Блокирует запуск скрипта на `Trassir Client`
Raises:
TrassirError: Если скрипт запускается на `Trassir Client`
Examples:
>>> PokaYoke.ban_client()
TrassirError: Скрипт недоступен для клиентской версии Trassir
"""
if BaseUtils.get_server_guid() == "client":
raise TrassirError("Скрипт недоступен для клиентской версии Trassir")
[документация] @classmethod
def ban_daemon(cls):
"""Блокирует запуск скрипта на сервре Trassir, который запущен как служба
Raises:
TrassirError: Если скрипт запускается на сервре Trassir,
который запущен как служба
Examples:
>>> PokaYoke.ban_daemon()
TrassirError: Скрипт недоступен для Trassir запущенным как служба
"""
if cls._host_api.settings("system_wide_options")["daemon"]:
raise TrassirError("Скрипт недоступен для Trassir запущенным как служба")
[документация] @staticmethod
def check_email_account(account_name):
"""Проверяет существование E-Mail аккаунта
Args:
account_name (:obj:`str`): Имя E-Mail аккаунта
Returns:
List[:class:`TrObject`]: Список объектов
Raises:
ParameterError: Если аккаунт не выбран
ObjectsNotFoundError: Если аккаунт не найден
Examples:
>>> PokaYoke.check_email_account("")
ParameterError: 'EmailAccount' не выбраны
>>> PokaYoke.check_email_account("YourAccount")
ObjectsNotFoundError: Не найдены объекты EmailAccount: YourAccount
>>> PokaYoke.check_email_account("MyAccount")
[TrObject('MyAccount')]
"""
e_accounts = EmailAccounts(BaseUtils.get_server_guid())
return e_accounts.get_all(account_name)
[документация] @classmethod
def parse_emails(cls, mailing_list, regex=None):
"""Парсит email дреса из строки
Каждый email проверяется с помощью regex ``r"[^@]+@[^@]+\.[^@]+"``.
Args:
mailing_list (:obj:`str`): Список email адресов, разделенный запятыми
regex (:obj:`SRE_Pattern`, optional): Новый regex шаблон для проверки.
По умолчанию :obj:`None`
Returns:
List[:obj:`str`]: Список адресов
Raises:
ParameterError: Если найден невалидный email
Examples:
>>> PokaYoke.parse_emails("a.trubilil!dssl.ru,support@dssl.ru")
ParameterError: Email 'a.trubilil!dssl.ru' is not valid!
>>>
>>> PokaYoke.parse_emails("a.trubilil@dssl.ru,support@dssl.ru")
['a.trubilil@dssl.ru', 'support@dssl.ru']
"""
mailing_list = mailing_list.replace(" ", "")
if not mailing_list:
raise ParameterError("No emails to send!")
if regex is None:
regex = cls._EMAIL_REGEXP
else:
if not isinstance(regex, cls._EMAIL_REGEXP.__class__):
raise TypeError(
"Expected re.compile, got '{}'".format(type(regex).__name__)
)
if isinstance(mailing_list, str):
mailing_list = mailing_list.split(",")
mailing_list = [mail.strip() for mail in mailing_list]
for mail in mailing_list:
if not regex.match(mail):
raise ParameterError("Email '{}' is not valid!".format(mail))
return mailing_list
[документация] @classmethod
def check_phones(cls, phones, regex=None):
"""Проверяет строку на валидность телефонных номеров
Строка проверяется с помощью regex ``r"[^\d,;]"``.
Args:
phones (:obj:`str`): Список телефонов, разделенный запятыми или точкой с запятой
regex (:obj:`SRE_Pattern`, optional): Новый regex шаблон для проверки.
По умолчанию :obj:`None`
Returns:
:obj:`str`: Список номеров телефона
Raises:
ParameterError: Если найден невалидный номер телефона
Examples:
>>> PokaYoke.check_phones("79999999999,78888888888A")
ParameterError: Bad chars in phone list: `A`
>>>
>>> PokaYoke.check_phones("a.trubilil@dssl.ru,support@dssl.ru")
'79999999999,78888888888'
"""
phones = phones.replace(" ", "")
if not phones:
raise ParameterError("No phones!")
if regex is None:
regex = cls._PHONE_REGEXP
else:
if not isinstance(regex, cls._PHONE_REGEXP.__class__):
raise TypeError(
"Expected re.compile, got '{}'".format(type(regex).__name__)
)
bad_chars = regex.findall(phones)
if bad_chars:
raise ParameterError(
"Bad chars in phone list: `{}`".format(", ".join(bad_chars))
)
return phones
[документация] @classmethod
def fire_recognizer_events(cls, enable=True, server_guid=None):
"""Проверяет "Режим для СКУД" настроек распознавания лиц.
По умолчанию проверяет активирован ли "Режим для СКУД"
на сервере, где запущен скрипт. По желанию можно указать
удаленный сервер дял проверки.
Args:
enable (:obj:`bool`, optional): Состояние параметра. По умолчанию :obj:`True`.
server_guid (:obj:`str`, optional): Guid сервера. По умолчанию :obj:`None`.
Raises:
RuntimeError: Если указанный сервер недоступен.
EnvironmentError: Если моудль распознавания или режим для СКУД не доступны.
TrassirError: Если текущее состояние не соотвествует необходимомому.
Examples:
>>> PokaYoke.fire_recognizer_events()
TrassirError: Пожалуйста, активируйте 'Режим для СКУД' в настройках распознавания лиц
"""
if server_guid is None:
server_guid = BaseUtils.get_server_guid()
try:
srv_sett = cls._host_api.settings("/%s" % server_guid)
except KeyError:
raise RuntimeError("Сервер '%s' не доступен" % server_guid)
fr_sett = srv_sett.cd("face_recognizer")
if fr_sett is None:
raise EnvironmentError(
"Модуль распознавания лиц не доступен на '%s'"
% (srv_sett.name or srv_sett.guid)
)
try:
if fr_sett["fire_recognizer_events"] != enable:
raise TrassirError(
"Пожалуйста, {} 'Режим для СКУД' в настройках распознавания лиц".format(
"активируйте" if enable else "отключите"
)
)
except KeyError:
raise EnvironmentError(
"'Режим для СКУД' не доступен. Пожалуйста, обновите сервер trassir."
)
[документация]class SoundPlayer(py_object):
"""Класс для проигрывания выбранной мелодии.
Можно указать один из стандартных зуков или
указать полный путь до своего файла.
Note:
Список стандартных файлов
.. raw:: html
<audio controls="controls" style="height: 20px; margin-bottom: -5px;">
<source src="https://github.com/aatrubilin/trassir_script_framework/raw/master/docs/source/sounds/SNES-startup.wav" type="audio/wav">
Your browser does not support the <code>audio</code> element.
</audio>
<code class="xref py py-obj docutils literal notranslate">
<span class="pre">"SNES-startup.wav"</span>
</code>
<br>
<audio controls="controls" style="height: 20px; margin-bottom: -5px;">
<source src="https://github.com/aatrubilin/trassir_script_framework/raw/master/docs/source/sounds/alarm.wav" type="audio/wav">
Your browser does not support the <code>audio</code> element.
</audio>
<code class="xref py py-obj docutils literal notranslate">
<span class="pre">"alarm.wav"</span>
</code>
<br>
<audio controls="controls" style="height: 20px; margin-bottom: -5px;">
<source src="https://github.com/aatrubilin/trassir_script_framework/raw/master/docs/source/sounds/bell.wav" type="audio/wav">
Your browser does not support the <code>audio</code> element.
</audio>
<code class="xref py py-obj docutils literal notranslate">
<span class="pre">"bell.wav"</span>
</code>
<br>
<audio controls="controls" style="height: 20px; margin-bottom: -5px;">
<source src="https://github.com/aatrubilin/trassir_script_framework/raw/master/docs/source/sounds/boxing-bell-1.wav" type="audio/wav">
Your browser does not support the <code>audio</code> element.
</audio>
<code class="xref py py-obj docutils literal notranslate">
<span class="pre">"boxing-bell-1.wav"</span>
</code>
<br>
<audio controls="controls" style="height: 20px; margin-bottom: -5px;">
<source src="https://github.com/aatrubilin/trassir_script_framework/raw/master/docs/source/sounds/boxing-bell-3.wav" type="audio/wav">
Your browser does not support the <code>audio</code> element.
</audio>
<code class="xref py py-obj docutils literal notranslate">
<span class="pre">"boxing-bell-3.wav"</span>
</code>
<br>
<audio controls="controls" style="height: 20px; margin-bottom: -5px;">
<source src="https://github.com/aatrubilin/trassir_script_framework/raw/master/docs/source/sounds/cardlock-open.wav" type="audio/wav">
Your browser does not support the <code>audio</code> element.
</audio>
<code class="xref py py-obj docutils literal notranslate">
<span class="pre">"cardlock-open.wav"</span>
</code>
<br>
<audio controls="controls" style="height: 20px; margin-bottom: -5px;">
<source src="https://github.com/aatrubilin/trassir_script_framework/raw/master/docs/source/sounds/chime.wav" type="audio/wav">
Your browser does not support the <code>audio</code> element.
</audio>
<code class="xref py py-obj docutils literal notranslate">
<span class="pre">"chime.wav"</span>
</code>
<br>
<audio controls="controls" style="height: 20px; margin-bottom: -5px;">
<source src="https://github.com/aatrubilin/trassir_script_framework/raw/master/docs/source/sounds/chip001.wav" type="audio/wav">
Your browser does not support the <code>audio</code> element.
</audio>
<code class="xref py py-obj docutils literal notranslate">
<span class="pre">"chip001.wav"</span>
</code>
<br>
<audio controls="controls" style="height: 20px; margin-bottom: -5px;">
<source src="https://github.com/aatrubilin/trassir_script_framework/raw/master/docs/source/sounds/chip019.wav" type="audio/wav">
Your browser does not support the <code>audio</code> element.
</audio>
<code class="xref py py-obj docutils literal notranslate">
<span class="pre">"chip019.wav"</span>
</code>
<br>
<audio controls="controls" style="height: 20px; margin-bottom: -5px;">
<source src="https://github.com/aatrubilin/trassir_script_framework/raw/master/docs/source/sounds/chip069.wav" type="audio/wav">
Your browser does not support the <code>audio</code> element.
</audio>
<code class="xref py py-obj docutils literal notranslate">
<span class="pre">"chip069.wav"</span>
</code>
<br>
<audio controls="controls" style="height: 20px; margin-bottom: -5px;">
<source src="https://github.com/aatrubilin/trassir_script_framework/raw/master/docs/source/sounds/cordless-phone-ring.wav" type="audio/wav">
Your browser does not support the <code>audio</code> element.
</audio>
<code class="xref py py-obj docutils literal notranslate">
<span class="pre">"cordless-phone-ring.wav"</span>
</code>
<br>
<audio controls="controls" style="height: 20px; margin-bottom: -5px;">
<source src="https://github.com/aatrubilin/trassir_script_framework/raw/master/docs/source/sounds/countdown.wav" type="audio/wav">
Your browser does not support the <code>audio</code> element.
</audio>
<code class="xref py py-obj docutils literal notranslate">
<span class="pre">"countdown.wav"</span>
</code>
<br>
<audio controls="controls" style="height: 20px; margin-bottom: -5px;">
<source src="https://github.com/aatrubilin/trassir_script_framework/raw/master/docs/source/sounds/dialtone.wav" type="audio/wav">
Your browser does not support the <code>audio</code> element.
</audio>
<code class="xref py py-obj docutils literal notranslate">
<span class="pre">"dialtone.wav"</span>
</code>
<br>
<audio controls="controls" style="height: 20px; margin-bottom: -5px;">
<source src="https://github.com/aatrubilin/trassir_script_framework/raw/master/docs/source/sounds/ding.wav" type="audio/wav">
Your browser does not support the <code>audio</code> element.
</audio>
<code class="xref py py-obj docutils literal notranslate">
<span class="pre">"ding.wav"</span>
</code>
<br>
<audio controls="controls" style="height: 20px; margin-bottom: -5px;">
<source src="https://github.com/aatrubilin/trassir_script_framework/raw/master/docs/source/sounds/horn-beep.wav" type="audio/wav">
Your browser does not support the <code>audio</code> element.
</audio>
<code class="xref py py-obj docutils literal notranslate">
<span class="pre">"horn-beep.wav"</span>
</code>
<br>
<audio controls="controls" style="height: 20px; margin-bottom: -5px;">
<source src="https://github.com/aatrubilin/trassir_script_framework/raw/master/docs/source/sounds/phone-beep.wav" type="audio/wav">
Your browser does not support the <code>audio</code> element.
</audio>
<code class="xref py py-obj docutils literal notranslate">
<span class="pre">"phone-beep.wav"</span>
</code>
<br>
<audio controls="controls" style="height: 20px; margin-bottom: -5px;">
<source src="https://github.com/aatrubilin/trassir_script_framework/raw/master/docs/source/sounds/police2.wav" type="audio/wav">
Your browser does not support the <code>audio</code> element.
</audio>
<code class="xref py py-obj docutils literal notranslate">
<span class="pre">"police2.wav"</span>
</code>
<br>
<audio controls="controls" style="height: 20px; margin-bottom: -5px;">
<source src="https://github.com/aatrubilin/trassir_script_framework/raw/master/docs/source/sounds/ship-on-fog.wav" type="audio/wav">
Your browser does not support the <code>audio</code> element.
</audio>
<code class="xref py py-obj docutils literal notranslate">
<span class="pre">"ship-on-fog.wav"</span>
</code>
<br>
<audio controls="controls" style="height: 20px; margin-bottom: -5px;">
<source src="https://github.com/aatrubilin/trassir_script_framework/raw/master/docs/source/sounds/ships-bell.wav" type="audio/wav">
Your browser does not support the <code>audio</code> element.
</audio>
<code class="xref py py-obj docutils literal notranslate">
<span class="pre">"ships-bell.wav"</span>
</code>
<br>
<audio controls="controls" style="height: 20px; margin-bottom: -5px;">
<source src="https://github.com/aatrubilin/trassir_script_framework/raw/master/docs/source/sounds/spin-up.wav" type="audio/wav">
Your browser does not support the <code>audio</code> element.
</audio>
<code class="xref py py-obj docutils literal notranslate">
<span class="pre">"spin-up.wav"</span>
</code>
<br>
<audio controls="controls" style="height: 20px; margin-bottom: -5px;">
<source src="https://github.com/aatrubilin/trassir_script_framework/raw/master/docs/source/sounds/tada1.wav" type="audio/wav">
Your browser does not support the <code>audio</code> element.
</audio>
<code class="xref py py-obj docutils literal notranslate">
<span class="pre">"tada1.wav"</span>
</code>
<br>
<audio controls="controls" style="height: 20px; margin-bottom: -5px;">
<source src="https://github.com/aatrubilin/trassir_script_framework/raw/master/docs/source/sounds/tape-slow9.wav" type="audio/wav">
Your browser does not support the <code>audio</code> element.
</audio>
<code class="xref py py-obj docutils literal notranslate">
<span class="pre">"tape-slow9.wav"</span>
</code>
<br>
Args:
sound_file (:obj:`str`): Имя файла с расширением
"""
_DEFAULT_SOUNDS = {
"SNES-startup.wav",
"alarm.wav",
"bell.wav",
"boxing-bell-1.wav",
"boxing-bell-3.wav",
"cardlock-open.wav",
"chime.wav",
"chip001.wav",
"chip019.wav",
"chip069.wav",
"cordless-phone-ring.wav",
"countdown.wav",
"dialtone.wav",
"ding.wav",
"horn-beep.wav",
"phone-beep.wav",
"police2.wav",
"ship-on-fog.wav",
"ships-bell.wav",
"spin-up.wav",
"tada1.wav",
"tape-slow9.wav",
}
def __init__(self, sound_file, host_api=host):
self._host_api = host_api
self._play = self._get_player(sound_file)
def _check_file(self, sound_file):
_, ext = os.path.splitext(sound_file)
if ext.lower() != ".wav":
raise RuntimeError("Expected *.wav file, got {!r}".format(ext))
if sound_file in self._DEFAULT_SOUNDS:
if os.name == "nt":
base_path = "sounds"
else:
base_path = "/opt/trassir/tech1/sounds"
sound_file = os.path.join(base_path, sound_file)
if not os.path.isfile(sound_file):
raise IOError("File {} not found".format(sound_file))
return sound_file
def _get_player(self, sound_file):
sound_file = self._check_file(sound_file)
if os.name == "nt":
def player():
winsound.PlaySound(
sound_file,
winsound.SND_FILENAME | winsound.SND_ASYNC | winsound.SND_NOWAIT,
)
else:
def player():
os.system('aplay -D "sysdefault:CARD=PCH" %s &' % sound_file)
return player
[документация] def play(self):
"""Проигрывает выбранный файл
Examples:
>>> player = SoundPlayer("alarm.wav")
>>> player.play()
"""
self._play()
class SenderError(Exception):
"""Base Sender Exception"""
pass
class Sender(py_object):
_HTML_IMG_TEMPLATE = """<img src="data:image/png;base64,{img}" {attr}>"""
def __init__(self, host_api=host):
self._host_api = host_api
@staticmethod
def _get_base64(image_path):
"""Returns base64 image
Args:
image_path (str): Image full path
"""
image_path = BaseUtils.win_encode_path(image_path)
if os.path.isfile(image_path):
with open(image_path, "rb") as image_file:
return base64.b64encode(image_file.read())
@staticmethod
def _get_html_img(image_base64, **kwargs):
"""Returns html img
Args:
image_base64 (str): Base64 image
"""
return BaseUtils.base64_to_html_img(image_base64, **kwargs)
def text(self, text, **kwargs):
"""Send text
Args:
text (str): Text message
"""
pass
def image(self, image_path, text="", **kwargs):
"""Send image and optional text
Args:
image_path (str | List[str]): Image path or paths
text (str, optional): Text message; default: ""
"""
pass
def files(self, file_paths, text="", **kwargs):
"""Send file or list of files
Args:
file_paths (str | List[str]): File path or list of paths
text (str, optional): Text message; default: ""
"""
pass
[документация]class EmailSender(Sender):
"""Класс для отправки уведомлений, изображений и файлов на почту
Note:
По умолчанию тема сообщений соответствует шаблону
``{server_name} -> {script_name}``
Tip:
При отправке изображения с текстом предпочтительней использовать метод
:meth:`EmailSender.image` с необязательным аргументом :obj:`text` чем
:meth:`EmailSender.text` с необазательным аргументом :obj:`attachments`
Args:
account (:obj:`str`): E-Mail аккаунт trassir. Проверяется
методом :meth:`PokaYoke.check_email_account`
mailing_list (:obj:`str`): Список email адресов для отправки писем
разделенный запятыми. Проверяется и парсится в список методом
:meth:`PokaYoke.parse_emails`
subject (:obj:`str`, optional): Общая тема для сообщений.
По умолчанию :obj:`None`
max_size (:obj:`int`, optional): Максимальный размер вложения, байт.
По умолчанию 25 * 1024 * 1024
Examples:
>>> sender = EmailSender("MyAccount", "my_mail@google.com")
>>> sender.text("Hello World!")
.. image:: images/email_sender.text.png
>>> sender.image(r"manual/en/cloud-devices-16.png")
.. image:: images/email_sender.image.png
>>> sender.files([r"manual/en/cloud.html", r"manual/en/cloud.png"])
.. image:: images/email_sender.files.png
"""
def __init__(self, account, mailing_list, subject=None, max_size=None):
super(EmailSender, self).__init__()
PokaYoke.check_email_account(account)
self.max_size = max_size or 25 * 1024 * 1024
self._account = account
self._mailing_list = PokaYoke.parse_emails(mailing_list)
self._subject_default = subject or self._generate_subject()
logger.info(
"EmailSender({}, {}, subject={}, max_size={})".format(
repr(account),
repr(mailing_list),
repr(self._subject_default),
repr(self.max_size),
)
)
def _generate_subject(self):
"""Returns `server name` -> `script name`"""
subject = "{server_name} -> {script_name}".format(
server_name=self._host_api.settings("").name or "Client",
script_name=self._host_api.stats().parent()["name"],
)
return subject
def _group_files_by_max_size(self, file_paths, max_size):
"""Split files to groups. Size of each group is less then max_size
Args:
file_paths (list): List of files
max_size (int): Max group size, bytes
"""
group = []
cur_size = 0
for idx, file_path in enumerate(file_paths):
file_size = os.stat(BaseUtils.win_encode_path(file_path)).st_size
if not cur_size or (cur_size + file_size) < max_size:
cur_size += file_size
group.append(file_path)
else:
break
else:
return [group]
return [group] + self._group_files_by_max_size(file_paths[idx:], max_size)
[документация] def text(self, text, subject=None, attachments=None, **kwargs):
"""Отправка текстового сообщения
Args:
text (:obj:`str`): Текст сообщения
subject (:obj:`str`, optional): Новая тема сообщения.
По умолчанию :obj:`None`
attachments (:obj:`list`, optional): Список вложений.
По умолчанию :obj:`None`
"""
if attachments is None:
attachments = []
self._host_api.send_mail_from_account(
self._account,
self._mailing_list,
subject or self._subject_default,
text,
attachments,
)
[документация] def image(self, image_path, text="", subject=None, **kwargs):
"""Отправка изображения
Args:
image_path (:obj:`str` | :obj:`List[str]`): Полный путь до изображения
или список путей.
text (:obj:`str`, optional): Текст сообщения.
По умолчанию :obj:`""`
subject (:obj:`str`, optional): Новая тема сообщения.
По умолчанию :obj:`None`
"""
if not isinstance(image_path, list):
image_path = [image_path]
self.files(image_path, text=text, subject=subject)
[документация] def files(self, file_paths, text="", subject=None, callback=None, **kwargs):
"""Отправка файлов
Note:
Если отправляется несколько файлов они могут быть разделены на
несколько сообщений, основываясь на максимальном размере вложений.
Args:
file_paths (:obj:`str` | :obj:`list`): Путь до файла или список
файлов для отправки
text (:obj:`str`, optional): Текст сообщения.
По умолчанию :obj:`""`
subject (:obj:`str`, optional): Новая тема сообщения.
По умолчанию :obj:`None`
callback (:obj:`function`, optional): Функция, которая вызывается после
отправки частей
"""
logger.debug(
"EmailSender.files({}, text={})".format(repr(file_paths), repr(text))
)
if isinstance(file_paths, str):
file_paths = [file_paths]
if callback is None:
callback = BaseUtils.do_nothing
files_to_send = []
for path in file_paths:
if BaseUtils.is_file_exists(path):
files_to_send.append(path)
else:
text += "\nFile not found: {}".format(path)
file_groups = self._group_files_by_max_size(files_to_send, self.max_size)
for grouped_files in file_groups:
logger.debug(
"EmailSender.files: grouped_files: {}".format(repr(grouped_files))
)
self.text(text, subject=subject, attachments=grouped_files)
callback(grouped_files)
[документация]class TelegramSender(Sender):
"""Работа с телеграм ботом `@trassirbot <https://t.me/trassirbot>`_
Warnings:
| Cкрипт должен быть запущен на **сервере** Trassir.
| На Клиенте скрипт вызовет ошибку ``ServerKeyError``
Args:
telegram_ids (:obj:`str`): Id пользователей, через запятую.
Examples:
>>> # Можно указать id для рассылки при инициализации
>>> # класса, для всех уведомлений
>>> sender = TelegramSender("123456789")
>>> sender.text("Hello World!")
.. image:: images/telegram_sender.text.png
>>> sender.image(r"manual/en/cloud-devices-16.png")
.. image:: images/telegram_sender.image.png
>>> sender.files([r"manual/en/cloud.html", r"manual/en/cloud.png"])
.. image:: images/telegram_sender.files.png
>>> # Или можно опередовать telegram id при вызове методов
>>> sender = TelegramSender()
>>> sender.text("Hello World!", tg_users=[123456789])
.. image:: images/telegram_sender.text.png
>>> sender.image(r"manual/en/cloud-devices-16.png", tg_users=[123456789])
.. image:: images/telegram_sender.image.png
>>> sender.files([r"manual/en/cloud.html", r"manual/en/cloud.png"], tg_users=[123456789])
.. image:: images/telegram_sender.files.png
"""
def __init__(self, telegram_ids=None):
super(TelegramSender, self).__init__()
self._host_api.exec_encoded(tbot_service)
self._tbot_api = TBotAPI()
if telegram_ids is not None:
self.telegram_ids = TBotAPI.prepare_users(telegram_ids)
else:
self.telegram_ids = None
[документация] def text(self, text, tg_users=None, **kwargs):
"""Отправка текстового сообщения
Args:
text (:obj:`str`): Текст сообщения.
tg_users (List[:obj:`int`], optional): Список id пользователей
telegram для отправки отдельных сообщений. По умолчанию :obj:`None`
"""
if tg_users is None:
tg_users = self.telegram_ids
self._tbot_api.send_message(tg_users, text)
[документация] def image(self, image_path, text="", tg_users=None, remove=False, **kwargs):
"""Отправка изображения
Args:
image_path (:obj:`str` | List[:obj:`str`]): Полный путь до изображения или
список путей
text (:obj:`str`, optional): Текст сообщения.
По умолчанию :obj:`""`
tg_users (List[:obj:`int`], optional): Список id пользователей
telegram для отправки отдельных сообщений. По умолчанию :obj:`None`
remove (bool, optional): Удалить файл после отправки или нет. По умолчанию :obj:`False`
"""
if tg_users is None:
tg_users = self.telegram_ids
if len(text) > 150:
self.text(text, tg_users=tg_users)
text = None
if isinstance(image_path, list):
self._tbot_api.send_image_album(
tg_users, image_path, captions=[text] or None, remove=remove
)
else:
if not os.path.isfile(image_path):
self.text("Image not found: {}".format(image_path))
return
self._tbot_api.send_image(tg_users, image_path, caption=text, remove=remove)
[документация] def files(self, file_paths, text="", tg_users=None, remove=False, **kwargs):
"""Отправка файлов
Args:
file_paths (:obj:`str` | :obj:`list`): Путь до файла или список
файлов для отправки
text (:obj:`str`, optional): Текст сообщения.
По умолчанию :obj:`""`
tg_users (List[:obj:`int`], optional): Список id пользователей
telegram для отправки отдельных сообщений. По умолчанию :obj:`None`
remove (bool, optional): Удалить файл после отправки или нет. По умолчанию :obj:`False`
"""
if tg_users is None:
tg_users = self.telegram_ids
if isinstance(file_paths, str):
file_paths = [file_paths]
if text and len(file_paths) == 1:
self.text(text, tg_users=tg_users)
text = ""
files_not_found_text = ""
for path in file_paths:
if os.path.isfile(BaseUtils.win_encode_path(path)):
self._tbot_api.send_document(
tg_users, path, caption=text, remove=remove
)
else:
files_not_found_text += "\nFile not found: {}".format(path)
if files_not_found_text:
self.text(files_not_found_text, tg_users=tg_users)
class SMSCSenderError(SenderError):
"""Raises with SMSCSender errors"""
pass
[документация]class SMSCSender(Sender):
"""Класс для отправки сообщений с помощью сервиса smsc.ru
See Also:
`https://smsc.ru/api/http/ <https://smsc.ru/api/http/>`_
Note:
| Номера проверяются методом
:meth:`PokaYoke.check_phones`
| Также при первом запуске скрипт проверяет данные авторизации
Warnings:
| По умолчанию сервис smsc.ru отправляет сообщения от своего имени *SMSC.RU.*
При этом отправка на номера Мегафон/Йота **недоступна** т.к. имя *SMSC.RU*
заблокировано оператором.
|
| Мы настоятельно **НЕ** рекомендуем использовать стандартное имя *SMSC.RU.*
|
| Для отправки смс от вашего буквенного имени необходимо его
создать в разделе - https://smsc.ru/senders/ и зарегистрировать для
операторов в колонке Действия по кнопке Изменить (после заключения договора
согласно инструкции - https://smsc.ru/contract/info/ ) а также приложить
гарантийное письмо на МТС в личный кабинет http://smsc.ru/documents/ и
отправить на почту inna@smsc.ru
Args:
login (:obj:`str`): SMSC Логин
password (:obj:`str`): SMSC Пароль
phones (:obj:`str`): Список номеров для отправки смс резделенный
запятыми или точкой с запятой
translit(:obj:`bool`, optional): Переводить сообщение в
транслит. По умолчанию :obj:`True`
Raises:
SMSCSenderError: При любых ошибках с отправкой сообщения
Examples:
>>> sender = SMSCSender("login", "password", "79999999999")
>>> sender.text("Hello World!")
.. image:: images/smsc_sender.text.png
"""
_BASE_URL = "https://smsc.ru/sys/send.php?{params}"
_ERROR_CODES = {
1: "URL Params error",
2: "Invalid login or password",
3: "Not enough money",
4: "Your IP is temporary blocked. More info: https://smsc.ru/faq/99",
5: "Bad date format",
6: "Message is denied (by text or sender name)",
7: "Bad phone format",
8: "Can't send message to this number",
9: "Too many requests",
}
def __init__(self, login, password, phones, translit=True):
super(SMSCSender, self).__init__()
if not login:
raise SMSCSenderError("Empty login")
if not password:
raise SMSCSenderError("Empty password")
self._params = {
"login": urllib.quote(login), # Login
"psw": urllib.quote(password), # Password or MD5 hash
"phones": urllib.quote(
PokaYoke.check_phones(phones)
), # Comma or semicolon spaced phone list
"fmt": 3, # Response format: 0 - string; 1 - integers; 2 - xml; 3 - json
"translit": 1 if translit else 0, # If 1 - transliting message
"charset": "utf-8", # Message charset: "windows-1251"|"utf-8"|"koi8-r"
"cost": 3, # Message cost in response: 0 - msg; 1 - cost; 2 - msg+cost, 3 - msg+cost+balance
}
self._check_account()
def _get_link(self, **kwargs):
"""Returns get link"""
params = self._params.copy()
params.update(kwargs)
url = self._BASE_URL.format(params=urllib.urlencode(params))
return url
def _request_callback(self, code, result, error):
"""Callback for async_get"""
if code != 200:
raise SMSCSenderError("RequestError [{}]: {}".format(code, error))
else:
try:
data = json.loads(result)
except ValueError:
data = {"error_code": 0, "error": "JSON loads error: {}".format(result)}
error_code = data.get("error_code")
if error_code is not None:
error = self._ERROR_CODES.get(error_code)
if not error:
error = data.get("error", "Unknown error")
raise SMSCSenderError(
"ResponseError [{}]: {}".format(error_code, error)
)
def _check_account(self):
"""Send test request to smsc server"""
url = self._get_link(cost=1, mes=urllib.quote("Hello world!"))
self._host_api.async_get(url, self._request_callback)
[документация] def text(self, text, **kwargs):
"""Отправка текстового сообщения
Args:
text (:obj:`str`): Текст сообщения.
"""
url = self._get_link(mes=text)
self._host_api.async_get(url, self._request_callback)
class FtpUploadTracker:
"""Upload progress class"""
size_written = 0.0
last_shown_percent = 0
def __init__(self, file_path, callback, host_api=host):
self._host_api = host_api
self.total_size = os.path.getsize(BaseUtils.win_encode_path(file_path))
self.file_path = file_path
self.callback = callback
# noinspection PyUnusedLocal
def handle(self, block):
"""Handler for storbinary
See Also:
https://docs.python.org/2/library/ftplib.html#ftplib.FTP.storbinary
"""
self.size_written += 1024.0
percent_complete = round((self.size_written / self.total_size) * 100)
if self.last_shown_percent != percent_complete:
self.last_shown_percent = percent_complete
self._host_api.timeout(
100, lambda: self.callback(self.file_path, int(percent_complete), "")
)
class FTPSenderError(SenderError):
"""Raises with FTPSender errors"""
pass
[документация]class FTPSender(Sender):
"""Класс для отправки файлов на ftp сервер
При инициализации проверят подключение к ftp серверу. Файлы отправляет
по очереди. Максимальный размер очереди можно изменить. Во время
выполнения передает текущий прогресс отправки файла в callback функцию.
Note:
Помимо прогресса в функцию callback может вернуться код ошибки.
- -1 Файл не существует.
- -2 Ошибка отправки на ftp, файл будет повторно отправлен.
- -3 Неизвестная ошибка.
Args:
host (:obj:`str`): Адрес ftp сервера.
port (:obj:`int`, optional): Порт ftp сервера. По умолчанию :obj:`port=21`
user (:obj:`str`, optional): Имя пользователя. По умолчанию :obj:`"anonymous"`
passwd (:obj:`str`, optional): Пароль пользователя. По умолчанию :obj:`passwd=""`
work_dir (:obj:`str`, optional): Директория на сервре для сохранения файлов.
По умолчанию :obj:`None`
callback (:obj:`function`, optional): Callable function. По умолчанию :obj:`None`
queue_maxlen (:obj:`int`, optional): Максимальная длина очереди на отправку.
По умолчанию :obj:`queue_maxlen=1000`
Examples:
>>> # noinspection PyUnresolvedReferences
>>> def callback(file_path, progress, error):
... # Пример callback функции, которая отображает
... # текущий прогресс в счетчике запуска скрипта
... # Args:
... # file_path (str): Путь до файла
... # progress (int): Текущий прогресс передачи файла, %
... # error (str | Exception): Ошибка при отправке файла, если есть
... host.stats()["run_count"] = progress
... if error:
... host.error(error)
...
... if progress == 100:
... host.timeout(3000, lambda: os.remove(BaseUtils.win_encode_path(file_path)))
>>>
>>> sender = FTPSender("172.20.0.10", 21, "trassir", "12345", work_dir="/test_dir/", callback=callback)
>>> sender.files(r"D:/Shots/export_video.avi")
"""
# noinspection SpellCheckingInspection,PyShadowingNames
def __init__(
self,
host,
port=21,
user="anonymous",
passwd="",
work_dir=None,
callback=None,
queue_maxlen=1000,
):
super(FTPSender, self).__init__()
self._host = host
self._port = port
self._user = user
self._passwd = passwd
self._work_dir = work_dir
self.queue = deque(maxlen=queue_maxlen)
self._ftp = None
if callback is None:
callback = BaseUtils.do_nothing
self.callback = callback
self._work_now = False
self._check_connection()
def _check_connection(self):
"""Check if it possible to connect"""
try:
ftp = ftplib.FTP()
ftp.connect(self._host, self._port, timeout=10)
ftp.login(self._user, self._passwd)
except ftplib.all_errors as err:
raise FTPSenderError(err)
if self._work_dir:
try:
ftp.cwd(self._work_dir)
except ftplib.error_perm:
ftp.mkd(self._work_dir)
ftp.cwd(self._work_dir)
ftp.quit()
def _get_connection(self):
"""Connecting to ftp
Returns:
ftplib.FTP: Ftp object
"""
try:
ftp = ftplib.FTP()
ftp.connect(self._host, self._port, timeout=10)
ftp.login(self._user, self._passwd)
if self._work_dir:
try:
ftp.cwd(self._work_dir)
except ftplib.error_perm:
ftp.mkd(self._work_dir)
ftp.cwd(self._work_dir)
ftp.encoding = "utf-8"
return ftp
except ftplib.all_errors:
return
def _close_connection(self):
"""Close ftp connection"""
try:
if self._ftp is not None:
self._ftp.close()
finally:
self._ftp = None
def _send_file(self, file_path, work_dir=None):
"""Storbinary file with self.ftp
Args:
file_path (str): Full file path
work_dir (str): Work dir on ftp
"""
if work_dir is not None:
if self._work_dir:
work_dir = os.path.normpath("{}/{}".format(self._work_dir, work_dir))
try:
self._ftp.cwd(work_dir)
except ftplib.error_perm:
self._ftp.mkd(work_dir)
self._ftp.cwd(work_dir)
file_name = os.path.basename(file_path)
upload_tracker = FtpUploadTracker(file_path, self.callback)
with open(BaseUtils.win_encode_path(file_path), "rb") as opened_file:
self._ftp.storbinary(
"STOR " + file_name, opened_file, 1024, upload_tracker.handle
)
@BaseUtils.run_as_thread
def _sender(self):
"""Send files in queue"""
if self.queue:
if self._ftp is None:
self._ftp = self._get_connection()
if self._ftp:
work_dir = None
file_path = self.queue.popleft()
if isinstance(file_path, tuple):
file_path, work_dir = file_path
if BaseUtils.is_file_exists(BaseUtils.win_encode_path(file_path)):
try:
self._send_file(file_path, work_dir)
except ftplib.all_errors as err:
self._host_api.timeout(
100, lambda: self.callback(file_path, -2, error=err)
)
self.queue.append(file_path)
self._close_connection()
except Exception as err:
self._host_api.timeout(
100, lambda: self.callback(file_path, -3, error=err)
)
else:
self._host_api.timeout(
100,
lambda: self.callback(file_path, -1, error="File not found"),
)
self._host_api.timeout(500, self._sender)
else:
self._work_now = False
self._close_connection()
[документация] def files(self, file_paths, *args, **kwargs):
"""Отправка файлов
Note:
Можно указать отдельный путь на ftp сервере для каждого файла.
Для этого список файлов на отправку должен быть приведен к виду
``[(shot_path, ftp_path), ...]`` При этом так же будет учитываться
глобальная папка :obj:`work_dir` заданная при инициализации класса.
Args:
file_paths (:obj:`str` | :obj:`list`): Путь до файла или список
файлов для отправки
"""
if not isinstance(file_paths, list):
file_paths = [file_paths]
self.queue.extend(file_paths)
if not self._work_now:
self._work_now = True
self._sender()